Business Logic

Member Functions

Repository

회원과 관련해서 DB로부터 데이터를 주고 받는 repository class를 설계한다.

@Repository
@RequiredArgsConstructor
public class MemberRepository {
    
    //원래는 EntityManager을 활용하기 위해서는 @PersistenceContext annotation을 활용해야되지만, spring data jpa dependency + @RequiredArgsConstructor을 통해 자동으로 필요한 의존성 객체를 주입한다.
    //@PersistenceContext
    private final EntityManager em;
    //회원을 저장 메소드
    public void save(Member member) {
        em.persist(member);
    }
    //id(primary key) 기반으로 회원을 검색하는 메소드
    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 = :membername",Member.class)
                .setParameter("membername", name)
                .getResultList();
    }
}

Spring Data Jpa를 활용하면 interface 상속을 통해 위의 기능들을 자동으로 구현할 수 있지만, 지금은 jpa 활용의 목적을 중점적으로 다루기 위해 모든 기능을 직접 구현하는 것을 목표로한다.

Service

실제 동작하는 비즈니스 로직을 담당하는 Service 클래스로, 순수 자바 기반으로 구현한다.

@Service
@Transactional(readOnly = true)   //class 기반의 annotation으로 transaction 내부에서 쿼리들을 수행할 수 있도록 하는데, 이때 읽기만 가능하도록 설정하여 조회만 진행하는 메소드에 대한 성능을 최적화한다.
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    //회원 저장 메소드
    @Transactional //회원을 저장하는 기능은 DB에 데이터를 써야하는 메소드로 readonly=false이어야한다.
    public long join(Member member) {
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }
    //회원 중복 점검 --> 회원의 이름을 기반으로 중복된 회원인지 여부를 판단한다. 
    private void validateDuplicateMember(Member member) {
        List<Member> members = memberRepository.findByName(member.getName());
        if (!members.isEmpty()) {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        }
    }
    //모든 회원을 조회하는 메소드
    public List<Member> findMembers(){
        return memberRepository.findAll();
    }
    //id를 기반으로 회원을 조회하는 메소드
    public Member findOne(Long id) {
        return memberRepository.findOne(id);
    }
}

@Transactional(readonly=false) 으로 설정하여 읽기 전용으로 트랜잭션으로 구동하도록 하며, 영속성 컨텍스트에서의 플러시를 방지해서 검색만을 위한 메소드에 대한 성능을 최적화한다.

중복 점검을 위해 DB에서 회원 이름을 검색 조건으로 회원을 조회하여 중복된 이름의 회원이 있는지 여부를 판단하는데, 이때, 중복된 이름이 없다 하더라도, 동시에 2개 이상의 트랜잭션에서 같은 이름을 입력하고자 할때, 중복 점검이 정상적으로 이루어지지 않을 수 있기 때문에 컬럼을 unique으로 설정하여 DB 계층에서의 유일성 조건을 추가한다.

Member Function Test

@SpringBootTest
@Transactional //각각의 테스트에 대해서 테스트를 진행한 이후에 rollback을 수행하여 각각의 테스트 간에 간섭을 방지한다. 
class MemberServiceTest {
    @Autowired
    private MemberService memberService;
    @Autowired
    private MemberRepository memberRepository;

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

        //when
        long memberId = memberService.join(member);

