IT recording...

[스프링 JPA1] 2. 도메인 개발 본문

Spring

[스프링 JPA1] 2. 도메인 개발

I-one 2022. 2. 15. 00:52

[원문 링크]

https://adorable-aspen-d23.notion.site/JPA1-2-147b0c2406c84ffda4944650c61a66e9

 

[스프링 JPA1] 2. 도메인 개발

1. 어플리케이션 아키텍처

adorable-aspen-d23.notion.site

김영한님의 [실전!스프링부트와JPA활용1 - 웹 어플리케이션개발] 강의를 듣고 작성한 글입니다.

1. 어플리케이션 아키텍처

  • 도메인, 리포지토리, 서비스 개발
  • 테스트를 통한 검증
  • 컨트롤러 개발

요구사항 분석

  • 회원 기능
    • 회원 등록
    • 회원 조회
  • 주문 기능
    • 상품 주문
    • 주문 내역 조회
    • 주문 취소
  • 상품 기능
    • 상품 등록
    • 상품 수정
    • 상품 조회
  • 기타 요구사항
    • 상품은 재고 관리가 필요하다.
    • 상품 주문시 배송 정보를 입력할 수 있다.

2. 회원 도메인 개발

  • 기능
    • 회원 등록
    • 회원 조회
  • 개발 순서
    • 리포지토리 → 서비스 → 테스트

2-1. MemberRepository

  • save, findOne, findAll, findByName 구현
@Repository //component스캔에 의해 자동으로 spring bean으로 관리
@RequiredArgsConstructor
public class MemberRepository {
    //@PersistenceContext //JPA를 사용하므로 JPA표준 어노테이션 사용
    private final EntityManager em;//spring이 entityMAnager 만들어서 자동으로 주입해줌

    public Long save(Member member){
        em.persist(member); //db에 insert쿼리
        return member.getId();
    }

    public Member findOne(Long id){
        return em.find(Member.class, id); //단건 조회 (타입,pk)
    }

    //SQL은 테이블을 대상으로 쿼리를 하지만, JPQL은 엔티티(객체)를 대상으로 쿼리를 한다.
    public List<Member> findAll(){
        return em.createQuery("select m from Member m", Member.class) //createQuery(쿼리,반환타입)
            .getResultList();
    }

    public List<Member> findByName(String name){
        return em.createQuery("select m from Member m where m.username = :name", Member.class)
                .setParameter("name",name) //파라미터바인딩
                .getResultList();
    }

}
  • @Repository
    • component 스캔에 의해 자동으로 spring bean 으로 관리된다.
  • @RequiredArgsConstructor
    • 생성자가 하나인 경우, final변수를 자동으로 injection해준다.
public class MemberRepository{
	@PersistenceContext
	private final EntityManager em;
}

=====================

@RequiredArgsConstructor
public class MemberRepository{
	private final EntityManager em;
}

2-2. MemberService

  • 회원가입(join), 회원조회(findMembers, findOne)
@Service
@Transactional(readOnly = true) 
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    //회원 가입
    @Transactional //쓰기
    public Long join(Member member){
        //중복회원 검증 로직
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }

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

    //회원 전체 조회
    public List<Member> findMembers(){
        return memberRepository.findAll();
    }

    //회원 조회
    public Member findOne(Long memberId){
        return memberRepository.findOne(memberId);
    }
}
  • @Transactional(readOnly = true)
    • 영속성 컨텍스트를 지정한다.
    • 읽기 전용에서 명시하여 성능 향상한다.
  • Injection
    • 필드 주입
      • 테스트 시 바꿔야 할 수 있는데 엑세스할 수 없다.
    public class MemberService {
    	 @Autowired
    	 MemberRepository memberRepository;
    	 ...
    }
    
    • 생성자 주입
      • 직접 주입하기 때문에 놓치지 않고 주입 가능하다.
      • 변경이 불가능한 안전한 객체 생성이 가능하다.
      • 생성자가 하나면, @Autowired 생략이 가능하다.
      • final 키워드를 추가하면 컴파일 시점에 memberRepository를 설정하지 않는 오류를 체크할 수 있다.
    public class MemberService {
    	 private final MemberRepository memberRepository;
    
    	 public MemberService(MemberRepository memberRepository) {
    		 this.memberRepository = memberRepository;
    	 }
    	 ...
    }
    
    • lombok
      • 와따다. 이걸 쓰자
