Spring/JPA

[JPA] 도메인 개발

lumana 2024. 10. 4. 23:55

 

JPA 개발 - 도메인 개발

#Spring/DB/JPA


회원 도메인


회원 리포지토리

package jpabook.jpashop.repository;

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jpabook.jpashop.domain.Member;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class MemberRepository {

    private final EntityManager em; // 스프링이 em을 주입

    public void save(Member member) {
        em.persist(member);
    }
    
    // 단건 조회
    public Member findOne(Long id) {
        return em.find(Member.class, id); // 타입, PK
    }

    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
@RequiredArgsConstructor
public class MemberRepository {

    private final EntityManager em; // 스프링이 em을 주입

원래는 @PersistenceContext를 EntityManager에 달아줘야 하지만, 스프링 부트와 Spring Data JPA를 사용해주면 @Autowired가 em을 주입해준다. (EntityManagerFactory도 주입이 가능하다.)
물론 여기선 롬복 라이브러리 @RequiredArgsConstructor가 Autowired 역할을 해주고 있는 것


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

영속성 컨텍스트에 멤버 객체가 올라간다. 이 때 Key와 Value에서 key는 pk가 된다(@GeneratedValue).
아직 DB에 들어가지 않아도 ID값이 생성됨이 보장된다.


참고 | member 객체가 생성된 직후 ID 필드는 null이다.

GenerationType.SEQUENCE을 사용하면 시퀀스에서 ID 값을 가져와 persist할 때 ID를 할당한다. GenerationType.TABLE을 사용하면 테이블에서 ID 값을 가져와 persist할 때 ID를 할당한다.

디폴트(GenerationType.IDENTITY)를 사용하면 flush 또는 트랜잭션 커밋 시에 ID가 할당된다.


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

jpql과 sql의 차이점 중 하나로 from 뒤에 테이블이 아니라 엔티티를 적어준다.
jpql과 반환타입을 넘겨줘서 쿼리를 생성한다.


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

sql은 테이블을 대상으로 쿼리를 날리지만, jpql은 엔티티 객체를 대상으로 쿼리를 날린다.
setParamenter("name", name)는 jpql에 있는 :name과 파라미터로 받은 name을 바인딩 해준다.


회원 서비스 개발

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 {

    private final MemberRepository memberRepository; // 생성자 주입

    // 회원 가입
    @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("이미 존재하는 회원입니다.");
        }
    }

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

    // 단건 조회
    public Member findOne(Long id) {
        return memberRepository.findOne(id);
    }
}

@Transactional(readOnly = true)

  • JPA의 모든 데이터 변경은 Transaction 안에서 실행되어야 한다. (그래야 LAZY 로딩 이런 것들이 가능해짐)
  • Transactional은 Spring과 자바 두 가지가 존재하는데 Spring이 쓸 수 있는게 많다.
  • readOnly = true라는 옵션을 설정하면 조회하는 곳에서 성능을 최적화해준다.
    • 영속성 컨텍스트를 flush 하지 않으므로 성능이 약간 향상된다.
    • 읽기에는 readOnly=true를, 쓰기에는 @Transactional만 쓴다
  • 여기선 읽기가 많으니까 클래스에 readOnly=true를 붙여주고 쓰기 부분에만 @Transactional을 써준다(디폴트 값이 readOnly = false임)

@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("이미 존재하는 회원입니다.");
        }
    }

둘이 동시에 검증 로직을 실행하면 둘 다 동시에 통과해버릴 수도 있다. (save 이전이니까).
비즈니스 로직이 있다고 하더라도 문제가 생길 수 있음. 실무에서는 최후의 방어를 위해 멀티쓰레드 상황을 고려해서 db에서 회원명 컬럼에 유니크 제약조건을 걸어주는게 좋다.


@RequiredArgsConstructor
@AllArgsConstructor는 생성자를 만들어주기는 함. @RequiredArgsConstructor는 final이 붙어있는 필드에 대해서만 생성자를 만들어줌. 필요한 부분은 setter 주입할 수 있다는 장점이 있다 @RequiredArgsConstructor를 쓰자


참고 | @NoArgsConstructor는 파라미터가 없는 디폴트 생성자를 자동으로 생성해준다.


회원 기능 테스트


테스트 요구사항

  1. 회원가입이 성공해야 한다.
  2. 회원가입할 때 같은 이름이 있으면 예외가 발생해야 한다.

