인프런/실전! 스프링부트와 JPA활용1

3) 웹 어플리케이션 개발 [회원,상품,주문 도메인]

backend dev 2024. 6. 7.

 

 

회원 기능

회원 등록

회원 조회

 

상품 기능

상품 등록

상품 수정

상품 조회

 

주문 기능

상품 주문

주문 내역 조회

주문 취소

 

예제를 단순화 하기 위해 다음 기능은 구현X

로그인과 권한 관리X

파라미터 검증과 예외 처리X

상품은 도서만 사용

카테고리는 사용X

배송 정보는 사용X

 

 

애플리케이션 아키텍처

 

 

계층형 구조 사용

controller, web: 웹 계층

service: 비즈니스 로직, 트랜잭션 처리

repository: JPA를 직접 사용하는 계층, 엔티티 매니저 사용

domain: 엔티티가 모여 있는 계층, 모든 계층에서 사용

 

패키지 구조

jpabook.jpashop

-domain

-exception

-repository

-service

-web

 

 

 

개발 순서: 서비스, 리포지토리 계층을 개발하고, 테스트 케이스를 작성해서 검증, 마지막에 웹 계층 적용


회원 리포지토리 코드

@Repository
public class MemberRepository {

    @PersistenceContext // 스프링이 생성한 엔티티매니저를 주입받기 위함.
    private EntityManager em;

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

기술 설명

@Repository : 스프링 빈으로 등록, JPA 예외를 스프링 기반 예외로 예외 변환

@PersistenceContext : 엔티티 메니저( EntityManager ) 주입

@PersistenceUnit : 엔티티 메니터 팩토리( EntityManagerFactory ) 주입 [ 받고싶다면 사용, 하지만 엔티티 매니저로 충분]

 

@Repository
@RequiredArgsConstructor
public class MemberRepository {
    
    private final EntityManager em; // 스프링이 엔티티매니저를 스프링빈으로 가지고있기 때문에 주입받을 수 있다.

생성자 주입으로 주입받는것이 더 간편하다. [ @PersistenceContext로 주입받아야하지만 @AutoWired로 받을 수 있게 스프링부트가 지원해준다.]

 

회원 서비스 코드

@Service
@Transactional(readOnly = true) // jpa 의 데이터변경이나 로직은 트랜잭션안에서 실행되어야한다 , 조회하는 로직에 readOnly=true 를 설정해두면 최적화로인해 성능향상
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional // 설정은 자세한것이 적용된다 즉 클래스레벨에 트랜잭션이있지만 메소드레벨에 있는 트랜잭션으로 적용된다. -> readOnly 설정이 적용안된다.
    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("이미 존재하는 회원입니다.");
        }
    }


    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

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

 

기술 설명

@Service

@Transactional : 트랜잭션, 영속성 컨텍스트

readOnly=true : 데이터의 변경이 없는 읽기 전용 메서드에 사용,

영속성 컨텍스트를 플러시 하지 않으므로 약간의 성능 향상(읽기 전용에는 다 적용)

데이터베이스 드라이버가 지원하면 DB에서 성능 향상

 

@Autowired

생성자 Injection 많이 사용, 생성자가 하나면 생략 가능

 

 

참고

실무에서는 검증 로직이 있어도 멀티 쓰레드 상황을 고려해서 회원 테이블의 회원명 컬럼에 유니크 제약 조건을

추가하는 것이 안전하다.

 

 

 

회원 기능 테스트

@SpringBootTest
@Transactional
class MemberServiceTest {

    @Autowired
    private MemberService memberService;

    @Autowired
    private MemberRepository memberRepository;

    @Test
    public void 회원가입() {
        //given
        Member member = new Member();
        member.setName("kim");

        //when
        Long savedId = memberService.join(member);
        // 로그상에서 insert 구문을 볼 수없는 이유는 ,커밋이 진행되지않기 때문이다. 커밋이 되어야 flush가 되어서 실제 쿼리가 실행된다.
        // insert 쿼리를 보고싶다면 1. @Rollback(false)를 붙여서 롤백안되게 하던지
        // 2. EntityManager를 가져와서 em.flush를 진행하면 된다. -> 최후에는 롤백이되긴함.

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

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

        Member member2 = new Member();
        member2.setName("kim");

        //when
        memberService.join(member1);

        //then
        Assertions.assertThatThrownBy(() -> memberService.join(member2)).isInstanceOf(IllegalStateException.class);

    }
}

 

@Transactional 

반복 가능한 테스트 지원, 각각의 테스트를 실행할 때마다 트랜잭션을 시작하고 테스트가 끝나면 트랜잭션을

