Skip to main content

Command Palette

Search for a command to run...

Jpa와 도메인 모델 패턴 예시

Updated
•8 min read•View as Markdown
Jpa와 도메인 모델 패턴 예시
  • 주문, 주문상품 엔티티 개발

    • 주문 엔티티 코드

      package jpabook.jpashop.domain;
      
      import static javax.persistence.FetchType.*;
      
      import java.time.LocalDateTime;
      import java.util.ArrayList;
      import java.util.Arrays;
      import java.util.List;
      
      import javax.persistence.CascadeType;
      import javax.persistence.Column;
      import javax.persistence.Entity;
      import javax.persistence.EnumType;
      import javax.persistence.Enumerated;
      import javax.persistence.GeneratedValue;
      import javax.persistence.Id;
      import javax.persistence.JoinColumn;
      import javax.persistence.ManyToOne;
      import javax.persistence.OneToMany;
      import javax.persistence.OneToOne;
      import javax.persistence.Table;
      
      import lombok.AccessLevel;
      import lombok.Getter;
      import lombok.NoArgsConstructor;
      import lombok.Setter;
      
      @Entity
      @Table(name = "orders")
      @Getter
      @Setter
      @NoArgsConstructor(access = AccessLevel.PROTECTED) //static 생성 메서드 사용 강제를 위해 설정
      public class Order {
      
        @Id
        @GeneratedValue
        @Column(name = "order_id")
        private Long id;
      
        @ManyToOne(fetch = LAZY) //default가 EAGER
        @JoinColumn(name = "member_id")
        private Member member;
      
        //JPQL select o From order o; -> SQL select * from order (order를 조회할 때 멤버를 중복 조회하면 n+1 문제!)
      
        @OneToMany(mappedBy = "order", // default가 LAZY
            cascade = CascadeType.ALL) // 관련된 update(persist) 전파
        private List<OrderItem> orderItems = new ArrayList<>();
      
        @OneToOne(fetch = LAZY, cascade = CascadeType.ALL)
        @JoinColumn(name = "delivery_id")
        private Delivery delivery;
      
        private LocalDateTime orderDate; //주문시간
      
        @Enumerated(EnumType.STRING)
        private OrderStatus status; //주문상태 [ORDER, CANCEL]
      
        //==연관관계 편의 메서드==//
        //DB에서는 연관관계 주인 기준으로 단방향 매핑되지만, 어플리케이션에서 논리적으로 양방향 연관관계 매핑해기 위해 정의
        //항상 직접 비즈니스 로직에서 매핑해주기보다 Entity에서 정의
        //두 연관관계 중 자주 조회하는 기준으로 정의
        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);
        }
      
        //==생성 메서드==//
        //객체끼리 연관되어 있는 객체의 생성은 묶어서 별도로 만들어주면 좋다
        public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
            Order order = new Order();
            order.setMember(member);
            order.setDelivery(delivery);
            Arrays.stream(orderItems).forEach(order::addOrderItem);
            order.setStatus(OrderStatus.ORDER);
            order.setOrderDate(LocalDateTime.now());
            return order;
        }
      
        //==비즈니스 로직==//
      
        /**
         * 주문 취소
         */
        public void cancel() {
            if (this.delivery.getStatus() == DeliveryStatus.COMP) {
                throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
            }
            this.setStatus(OrderStatus.CANCEL);
            this.orderItems.forEach(OrderItem::cancel);
        }
      
        //==조회 로직==//
      
        /**
         * 전체 주문 가격 조회
         */
        public int getTotalPrice() {
            return this.orderItems.stream()
                .mapToInt(OrderItem::getTotalPrice)
                .sum();
        }
      }
      
    • 기능설명

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

      • 주문 취소( cancel() ): 주문 취소시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린 다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.

      • 전체 주문 가격 조회: 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다. (실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 한다.)

  • 주문상품 엔티티 코드

      package jpabook.jpashop.domain;
    
      import static javax.persistence.FetchType.*;
    
      import javax.persistence.Column;
      import javax.persistence.Entity;
      import javax.persistence.GeneratedValue;
      import javax.persistence.Id;
      import javax.persistence.JoinColumn;
      import javax.persistence.ManyToOne;
    
      import jpabook.jpashop.domain.item.Item;
      import lombok.AccessLevel;
      import lombok.Getter;
      import lombok.NoArgsConstructor;
      import lombok.Setter;
    
      @Entity
      @Getter
      @Setter
      @NoArgsConstructor(access = AccessLevel.PROTECTED) //static 생성 메서드 사용 강제를 위해 설정
      public class OrderItem {
    
          @Id
          @GeneratedValue
          @Column(name = "order_item_id")
          private Long id;
    
          @ManyToOne(fetch = LAZY)
          @JoinColumn(name = "item_id")
          private Item item;
    
          @ManyToOne(fetch = 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(this.count);
          }
    
          //==조회 로직==//
    
          /**
           * 주문상품 전체 가격 조회
           */
          public int getTotalPrice() {
              return getOrderPrice() * getCount();
          }
      }
    
    • 기능설명

      • 기능 설명 생성 메서드( createOrderItem() ): 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다. 그리고 item.removeStock(count) 를 호출해서 주문한 수량만큼 상품의 재고를 줄인다.

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

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

  • 주문 리포지토리 개발

    • 주문 리포지토리 코드

      package jpabook.jpashop.repository;
      
      import javax.persistence.EntityManager;
      
      import org.springframework.stereotype.Repository;
      
      import jpabook.jpashop.domain.Order;
      import lombok.RequiredArgsConstructor;
      
      @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(OrderSearch orderSearch) {
        //
        // }
      }
      
  • 주문 서비스 개발

    • 주문 서비스 코드

      package jpabook.jpashop.service;
      
      import org.springframework.stereotype.Service;
      import org.springframework.transaction.annotation.Transactional;
      
      import jpabook.jpashop.domain.Delivery;
      import jpabook.jpashop.domain.Member;
      import jpabook.jpashop.domain.Order;
      import jpabook.jpashop.domain.OrderItem;
      import jpabook.jpashop.domain.item.Item;
      import jpabook.jpashop.repository.ItemRepository;
      import jpabook.jpashop.repository.MemberRepository;
      import jpabook.jpashop.repository.OrderRepository;
      import lombok.RequiredArgsConstructor;
      
      @Service
      @Transactional(readOnly = true)
      @RequiredArgsConstructor
      public class OrderService {
      
        private final OrderRepository orderRepository;
        private final MemberRepository memberRepository;
        private final ItemRepository itemRepository;
      
        /**
         * 주문
         */
        @Transactional
        //Order 엔티티에서 cascade = CascadeType.ALl이라 persist 전파 됨
        //따라서 order만 save해도 orderItem, delivery 자동 persist
        //연관된 객체를 나만 참조할 때 cascade를 사용할만함(그게 아니면 cascade 사용에 매우 주의해야 함!!)
        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();
        }
      
        /**
         * 취소
         */
        @Transactional
        public void cancelOrder(Long orderId) {
            //주문 엔티티 조회
            Order order = orderRepository.findOne(orderId);
            //주문 취소
            order.cancel();
        }
      
        //검색
        // public List<Order> findOrders(OrderSearch orderSearch) {
        //     return orderRepository.findAll(orderSearch);
        // }
      }
      
    • 주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검색 기능을 제공한다.

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

    • 기능설명

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

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

      • 주문 검색( findOrders() ): OrderSearch 라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다. 자세한 내용은 다음에 나오는 주문 검색 기능에서 알아보자.

    • 참고: 주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.

  • 주문 기능 테스트

    • 상품 주문 테스트 코드

    • 재고 수량 초과 테스트 코드

    • 주문 취소 테스트 코드

      package jpabook.jpashop.service;
      
      import static org.junit.Assert.*;
      
      import javax.persistence.EntityManager;
      
      import org.junit.Test;
      import org.junit.runner.RunWith;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.boot.test.context.SpringBootTest;
      import org.springframework.test.context.junit4.SpringRunner;
      import org.springframework.transaction.annotation.Transactional;
      
      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.exception.NotEnoughStockException;
      import jpabook.jpashop.repository.OrderRepository;
      
      //단위테스트가 좋지만 JPA 강의니까 DB와 연동해서 테스트 코드 작성
      @RunWith(SpringRunner.class)
      @SpringBootTest
      @Transactional
      public class OrderServiceTest {
      
        @Autowired
        EntityManager em;
      
        @Autowired
        OrderService orderService;
        @Autowired
        OrderRepository orderRepository;
      
        @Test
        public void 상품주문() throws Exception {
            //given
            final int ORDER_COUNT = 2;
            final int BOOK_PRICE = 10000;
            final int BOOK_STOCK_QUANTITY = 10;
      
            Member member = createMember();
            Book book = createBook("JPA", BOOK_PRICE, BOOK_STOCK_QUANTITY);
      
            //when
            Long orderId = orderService.order(member.getId(), book.getId(), ORDER_COUNT);
      
            //then
            Order getOrder = orderRepository.findOne(orderId);
      
            assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus());
            assertEquals("주문한 상품 종류 수가 정확해야 한다.", 1, getOrder.getOrderItems().size());
            assertEquals("주문 가격은 가격 * 수량이다.", BOOK_PRICE * ORDER_COUNT, getOrder.getTotalPrice());
            assertEquals("주문 수량만큼 재고가 줄어야 한다.", BOOK_STOCK_QUANTITY - ORDER_COUNT, book.getStockQuantity());
        }
      
        @Test(expected = NotEnoughStockException.class)
        public void 상품주문_재고수량초과() throws Exception {
            //given
            final int ORDER_COUNT = 11;
            final int BOOK_PRICE = 10000;
            final int BOOK_STOCK_QUANTITY = 10;
      
            Member member = createMember();
            Item item = createBook("JPA", BOOK_PRICE, BOOK_STOCK_QUANTITY);
      
            //when
            orderService.order(member.getId(), item.getId(), ORDER_COUNT);
      
            //then
            fail("재고 수량 부족 예외가 발생해야 한다.");
        }
      
        @Test
        public void 주문취소() throws Exception {
            //given
            final int ORDER_COUNT = 2;
            final int BOOK_PRICE = 10000;
            final int BOOK_STOCK_QUANTITY = 10;
      
            Member member = createMember();
            Item item = createBook("JPA", BOOK_PRICE, BOOK_STOCK_QUANTITY);
            Long orderId = orderService.order(member.getId(), item.getId(), ORDER_COUNT);
      
            //when
            orderService.cancelOrder(orderId);
      
            //then
            Order getOrder = orderRepository.findOne(orderId);
      
            assertEquals("주문 취소시 상태는 CANCLE이다", OrderStatus.CANCEL, getOrder.getStatus());
            assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다.", BOOK_STOCK_QUANTITY, item.getStockQuantity());
        }
      
        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.setName("회원1");
            member.setAddress(new Address("수원", "영통", "100"));
            em.persist(member);
            return member;
        }
      }
      
  • 주문 검색 기능 개발

    • 주문 검색 기능 코드

      package jpabook.jpashop.repository;
      
      import java.util.ArrayList;
      import java.util.List;
      
      import javax.persistence.EntityManager;
      import javax.persistence.TypedQuery;
      import javax.persistence.criteria.CriteriaBuilder;
      import javax.persistence.criteria.CriteriaQuery;
      import javax.persistence.criteria.Join;
      import javax.persistence.criteria.JoinType;
      import javax.persistence.criteria.Predicate;
      import javax.persistence.criteria.Root;
      
      import org.springframework.stereotype.Repository;
      import org.springframework.util.StringUtils;
      
      import jpabook.jpashop.domain.Member;
      import jpabook.jpashop.domain.Order;
      import lombok.RequiredArgsConstructor;
      
      @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);
        }
      
        //JPQL로 처리(실무에서는 Querydsl 사용)
        public List<Order> findAllByString(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();
        }
      
        //JPA Criteria로 처리(실무에서는 Querydsl 사용)
        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 소개장에서 간 단히 언급하겠다. 지금은 이대로 진행하자.

    • 참고: JPA Criteria에 대한 자세한 내용은 자바 ORM 표준 JPA 프로그래밍 책을 참고하자

  • 출처