package jpabook.jpashop.service;

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

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

@ExtendWith(SpringExtension.class)
@SpringBootTest
@Transactional
public class MemberServiceTest {

    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;
    @Test
    void 회원가입() throws Exception {
        // given
        Member member = new Member();
        member.setName("kim");

        // when
        Long savedId = memberService.join(member);
        // DB마다 다르지만, 보통 영속화 된다고 해서 DB에 insert되는 건 아니다. 커밋이 되야 저장됨

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

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

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

        // when
        memberService.join(member1);
        assertThrows(IllegalStateException.class, () ->
                memberService.join(member2));

        // then
    }
}

  • @ExtendWith(SpringExtension.class) : 스프링과 테스트 통합
    • Junit4에서는 @RunWith(SpringRunner.class를 사용하지만,
    • Junit5에서는 @ExtendWith(SpringExtension.class)를 사용하자.
  • 또한, Junit5에서는 접근제어자 public을 생략할 수 있다.
  • @SpringBootTest
    • 스프링 부트 통합 테스트이다. 근데 순수 자바 테스트를 지향해야 한다.
    • 이게 없으면 의존관계 자동 주입이 일어나지 않는다.

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

        // when
        Long savedId = memberService.join(member);
        // DB마다 다르지만, 보통 영속화 된다고 해서 DB에 insert되는 건 아니다. 커밋이 되야 저장됨

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

  • DB마다 다르지만, 보통 영속화 된다고 해서 DB에 insert되는 건 아니다. 커밋이 되야 저장됨
    • @Transactional이 디폴트로 트랜잭션을 롤백해버리기 때문에 커밋이 안나간다. @Rollback(false)를 해주면 커밋이 나가서 insert가 나가긴 한다.
    • DB에 쿼리날리는 걸 로그로 확인하고 싶으면 @Autowired EntityManager em;으로 만들고 직접 em.flush() 해주면 확인 가능하다.
  • JPA 같은 트랜잭션 안에서 PK 값이 같으면 같은 영속성 컨텍스트에서 관리되기 때문에 assertEquals를 통과할 수 있다.

메모리에서 테스트하기

실제 DB에서 테스트하는 것 보단 메모리 DB를 사용해서 실제 서비스와 격리된 환경에서 테스트하고, 테스트가 끝나면 데이터 초기화까지 이루어지면 좋지 않을까?
메모리에 DB를 띄워서 테스트할 수 있는 방법이 존재한다.


test/resources 디렉토리를 만들고 이 디렉토리 안에 application.yml을 생성하자
테스트 환경과 운영 환경이 다르기 때문에 yml 파일을 분리하여 설정을 따로 가져가는게 좋다.


spring datsource의 url을 jdbc:h2:mem:testdb로 설정해주면, 실제 DB를 띄우지 않고 가능하다.
하지만 더 간단한 방법이 있다.


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

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.type: trace

주석 처리한 부분이 없으면 스프링부트는 알아서 메모리 모드로 실행해준다.


참고 | ddl-auto의 create-drop

테스트 종료시점에 drop 시켜주는 옵션이다. 물론 스프링 메모리 모드는 메모리가 내려가면 drop이 자동적으로 된다.


상품 도메인

상품 엔티티 개발(비즈니스 로직 추가)

package jpabook.jpashop.domain.item;

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

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

    //==비즈니스 로직==//
    // stock 증가
    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;
    }
}

비즈니스 로직 추가

// stock 증가
    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;
    }

  • DDD를 할 때 엔티티 자체가 해결할 수 있는 것들은 엔티티 안에 비즈니스 로직을 넣어주는게 좋다 (객체지향적).
    • stockQuantity도 엔티티안에 있으니, 여기에 비즈니스 로직을 넣어준다.
    • 데이터를 가지고 있는쪽에 비즈니스 로직을 넣어줘야 응집력이 있는 것
  • 바깥에서 Setter를 호출하는게 아니라, 이 안에서 핵심 비즈니스 메서드를 가지고 변경해야 한다.
    • 재고가 증가하거나 상품 주문을 취소해서 재고를 다시 늘려야 할 때 바깥에서 이 메서드를 사용한다.
  • 상품 수량이 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);
    }

}
  • NotEnoughStockException이라는 예외 클래스를 만들고 RuntimeException 클래스를 implements 했다.

