[스프링부트 JPA 활용] 주문 검색 기능 개발

[스프링부트 JPA 활용] 주문 검색 기능 개발

애플리케이션 구현


목차#


  1. 회원 도메인 개발
  • 회원 리포지토리 개발
  • 회원 서비스 개발
  • 기능 테스트
  1. 상품 도메인 개발
  • 상품 엔티티개발(비즈니스 로직추가)
  • 상품 리포지토리 개발
  • 상품 서비스 개발
  1. 주문 도메인 개발
  • 주문, 주문상품 엔티티 개발
  • 주문 리포지토리 개발
  • 주문 서비스 개발
  1. 웹 계층 개발
  • 홈 화면과 레이아웃
  • 회원 등록
  • 회원 목록 조회
  • 상품 등록
  • 상품 목록
  • 상품 수정
  • 변경 감지와 병함(merge)
  • 상품 주문
  • 주문 목록 검색, 취소
  1. API 개발 기본
  • 회원 등록 API
  • 회원 수정 API
  • 회원 조회 API
  1. API 개발 고급
  • 조회용 샘플 데이터 입력
  • 지연 로딩과 조회 성능 최적화
  • 페이징과 한계 돌파
  • OSIV와 성능 최적화
  1. 다음으로
  • 스프링 데이터 JPA 소개
  • QueryDSL 소개
  • 마무리

주문 검색 기능 개발#


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


contact

화면을 보시면 회원명과 주문상태를 검색조건으로 필터링 할 수 있는 기능인 것을 확인 할 수 있습니다.
회원명이 있으면 회원에 대한 조건을, 상태를 선택하면 선택한 상태 조건을 동적으로 추가하고 제거해야 하기 때문에, 동적쿼리가 필요하게 됩니다.

이전에 OrderRepository에 틀을 만들었던 것을 채워보도록 하겠습니다.

검색 기능#

검색에 필요한

java/jpabook/jpashop/repository/OrderSearch.java

package jpabook.jpashop.repository;

import jpabook.jpashop.domain.OrderStatus;
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class OrderSearch {

    private String memberName; // 회원 이름
    private OrderStatus orderStatus; //주문 상태 [ORDER, CANCEL]

}

만약 항상 파라미터가 있다면, 아래와 같은 코드로 검색은 끝나게 됩니다.

OrderRepository.java

public List<Order> findAll(OrderSearch orderSearch){

        List<Order> findOrders = em.createQuery("select o from Order o left join o.member m" +
                        " where o.status = :status" +
                        " and m.name like :memberName" +
                        "", Order.class)
                .setParameter("status", orderSearch.getOrderStatus())
                .setParameter("memberName", orderSearch.getMemberName())
                .getResultList();

        return findOrders;
    }
검색 결과 수 제한#

OrderRepository.java

public List<Order> findAll(OrderSearch orderSearch){

        return em.createQuery("select o from Order o left join o.member m" +
                        " where o.status = :status" +
                        " and m.name like :name" +
                        "", Order.class)
                .setParameter("status", orderSearch.getOrderStatus())
                .setParameter("name", orderSearch.getMemberName())
                .setMaxResults(1000)  // 최대 1000건 조회
                .getResultList();
    }
페이징 처리#

OrderRepository.java

public List<Order> findAll(OrderSearch orderSearch){

        return em.createQuery("select o from Order o left join o.member m" +
                        " where o.status = :status" +
                        " and m.name like :name" +
                        "", Order.class)
                .setParameter("status", orderSearch.getOrderStatus())
                .setParameter("name", orderSearch.getMemberName())
                .setFirstResult(10)  // 10페이지 부터 
                .setMaxResults(1000) // 최대 1000건 조회
                .getResultList();
    }

만약 검색 파라미터가 없다고 한다면

OrderRepository.java

 public List<Order> findAll(OrderSearch orderSearch){

        return em.createQuery("select o from Order o left join o.member m" +
                 //       " where o.status = :status" +
                 //       " and m.name like :name" +
                        "", Order.class)
                //.setParameter("status", orderSearch.getOrderStatus())
                //.setParameter("name", orderSearch.getMemberName())
                .setFirstResult(10)
                .setMaxResults(1000)
                .getResultList();
    }

해당 소스 처럼 JPQL과 setParameter가 없으면 전체 조회가 될것 같습니다.