강제로 롤백 (이 어노테이션이 테스트 케이스에서 사용될 때만 롤백)

 

 

참고 테스트 케이스 작성 고수 되는 마법

Given, When, Then (http://martinfowler.com/bliki/GivenWhenThen.html)

이 방법이 필수는 아니지만 이 방법을 기본으로 해서 다양하게 응용하는 것을 권장한다.

 

테스트 케이스를 위한 설정 : 메모리 데이터베이스 사용

 

테스트는 케이스 격리된 환경에서 실행하고, 끝나면 데이터를 초기화하는 것이 좋다.

그런 면에서 메모리 DB를 사용하 는 것이 가장 이상적이다.

추가로 테스트 케이스를 위한 스프링 환경과, 일반적으로 애플리케이션을 실행하는 환경은 보통 다르므로

설정 파일을 다르게 사용하자. 다음과 같이 간단하게 테스트용 설정 파일을 추가하면 된다.

 

 

스프링은 테스트용 h2 메모리 데이터베이스를 제공한다.

 

이제 테스트에서 스프링을 실행하면 이 위치에 있는 설정 파일을 읽는다.

(만약 이 위치에 없으면 src/resources/application.yml 을 읽는다.)

스프링 부트는 datasource 설정이 없으면, 기본적을 메모리 DB를 사용하고,

driver-class도 현재 등록된 라이브러를 보고 찾아준다.

추가로 ddl-auto 도 create-drop 모드로 동작한다. 따라서 데이터소스나, JPA 관련된 별도의 추가 설정을 하지 않아도 된다.

 

spring:
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:test

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:

        format_sql: true

logging:
  level:
    org.hibernate.SQL: debug

 

url: jdbc:h2:mem:test

url을 다음과 같이 설정하면 사용가능하다.

h2 공식문서

 

 

하지만

spring:
#  datasource:
#    driver-class-name: org.h2.Driver
#    url: jdbc:h2:mem:test
#
#  jpa:
#    hibernate:
#      ddl-auto: create
#    properties:
#      hibernate:
#
#        format_sql: true

logging:
  level:
    org.hibernate.SQL: debug

 

저런 설정없이도 기본적으로 스프링이 제공하므로 잘 동작한다. [ 별도의 설정이 없다면 메모리 모드로 돌리기 때문]

 

 

그리고 데이터베이스 스키마를 어떻게 관리할지 지정하는 옵션 ddl-auto 스프링부트의 기본값은 create-drop이다.

jpa:
  hibernate:
    ddl-auto: create-drop

 

 


상품 도메인 개발

상품 엔티티 코드 수정

@Entity
@Getter @Setter // 예제 코드이므로 Setter 추가
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype") // 가본값은 DTYPE
public abstract class Item {
    @Id
    @GeneratedValue
    @Column(name = "item_id") // 기본설정은 필드이름으므로 필드이름과 컬럼이름이 다르다면 설정해준다.
    private Long id;

    private String name;

    private int price;
    private int stockQuantity;

    @ManyToMany
    private List<Category> categories = new ArrayList<>();

    /*
    도메인 주도 설계(DDD)에서 엔티티 자체가 해결할 수 있는건 엔티티 안에 비즈니스로직을 넣는것이 좋다.
    stockQuantity를 관련해서 로직을 처리하는것이니까 stockQuantity를 가지고있는 Item에서 처리하는것이 객체 응집도가 좋다.

    +
    setter를 없애고 아래와 같이 핵심 비즈니스 로직을 통해서 값을 변경해야한다. 밖에서 필드값을 변경하는것이 아닌 엔티티 내부의 메소드를 이용하여 변경한다. 
     */
    // == 비즈니스 로직 == //
    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;
    }




}

 

비즈니스 로직 분석

addStock() 메서드는 파라미터로 넘어온 수만큼 재고를 늘린다.

이 메서드는 재고가 증가하거나 상품 주문을 취소해서 재고를 다시 늘려야 할 때 사용한다.

 

removeStock() 메서드는 파라미터로 넘어온 수만큼 재고를 줄인다. 만약 재고가 부족하면 예외가 발생한다. 주로 상품을 주문할 때 사용한다.

 

 

비즈니스 로직을 엔티티안에 만드는 이유 - GPT

비즈니스 로직을 엔티티 안에 적어야 하는 이유는 객체 지향 설계의 원칙 중 하나인 응집도(Cohesion)를 높이고, 엔티티가 자신의 상태와 행동을 책임지도록 하기 위함입니다. 도메인 주도 설계(DDD, Domain-Driven Design)에서도 이러한 접근 방식을 권장합니다. 다음은 비즈니스 로직을 엔티티 안에 작성하는 주요 이유들입니다:

1. 응집도(Cohesion) 증가
응집도는 클래스나 모듈 내의 구성 요소들이 얼마나 밀접하게 연관되어 있는지를 나타내는 개념입니다. 엔티티는 자신의 상태를 유지하고 변경하는 책임을 가지므로, 비즈니스 로직을 엔티티 내부에 두면 상태와 행동이 한 곳에 모여 응집도가 높아집니다. 응집도가 높은 코드는 이해하기 쉽고 유지보수가 용이합니다.

2. 객체 지향 설계의 원칙 준수
객체 지향 설계에서는 객체가 자신의 데이터를 가지고 있고, 그 데이터에 대한 행동도 함께 정의하는 것이 원칙입니다. 즉, 객체는 자신의 상태를 책임지고, 그 상태를 변경하는 행동(메서드)을 포함합니다. 예를 들어, Item 엔티티는 자신의 재고(stockQuantity)를 관리하므로, 재고를 추가하거나 감소시키는 로직은 Item 엔티티 내부에 있어야 합니다.

3. 캡슐화(Encapsulation)
캡슐화는 객체의 데이터를 외부에서 직접 접근하지 못하게 하고, 객체의 메서드를 통해서만 접근하게 하는 원칙입니다. 이를 통해 객체의 내부 상태를 보호하고, 데이터 무결성을 유지할 수 있습니다. 재고를 관리하는 로직이 Item 엔티티 내부에 있으면, 외부에서는 재고를 직접 수정할 수 없고, 항상 메서드를 통해서만 수정할 수 있습니다.

4. 도메인 주도 설계(DDD)의 원칙
도메인 주도 설계에서는 도메인 모델이 비즈니스 로직을 직접 포함하고, 도메인 객체들이 자신의 상태와 행동을 관리하는 것이 중요합니다. 이를 통해 도메인 모델이 비즈니스 요구사항을 더 잘 반영하고, 코드가 비즈니스 로직을 명확히 드러낼 수 있습니다.

 

 

 

상품 리포지토리 개발

@Repository
@RequiredArgsConstructor
public class ItemRepository {

    private final EntityManager em;

    public void save(Item item) {
        if (item.getId() == null) { // 엔티티의 id값이 없다는것은 아직 해당 엔티티가 저장이 안되어있다는것
            em.persist(item);
        } else {
            em.merge(item); // merge는 업데이트와 비슷한것, 뒤쪽에서 설명
        }
    }

    public Item findById(Long id) {
        return em.find(Item.class, id);
    }

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

기능 설명

save()

id 가 없으면 신규로 보고 persist() 실행

id 가 있으면 이미 데이터베이스에 저장된 엔티티를 수정한다고 보고, merge() 를 실행,

자세한 내용은 뒤에 웹에서 설명(그냥 지금은 저장한다 정도로 생각하자)

 

상품 서비스 개발

상품 서비스 코드

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {
    private final ItemRepository itemRepository;

    @Transactional // 조회가 아니므로 메소드레벨에 트랜잭션
    public void save(Item item) {
        itemRepository.save(item);
    }

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

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

 

 

주문 엔티티 개발

 

주문 엔티티 코드

 

@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_Id")
    private Long id;

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

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) // cascade로 Order를 통해 OrderItem의 생명주기까지 관여가능 + 라이프사이클이 비슷 + OrderItem의 단일사용자
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY, cascade =CascadeType.ALL) // delivery 랑 일대일이므로 라이프사이클이 비슷하니까 사용 + delivery의 단일사용자여야한다.
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate; // Localdate,localdatetime을 쓰면 날짜관련 어노테이션을 붙이지않아도 하이버네이트가 알아서해준다.

    @Enumerated(EnumType.STRING)
    private OrderStatus status; // 주문상태 -> order, cancel

    // 연관관계 편의 메소드 [ 양방향 연관일때 한쪽의 값만 변경시키지말고 양쪽 다 최신화를 시켜줘야한다, 사용할때마다 매번 DB에서 불러올수없으므로 -> 1차캐시에 저장되어있는 엔티티는 불러온때의 상태를 가지므로 -> 객체 또한 최신화 시켜줘야한다.]
    /*
    Setter 어노테이션을 사용하여 모든 필드에 대한 Setter 메소드를 생성하더라도, 특정 필드에 대한 Setter 메소드를 직접 구현하여 로직을 추가할 수 있다.
    그래서 해당 setter 메소드가 실행된다.
     */
    public void setMember(Member member) {
        this.member = member;
        member.getOrders().add(this);
    }

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

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

    /*
    order를 생성하기 위해서는 orderItem도 생성해야하고, delivery도 연관관계를 가져야하고 등 복잡하다. 이런 복잡한 생성에는 별도의 메서드가 존재하면 좋다.
     */
    //==생성 메소드==//
    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.setStatus(OrderStatus.ORDER);
        order.setOrderDate(LocalDateTime.now());
        return order;
    }

    //==비즈니스 로직==//
    /*
    주문 취소
     */
    public void cancel() {
        if(delivery.getStatus() == DeliveryStatus.COMP) { // 이미 배송완료라면
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }

        this.setStatus(OrderStatus.CANCEL); // 주문취소상태로 변경
        for(OrderItem orderItem : orderItems) { // 해당 주문의 OrderItem도 취소 처리 해줘야한다.
            orderItem.cancel();
        }
    }

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

}