@RequiredArgsConstructor
public class MemberService {
	 private final MemberRepository memberRepository;
	 ...
}

2-3. MemberServiceTest

  • 테스트 요구사항
    • 회원가입을 성공해야한다.
    • 회원가입 시 중복이름이 존재하면 예외가 발생해야 한다.
@RunWith(SpringRunner.class)
@SpringBootTest //AutoWired하기 위해 필요
@Transactional //테스트가 끝나면 롤백
class MemberServiceTest {
    @Autowired
    MemberService memberService;
    @Autowired
    MemberRepository memberRepository;
    @Autowired //2. 눈으로 보고싶어
    EntityManager em;

    @Test
    //@Rollback(false) //1. @Transactional은 기본적으로 롤백을 하는데, 롤백안하고 눈으로 다 보고싶어 /But 테스트는 반복해서 해야하기 때문에 롤백되어야 함 -> 2
    public void 회원가입() throws Exception{
        //given
        Member member = new Member();
        member.setUsername("kim");

        //when
        em.flush(); //2. 눈으로 보고싶어
        Long savedId = memberService.join(member);

        //then
        Assertions.assertEquals(member, memberRepository.findOne(savedId));
    }

    @Test
    public void 중복_회원_예외() throws Exception{
        //given
        Member member1 = new Member();
        member1.setUsername("kim");
        Member member2 = new Member();
        member2.setUsername("kim");

        //when
        memberService.join(member1);

        //then
				//Junit4
        //fail("예외가 발생해야 한다."); //위에서 예외가 터져서 여기 도달하지 말아야 함
				//Junit5
        IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
        assertEquals("이미 존재하는 회원입니다.", thrown.getMessage());

    }
}
  • @RunWith(SpringRunner.class)
    • 스프링과 테스트 통합
  • @SpringBootTest
    • 스프링 부트 띄우고 테스트
    • AutoWired가 가능하게 한다.
  • @Transactional
    • 반복 가능한 테스트 지원
    • 각 테스트마다 트랜잭션을 시작하고 테스트가 끝나면 트랜잭션을 강제로 롤백한다.
  • @Rollback(false)
    • 데이터를 롤백하지 않고 눈으로 보고싶을 때 사용한다.
  • TDD
@Test
public void 테스트() throws Exception{

	//given

	//when

	//then

}

3. 주문 도메인 개발

  • 기능
    • 상품 주문
    • 주문 내역 조회
    • 주문 취소
    • 주문 검색
  • 개발 순서
    • 엔티티 → 리포지토리 → 서비스 → 테스트

2-1. Order 엔티티

  • 도메인 내부에서 해결할 수 있는 비즈니스 로직을 처리한다. (도메인 중심 설계)
    • 주문취소 - cancel()
    • 전체 주문 가격 조회 - getTotalPrice()
/**
 * 주문 취소
 */
public void cancel(){
    if(delivery.getStatus() == DeliveryStatus.COMP){ //이미 배송 완료일 경우
        throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
    }
    this.setStatus(OrderStatus.CANCEL);
    for(OrderItem orderItem:orderItems){
        orderItem.cancel();
    }
}

//==조회 로직==//
/**
 * 전체 주문 가격 조회
 */
public int getTotalPrice(){
    int totalPrice = 0;
    for (OrderItem orderItem : orderItems) {
        totalPrice += orderItem.getTotalPrice();
    }
    return totalPrice;
}
  • 생성 메서드 createXXX
    • 엔티티 생성
    • 엔티티의 생성 시점을 명확히 하여 유지보수를 용이하게 한다.