JPA가 아닌 마이바티스나 아이바티스는 동적쿼리에 대해 비교적 간편히 적용 할 수 있지만, JPA에서 동적쿼리를 어떻게 해야할지 고민에 빠지게 됩니다.

1. JPQL을 문자로 동적 쿼리 적용#


무식한 방법이지만, 동적 쿼리를 문자열로 만들어 조건에 따라 넣고 빼고 할 수 있지만 너무 복잡하고 조건이 많아진다면 유지보수는 불가능에 가까워지므로 이런 방법이 있다는 것만 확인 하고 넘어가겠습니니다.

OrderRepository.java

     /**
     *  String
     * */
    public List<Order> findAllByString(OrderSearch orderSearch){

        String jpql = "select o from Order o left 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);

        //주문 상태 검색 setParameter
        if(orderSearch.getOrderStatus() != null){
            query.setParameter("status", orderSearch.getOrderStatus());
        }

        //회원 이름 검색 setParameter
        if(StringUtils.hasText(orderSearch.getMemberName())){
            query.setParameter("name", orderSearch.getMemberName());
        }

        return query.getResultList();
    }

마이바티스, 아이바티스를 사용하는 이유가 이런 동적 쿼리를 편리하게 사용할 수 있기 때문입니다.

2. Criteria (JPA 표준) 동적 쿼리 적용#


JPA에서 동적 쿼리를 위해 표준으로 만들었지만 실무에서 사용하기 힘든 부분이 있어 이번에도 간략하게 보고 넘어가겠습니다.

OrderRepository.java

     /**
     *  JPA Criteria
     * */
    public List<Order> findAllByCriteria(OrderSearch orderSearch){

        CriteriaBuilder cb = em.getCriteriaBuilder(); //엔티티 매니저에서 CriteriaBuilder를 가져옴

        CriteriaQuery<Order> cq = cb.createQuery(Order.class); //CriteriaQuery 생성

        Root<Order> o = cq.from(Order.class); // o를 Alias로 Root 생성

        Join<Object, Object> m = o.join("member", JoinType.INNER); // m을 Alias로 join 한 Member 생성

        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);
        return query.getResultList();

    }

이전의 문자열로 만든 것보다 동적쿼리를 훨씬 좋은 방식으로 만들 수 있지만, 치명적인 단점을 가지고 있습니다.

작성된 Criteria 코드를 봐도 어떤 쿼리가 만들어지는지 한번에 알 수 없기 때문에 유지보수성에 큰 고통을 겪을 것입니다.

실무에서 운영 서비스에서 오류가 빵빵 나는데 쿼리를 열어 봤더니 저런 코드로 되어있다… 과연 빠르게 캐치하고 수정할 수 있을 것인가 고민해 보셔도 될것 같습니다.

3. Querydsl 동적 쿼리 적용#


이러한 불편한점들을 해결하기 위해 많은 개발자들이 고심한 끝에 Querydsl을 만들었습니다. 이후 더 자세히 다루도록 하겠습니다.

     /**
     *  Querydsl
     * */
    public List<Order> findAll(OrderSearch orderSearch){

        QOrder order = QOrder.order;
        QMember member = QMember.member;
        
        return query
        			.select(order)
        			.from(order)
        			.join(order.member, member)
        			.where(statusEq(orderSearch.getOrderStatus()),
        					nameLike(orderSearch.getMemberName()))
        			.limit(1000)
        			.fetch();
    }
    
    private BooleanExpression statusEq(OrderStatus statusCond){
    		if(statusCond == null){
    			return null;
    		}
    		return order.status.eq(statusCond);
    }
    
    private BooleanExpression nameLike(String nameCond){
    		if(!StringUtils.hasText(nameCond)){
    			return null;
    		}
    		return member.name.like(nameCond);
    }

이전 소스#


설정#

/main/resources/application.properties

application.properties
spring.devtools.restart.enabled=true
spring.devtools.restart.poll-interval=2s
spring.devtools.restart.quiet-period=1s
spring.thymeleaf.cache=false
spring.jpa.properties.hibernate.format_sql=true

main/resources/application.yml