More from this blog

[프론트] 노코드 툴 FlutterFlow로 개발하기

노코드 툴 사용 이유 프론트엔드에 적합하다 판단 앱을 개발하기 위해서는 사용자가 조작할 프론트와 기능 구현을 위한 백엔드가 모두 필요하다. 원하는 기능을 자세하게 구현하기 위해서는 코드를 작성해야한다. LLM이 더 발달하여 자연어만으로 구체적인 기능을 노코드로 개발하게 될 수도 있다. 하지만 아직 포트폴리오 리밸런싱 알고리즘 같은 기능은 코드로 구현하고 이해해야 한다. 반면 사용자가 조작할 화면은 노코드로 개발하기 적합하다고 판단했다. ...

May 28, 20241 min read
[프론트] 노코드 툴 FlutterFlow로 개발하기

부처님오신날 대체공휴일 쉬면서 떠오른 소소한 영감 기록

전환형 인턴 전반전이 끝나고 푹 쉬다 보니 소소하게 영감이 떠오른다. 정신 없이 개발만 집중하다가 확실히 여유로우니까 여러 생각이 떠오르는 것 같다. 보통 이동할 때 지하철 타고 가면서 코딩하는데, 오랜만에 버스 타고 바깥 풍경을 보면서 유튜브를 듣고 있다. 가만 보니 산은 경사져서 사람이 살기가 힘들다. 에너지적으로 xy축으로 같은 거리를 이동하는데 z축으로 위치 에너지가 계속 바뀌고 사람은 인체적으로 올라갈 때나 내려갈 때 모두 에너지를 소...