상품 리포지토리

package jpabook.jpashop.repository;

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

import java.util.List;

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

    public void save(Item item) {
        if (item.getId() == null) { // item을 처음 저장할 때는 id가 없기 때문
            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();
    }
}

public void save(Item item) {
        if (item.getId() == null) { // item을 처음 저장할 때는 id가 없기 때문
            em.persist(item);
        } else {
            em.merge(item);
        }
    }

  • item을 처음 저장하는 경우에는 id가 없다.
    • 영속화를 하지 않았기 때문
    • 신규 등록으로 보고 영속화 해준다.
  • 그 외의 경우는 merge해준다.
    • DB에 이미 등록된 것. update 같은 느낌

상품 서비스 개발

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 void saveItem(Item item) {
        itemRepository.save(item);
    }

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

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

위 코드를 보면 단순히 리포지토리의 메서드만 호출하고 있다.
상품 서비스는 상품 리포지토리에 단순히 위임만 하는 클래스로, 경우에 따라서 위임만 하는 클래스를 꼭 만들어야 할 지에 대해 고민할 필요가 있다. (그냥 컨트롤러에서 리포지토리에 바로 접근할 수 있도록 하는 등..)


주문, 주문상품 엔티티 개발

주문 엔티티

package jpabook.jpashop.domain;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
    @Id
    @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    // 여기에 값을 세팅하면 FK 값이 다른 멤버로 변경된다
    private Member member;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) // order에 의해서 매핑
    private List<OrderItem> orderItems = new ArrayList<>();

    /*
    persist(orderItemA)
    persist(orderItemB)
    persist(orderItemC)
    persist(order)

    에서 Cascade를 사용하면
    persist(order)로, 위 3줄을 지울 수 있다.
    * */

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

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING) // ORDINAL 사용 금지!
    private OrderStatus status; // 주문상태 [ORDER, CANCEL]

    //==연관관계 메서드==//
    // 컨트롤 하는 쪽에 연관관계 메서드가 있는게 좋다.
    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);
        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.cancel();
        }
    }

    // 조회 로직
    public int getTotalPrice() {
        return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
    }
}


생성 메서드

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;
    }
  • 주문을 생성하기 위해서는 member도 필요하고, delivery도 필요하고, orderItems도 필요하다.
  • 이렇게 객체를 생성하는데 복잡한 연관관계가 얽혀있으면 생성 메서드를 생성해주는게 좋다.
  • 생성 메서드도 “생성”이라는 비즈니스 로직의 하나로, 주문 생성에 대한 비즈니스 로직을 여기에 완성해놓는다.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
내가 개발을 할 때는 생성 메서드를 통해서 Order 객체를 만들겠지만, 다른 사람이 객체를 생성할 때 생성자를 호출하고 setter를 호출하려고 시도할 가능성이 있다. 따라서 기본 생성자만 만들어두고, 접근 제어 지시자를 PROTECTED로 만들어주자.
PROTECTED 깡통 생성자를 롬복 라이브러리 애노테이션으로 만들어줄 수 있다.


비즈니즈 로직
위에서 봤던데로 엔티티 자체가 해결할 수 있는 비즈니스 로직은 엔티티 내부에서 처리해주는게 좋다. 주문을 단순히 취소하는 로직 또한 주문 엔티티 내부에서 처리해주는게 좋다.


public void cancel() {
        if (delivery.getStatus() == DeliveryStatus.COMP) {
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }

        this.setStatus(OrderStatus.CANCEL);
        for (OrderItem orderItem : orderItems) {
            orderItem.cancel();
        }
    }

만약 이미 배송이 완료된 상품이면 주문을 취소하지 못하도록 예외를 발생시키고, 그렇지 않으면 주문 상태를 취소로 변경하고 주문상품에 취소를 알린다.


따라서 order에 연관되어 있는 OrderItem(주문상품) 또한 order의 취소를 반영해줘야 한다. 따라서 OrderItem에도 취소 메서드를 만들어줘야 한다. 아래 부분에서 체크!


조회 로직
주문서에 포함된 주문 상품들의 총 금액을 조회하는 것 또한 주문 엔티티 내부에서 처리 가능하다.
전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 하기 때문에, 주문상품에도 가격을 조회하는 메서드를 만들고 조회하여 이 값들을 더한 값을 반환해주자.