application.yml
spring:
  datasource:
    url: jdbc:h2:tcp://localhost/~/jpashop; # MVCC=true H2 1.4.200 버전부터 MVCC 옵션이 제거되었습니다.
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop # 애플리케이션 동작 시점에 엔티티 재생성
      use_sql_comments: true
    database: h2

  devtools:
    livereload:
      enabled: true # livereload 사용시 활성화
    restart:
      enabled: false #운영 에서는 제거.

  thymeleaf:
    cache: false

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.type: trace #파라미터 로깅
    org.hibernate.type.descriptor.sql: trace

decorator:
  datasource:
    p6spy:
      enable-logging : true
      multiline: true
      logging: slf4j

test/resources/application.properties

application.properties
spring.devtools.restart.enabled=true
spring.devtools.restart.poll-interval=2s
spring.devtools.restart.quiet-period=1s
spring.thymeleaf.cache=false
spring.jpa.properties.hibernate.format_sql=true

test/resources/application.yml

application.yml
spring:
#  datasource:
  #    url: jdbc:h2:mem:test
  #    username: sa
  #    password:
  #    driver-class-name: org.h2.Driver
  #  jpa:
  #    hibernate:
  #      ddl-auto: create-drop # 애플리케이션 동작 시점에 엔티티 재생성
  #     use_sql_comments: true
  #   database: h2

  devtools:
    livereload:
      enabled: true # livereload 사용시 활성화
    restart:
      enabled: false #운영 에서는 제거.

  thymeleaf:
    cache: false

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.type: trace #파라미터 로깅
    org.hibernate.type.descriptor.sql: trace

decorator:
  datasource:
    p6spy:
      enable-logging : true
      multiline: true
      logging: slf4j

엔티티#

java/jpabook/jpashop/domain/Address.java

Address.java
package jpabook.jpashop.domain;

import lombok.Getter;

import javax.persistence.Embeddable;

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

}

java/jpabook/jpashop/domain/Order.java

Order.java
package jpabook.jpashop.domain;

import jpabook.jpashop.domain.item.Item;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "orders")
public class Order {

    //protected Order() {} //생성자를 사용 불가로 하고 CteateOrder 사용 유도 // @NoArgsConstructor로 대체

    @Id @GeneratedValue
    @Column(name="order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)  // ToOne은 fetch = FetchType.LAZY로 꼭 !!! 세팅
    @JoinColumn(name = "member_id") // Order의 member가 수정되면 Order의 외래키 값이 변경됩니다.
    private Member member;


    // mappedBy 연관관계의 주인인 OrderItem의 order로 매핑 되어있다는 뜻
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();


    // ToOne은 fetch = FetchType.LAZY로 꼭 !!! 세팅
    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate; //주문시간

    @Enumerated(EnumType.STRING) // EnumType.ORDINAL(숫자라 순서바뀌면 큰일)이 기본이지만 무조건 EnumType.STRING(문자 코드)
    private OrderStatus status; // 주문상태 [ORDER, CANCEL]


    //==연관관계 메서드 (양방향 연관관계시 추가)==//
    public void setMember(Member member){
        this.member = member;
        member.getOrders().add(this);
    }

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

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

    //== 생성 메서드==//
    public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems){ // OrderItem...  여러개를 넘길 수 있음

        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 : this.orderItems){
            orderItem.cancel();
        }
    }


    //==조회 로직==//
    /**
     * 전체 주문 가격 조회
     */
    public int getTotalPrice(){

        /*
        int totalPrice = 0;

        for (OrderItem orderItem : this.orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }

        return totalPrice;
        */


        return this.orderItems.stream()
                .mapToInt(OrderItem::getTotalPrice)
                .sum();
    }




}

java/jpabook/jpashop/domain/OrderItem.java

OrderItem.java
package jpabook.jpashop.domain;

import jpabook.jpashop.domain.item.Item;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
    
    //protected OrderItem() {} //생성자를 사용 불가로 하고 CteateOrderItem 사용 유도 //@NoArgsConstructor로 대체
    
    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY) // ToOne은 fetch = FetchType.LAZY로 꼭 !!! 세팅
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY) // ToOne은 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){ //orderPrice는 구매 당시의 가격을 받기 위함
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice);
        orderItem.setCount(count);

        item.removeStock(count);
        return orderItem;
    }


    //==비즈니스 로직==//
    /**
     * 주문 취소
     */
    public void cancel() {
        getItem().addStock(this.count);
    }


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