May 29, 20232 min read

로그인 처리2(서블릿 필터, 스프링 인터셉터, 로깅, @Login)

서블릿 필터 - 소개 공통 관심 사항 요구사항을 보면 로그인 한 사용자만 상품 관리 페이지에 들어갈 수 있어야 한다 앞에서 로그인을 하지 않은 사용자에게는 상품 관리 버튼이 보이지 않기 때문에 문제가 없어 보인다 그런데 문제는 로그인 하지 않은 사용자도 다음 URL을 직접 호출하면 상품 관리 화면에 들어갈 수 있다는 점이다 상품 관리 컨트롤러에서 로그인 여부를 체크하는 로직을 하나하나 작성하면 되겠지만, 등록, 수정, 삭제, 조회 등등 ...

Mar 24, 202312 min read
로그인 처리2(서블릿 필터, 스프링 인터셉터, 로깅, @Login)

로그인 처리1(쿠키, 세션)

로그인 처리하기 - 쿠키 사용 쿠키 서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하자 그러면 브라우저는 앞으로 해당 쿠키를 지속해서 보내준다 쿠키 종류 영속 쿠키 만료 날짜를 입력하면 해당 날짜까지 유지 세션 쿠키 만료 날짜를 생략하면 브라우저 종료시 까지만 유지 LoginController - login() @PostMapping("/login") public String l...

Mar 24, 20237 min read
로그인 처리1(쿠키, 세션)

배우고, 기록하고, 적용하기

55 posts