728x90

들어가며,

어플리케이션을 만드는데 기본적으로 사용되는 MVC 패턴을 공부하기 전에 Servlet과 JSP에서 MVC패턴으로 변화되는 과정에서 중요한 패턴인 프론트 컨트롤러에 대해 공부해보려고 한다.

MVC패턴은 request를 받아서 컨트롤러가 정상적인 HTTP 요청인지 확인 후 서비스, 리포지토리 계층에 요청을 전달한다. 그러면 서비스 계층에서는 비즈니스 로직을, 리포지토리에서는 DB로 전달을 하게 된다.
이 동작을 통해 Model이라는 도메인에 데이터를 전달하고 참조할 수 있게 되는 것이다.
마지막으로 View에서는 response를 내보내면 client가 확인할 수 있다.

프론트 컨트롤러란?

 

이전에 request를 Servlet으로 받아 처리할 때는 각 컨트롤러가 직접 요청을 받아 처리했는데 Front Controller를 도입하면 먼저 Front Controller에서 클라이언트의 모든 요청 값들을 받아서 각 컨트롤러에 맞게 전달해준다.
이를 통해 요청 처리의 일관성을 유지하고 중복 코드를 줄일 수 있으며, 보안, 로깅 등의 부가적인 작업을 효율적으로 수행할 수 있다.

 