//==생성 메서드==//
public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems){
    Order order = new Order();
		//set
		order.setMember(member);
    order.setDelivery(delivery);
    for(OrderItem orderItem:orderItems){
        order.addOrderItem(orderItem);
    }
    order.setStatus(OrderStatus.ORDER);
    order.setOrderDate(LocalDateTime.now());
    return order;
}

2-2. OrderItem 엔티티

@Entity
@Getter @Setter
**@NoArgsConstructor(access = AccessLevel.PROTECTED) //protected 생성자 역할**

public class OrderItem {
    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

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

    @ManyToOne(fetch = FetchType.LAZY) //orderItem이 다 관계
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice; //주문 당시 가격
    private int count; //주문 당시 수량

//    protected OrderItem() { //protected -> 생성자를 사용하지 말아라라고 말해주는 것임
//    }

    **//==생성 메서드==//
    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;
    }**

    **//==비즈니스 로직==//
    public void cancel() {
        getItem().addStock(count); //재고수량 원복e
    }

    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }**
}
  • @NoArgsConstructor
    • 외부에서 생성자를 통해 객체를 생성할 수 없도록 한다.
-> protected OrderItem(){}  의 역할 수행
  • 주문과 동시에 재고를 관리해야 한다.
    • item의 수량은 item의 비즈니스 로직을 이용하여 처리한다.

2-3) OrderRepository

@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);
    }

    //동적쿼리 (조건이 어느하나 빠질수도있음 -> 없으면 다 가져와)
    //1. JPQL을 string으로 하기
    //2. JPA에서 표준으로 동적쿼리를 작성할 수 있게 하는 것 -> But 유지보수가 안됨
    //3. 결론) QueryDSL을 쓰자!
		public List<Order> findAll(OrderSearch orderSearch) {
        ....
				return query.getResultList();
    }
}
  • QueryDSL 알아보기

2-4) OrderService

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

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

    /**
     * 주문
     */
    @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);
        //new Order(); -> 생성자에 오류가 남 -> noArgsConstructor를 사용했네? -> create메서드가 있구나

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

        return order.getId();
    }

    //취소
    @Transactional
    public void cancelOrder(Long orderId){
        //주문 탐색
        Order order = orderRepository.findOne(orderId);
        //주문 취소
        order.cancel(); //도메인 내부에서 비즈니스 로직 수행
    }

    //검색
    public List<Order> findOrders(OrderSearch orderSearch){
        return orderRepository.findAllByCriteria(orderSearch);
    }
}
  • 주문
    • 회원id, 상품id, 수량을 전달 받아 주문을 진행한다.
    • 각 repository에서 entity를 찾아야 한다.
  • cascade
    • 각 entity들의 라이프 사이클이 정확하게 동일하게 관리될 때만 사용한다.
      • 만약 delivery가 굉장히 중요한 정보라 다른 곳에서 참조해서 사용한다면, cascade를 사용할 때 order를 지울 때 delivery도 함께 지워진다.
@OneToMany(mappedBy = "order", **cascade = CascadeType.ALL**)
private List<OrderItem> orderItems = new ArrayList<>();

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

→ Order에서 OrderItem과 Delivery를 cascade하게 해두었기 때문에, Order만 저장하면 이 둘은 알아서 각 리포지터리에 저장된다.

  • 영속성 객체의 JPA의 자동 update쿼리
    • 영속성 객체 : repository에서 꺼내온 객체
    • JPA에서는 엔티티(객체) 자체의 데이터만 변경해주면 ‘변경내역감지'가 작동하여 update SQL 쿼리가 알아서 촥촥 날라간다.
@Transactional
public void cancelOrder(Long orderId){
    //주문 탐색
    Order order = orderRepository.findOne(orderId);
    //주문 취소
    order.cancel(); //도메인 내부에서 비즈니스 로직 수행
    //JPA에서는 엔티티 내부에서 데이터만 바꿔주면 변경내역감지가 작동하여 sql쿼리가 알아서 촥촥 날라간다.
    //여기다가 sql쿼리 블라블라 안적어도 됨
}