        //then
        assertThat(member).isEqualTo(memberRepository.findOne(memberId));
    }

    @Test
    @DisplayName("중복_회원_예외")
    void duplicate_join() {
        //given
        Member member1 = new Member();
        member1.setName("test1");
        Member member2 = new Member();
        member2.setName("test1");
        //when
        long memberId1 = memberService.join(member1);

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

application.yml for Test

test 폴더 아래에 resources/application.yml을 추가하게 되면 test을 진행할 때 기존의 application.yml 대신에 test 폴더에 존재하는 application.yml 내부의 환경 설정을 토대로 테스트가 수행된다.

test/resources/application.yml

spring:
 datasource:
   url: jdbc:h2:mem:testdb
   username: sa
   password:
   driver-class-name: org.h2.Driver
 jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        # show_sql: true system.out을 통한 log 출력
        format_sql: true
logging.level:
  org.hibernate.SQL: debug
  org.hibernate.orm.jdbc.bind: trace #Spring Boot 3.x, hibernate6

위와 같이 jdbc:h2:mem:testdb으로 h2 database url을 지정하게 되면 h2를 memory 상에 동작하게끔 하여 실제 서비스와 동작되는 환경과 격리시켜 실행하는 것이 가능하다.

하지만, springboot는 datasource에 대한 설정이 없으면 기본적으로 메모리에서 db을 동작시키게 되며, ddl-auto 또한 create-drop 상태로 기본으로 설정되어 실행된다. 따라서, test 환경에서의 application.yml은 아래와 같이 설정하면 된다.

spring:
logging.level:
  org.hibernate.SQL: debug
  org.hibernate.orm.jdbc.bind: trace #Spring Boot 3.x, hibernate6

Item Functions

상품과 관련된 기능 설계를 진행한다.

Business Logic

상품에 필요한 로직(연산)의 경우 상품 도메인과 같이 묶어서 관리하는 것이 객체지향 프로그래밍 관점에 부합하는 부분이다.

Item Domain

//재고를 증가시키는 메소드
  public void addStock(int quantity) {
      stockQuantity += quantity;
  }

  //재고를 증가시키는 메소드
  public void removeStock(int quantity) {
      int restStock = stockQuantity - quantity;
      if (restStock < 0) {
          throw new NotEnoughStockException("need more stock");
      }
      this.stockQuantity = restStock;
  }

NotEnoughStockException

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

상품의 재고를 관리하기 위한 메소드와 관련 에러 클래스이다. setter 함수를 통한 객체의 수정보다는 위와 같이 의미있는 메소드를 생성해서 기능을 처리하는 것을 권장한다.

Repository

ItemRepository class

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

상품의 id가 null이면 상품 엔티티가 영속 상태가 아닌 것을 의미하는데, 이는 처음 상품을 등록하려고 할때 활용된다.

상품의 id가 null이 아닌 것은 이미 이전에 영속 상태로 등록될 때 id를 할당받아 id 값이 null이 아닌 상태이며, 준영속 상태로 유지되다가 병합될 때 영속상태로 변경되면서 엔티티에 수정된 값이 반영된다.

Service

ItemService class

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ItemService {
    private final ItemRepository itemRepository;
    //상품 등록을 위한 메소드
    @Transactional
    public long saveItem(Item item) {
        itemRepository.save(item);
        return item.getId();
    }
    //상품 목록을 조회하기 위한 메소드
    public List<Item> findItems() {
        return itemRepository.findAll();
    }
    //상품을 조회하기 위한 메소드
    public Item findOne(Long id) {
        return itemRepository.findOne(id);
    }
}

ItemServiceTest

@SpringBootTest
@Transactional
class ItemServiceTest {
    @Autowired
    private ItemService itemService;

    @Autowired
    private ItemRepository itemRepository;


    @Test
    @DisplayName("상품을 저장하는 테스트")
    void saveItem() {
        //given
        Book book = new Book();
        book.setName("book1");
        book.setAuthor("author1");
        book.setIsbn("1234");

        //when
        long savedId = itemService.saveItem(book);
        Book savedBook =(Book)itemService.findOne(savedId);
        //then
        assertThat(savedBook).isEqualTo(book);
    }

    @Test
    @DisplayName("상품 목록을 조회하는 테스트")
    void findItems(){
        //given
        Book book1 = new Book();
        book1.setName("book1");
        book1.setAuthor("author1");
        book1.setIsbn("1234");

        Book book2 = new Book();
        book2.setName("book2");
        book2.setAuthor("author2");
        book2.setIsbn("1235");

        //when
        long savedId = itemService.saveItem(book1);
        long savedId2 = itemService.saveItem(book2);
        List<Item> items = itemService.findItems();
        //then
        assertThat(items.size()).isEqualTo(2);
    }
}

Order Functions

주문과 관련된 기능 설계를 진행한다.

Business Logic

Order class

public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
    Order order = new Order();
    order.setMember(member);
    order.setDelivery(delivery);
    //한번에 여러개의 상품을 주문할 수 있기 때문에 가변인자로 설정된 OrderItem에 대해 반복을 통해 일괄적으로 주문 객체에 등록한다.
    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() {
    int totalPrice = 0;
    for (OrderItem orderItem : orderItems) {
        totalPrice += orderItem.getTotalPrice();
    }
    return totalPrice;
}

OrderItem class

//생성 로직//
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(getCount());
}

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

객체의 생성과정이 복잡한 경우 위와 같이 정적 메소드를 통해 관리하여 생성 과정을 효율적으로 처리한다.

주문을 취소하게 되면 상품의 재고를 다시 증가시키는 작업을 진행해야한다. 또한, 총 주문액을 조회하기 위한 메소드를 생성하여 조회 과정에 편의를 더한다.

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

Service

주문 관련 서비스 개발

OrderService class

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
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.findAll(orderSearch);
    }*/
}

주문을 진행할 때 필요한 회원 정보, 상품 정보를 받아와서 주문상품, 주문 객체를 생성하는 메소드이다.

orderRepository.save(order);

원래는 order와 연관되어 있는 객체인 Delivery, OrderItem을 영속 상태로 만든 다음에 order을 저장해야되지만, 필드를 아래와 같이 Cascade.ALL 상태로 만들어서 부모 엔티티를 저장할 때 자동으로 자식 엔티티를 같이 저장하도록 영속성 전이 설정을 했기 때문에 부모 엔티티만 저장하면 된다.

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems=new ArrayList<>();

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

보통 해당 객체에만 국한되어 있는 즉, private 한 필드 정보에 대해서는 Cascade.ALL을 설정하여 부모 엔티티와 같은 라이프 사이클을 가지도록 한다.

Domain Model Pattern vs Transaction Script Pattern