기능 설명

생성 메서드( createOrder() )

주문 엔티티를 생성할 때 사용한다. 주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔티티를 생성한다.

 

주문 취소( cancel() )

주문 취소시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다.

만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.

 

전체 주문 가격 조회

주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다.

로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다.

(실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 한다.)

 

주문상품 엔티티 개발

 

주문상품 엔티티 코드

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


    //==비즈니스 로직==//
    public void cancel() {
        getItem().addStock(count);
    }

    //==조회 로직==//
    public int getTotalPrice() { // 주문상품 전체 가격 조회
        return getOrderPrice() * getCount();
    }
}

 

 

기능 설명

 

생성 메서드( createOrderItem() ): 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다.

그리고 item.removeStock(count) 를 호출해서 주문한 수량만큼 상품의 재고를 줄인다.

 

주문 취소( cancel() ): getItem().addStock(count) 를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킨다.

 

주문 가격 조회( getTotalPrice() ): 주문 가격에 수량을 곱한 값을 반환한다.

 

 

 

주문 리포지토리 개발

주문 리포지토리 코드 

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

    public List<Order> findAll() {
        return em.createQuery("select o from Order o", Order.class).getResultList();
    }

}

 

 

 

주문 서비스 개발

주문 서비스 코드

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

        // 주문상품 생성 -> 기본 생성자를 protected로 막고(@NoArgsConstructor(access = AccessLevel.PROTECTED), 정적 팩토리 메서드 패턴을 이용하여 생성메소드를 사용하게끔 유도해야한다. 기본생성자로 객체 생성시 객체 생성로직 바뀌면 전부 수정해야한다.
        OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

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

        // 주문 저장  -> Delivery , OrderItem 도 생성했으므로 save를 해줘야하지않나? -> Order안에 필드로 delivery , OrderItem이 있는데 Cascade 설정이 되어있으므로
        // Order가 생성될때 같이 생성된다.
        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.findAll(orderSearch);
//    }

}

 

주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검색 기능을

제공한다.

 

비즈니스 로직을 엔티티 내부에서 구현해서 사용할 수 있는이유는 

jpa의 변경감지(더티체크)기능 덕분이다. 마이바티스를 사용했더라면

엔티티의 변경이 있을경우 update쿼리를 날리기위해 어차피 필드값들을 가져와야하므로 

서비스상에서 비즈니스 로직을 짜는것이 나았을것이다.

jpa를 사용하므로 변경감지를 통해 update 쿼리없이 엔티티를 변경가능하다.

 

 

참고

예제를 단순화하려고 한 번에 하나의 상품만 주문할 수 있다.

 

주문( order() ): 주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장한다.

 

주문 취소( cancelOrder() ): 주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소를 요청 한다.

 

주문 검색( findOrders() ): OrderSearch 라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다.

자세한 내용은 다음에 나오는 주문 검색 기능에서 알아보자.

 

 

 

 

주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다.

 

서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.

이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라한다.