java/jpabook/jpashop/domain/OrderStatus.java

OrderStatus.java
package jpabook.jpashop.domain;

import jpabook.jpashop.domain.item.Item;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter @Setter
public class OrderItem {

    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY) // ToOne은 fetch = FetchType.LAZY로 꼭 !!! 세팅
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY) // ToOne은 fetch = FetchType.LAZY로 꼭 !!! 세팅
    @JoinColumn(name = "order_id")
    private Order order;

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

java/jpabook/jpashop/domain/Delivery.java

Delivery.java
package jpabook.jpashop.domain;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Entity
@Getter @Setter
public class Delivery {

    public Delivery() {
    }

    @Id @GeneratedValue
    @Column(name = "delivery_id")
    private Long id;

    @OneToOne(fetch = FetchType.LAZY  // ToOne은 fetch = FetchType.LAZY로 꼭 !!! 세팅
            , mappedBy = "delivery")
    private Order order;

    @Embedded
    private Address address;

    @Enumerated(EnumType.STRING)
    private DeliveryStatus status; //READY, COMP

}

java/jpabook/jpashop/domain/item/Item.java

Item.java
package jpabook.jpashop.domain.item;

import jpabook.jpashop.exception.NotEnoughStockException;
import jpabook.jpashop.domain.Category;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {

    @Id @GeneratedValue
    @Column(name = "item_id")
    private Long id;

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

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

    //==비즈니스 로직==//

    /**
     * 재고 증가
     * @param quantity
     */
    public void addStock(int quantity){
        this.stockQuantity += quantity;
    }

    /**
     * 재고 감소
     * @param quantity
     */
    public void removeStock(int quantity){
        int restStock = this.stockQuantity - quantity;

        if (restStock < 0) {
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restStock;
    }
}

java/jpabook/jpashop/domain/item/Album.java

Album.java
package jpabook.jpashop.domain.item;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("A") //구분값 A
@Getter @Setter
public class Album extends Item{
    private String artist;
    private String etc;
}

java/jpabook/jpashop/domain/item/Book.java

Book.java
package jpabook.jpashop.domain.item;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("B") //구분값 B
@Getter @Setter
public class Book extends Item{
    private String author;
    private String isbn;
}

java/jpabook/jpashop/domain/item/Movie.java

Movie.java
package jpabook.jpashop.domain.item;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("M") //구분값 M
@Getter @Setter
public class Movie extends Item{
    private String director;
    private String actor;
}

java/jpabook/jpashop/domain/Category.java

Category.java
package jpabook.jpashop.domain;

import jpabook.jpashop.domain.item.Item;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Getter @Setter
public class Category {

    @Id @GeneratedValue
    @Column(name = "category_id")
    private Long id;

    private String name;

    @ManyToMany
    @JoinTable(name = "category_item"
            , joinColumns = @JoinColumn(name = "category_id")
            , inverseJoinColumns = @JoinColumn(name = "item_id")
    )
    private List<Item> items = new ArrayList<>();

    @ManyToOne(fetch = FetchType.LAZY)  // ToOne은 fetch = FetchType.LAZY로 꼭 !!! 세팅
    @JoinColumn(name = "parent_id")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    private List<Category> child = new ArrayList<>();

    //==연관관계 메서드 (양방향 연관관계시 추가)==//
    public void addChildCategory(Category child){
        this.child.add(child);
        child.setParent(this);
    }
}

도메인#

java/jpabook/jpashop/repository/MemberRepository.java

MemberRepository.java
package jpabook.jpashop.repository;

import jpabook.jpashop.domain.Member;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class MemberRepository {

    /*
    //최초 소스이며 아래 소스로 대체
    @PersistenceContext // EntityManager는 @PersistenceContext라는 표준 어노테이션을 통해서만 가능 (@AutoWired 불가)
    private EntityManager em;
    */

    /*
    //2번째 버전의 소스이며, @RequiredArgsConstructor로 대체
    @Autowired //스프링 DATA JPA 에서 지원
    private EntityManager em;

    public MemberRepository(EntityManager em){
        this.em = em;
    }
    */

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

}

java/jpabook/jpashop/service/MemberService.java

MemberService.java
package jpabook.jpashop.service;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;



@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor  // 생성자 주입
public class MemberService {

    /*
    // 최초 코드 이며, Setter Injection로 대체
    @Autowired
    private MemberRepository memberRepository;
    */

    /*
    //Constructor Injection로 대체
    private MemberRepository memberRepository;

    public void setMemberService(MemberRepository memberRepository) { //Setter Injection
        this.memberRepository = memberRepository;
    }
    */

    /*
    // @RequiredArgsConstructor로 대체
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) { //Constructor Injection
        this.memberRepository = memberRepository;
    }
    */

    private final MemberRepository memberRepository;

    /**
     * 회원 가입
     */
    @Transactional(readOnly = false)
    public Long join(Member member){
        validateDuplicateMember(member); //중복 회원 검증
        memberRepository.save(member);
        return member.getId(); //save()를 통해 em.persist()를 수행하므로 Member 엔티티의 키 생성을 보장함
    }

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

    }

    /**
     * 회원 전체 조회
     */
    //@Transactional(readOnly = true)
    public List<Member> findMembers(){
        return memberRepository.findAll();
    }

    /**
     * 회원 조회
     */
    //@Transactional(readOnly = true)
    public Member findOne(Long memberId){
        return memberRepository.findOne(memberId);
    }

}