위의 Order 객체처럼 관련 비즈니스 로직을 엔티티에 저장하고, 서비스 클래스에서는 해당 로직들을 호출하는 위임 메소드로 구성하는 방식이 Domain Model Pattern이며, 반대로 서비스 클래스에서 대부분의 비즈니스 로직을 설계하는 것을 Transaction Script Pattern이라고 한다.

주로, Domain Model Pattern은 객체지향 프로그래밍에서 활용되며 ORM을 활용한 JPA에 주로 활용되며, Transaction Script Pattern은 직접 sql을 다루는 JDBC에 주로 활용된다.

OrderService Test

@SpringBootTest
@Transactional
class OrderServiceTest {

    @Autowired OrderService orderService;

    @Autowired EntityManager entityManager;

    @Autowired OrderRepository orderRepository;

    @Test
    @DisplayName("상품 주문 테스트")
    void order() {
        //given
        //Member 객체 생성
        Member member = createMember();
        //Book 객체 생성
        Book book = createBook();

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

        //then
        //상품 주문시 주문의 상태는 OrderStatus.ORDER이어야한다.
        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.ORDER);
        //주문한 상품의 종류와 총주문액 일치해야한다.
        assertThat(savedOrder.getOrderItems().size()).isEqualTo(1);
        assertThat(savedOrder.getTotalPrice()).isEqualTo(book.getPrice() * orderCount);
        //주문 이후에 상품의 재고가 줄어야한다.
        assertThat(book.getStockQuantity()).isEqualTo(8);
    }

    @Test
    @DisplayName("재고 수량을 초과한 주문 테스트")
    void invalidateOrder() {
        //given
        //Member 객체 생성
        Member member = createMember();
        //Book 객체 생성
        Book book = createBook();

        //when
        int orderCount = 15;

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

    }

    @Test
    @DisplayName("상품 취소 테스트")
    void cancelOrder() {
        //given
        //Member 객체 생성
        Member member = createMember();
        //Book 객체 생성
        Book book = createBook();

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

        Order savedOrder = orderRepository.findOne(orderId);

        //then
        //주문을 취소하면 주문의 상태는 OrderStatus.CANCEL 이어야한다.
        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.CANCEL);
        //주문을 취소하면 상품의 재고가 복구되야한다.
        assertThat(book.getStockQuantity()).isEqualTo(10);
    }


    //테스트 데이터를 생성하기 위한 메소드
    private Member createMember(){
        Member member = new Member();
        member.setName("user1");
        member.setAddress(new Address("창원","상남동","12345"));
        entityManager.persist(member);
        return member;
    }

    private Book createBook() {
        Book book = new Book();
        book.setName("JPA");
        book.setPrice(10000);
        book.setStockQuantity(10);
        entityManager.persist(book);
        return book;
    }
}

위와 같이 필요한 OrderService의 기능을 테스트하기 위한 클래스도 구성한다.

OrderSearch

주문 검색의 경우, 회원 이름, 주문 상태와 같은 정보에 따라 요청하는 데이터의 정보가 달라지기 떄문에 동적 쿼리를 처리하도록 메소드를 설계해야한다.

OrderSearch class

@Data
public class OrderSearch {
    private String memberName;
    private OrderStatus orderStatus;
}

JPQL

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

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

//회원 이름 필터링
if (StringUtils.hasText(orderSearch.getMemberName())) {
    query.setParameter("name", orderSearch.getMemberName());
}
return query.getResultList();

jpql을 활용하여 동적 쿼리를 처리할 수 있지만, 위와 같이 직접적으로 jpql을 작성하다 보니 if/else 조건문을 통한 쿼리를 생성하는 작업이 복잡하게 얽혀있는 것을 확인할 수 있다.

JPA에서는 이렇게 jpql을 활용하기 보다는 아래와 같이 Criteria API 혹은 QueryDSL을 활용하여 동적 쿼리를 처리한다. Criteria API 보다는 QueryDsl을 활용하는 것이 더 간편하다.

Criteria

public List<Order> findAll(OrderSearch orderSearch) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> order = cq.from(Order.class);
Join<Order, Member> member = order.join("member", JoinType.INNER);

List<Predicate> criteria = new ArrayList<>();

//주문 상태 필터링
if (orderSearch.getOrderStatus() != null) {
    Predicate status = cb.equal(order.get("status"), orderSearch.getOrderStatus());
    criteria.add(status);
}

//회원 이름 필터링
if (StringUtils.hasText(orderSearch.getMemberName())) {
    Predicate name = cb.like(member.<String>get("member"), "%"+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();
}

QueryDsl

QOrder order=QOrder.order;
QMember member=QMember.member;

BooleanBuilder builder = new BooleanBuilder();
//주문 상태 필터링
if (StringUtils.hasText(orderSearch.getOrderStatus())) {
    builder.and(order.status.eq(orderSearch.getOrderStatus()));
}
//회원 이름 필터링
if (StringUtils.hasText(orderSearch.getMemberName()))  {
    builder.and(member.name.like(orderSearch.getMemberName()));
}

return query
    .select(order)
    .from(order)
    .join(order.member,member)
    .where(builder)
    .limit(1000)

References

link: inflearn

link:jpa

link:thymeleaf

link: springboot3

댓글남기기