2-4. 도메인 모델 패턴

  • 비즈니스 로직의 대부분이 엔티티에 존재한다.
  • 서비스 계층은 단순히 위임의 역할을 수행한다.

2-5. OrderService 테스트

  • 테스트 요구사항
    • 상품 주문이 성공해야 한다.
    • 상품을 주문할 때 재고 수량을 초과하면 안된다.
    • 주문 취소가 성공해야 한다.
@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {

    @Autowired EntityManager em;
    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;
    @Test
    public void 상품주문() throws Exception{
        //given
        Member member = createMember();
        Book book = createBook("시골 JPA", 10000, 10);

        //when
        int orderCount = 2;
        Long orderId = orderService.order(member.getId(), book.getId(), orderCount);

        //then
        Order getOrder = orderRepository.findOne(orderId);
        assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus());
        assertEquals("주문한 삼품 종류 수가 정확해야 한다.",1,getOrder.getOrderItems().size());
        assertEquals("주문 가격은 가격 * 수량이다.",10000 * orderCount,getOrder.getTotalPrice());
        assertEquals("주문 수량만큼 재고가 줄어야 한다.",8,book.getStockQuantity());
    }

    @Test
    public void 주문취소() throws Exception{
        //given
        Member member = createMember();
        Book item = createBook("시골 JPA", 10000, 10);
        int orderCount = 2;
        Long orderId = orderService.order(member.getId(), item.getId(), orderCount);

        //when
        orderService.cancelOrder(orderId);

        //then
        Order getOrder = orderRepository.findOne(orderId);
        assertEquals("주문 취소시 상태는 CANCEL이다.",OrderStatus.CANCEL,getOrder.getStatus());
        assertEquals("주문 취소된 상품은 그만큼 재고가 증가해야 한다.",10,item.getStockQuantity());
    }

    //통합테스트도 의미가 있지만, Item의 removeStock을 테스트하는 unitTest가 더 중요하다.
    @Test(expected = NotEnoughStockException.class)
    public void 상품주문_재고수량초과() throws Exception{
        //given
        Member member = createMember();
        Book item = createBook("시골 JPA", 10000, 10);
        int orderCount = 11;

        //when
        orderService.order(member.getId(),item.getId(), orderCount);

        //then
        Assertions.fail("재고 수량 부족 예외가 발생해야 한다."); //위에서 예외가 터져서 여기를 오면 안됨
    }

		//Data setting
    private Book createBook(String name, int price, int stockQuantity) {
        Book book = new Book();
        book.setName(name);
        book.setPrice(price);
        book.setStockQuantity(stockQuantity);
        em.persist(book);
        return book;
    }

    private Member createMember() {
        Member member = new Member();
        member.setUsername("회원1");
        member.setAddress(new Address("서울","강가","123-123"));
        em.persist(member);
        return member;
    }
}

2-6. 주문 검색 기능 개발

  • 회원 이름, 상품 이름으로 검색하는 기능이 있다고 하자.
    • 이때 모든 정보를 다 입력하지 않아도 검색이 가능하다고 하면 동적으로 쿼리를 작성해야 한다.
    • ⇒ QueryDSL 사용
  • OrderSearch
    • 검색 조건 파라미터 클래스 생성
    • 검색에서 쓰이는 파라미터들만 받아오는 data를 작성한다.
    • Controller에서 OrderSearch를 사용하여 정보를 받아옴
@GetMapping("/orders")
public String orderList(**@ModelAttribute("orderSearch")OrderSearch orderSearch**, Model model){
    List<Order> orders = orderService.findOrders(orderSearch);
    model.addAttribute("orders",orders);
    return "order/orderList";
}
public List<Order> findOrders(OrderSearch orderSearch){
    return orderRepository.findAllByCriteria(orderSearch);
}
public List<Order> findAll(OrderSearch orderSearch) {
    //QueryDSL사용
		...
		return query.getResultList();
}
Comments