@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/
v1/*")
public class FrontControllerServletV1 extends HttpServlet {
	private Map<String, ControllerV1> controllerMap = new HashMap<>();
	public FrontControllerServletV1() {
		controllerMap.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
		controllerMap.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
		controllerMap.put("/front-controller/v1/members", new MemberListControllerV1());
	}

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    
    System.out.println("FrontControllerServletV1.service");
    String requestURI = request.getRequestURI();
    
    ControllerV1 controller = controllerMap.get(requestURI);
    if (controller == null) {
    	response.setStatus(HttpServletResponse.SC_NOT_FOUND);
    	return;
    }
    controller.process(request, response);
    }
}

 

FrontControllerServletV1 생성자를 통해 등록된 controllerMap을 통해 클라이언트 요청을 각 컨트롤러에 매핑한다.
service 메서드는 HttpServletRequest와 Response를 받아서 URI를 조회하고 controllerMap에서 객체를 찾아온다.
객체 값이 null인 경우 NOT_FOUND 예외 처리를 한다.
request와 response를 받아 process 메서드를 동작한다.

 

controllerMap 구성

  • key: 매핑 URL
  • value: 호출될 컨트롤러 

public class MyView {
    private String viewPath;
    public MyView(String viewPath) {
        this.viewPath = viewPath;
    }
    public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

MyView라는 중간 단계를 두어 Controller가 Jsp에 직접 전달하지 않고 Front Controller를 거치게 설계됐다.

 

@WebServlet(name = "frontControllerServletV2", urlPatterns = "/front-controller/
v2/*")
public class FrontControllerServletV2 extends HttpServlet {
    private Map<String, ControllerV2> controllerMap = new HashMap<>();
    public FrontControllerServletV2() {
        controllerMap.put("/front-controller/v2/members/new-form", new MemberFormControllerV2());
        controllerMap.put("/front-controller/v2/members/save", new MemberSaveControllerV2());
        controllerMap.put("/front-controller/v2/members", new MemberListControllerV2());
    }
    
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String requestURI = request.getRequestURI();
        
        ControllerV2 controller = controllerMap.get(requestURI);
        if (controller == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        MyView view = controller.process(request, response);
        view.render(request, response);
        }
    }

 

이제 각각의 Controller는 MyView만 반환하면, 프론트 컨트롤러에서 render메서드를 통해 일관되게 처리해준다.

 

public class ModelView {

	private String viewName;
	private Map<String, Object> model = new HashMap<>();
    
    public ModelView(String viewName) {
    	this.viewName = viewName;
    }
    public String getViewName() {
    	return viewName;
    }
    public void setViewName(String viewName) {
   		this.viewName = viewName;
    }
    public Map<String, Object> getModel() {
    	return model;
    }
    public void setModel(Map<String, Object> model) {
    	this.model = model;
    }
}

 

서블릿 종속성을 제거하는 것이 목적이다.
1. ModelView를 통해 컨트롤러는 논리 View 이름을 반환하게 되며, 이를 View Resolver를 통해 실제 View 경로로 변경해준다.
public class MemberSaveControllerV3 implements ControllerV3 {
    
    private MemberRepository memberRepository = MemberRepository.getInstance();
    
    @Override
    public ModelView process(Map<String, String> paramMap) {
        String username = paramMap.get("username");
        int age = Integer.parseInt(paramMap.get("age"));
        
        Member member = new Member(username, age);
        memberRepository.save(member);
        
        ModelView mv = new ModelView("save-result");
        mv.getModel().put("member", member);
        return mv;
    	}
    }​

 


2. 요청 파라미터에서 서블릿을 제거하고 Map을 통해 넘겨준다.

 

Spring MVC 구조

 

Spring MVC 구조에서는 Front Controller가 Dispatcher Servlet으로 구성되어 있다. 
이외에도 어댑터를 통해 핸들러(컨트롤러)를 유연하게 사용할 수 있다는 장점이 있다.
728x90

홈 컨트롤러 개발


@Controller 계층은 MVC패턴에서 클라이언트의 요청에 대응해 결과를 뷰에 전달하여, 웹 페이지를 생성한다.

@Slf4j와 log를 통해 로거 인스턴스를 생성한다. 

return "home"은 home.html 페이지를 반환함을 의미한다.

@Controller
@Slf4j
public class HomeController {

    @RequestMapping("/")
    public String home() {
        log.info("home controller");
        return "home";
    }

}

 

회원 컨트롤러 개발


@Controller
@RequiredArgsConstructor
public class MemberController {
 private final MemberService memberService;

 

/members/new로 들어오는 HTTP의 GET과 POST 메서드를 처리한다.

 

GET메서드가 들어올 때,

  • createForm은 모델 파라미터를 통해 뷰에 데이터를 전달하고 회원가입 폼을 생성하는 메서드다.
  • 모델 파라미터에는 "memberForm"이라는 이름의 빈 멤버 폼 객체가 뷰로 전달된다.
  • 뷰는 회원가입 폼 화면을 반환한다.

POST메서드가 들어오면,

  • @Valid를 통해 생성된 멤버 폼 객체의 유효성을 검증하고 BindingResult를 통해 결과를 확인한다.
  • 만약 결과가 에러를 가지고 있다면, members/createMemberForm 뷰로 이동한다.
  • 성공한다면, 홈 페이지로 redirect한다.
@GetMapping(value = "/members/new")
 public String createForm(Model model) {
 model.addAttribute("memberForm", new MemberForm());
 return "members/createMemberForm";
 }
 
 @PostMapping(value = "/members/new")
 public String create(@Valid MemberForm form, BindingResult result) {
 if (result.hasErrors()) {
 return "members/createMemberForm";
 }

 

members로 GET메서드가 들어오면,

멤버 리포지토리에서 findAll로 찾아온 값을 반환하는 멤버서비스의 findMembers메서드로 members라는 List를 생성한다.

모델 파라미터에 "members"라는 이름의 members값을 뷰로 전달한다.

@GetMapping(value = "/members")
 public String list(Model model) {
 List<Member> members = memberService.findMembers();
 model.addAttribute("members", members);
 return "members/memberList";
 }

 

주소 객체를 만드는데 form의 정보들을 변수로 불러와 생성한다.

새로운 멤버 정보를 빈 객체를 생성한 뒤, 폼에서 입력받은 이름 값과 생성된 주소값을 설정한다.

생성된 멤버 값은 멤버 서비스 계층에서 만든 회원가입 join 메서드를 활용해 멤버 서비스에 보내 DB에 반영시킨다.

화면은 redirect:/를 통해 홈화면으로 돌아간다.

Address address = new Address(form.getCity(), form.getStreet(), 
form.getZipcode());
 Member member = new Member();
 member.setName(form.getName());
 member.setAddress(address);
 memberService.join(member);
 return "redirect:/";
 }

 

 

728x90

주문 엔터티 개발


@Entity, @Table를 통해 엔터티 계층임을 지정하고 DB에서 매핑할 테이블명을 설정한다.

@Getter와 @Setter는 해당클래스의 조회와 설정을 허용함을 의미하는데, 엔터티의 값은 불변성을 유지해야 하므로 보통의 경우에는 Setter는 사용하지 않는다.

@Entity
@Table(name = "orders")
@Getter
public class Order {

주문 엔터티를 중심으로 보면, 연결된 엔터티간의 관계에서 아래와 같은 필드를 가진다.

  • 회원 id (FK) -> 한 명의 회원은 여러 주문 정보를 가질 수 있다. 

Member 엔터티와 다대일 양방향 관계를 설정한다. @JoinColumn은 member_id에 FK를 지정한다.

FetchType.LAZY를 사용해 지연로딩을 설정, 이는 실제 사용시 로딩됨을 의미한다.

  • 배송 id (FK) -> 하나의 주문은 하나의 배송 정보를 가진다.

Delivery 엔터티와 일대일 단방향 관계를 설정한다. 회원 id외 마찬가지로 delivery_id에 FK를 지정한다.

지연로딩 역시 마찬가지다. 그러나 CasacadeType을 설정해 생명주기를 관리한다는 점이 회원id와 차이가 있다.

이는 모든 변경 내용이 적용됨을 의미하는데, 주문과 배송의 관계에서 서로 반드시 필요한 존재이기 때문이다.

  • 주문 리스트 -> 하나의 주문은 여러 개의 주문 상품을 가질 수 있다.

연관관계를 맺을 때는 주인이 되는 엔터티를 결정해야 한다. 주인 엔터티가 DB에 직접적인 영향을 주는 주체이며, 주인이 아닌 엔터티는 단순 참조 역할을 한다.

주로 FK키 값을 지닌 쪽이 주인의 역할을 맡는다. 반대로 주인이 아닌 쪽은 mappedBy 속성을 통해 매핑된 필드를 지정한다.

private List<OrderItem> orderItems = new ArrayList<>();는 크기 조절에 동적인 자료구조를 통해 OrderItem 엔터티를 담는 컬렉션을 구현했다.

 

연관관계의 주인 측에 연관 관계 메서드를 설정한다.

불변성을 유지시키기 위해 필요한 메서드에 한하여 Setter를 사용할 수 있다.

@ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();
    
    //연관관계 메서드
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }

    public void setDelivery(Delivery delivery) {
        this.delivery = delivery;
        delivery.setOrder(this);
    }

    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

 

주문 엔터티 자체에 속하며, 주문을 생성하는 정적인 메서드/

빈 주문 객체를 생성하고 회원 정보와, 배송 정보를 설정한다.

for문을 통해 주문 상품을 주문 리스트에 추가한다. 추가하는 동작은 연관관계 메서드에서 만든 addOrderItem을 사용한다.

주문 상태는 주문됨, 주문 일자는 현재 시각을 설정한다.

완성된 주문을 반환한다.

//생성 메서드
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
        Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);
        for (OrderItem orderItem : orderItems) {
            order.addOrderItem(orderItem);
        }
        order.setOrderStatus(OrderStatus.ORDER);
        order.setOrderdate(LocalDateTime.now());
        return order;
    }

 

주문을 취소하는 cancel메서드이다.

배송의 상태를 조회해서 배송상태가 COMP이면, IllegalStateException 예외 처리.

주문 객체의 주문상태를 취소됨으로 설정한다.

for문을 통해 주문에 속한 주문상품을 취소한다.

//비즈니스 로직
    //주문 취소
    public void cancel() {
        if (delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }
        this.setOrderStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : orderItems) {
            orderItem.cancel();
        }
    }

 

TotalPrice를 0으로 초기화한 뒤, for문을 통해 주문상품들을 돌며 주문상품의 전체 가격을 더한다.

//조회 로직
    //전체 주문 가격 조회
    public int getTotalPrice() {
        int totalPrice = 0;
        for (OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }

주문 상품 엔터티 개발


@Entity
@Table(name = "order_item")
@Getter
@Setter
public class OrderItem {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice;
    private int count;

 

주문에 속하는 주문상품을 생성하는 정적 메서드/

상품 객체, 주문가격, 수량을 변수로 받아 주문상품을 생성한다.

빈 주문상품 객체를 생성한 뒤, 상품, 주문가격, 수량을 설정한다.

-> 상품 엔터티의 재고는 수량만큼 감소시킨다.

생성된 주문상품 객체 반환.

    //생성 메서드
    public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice);
        orderItem.setCount(count);
        item.removeStock(count);
        return orderItem;
    }

createOrder와 createOrderItem의 관계 설명:

createOrderItem 메서드를 사용하여 주문 상품들을 생성하고, 이들을 createOrder 메서드에 전달하여 주문을 생성한다.

    //비즈니스 로직
    //주문 취소시 재고를 다시 증가시킴.
    public void cancel() {
        getItem().addStock(count);
    }

    //조회 로직
    //주문상품 전체 가격 조회 -> 주문가격과 수량을 조회해 곱셈.
    public int getTotalPrice() {
        return getOrderPrice()*getCount();
    }

 

주문 리포지토리 개발


리포지토리 개발 내용 참고.

 

회원 도메인 개발 Spring Boot 기본 (3)

들어가며 이전 1편과 2편에서의 내용은 Domain을 DB와 연동시키는 내용이었다. 3편부터 5편까지(아마도?)는 회원, 상품, 주문 도메인 내의 Controller, Service, Repository를 개발하여 기능을 부여하려고 한

wooltech.tistory.com

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    public void save(Order order) {
        em.persist(order);
    }

    public Order findOne(Long id) {
        return em.find(Order.class, id);
    }

 

주문 서비스 개발


@Service계층, 읽기 전용, 생성자 주입을 통해 틀을 갖춘다.

주문 서비스는 회원, 주문, 상품 리포지토리를 모두 주입받는다.

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {

    private final MemberRepository memberRepository;
    private final OrderRepository orderRepository;
    private final ItemRepository itemRepository;

 

주문 서비스는 주문과 주문상품 엔터티에서 생성한 비즈니스 로직을 통해 기능 위임한다.

=> 도메인 모델 패턴을 통해 비즈니스 로직을 구현하는 메서드를 엔터티에서 사용하고 있기 때문에, 서비스 계층은 구현이 아닌 로직을 호출하고 트랜잭션을 관리하여, DB에 반영하는 역할을 수행한다.

=> 이후, 도메인 주도 설계 DDD에 대해 공부할 것.

 

회원id, 상품id, 수량을 변수로 받아 id값을 통해 리포지토리에서 반환했던 엔터티 값을 조회한다.

빈 배송정보 객체 생성한 뒤, 받아온 회원 엔터티의 주소 정보를 배송정보의 주소정보로 설정한다.

 

도메인에서 생성한 비즈니스 로직인 주문상품과 주문을 생성한다.

리포지토리에 구현된 주문 정보를 저장하고 id값을 반환한다.

 

//주문
    @Transactional
    public Long order(Long memberId, Long itemId, int count) {

        //엔티티 조회
        Member member = memberRepository.findOne(memberId);
        Item item = itemRepository.findOne(itemId);

        //배송정보 생성
        Delivery delivery = new Delivery();
        delivery.setAddress(member.getAddress());

        //주문상품 생성
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

        //주문 생성
        Order order = Order.createOrder(member, delivery, orderItem);

        //주문 저장
        orderRepository.save(order);
        return order.getId();
    }

 

728x90

상품 엔티티 개발


추상 클래스 (Abstract Class):

상품 클래스는 추상클래스로 설정되어 있다. 

추상클래스는 직접 객체 생성이 불가능하다. 상속을 통해 확장하고 자식(하위)클래스에서 구현된다.

 

상품 클래스가 가진 속성은 아래와 같다.

  • 상품 id (PK)
  • 이름
  • 가격
  • 재고수량
  • 카테고리 목록 

@Inheritance와 @DiscriminatorColumn은 상속 관계 매핑에서 단일 테이블 전략에 사용된다.

@Inheritance은 아래와 같이 전략 타입을 설정한다.

  • InheritanceType.SINGLE_TABLE: 단일 테이블 전략
  • InheritanceType.JOINED: 조인 전략
  • InheritanceType.TABLE_PER_CLASS: 구현 클래스마다 테이블 생성 전략

@DiscriminatorColumn을 dtype으로 명명해 하위 클래스의 값을 구분하여 다형성을 구현한다.

하위 클래스는 @DiscriminatorValue에 "값"을 지정해 단일 테이블 내에서 구분될 수 있다.

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "item_id")
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;

 

상품 엔티티는 stockQuantity라는 정수 값을 갖는다.재고 수량은 주문에 따라 변화하는 값이며, 0이하로 내려갈 수 없는 성격을 가지고 있다.따라서, 추가적인 비즈니스 로직이 필요하고 예외 처리를 위한 로직도 작성해야 한다.

 public void addStock(int quantity) {
 	this.stockQuantity += quantity;
 }
 public void removeStock(int quantity) {
 	int restStock = this.stockQuantity - quantity;
 	if (restStock < 0) {
 		throw new NotEnoughStockException("need more stock");
 	}
 	this.stockQuantity = restStock;
    }
}

 

add와 remove를 통해 증감을 계산하고, if문의 restStock이 0보다 작은 경우 예외처리를 수행한다.

package jpabook.jpashop.exception;

public class NotEnoughStockException extends RuntimeException {
    public NotEnoughStockException() {
        super();
    }

    public NotEnoughStockException(String message) {
        super(message);
    }

    public NotEnoughStockException(String message, Throwable cause) {
        super(message, cause);
    }

    public NotEnoughStockException(Throwable cause) {
        super(cause);
    }

    protected NotEnoughStockException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}

 

비체크 예외인 RuntimeException을 상속 받아 생성자를 두고 다양한 예외에 대응한다.자바에서는 체크 예외와 비체크 예외를 통해 예외 처리를 한다. 이후 예외 처리에 대해 공부할 것.

 

상품 리포지토리 개발


 

@Repository
@RequiredArgsConstructor
public class ItemRepository {
	private final EntityManager em;

	public void save(Item item) {
		if (item.getId() == null) {
			em.persist(item);
		} else {
			em.merge(item);
 		}
	}

 

 

public Item findOne(Long id) {
	return em.find(Item.class, id);
 }
 public List<Item> findAll() {
 	return em.createQuery("select i from Item i",Item.class).getResultList();
 }

 

상품 서비스개발


회원 서비스 개발 내용 참고.

 

회원 도메인 개발 Spring Boot 기본 (3)

들어가며 이전 1편과 2편에서의 내용은 Domain을 DB와 연동시키는 내용이었다. 3편부터 5편까지(아마도?)는 회원, 상품, 주문 도메인 내의 Controller, Service, Repository를 개발하여 기능을 부여하려고 한

wooltech.tistory.com

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {
    //lombok 생성자 주입
    private final ItemRepository itemRepository;

    @Transactional
    public void saveItem(Item item) {
        itemRepository.save(item);
    }

    public List<Item> findItems() {
        return itemRepository.findAll();
    }

    public Item findOne(Long itemId) {
        return itemRepository.findOne(itemId);
    }

 

728x90

들어가며


 

 

 

MVC패턴


2023.11.26 - [Learning Java Spring] - 처음 자바 스프링을 공부한다면?Java Spring 프레임워크의 기본

 

처음 자바 스프링을 공부한다면?Java Spring 프레임워크의 기본

백엔드 개발자를 선택하고 나서 개발자에게 프론트는 js, 백은 Java를 선택해야(?)만 할정도로 한국 사회에서 가장 널리 보급되고 있는 것 같다. 프론트 백엔드 웹 html, css, js (React, Next) Java, Java Spri

wooltech.tistory.com

회원 엔터티 개발


@Entity @Getter @Setter 등을 통해 클래스 생성하며, @Id @GeneratedValue @Column으로 기본키 PK설정 및 컬럼명을 지정한다.

 

 

@Entity
@Table(name = "member")
@Getter @Setter
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String name;

    @Embedded
    private Address address;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

@Embeded와 protected를 통해 별도의 정보 클래스를 정의하고 상위 엔티티에 포함시키는 방법:

  • 회원과 배송 모두 @Embeded를 통해 주소 클래스의 정보를 가진다.
  • 주소 클래스는 생성자를 통해 객체 정보를 사용가능하도록 초기화 한다.
  • 생성된 객체는 protected로 보호되는데 이는 캡슐화된 정보에 접근하기 위한 접근자이다. 즉, 변경을 제한한다.
@Embedded
 private Address address;

@Embeddable
@Getter
public class Address {
 private String city;
 private String street;
 private String zipcode;
 
 protected Address() {
 }
 public Address(String city, String street, String zipcode) {
 this.city = city;
 this.street = street;
 this.zipcode = zipcode;
 }

 

 

 

회원 리포지토리 개발


@Repository를 통해 리포지토리 클래스임을 지정한다.

@PersistenceContext에서 엔티티 매니저를 주입해 도메인에서 만들어 둔 엔티티를 관리한다.

 

save(), findOne(), findAll(), findByName() 메서드를 통해 본격적으로 기능을 구현한다.

@Repository
public class MemberRepository {

    @PersistenceContext
    private EntityManager em;

 

1.  save(Member member) 메서드:

save를 통해 전달 받은 Member 객체를 persist메서드를 사용하여 영속성 컨텍스트에 저장한다.

이후 서비스 계층에서 트랜잭션이 커밋되면, DB에 반영된다.

 

2.  findOne(Long id) 메서드:

Member의 주어진 id를 찾는 메서드이다.

1차적으로 영속성 컨텍스트내의 캐시에서 조회를 한 후, 없다면 JPA가 직접 데이터베이스에 select구문을 만들어 조회한다. 이는 id가 식별자이기 때문에 가능한 것이다.

  • em.find(클래스, 기본키)

3. findAll() 메서드:

DB에 저장된 모든 회원을 List로 반환한다. 

select m from Member m을 통해 모든 회원을 조회하는 쿼리 구문을 생성한다.

select 쿼리가 실행된 후 getResultList를 통해 리스트로 받아온다.

 

4. findByName(String name) 메서드:

매개변수인 이름값으로 조회하는 방법

이름에 들어갈 내용은 동적인 데이터이기 때문에 setParameter 매개변수 바인딩을 사용해 안정적으로 제공한다.

예를 들면,

select m from Member m where m.name = 'John' 이 쿼리에서는 .setParameter("name", John)이 된다.

    public void save(Member member) {
        em.persist(member);
    }

    public Member findOne(Long id) {
        return em.find(Member.class, id);
    }

    public List<Member> findAll() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }
    public List<Member> findByName(String name) {
        return em.createQuery("select m from Member m where m.name = :name", Member.class)
                .setParameter("name", name)
                .getResultList();
    }
}

회원 서비스 개발


@Service 서비스 계층임을 나타내며, readOnly = true를 통해 읽기 전용으로 사용된다.

즉, 데이터의 수정은 일어나지 않음을 의미한다.

@RequiredArgsConstructor를 통해 생성자를 자동으로 생성하여, ItemRepository를 주입받는다.

(final 또는 @NonNull로 표시된 필드를 사용하여, 생성자를 만든다.)

 

생성자 주입을 하는 이유:

  • 리포지토리에서 구현된 회원 정보를 외부에서 받아와 사용하기 때문에 유연성과 재사용성의 장점을 가진다.
  • 리포지토리의 회원 정보를 수정하더라도 서비스의 내용은 수정하지 않아도 된다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

 

메서드에서 id를 반환하는 이유:

  • 새로운 회원정보가 데이터베이스에 저장되고 회원id가 반환되어야 클라이언트가 이 정보를 활용할 수 있다.
  • 로그인, 회원 정보 수정 등에 활용된다.
 //회원가입
    @Transactional
    public Long join(Member member) {
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        List<Member> findMembers = memberRepository.findByName(member.getName());
        if (!findMembers.isEmpty()) {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        }
    }

 

 회원조회는 사용하지 않고 회원가입에만 Transactional이 사용된 이유:

  • Transactional을 사용하는 이유는 트랜잭션을 통해 리포지토리에서 구현한 정보를 DB에 반영하기 위함이다.
  • 즉, 가입에는 새로운 회원 정보를 DB에 저장해야 하지만 회원 조회에서는 새로운 값을 넣는 것이 아닌 get을 통한 호출만 다루고 있기 때문에 Transactional이 필요하지 않다.
    // 전체 회원 조회
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Member findOne(Long memberId) {
        return memberRepository.findOne(memberId);
    }
}
728x90

들어가며


스프링 프레임워크 기본편을 수강하고서, 간단한 웹 어플리케이션을 만들기 위해 스프링 부트로 넘어오게 되었다. 아직 MVC패턴, DB연동기술 그리고 고급 활용에 대해 공부할게 많지만 간단하게나마 웹 페이지를 구축하고 나면 이후 학습에 도움이 되리라고 생각한다!

프로젝트 설정


핵심 라이브러리
스프링 MVC
스프링 ORM
JPA, 하이버네이트
스프링 데이터 JPA
기타 라이브러리
H2 데이터베이스 클라이언트
커넥션 풀: 부트 기본은 HikariCP
WEB(thymeleaf)
로깅 SLF4J & LogBack
테스트

Spring Initializr

위 페이지에서 매우 간단하게 스프링 부트 프로젝트를 세팅할 수 있다.

그리고 프로젝트의 SDK와 Gradle JVM의 버전을 꼭 확인해야한다.

도메인 설계


엔티티와 테이블 분석


가장 먼저 중심부를 보면 ORDERS와 ITEM이 놓여있다. 이로써 상품과 주문을 중심으로 비즈니스 로직이 돌아가는 프로젝트임을 알 수 있다.

 

1. 주문 클래스 위아래로 회원 클래스와 배송 클래스가 붙어 있음을 볼 수 있다.

- 회원 클래스는 일대다로 회원을 기준으로 여러 주문을 가질 수 있다.
- 배송 클래스는 주문과 일대일 관계로 하나의 주문은 하나의 배송을 가진다.

 

2. 상품 클래스는 카테고리와 다대다 관계를 가진다.

그러나, 실제로 직접 다대다 연결을 할 수 없어 중간에 카테고리_상품 클래스를 두고 각각 다대일 관계를 맺어 연결한다.

 

3. 테이블 설계와 연관관계 매핑의 차이:

참조와 매핑이라는 단어를 생각해보면 이해가 쉽다.
공통적으로는 정보덩어리인 엔티티를 연결시킨다.
테이블 설계는 데이터베이스에서 테이블 간의 참조를 통한 관계 형성을 다루고, 연관관계 매핑은 객체간의 관계를 설정한다.
쉽게 테이블 참조는 비교적 간단한 관계 형성에 사용된다. 이를테면 회원과 주문이 있을 때, 이 주문이 어떤 회원의 것인지 알기 위해 회원의 고유키를 주문의 외래키로 참조하는 것이다.
반면, 객체의 관계에서는 좀더 복잡해진다. 주문과 배송은 각각 서로를 위해 존재한다. 하나의 주문에는 하나의 배송이 있어야 하고 하나의 배송에는 하나의 주문 정보가 필요하다. 반대로 회원과 주문처럼 한명의 회원이 여러 주문을 가지는 관계도 있다. 

 

 

728x90

들어가며


5편에서는 IoC컨테이너(스프링 컨테이너)를 통해 Config창을 따로 두어 역할 분리를 했습니다.

또한, @Configuration과 @Bean을 통해 빈 저장소를 만들고 빈을 생성했습니다.

그러나 Bean을 각각 지정해야하는 불편함이 있음을 알았고 이를 해결하기 위한 방법을 공부하려고 합니다.

컴포넌트 스캔


 

@ComponentScan(스캐너), @Component(바코드)

업무를 하다보면 비즈니스 로직을 구성한 뒤에 따로 설정 정보를 입력하기가 매우 귀찮다. 그래서 비즈니스 로직 창에 @Component을 달면 설정창의 ComponentScan이 알아서 찾아 등록해준다.

 

그렇지만 장점에는 단점도 항상 존재한다.

편리하고 유연한 대신에 설정창에서 명시적으로 빈의 역할을 확인할 수 있던 것과 달리 직접 확인이 어렵다. 또한, 대규모 프로젝트에서 오버헤드가 발생하기도 한다.

더보기

컴포넌트 스캔(Component Scanning)은 스프링이 자동으로 빈(Bean)을 찾아 등록하는 메커니즘 중 하나입니다. 스프링은 어플리케이션을 실행할 때, 특정 패키지 이하에서 @Component 및 관련 어노테이션들이 붙은 클래스를 찾아서 자동으로 빈으로 등록합니다.

 @ComponentScan 어노테이션을 사용하면 @Component 외에도 @Controller, @Service, @Repository, @Configuration 등과 같은 특정 어노테이션이 붙은 클래스들도 스캔 대상에 포함된다.

package hello.core;
import ...

@Configuration
@ComponentScan
	public class AutoAppConfig {
}
@Component
public class OrderServiceImpl implements OrderService {
 private final MemberRepository memberRepository;
 private final DiscountPolicy discountPolicy;
 @Autowired
 public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy
discountPolicy) {
 this.memberRepository = memberRepository;
 this.discountPolicy = discountPolicy;
 }
}

@ComponentScan, @Component를 통해 설정 정보 작성, 그리고 @Autowired를 통해 의존관계 주입.

탐색 위치 지정과 필터


위에서 말했듯, @ComponentScan을 사용시, 자동으로 등록해 대규모 프로젝트의 경우 오버헤드의 위험이 있다. 이 단점을 해결하기 위한 방법이 탐색 위치 지정 그리고 필터다.

 

탐색 위치 지정

basePackages를 통해 시작 위치를 설정하는 방법이 있다.

그러나 추천하는 방식은 프로젝트의 상단을 활용해 시작점을 두는 것이다.

@ComponentScan(basePackages = "com.example")
public class AppConfig {
    // configuration content here
}

필터

@Component, @Service, @Repository, @Controller 등 컴포넌트 등록할 대상과 제외할 대상을 구별할 수 있다.

 

 

롬복 lombok


컴포넌트 스캔을 통해 설정창을 간단하게 정리하고 비즈니스 로직을 구성하는 창에는 @Component와 @Autowired를 통해 자동으로 등록할 수 있게 하고 의존관계를 정리했다.

 

그리고 lombok을 이용하면 비즈니스 로직을 보다 간단하게 정리할 수 있다.

아래 코드를 보면,

OrderServiceImpl가 주문서비스를 의존해 생성자를 생성하기 위해 초기화하고 있다.

private final을 통해 불변하게 멤버저장소와 할인정책을 의존 가능하게 주입받고 있다.

이외에 생성과 관련된 코드는 @RequiredArgsConstructor을 통해 자동으로 진행된다.

더보기

롬복은 주로 클래스의 Getter, Setter, Equals 및 HashCode 메서드, ToString 메서드 등을 애노테이션을 사용하여 생성할 수 있게 해줍니다. 이로써 개발자는 이러한 반복적인 코드를 일일이 작성하지 않아도 됩니다. 여러 롬복 애노테이션 중에서 @Getter, @Setter, @ToString, @EqualsAndHashCode 등은 주로 빈 클래스에서 사용되며, 이는 주로 스프링의 컴포넌트 스캔과 함께 사용됩니다.

@Component
@RequiredArgsConstructor
	public class OrderServiceImpl implements OrderService { 
		private final MemberRepository memberRepository;
		private final DiscountPolicy discountPolicy;
}

 

728x90

들어가며


지난 4편에서 좋은 객체 지향 프로그래밍을 위해 Config 구성 인터페이스를 만들어 역할을 분리하는 것에 대해 공부했습니다. 이번 5편에서 다룰 스프링 컨테이너 즉, IoC 컨테이너는 이렇게 따로 분리해 낸 Config 인터페이스를 @ 어노테이션을 활용해 보다 더 모듈화하고 유지보수에 용이하도록 로직을 다듬으려고 합니다.

스프링 컨테이너란


 

스프링 컨테이너란?

스프링 컨테이너는 스프링 프레임워크에서 IoC (Inversion of Control)를 구현한 핵심 컴포넌트다. IoC란 객체의 생성, 관리, 생명주기를 개발자가 아닌 프레임워크 또는 컨테이너가 담당하는 디자인 패턴을 말한다.

 

위 그림과 같이 IoC컨테이너를 이용하면 AnnotationConfigApplicationContext를 읽어 대신 객체를 생성하고 의존관계를 주입해준다는 것인데, 이것은 자바 코드를 통해 직접 구현할 수 있었다.

그럼에도 스프링 컨테이너를 사용하는 이유는 아래에서 설명할 싱글톤 컨테이너의 개념과 연관된다.

//MemberApp 내용 수정!

//AppConfig를 통해 직접 객체를 생성하고 의존관계를 주입했었다.
// AppConfig appConfig = new AppConfig();
// MemberService memberService = appConfig.memberService();
// OrderService orderService = appConfig.orderService();

//스프링 컨테이너를 이용해 빈 저장소로부터 빈을 관리한다.
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
OrderService orderService = applicationContext.getBean("orderService", OrderService.class);

 

 

스프링 컨테이너를 사용해야 하는 이유 = 싱글톤 컨테이너


1. 스프링 없는 순수한 DI컨테이너

다시 등장한 자동차 그림이다. 이미 좋은 객체 지향 프로그래밍을 위해 역할 분리를 마친 상태다.

운전자는 K3를 탈지, 아반떼를 탈지, 모델3를 탈지 모르고 자동차가 필요해 호출한다.

그러면 config 구성 창에서 지정된 K3 객체를 new를 통해 생성하게 되는 것이다.

그런데 운전자가 만약 자동차를 한번 더 호출한다고 하자. 이 상황에서 new를 통해 또다른 K3가 생성된다.

결국 운전자가 필요한 자동차는 1대인데 수십 수백대가 생성된다면, 엄청난 자원 낭비인 것이다.

2. 싱글톤 패턴을 적용

아래는 싱글톤 패턴을 위해 임의로 만든 서비스다.

싱글톤 패턴은 new를 통해 객체가 무한정 생성되는 상황을 방지하기 위해 객체가 생성되고 나면 private을 통해 추가 생성을 방지하는 디자인 패턴이다.

public class SingletonService {

    private static final SingletonService instance = new SingletonService();

    public static SingletonService getInstance() {
        return instance;
    }
    private SingletonService() {
    }

 

즉, K3 객체 1대만 생성해서 Config 구성창에서 K3를 운전하도록 지정된 운전자들은 모두 공유하도록 하는 것이다. 렌터카(?)를 생각하면 비슷할지도...

공유되는 인스턴스다 보니 여러가지 문제점이 등장한다.

  1. .getInstance를 통해 직접 구현체를 의존해 앞서 강조했던 DIP, OCP를 위반한다.
  2. Stateful이 아닌 Stateless 무상태로 설계해야 한다.

Stateful과 Stateless


Stateful은 자차, Stateless는 렌터카라고 비유해보면, km당 이용 요금을 내야 한다고 하자.
Stateful은 자차이기 때문에 다른 운전자가 이용하지 않아 계기판을 확인하면 바로 내가 탄 키로수를 확인할 수 있다. 그러나 Stateless는 내가 이용하고 바로 확인하지 않고 그 사이에 다른 운전자가 이용하게 된다면, 계기판에는 내가 탄 키로수가 아닌 다른 운전자가 탄 키로수가 적혀 있을 것이다.

자원의 효율 측면에서는 장점을 가지고 있지만 변경과 저장이 어렵다는 점에서 단점을 지니고 있다.

 

*아래는 간단한 스레드를 통한 예시

//ThreadA: A사용자 1000키로 이용
 statefulService1.distance("userA", 1000);
 //ThreadB: B사용자 2000키로 이용
 statefulService2.distance("userB", 2000);
 //ThreadA: 사용자A 키로수 조회
 int kilometer = statefulService1.getKilometer();
 //ThreadA: 사용자A는 1000키로를 이용했지만, 2000키로 출력
 System.out.println("kilometer = " + kilometer);

3. 스프링 컨테이너와 싱글톤

스프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다
른 클래스를 만들고, 그 다른 클래스를 스프링 빈으로 등록한다.

 

스프링 컨테이너에서는 이런 논리로 싱글톤을 유지한다고 하는데,, 이 부분은 깊게 파기보다 @Configuration을 사용하면 된다는 정도면 충분하다.

@Configuration, @Bean


@Configuration 애노테이션을 통해 빈 저장소를 만들고, @Bean 애노테이션을 부여해 빈 저장소에 저장한다.

저장된 Bean은 .getBean을 통해 조회 할 수 있다.

@Configuration 을 붙이면 바이트코드를 조작하는 CGLIB 기술을 사용해서 싱글톤을 보장한다.

package hello.core;

import ...

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(
                memberRepository(),
                discountPolicy());
    }
    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }
}

 

 

@Test 코드를 이용한 컨테이너 테스트

package hello.core.singletone;

import ...

public class SingleToneTest {

    @Test
    @DisplayName("스프링 없는 순수한 DI컨테이너")
    void pureContainer() {
        AppConfig appConfig = new AppConfig();
        //조회 : 호출할 때 마다 객체를 생성
        MemberService memberService1 = appConfig.memberService();
        MemberService memberService2 = appConfig.memberService();

        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);

        Assertions.assertThat(memberService1).isNotSameAs(memberService2);

    }

    @Test
    @DisplayName("싱글톤 패턴을 적용한 객체 사용")
    void singletonService() {
        SingletonService singletonService1 = SingletonService.getInstance();
        SingletonService singletonService2 = SingletonService.getInstance();

        System.out.println("memberService1 = " + singletonService1);
        System.out.println("memberService2 = " + singletonService2);
    }

    @Test
    @DisplayName("스프링 컨테이너와 싱글톤")
    void SpringContainer() {

        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

        //조회 : 호출할 때 마다 객체를 생성
        MemberService memberService1 = ac.getBean("memberService", MemberService.class);
        MemberService memberService2 = ac.getBean("memberService", MemberService.class);

        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);

        Assertions.assertThat(memberService1).isSameAs(memberService2);
    }
}

 

+ Recent posts