// 조회 로직
    public int getTotalPrice() {
        return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
    }

OrderItem 엔티티에도 getTotalPrice라는 조회 메서드를 추가해줘야 한다.


주문상품 엔티티

package jpabook.jpashop.domain;

import jakarta.persistence.*;
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)
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) // Order는 여러 개의 OrderItem, OrderItem은 하나의 Order
    @JoinColumn(name = "order_id") // 여기서 값을 설정하면 FK 값이 다른 order로 변경 (연관관계 주인)
    private Order order;

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

    // 생성 메서드
    // 실무에서는 파라미터로 DTO가 넘어온다.
    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();
    }
}

생성 메서드

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


OrderItem을 생성하는데 복잡한 연관관계가 얽혀있고, 주문 수량에 따라 item의 재고가 바뀐다. 따라서 주문 상품, 가격, 수량 정보를 받아 주문 상품 엔티티를 생성하고, item의 재고를 줄여주는 메서드를 만들고 호출하자.


비즈니스 로직

// 비즈니스 로직
    public void cancel() {
        // 재고를 원복해야 함
        getItem().addStock(count);
    }

주문 엔티티에서 확인했듯이 주문을 취소하면, 주문상품 또한 주문의 취소를 반영해야 한다. 주문 상품의 상품 정보를 가져와 취소한 주문 수량만큼 원복해준다.


조회 로직

public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }

주문 엔티티에서 각 주문상품당 가격을 조회하였고, 이를 위해서는 주문 가격에 주문 수량을 곱한 값을 반환해준다.


주문 리포지토리

package jpabook.jpashop.repository;

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

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

// findAll은 나중에..
}

findAll의 경우 주문 검색 기능 구현과 함께 나중에 작성한다.


주문 서비스

package jpabook.jpashop.service;

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;
import org.aspectj.weaver.ast.Or;
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());

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

        // 주문 생성
		// 편의를 위해서 orderItem 하나만 주문에 생성할 수 있도록 만듬. 여러개로 해도 됨
        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);
    }*/
}

주문상품 생성 + 주문 생성 메서드

  • createOrderItem이라는 메서드 없이 생성자 호출 + 세터를 사용한다면 유지보수하기 굉장히 어려워진다
  • 다른 스타일의 생성을 막아줘야 한다. protected 생성자를 사용하자.
    • 롬복으로 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 사용하면 된다.
    • 코드를 제약하는 스타일로 짜야 유지보수하기 편하다

주문 저장

 // 주문 저장
        orderRepository.save(order);
        return order.getId();
  • JPA 한테 여기서 생성한 delivery를 리포지토리에 저장하지 않고, orderItem을 persist하지 않고 orderRepository만 저장한 이유는 cascade 때문이다. orderRepository에 persist해주면 나머지에도 persist된다.

참고 | orderItem의 경우 order를 제외하면 참조하는 곳이 없기 때문에 cascade를 걸어도 괜찮다.

하지만 delivery를 막 다른 엔티티에서 가져다 사용한다면 주의해야 한다. (주문을 지웠는데 다른게 삭제될 수 있음.)

persist 라이프사이클이 똑같을 때 사용하자. 애매하면 쓰지 말자


주문 취소 메서드

// 취소
    @Transactional
    public void cancelOrder(Long orderId) {
        // 주문 엔티티 호출
        Order order = orderRepository.findOne(orderId);
        // 주문 취소
        order.cancel();
    }

만약 JdbcTemplate나 Mybatis를 사용한다면 (직접 SQL을 날린다면) 데이터를 변경한 뒤 바깥에서
update 쿼리를 짜서 날려야 한다. 아이템의 재고를 + 해주는 로직을 바깥에 추가해줘야 한다.
Transactional Script를 짜야한다.


JPA를 활용하면 엔티티만 바꿔줘도 더티 체킹과 변경내역감지를 통해서 데이터베이스 업데이트 쿼리를 날려준다


참고 | 주문 서비스, 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임한다. 이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라고 한다. 반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라고 한다.


JPA와 같은 ORM에서는 도메일 모델 패턴을 주로 사용한다. 때로는 트랜잭션 스크립트 패턴이 좋을 때도 있다. 유지보수가 쉬운 방향에 맞춰 사용하자. 한 프로젝트 안에 양립하기도 한다. 문맥에 맞게 사용하자


주문 기능 테스트