java/jpabook/jpashop/repository/ItemRepository.java

ItemRepository.java
package jpabook.jpashop.repository;

import jpabook.jpashop.domain.item.Item;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

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

java/jpabook/jpashop/service/ItemService.java

ItemService.java
package jpabook.jpashop.service;

import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.repository.ItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor  // 생성자 주입
public class ItemService {

    private final ItemRepository itemRepository;

    @Transactional
    public Item saveItem(Item item){
        itemRepository.save(item);
        return item; //등록한 엔티티 정보 리턴, api response 리턴 및 test code 검증용
    }

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

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

}

java/jpabook/jpashop/repository/OrderRepository.java

OrderRepository.java
package jpabook.jpashop.repository;

import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

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

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

    //public List<Order> findAll(OrderSearch orderSearch){}
}

java/jpabook/jpashop/service/OrderService.java

OrderService.java
package jpabook.jpashop.service;

import jpabook.jpashop.domain.*;
import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.repository.ItemRepository;
import jpabook.jpashop.repository.MemberRepository;
import jpabook.jpashop.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@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());
        delivery.setStatus(DeliveryStatus.READY);

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

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

        // 주문 저장
        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.notifyAll(orderSearch);
    }*/

}

Exception#

java/jpabook/jpashop/exception/NotEnoughStockException.java

NotEnoughStockException.java
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);
    }
}

테스트#

test/java/jpabook/jpashop/service/MemberServiceTest.java

MemberServiceTest.java
package jpabook.jpashop.service;

import static org.junit.jupiter.api.Assertions.*;

import jpabook.jpashop.domain.Member;
import jpabook.jpashop.repository.MemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;

@SpringBootTest
@Transactional
class MemberServiceTest {

    // 테스트 케이스에서는 다른곳에서 참조할 곳이 없으므로 @Autowired로 사용
    @Autowired MemberRepository memberRepository;
    @Autowired MemberService memberService;
    @Autowired EntityManager em;

    @Test
    //@Rollback(value = false)
    public void 회원가입() throws Exception{
        //given //given : 이렇게 주어졌을때
        Member member = new Member();
        member.setName("userA");
        
        //when //when : 이렇게 하면
        Long savedId = memberService.join(member);

        //then //then : 이렇게 된다.
        // JPA안에서 하나의 트랜잭션에서 같은 엔티티에서 PK 키가 같으면 같은 영속성 컨텍스트 1차 캐시로 같은 객체로 관리
        em.flush();
        assertEquals(member, memberRepository.findOne(savedId));
    }


    @Test
    public void 중복_회원_예외() throws Exception{
        //given

        String username = "user";
        Member member1 = new Member();
        member1.setName(username);

        Member member2 = new Member();
        member2.setName(username);

        //when
        memberService.join(member1);

        //then
        IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> memberService.join(member2));

    }
}

test/java/jpabook/jpashop/service/ItemServiceTest.java

ItemServiceTest.java
package jpabook.jpashop.service;

import static org.junit.jupiter.api.Assertions.*;