(https://martinfowler.com/eaaCatalog/domainModel.html)

 

반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것

트랜잭션 스크립트 패턴이라 한다.

(https://martinfowler.com/eaaCatalog/transactionScript.html)

 

일반적인 마이바티스,jdbcTemplate을 사용할때는 트랜잭션 스크립트 패턴을 많이사용했고

jpa를 사용하게 되면 도메인 모델 패턴을 많이 사용한다고 한다. 

 

한 프로젝트안에서도 도메인 모델 패턴과 트랜잭션 스크립트 패턴이 양립할 수 있다. 

현재 문맥에서 뭐가 더 맞는지 골라서 사용하면 된다.

 

주문 기능 테스트

@SpringBootTest
@Transactional
class OrderServiceTest {
    /*
    좋은 테스트란 DB,Spring없이 순수하게 그 함수를 단위테스트 할 수 있으면 좋은 테스트이지만
    여기서는 jpa와 잘 엮어서 동작하는것을 확인하기 위함.
     */
    @Autowired
    private EntityManager em;
    @Autowired
    private OrderService orderService;
    @Autowired
    private OrderRepository orderRepository;

    @Test
    public void 상품주문() {
        //given
        Member member = createMember();
        Book book = createBook("시골 JPA", 10000, 10);

        int orderCount =2;

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

        //then
        Order getOrder = orderRepository.findOne(orderId);

        Assertions.assertThat(getOrder.getStatus()).isEqualTo(OrderStatus.ORDER);
        Assertions.assertThat(getOrder.getOrderItems().size()).isEqualTo(1);
        Assertions.assertThat(getOrder.getTotalPrice()).isEqualTo(10000*orderCount);
        Assertions.assertThat(book.getStockQuantity()).isEqualTo(10-orderCount);

    }



    @Test
    public void 상품주문_재고수량초과() {
        //given
        Member member = createMember();
        Book book = createBook("시골 JPA", 10000, 10);
        int orderCount =11;
        //when

        //then
        Assertions.assertThatThrownBy(() -> orderService.order(member.getId(), book.getId(), orderCount)).isInstanceOf(NotEnoughStockException.class);


    }


    @Test
    public void 주문취소() {
        //given
        Member member = createMember();
        Book book = createBook("시골 JPA", 10000, 10);

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

        //then
        Order getOrder = orderRepository.findOne(orderId);
        Assertions.assertThat(getOrder.getStatus()).isEqualTo(OrderStatus.CANCEL);
        Assertions.assertThat(book.getStockQuantity()).isEqualTo(10);
    }


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

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

}

 

 

 

주문 검색 기능 개발

 

JPA에서 동적 쿼리를 어떻게 해결해야 하는가?

여러타입으로 검색을 할 수 있다.

 

JPQL로 처리

public List<Order> findAll(OrderSearch orderSearch) {
    //language=JPAQL
    String jpql = "select o From Order o join o.member m";
    boolean isFirstCondition = true;
    //주문 상태 검색
    if (orderSearch.getOrderStatus() != null) {
        if (isFirstCondition) {
            jpql += " where";
            isFirstCondition = false;
        } else {
            jpql += " and";
        }
        jpql += " o.status = :status";
    }
    //회원 이름 검색
    if (StringUtils.hasText(orderSearch.getMemberName())) {
        if (isFirstCondition) {
            jpql += " where";
            isFirstCondition = false;
        } else {
            jpql += " and";
        }
        jpql += " m.name like :name";
    }
    TypedQuery<Order> query = em.createQuery(jpql, Order.class)
            .setMaxResults(1000); //최대 1000건
    if (orderSearch.getOrderStatus() != null) {
        query = query.setParameter("status", orderSearch.getOrderStatus());
    }
    if (StringUtils.hasText(orderSearch.getMemberName())) {
        query = query.setParameter("name", orderSearch.getMemberName());
    }
    return query.getResultList();
}

사용 X

 

JPQL 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 충분히 발생할 수 있다.

 

JPA Criteria로 처리

public List<Order> findAllByCriteria(OrderSearch orderSearch) {
    CriteriaBuilder cb = em.getCriteriaBuilder();
    CriteriaQuery<Order> cq = cb.createQuery(Order.class);
    Root<Order> o = cq.from(Order.class);
    Join<Order, Member> m = o.join("member", JoinType.INNER); //회원과 조인
    List<Predicate> criteria = new ArrayList<>();
    //주문 상태 검색
    if (orderSearch.getOrderStatus() != null) {
        Predicate status = cb.equal(o.get("status"),
                orderSearch.getOrderStatus());
        criteria.add(status);
    }
    //회원 이름 검색
    if (StringUtils.hasText(orderSearch.getMemberName())) {
        Predicate name =
                cb.like(m.<String>get("name"), "%" + orderSearch.getMemberName()
                        + "%");
        criteria.add(name);
    }
    cq.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
    TypedQuery<Order> query = em.createQuery(cq).setMaxResults(1000); //최대 1000
    건
    return query.getResultList();
}

JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡하다.

결국 다른 대안이 필요하다. 많은 개발자가 비슷한 고민을 했지만,

가장 멋진 해결책은 Querydsl이 제시했다. Querydsl 소개장에서 간단히 언급하겠다.

지금은 이대로 진행하자.

 

댓글