테스트 요구사항

  • 상품 주문이 성공해야 한다.
  • 상품을 주문할 때 재고 수량을 초과하면 안 된다.
  • 주문 취소가 성공해야 한다.

package jpabook.jpashop.service;

import jakarta.persistence.EntityManager;
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;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.transaction.annotation.Transactional;

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

@ExtendWith(SpringExtension.class)
@SpringBootTest
@Transactional
class OrderServiceTest {
    @Autowired
    EntityManager em;
    @Autowired
    OrderService orderService;
    @Autowired
    OrderRepository orderRepository;
    @Test
    public void 상품주문() throws Exception {
        // give
        Member member = createMember();

        Item book = createBook("시골 JPA", 10000, 10);

        int orderCount = 2;

        // when
        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(10000 * orderCount, getOrder.getTotalPrice());
        assertEquals(8, book.getStockQuantity(), "주문 수량만큼 재고가 줄어야 한다.");
    }

    private Item createBook(String name, int price, int stockQuantity) {
        Item 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("서울", "경기", "123-123"));
        em.persist(member);
        return member;
    }

    @Test
    public void 주문취소() throws Exception {
        // given
        Member member = createMember();
        Item 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(OrderStatus.CANCEL, getOrder.getStatus(), "주문 취소시 상태는 CANCEL 이다.");
        assertEquals(10, item.getStockQuantity(), "주문이 취소된 상품은 그만큼 재고가 증가해야 한다.");
    }
    @Test
    public void 상품주문_재고수량초과() throws Exception {
        // given
        Member member = createMember();
        Item item = createBook("시골 JPA", 10000, 10);

        int orderCount = 11;
        // when
        assertThrows(NotEnoughStockException.class, () -> orderService.order(member.getId(), item.getId(), orderCount));
        // then
//        fail("재고 수량 부족 예외가 발생해야 한다."); : JUnit 4의 경우
    }
}

모듈화

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

테스트에서 중복해서 생성하는 객체의 경우 모듈화하는 것이 편하다.


인텔리제이 단축키 : command + option + m

private 메서드로 모듈화해준다.


참고 | Junit4에서 Junit5로 넘어오면서 Argument 순서가 바뀌었다. Junit4에서는 메시지 파라미터가 제일 먼저 오지만, Junit5에서는 메시지 파라미터가 제일 뒤에 온다.


정말 좋은 테스트는 단위 테스트. DB에 접근하는게 아니라 mocking을 해서 테스트하는게 좋다.
도메인 모델 패턴을 사용하면 orderEntity에 비즈니스 로직이 있기 때문에 여기서 단위 테스트를 만들 수 있다는 장점이 있다.


주문 검색 기능 개발

검색 조건 파라미터 OrderSearch


package jpabook.jpashop.domain;
public class OrderSearch {
	private String memberName; //회원 이름
	private OrderStatus orderStatus;//주문 상태[ORDER, CANCEL] 
    	 //Getter, Setter
	} 

검색을 추가한 주문 리포지토리 코드


package jpabook.jpashop.repository;

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

@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) {
    	// setFirstResult(startPosition) 으로 페이징 가능
	    return em.createQuery("select o from Order o 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();
	    // 위 코드 대신 동적 쿼리가 되도록 구현해야 함
	}
}

위 findAll메서드로는 한계가 존재한다.
검색 기능을 구현하기 위해서는 동적으로 쿼리를 생성해서 주문 엔티티를 조회해야 한다.
(동적 쿼리를 사용하면 orderSearch 객체에서 제공하는 값에 따라 필요한 조건만을 추가할 수 있다.)


  1. JPQL 쿼리 문자를 분기문을 통해서 생성
  2. JPA Criteria로 처리

JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡하다.
(치명적인 단점: 유지 보수하기 굉장히 어렵다. 한 눈에 안들어옴)
결국 다른 대안이 필요하다. Querydsl이 제일 좋은 해결 방법인데, 나중에 따로 다룬다.




'Spring > JPA' 카테고리의 다른 글

[JPA] About JPA  (0) 2024.11.06
[JPA] 웹 계층 개발  (0) 2024.10.09
[JPA] Cascade 적용 기준  (0) 2024.09.29
[JPA] 다대일(N:1) 단방향 VS 양방향 정리  (0) 2024.09.29
[JPA] JPA 개발 - 도메인 분석 및 설계  (0) 2024.09.29