import jpabook.jpashop.domain.item.Album;
import jpabook.jpashop.domain.item.Book;
import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.domain.item.Movie;
import jpabook.jpashop.repository.ItemRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;

@SpringBootTest
@Transactional
class ItemServiceTest {
    // 테스트 케이스에서는 다른곳에서 참조할 곳이 없으므로 @Autowired로 사용
    @Autowired ItemRepository itemRepository;
    @Autowired ItemService itemService;
    @Autowired EntityManager em;

    @Test
    public void 음반_상품등록() throws Exception{
        //given
        Item item = new Album();
        item.setName("멜론 TOP 100");
        ((Album) item).setArtist("Various Artists");
        ((Album) item).setEtc("방탄소년단 외 다수");
        item.setPrice(20000);
        item.addStock(50);

        //when
        Item savedItem = itemService.saveItem(item);

        //then
        em.flush();
        assertEquals(item, itemRepository.findOne(savedItem.getId()));
    }
    
    @Test
    public void 책_상품등록() throws Exception{
        //given
        Item item = new Book();
        item.setName("JPA BOOK");
        ((Book) item).setAuthor("김영한");
        ((Book) item).setIsbn("11111");
        item.setPrice(15000);
        item.addStock(100);

        //when
        Item savedItem = itemService.saveItem(item);

        //then
        em.flush();
        assertEquals(item, itemRepository.findOne(savedItem.getId()));
    }
    
    @Test
    public void 영화_상품등록() throws Exception{
        //given
        Item item = new Movie();
        item.setName("쥬라기월드: 도미니언");
        ((Movie) item).setDirector("콜린 트레보로우");
        ((Movie) item).setActor("크리스 프랫");
        item.setPrice(15000);
        item.addStock(1000);

        //when
        Item savedItem = itemService.saveItem(item);

        //then
        em.flush();
        assertEquals(item, itemRepository.findOne(savedItem.getId()));
    }
}

test/java/jpabook/jpashop/service/OrderServiceTest.java

OrderServiceTest.java
package jpabook.jpashop.service;

import jpabook.jpashop.exception.NotEnoughStockException;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.domain.item.Book;
import jpabook.jpashop.domain.item.Item;
import jpabook.jpashop.repository.ItemRepository;
import jpabook.jpashop.repository.MemberRepository;
import jpabook.jpashop.repository.OrderRepository;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@Transactional
class OrderServiceTest {

    @Autowired ItemRepository itemRepository;
    @Autowired MemberRepository memberRepository;
    @Autowired OrderRepository orderRepository;
    @Autowired OrderService orderService;
    @Autowired EntityManager em;

    @Test
    public void 상품주문() throws Exception{
        //given
        Member member = createMember(); //회원1 생성

        int stockQuantity = 10;
        Book book = createBook("JPA 기본서",100000,stockQuantity);

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

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

        assertEquals( OrderStatus.ORDER, getOrder.getStatus(), "상품 주문시 상태는 ORDER");
        assertEquals( 1, getOrder.getOrderItems().size(), "주문한 상품 종류 수가 일치해야한다.");
        assertEquals( 100000 * orderCount, getOrder.getTotalPrice(), "주문 가격은 가격 * 수량이다.");
        assertEquals( stockQuantity-orderCount, book.getStockQuantity(), "주문 수량만큼 재고가 줄어야 한다.");

    }

    @Test
    public void 상품주문_재고수량초과() throws Exception{
        //given
        Member member = createMember(); //회원1 생성

        int stockQuantity = 10;
        Item item = createBook("JPA 기본서",100000,stockQuantity);

        //when
        int orderCount = 11;

        assertThrows(NotEnoughStockException.class, () -> {
            orderService.order(member.getId(), item.getId(), orderCount);
        });

        //then
        //fail("재고 수량 부족 예외가 발생해야 한다.");

    }

    @Test
    public void 주문취소() throws Exception{
        //given
        Member member = createMember(); //회원1 생성

        int stockQuantity = 10;
        Item item = createBook("JPA 기본서",100000,stockQuantity);

        int orderCount = 2;

        Long orderId = orderService.order(member.getId(), item.getId(), orderCount);

        //when
        orderService.cancelOrder(orderId);


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


    }

    private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울", "강변로", "123-123"));
        em.persist(member);
        return member;
    }
    
    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;
    }
}

참고#