쌩로그

Practical Testing 실용적인 테스트 가이드 - Section 05. Spring & JPA 기반 테스트 (2) - Business Layer 본문

Spring/Test

Practical Testing 실용적인 테스트 가이드 - Section 05. Spring & JPA 기반 테스트 (2) - Business Layer

.쌩수. 2024. 1. 21. 10:53
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. Business Layer 테스트
  3. 요약

1. 포스팅 개요

인프런에서 박우빈님의 Practical Testing: 실용적인 테스트 가이드 강의 섹션 5 Spring & JPA 기반 테스트 중 Business Layer(Service 계층) 테스트 부분을 학습하며 정리한 포스팅이다.

| 참고 이전 포스팅

2. 본론

2-1. Business Layer 테스트

Persistence Layer

  • Data Access의 역할을 한다.
  • 비즈니스 가공로직이 포함되어서는 안 된다.
  • Data에 대한 CRUD에만 집중한 레이어이다.

Business Layer

  • 비즈니스 로직을 구현하는 역할
  • Persistence Layer와의 상호작용(Data를 읽고 쓰는 행위)을 하는 비즈니스 로직을 전개시킨다.
  • 트랜잭션을 보장해야 한다.

이번 파트에서는 Service 테스트를 할 것인데, Persistence Layer를 배제하지 않고, Business Layer를 테스트 하면서 통합적으로 동작을 하는지 진짜 통합 테스트 느낌이 나게 작성을 해본다.

요구사항

  • 상품 번호 리스트를 받아 주문 생성하기
  • 주문은 주문 상태, 주문 등록 시간을 가진다.
  • 주문의 총 금액을 계싼할 수 있어야 한다.

필요한 클래스

Order

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sample.cafekiosk.spring.domain.BaseEntity;
import sample.cafekiosk.spring.domain.orderproduct.OrderProduct;

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

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

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus;

    private int totalPrice;

    private LocalDateTime registeredDateTime;

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

}

OrderStatus

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum OrderStatus {

    INIT("주문생성"),
    CANCELED("주문 취소"),
    PAYMENT_COMPLETED("결제 완료"),
    PAYMENT_FAILED("결제 실패"),
    RECEIVED("주문 접수"),
    COMPLETED("처리 완료");

    private final String text;
}

OrderProduct

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import sample.cafekiosk.spring.domain.BaseEntity;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.order.Order;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class OrderProduct extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    private Product product;

    public OrderProduct(Order order, Product product) {
        this.order = order;
        this.product = product;
    }
}

OrderController

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import sample.cafekiosk.spring.api.controller.order.request.OrderCreateRequest;
import sample.cafekiosk.spring.api.service.order.OrderService;

@RequiredArgsConstructor
@RestController
public class OrderController {

    private final OrderService orderService;

    @PostMapping("/api/v1/orders/new")
    public void createOrder(@RequestBody OrderCreateRequest request) {
        orderService.createOrder(request);
    }

}

OrderCreateRequest

public class OrderCreateRequest {

    private List<String> productNumbers;
}

Order와 지난 시간에 만들었던 Product는 다대다 관계이다.
그런데 다대다 관계는 일대다 다대일 관계로 풀어내기 때문에,
중간에 OrderProduct 클래스를 하나 두었다.

Order와 OrderProduct는 일대다 양방향 연관관계이지만,
OrderProduct와 Product는 다대일 단방향 연관관계다.

다음은 서비스 클래스이다.

@Service
public class OrderService {


    public void createOrder(OrderCreateRequest request) {
      return null;
    }

}

이렇게만 두고, TDD를 이용해서 구현하면 다음과 같다.

다음 코드는 OrderSerivce에서 createOrder(OrderCreateRequest request)에 대한 테스트이다.

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import sample.cafekiosk.spring.api.controller.order.request.OrderCreateRequest;
import sample.cafekiosk.spring.api.controller.order.response.OrderResponse;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.ProductRepository;
import sample.cafekiosk.spring.domain.ProductType;

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

import static org.assertj.core.api.AssertionsForClassTypes.tuple;
import static sample.cafekiosk.spring.domain.ProductSellingStatus.*;
import static sample.cafekiosk.spring.domain.ProductType.HANDMADE;

@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private ProductRepository productRepository;


    @Test
    @DisplayName("주문 번호 리스트를 받아 주문을 생성한다.")
    void createOrder() {
        // given
        Product product1 = createProduct(HANDMADE, "001", 1000);
        Product product2 = createProduct(HANDMADE, "002", 3000);
        Product product3 = createProduct(HANDMADE, "003", 5000);

        productRepository.saveAll(List.of(product1, product2, product3));

        OrderCreateRequest request = OrderCreateRequest.builder()
                .productNumbers(List.of("001", "002"))
                .build();

        // when
        OrderResponse orderResponse = orderService.createOrder(request);

        // then
        Assertions.assertThat(orderResponse.getId()).isNotNull();
        Assertions.assertThat(orderResponse)
                .extracting("registeredDateTime", "totalPrice")
                .contains(LocalDateTime.now(), 4000);

        Assertions.assertThat(orderResponse.getProducts()).hasSize(2)
                .extracting("productNumber", "price")
                .containsExactlyInAnyOrder(
                        tuple("001", 1000),
                        tuple("002", 3000)
                );
    }

    private Product createProduct(ProductType type, String productNumber, int price) {
        return Product.builder()
                .type(type)
                .productNumber(productNumber)
                .price(price)
                .sellingStatus(SELLING)
                .name("메뉴 이름") // 이름이 같아도 상관없음.
                .build();
    }
}
  • 가장 아래에 있는 createProduct 메서드는 Builder로 Product를 생성하기에 코드가 길어져서 메서드화 시켰다.
  • given을 통해서 "이런 상황이 주어졌을 때"라는 의미로, Product를 생성해서 넣어주었고, 요청정보에 대해 넣어주었다.
  • 그리고 주문을 생성해서 assertj의 Assertions를 통해서 id가 null이 아닌지 검증하려 했다.
  • registeredDateTimetotalPrice의 필드를 추출해서 검증하려고 했다.
  • 주문하려는 상품 개수가 2개가 맞는지, 제품 번호와 가격이 어떻게 되는지 데이터 입력 순서와 상관없이 데이터가 있는지 확인하는 검증을 했다.

그리고 위의 테스트 코드를 작성하기 위해서 코드가 수정되거나, 새로 생성된 코드들이 있다.

변경된 OrderCreateRequest 클래스

public class OrderCreateRequest {

    private List<String> productNumbers;

    @Builder
    public OrderCreateRequest(List<String> productNumbers) {
        this.productNumbers = productNumbers;
    }
}

새로 생성된 OrderResponse 클래스

@Getter
public class OrderResponse {

    private Long id;
    private int totalPrice;
    private LocalDateTime registeredDateTime;
    private List<ProductResponse> products;

}

그리고 현재는 테스트가 실패한다.. ^^

원인은 다음과 같다.

java.lang.NullPointerException: Cannot invoke "sample.cafekiosk.spring.api.controller.order.response.OrderResponse.getId()" because "orderResponse" is null

해당 에러는 다음 코드로 인해서 발생한 것이다.

Assertions.assertThat(orderResponse.getId()).isNotNull();

orderResponse 가 사실 현재는 null을 반환하고 있기 때문이다.

이를 빠른 그린(테스트 성공)을 먼저 보기 위해 구현해본다.

먼저 OrderService에서 다음 메서드를 구현해야 한다.

    public OrderResponse createOrder(OrderCreateRequest request) {
        List<String> productNumbers = request.getProductNumbers();

        // Procudt를 찾는다. ProductRepository가 필요하다.
        // productRepository.findAllBy...


        // Order 생성
        return null;

주석으로 나와있듯이 Product를 먼저 찾아야 한다.
그러기 위해서 ProductRepository가 필요하다.

그래서 서비스 클래스의 코드는 다음과 같다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final ProductRepository productRepository;

    public OrderResponse createOrder(OrderCreateRequest request) {
        List<String> productNumbers = request.getProductNumbers();

        // Product를 찾는다. ProductRepository가 필요하다.
//        productRepository.findAllBy...


        // Order 생성
        return null;
    }

}

ProductRepository를 DI받는다.

그런데, request 파리미터로 받아온 productNumbers로부터 Product를 찾아야하는데, ProductRepository에는 현재 그런 기능을 해주는 메서드가 없다.
따라서 만들어줘야 한다.

ProductRepository에서 다음과 같이 메서드를 만든다.

List<Product> findAllByProductNumberIn(List<String> productNumbers);

따라서 ProductRepository는 다음과 같다.

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {

    List<Product> findAllBySellingStatusIn(List<ProductSellingStatus> sellingTypes);

    List<Product> findAllByProductNumberIn(List<String> productNumbers);
}

그런데 지금 findAllByProductNumberIn(List<String> productNumbers) 이 메서드를 작성한 순간
"이 메서드에 대한 테스트를 해야한다!"는 생각이 들어야한다.

그래서 이 메서드에 대한 테스트를 작성해보면 다음과 같다.
Persistance Layer 테스트 클래스에서 다음과 같이 작성한다.

@Test
@DisplayName("상품번호 리스트로 상품들을 조회한다.")
void findAllByProductNumberIn() {
   // given
   Product product1 = Product.builder()
            .productNumber("001")
            .type(HANDMADE)
            .sellingStatus(SELLING)
            .name("아메리카노")
            .price(4000)
            .build();

   Product product2 = Product.builder()
            .productNumber("002")
            .type(HANDMADE)
            .sellingStatus(HOLD)
            .name("카페라떼")
            .price(4500)
            .build();

   Product product3 = Product.builder()
            .productNumber("003")
            .type(HANDMADE)
            .sellingStatus(STOP_SELLING)
            .name("팥빙수")
            .price(7000)
            .build();

   productRepository.saveAll(List.of(product1, product2, product3));

   // when
   List<Product> products = productRepository.findAllByProductNumberIn(List.of("001", "002"));

   // then
   assertThat(products).hasSize(2)
            .extracting("productNumber", "name", "sellingStatus")
            .containsExactlyInAnyOrder(
                  tuple("001", "아메리카노", SELLING),
                  tuple("002", "카페라떼", HOLD)
            );

}

테스트를 돌려보면 결과는 다음과 같다.

그리고 다시 Service 코드를 작성하러 간다.

    public OrderResponse createOrder(OrderCreateRequest request) {
        List<String> productNumbers = request.getProductNumbers();

        // Product를 찾는다. ProductRepository가 필요하다.
        List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);

        // Order 생성
//        Order.create(products);

        return null;
    }

현재 찾은 products를 통해서 주문을 생성해야 하는데,
products를 통해서 주문을 생성하는 기능이 없다.

그래서 만들어줘야 한다.

그래서 Order에 다음 두 코드가 추가되었다.

// Order
   ...

   public Order(List<Product> products) {
       this.orderStatus = OrderStatus.INIT;
//       this.totalPrice =
   }

   public static Order create(List<Product> products) {
       return new Order(products);
   }

그리고 이 Order의 create 메서드에 대한 단위 테스트를 진행한다.

다음은 OrderTest 클래스이다.

class OrderTest {

    @Test
    @DisplayName("주문 생성시 상품 리스트에서 주문의 총 금액을 계산한다.")
    void calculateTotalPrice() {
        // given
        List<Product> products = List.of(
                createProduct("001", 1000),
                createProduct("002", 2000)
        );

        // when
        Order order = Order.create(products);


        // then
        Assertions.assertThat(order.getTotalPrice()).isEqualTo(3000);

    }

    private Product createProduct(String productNumber, int price) {
        return Product.builder()
                .type(ProductType.HANDMADE)
                .productNumber(productNumber)
                .price(price)
                .sellingStatus(SELLING)
                .name("메뉴 이름") // 이름이 같아도 상관없음.
                .build();
    }

}
  • createProduct 메서드는 간소화 시켰다.

이 테스트 또한 실패가 일어난다.

왜냐하면 구현을 하지 않았기 때문이다.

이 또한 TDD다..
서비스에 대한 TDD를 하면서 작은 단위로 또 테스트를 진행하고 있다.
다시 Order 클래스로 가자.

그리고 작성하던 생성자를 다음과 같이 마저 작성해준다.


// Order - Order생성자
public Order(List<Product> products) {
   this.orderStatus = OrderStatus.INIT;
   this.totalPrice = products.stream()
            .mapToInt(Product::getPrice)
            .sum();
}

그리고 OrderTest의 calculateTotalPrice() 테스트를 돌려보면, 결과는 성공한다.

초록불을 보았다.

참고

참고로 인텔리제이가 제공하는 메서드 추출 기능으로 다음 코드를 다른 메서드로 뽑을 수 있다.

this.totalPrice = products.stream()
         .mapToInt(Product::getPrice)
         .sum();

바로 아래쪽에 해당하는 내용을 마우스로 드래그한다.

products.stream()
         .mapToInt(Product::getPrice)
         .sum();

그리고 컨트롤 + 알트 + M을 누르면 메서드를 추출할 수 있다.

여기서는 다음과 같이 했다.

    public Order(List<Product> products) {
        this.orderStatus = OrderStatus.INIT;
        this.totalPrice = calculateTotalPrice(products);
    }

   ...

    private int calculateTotalPrice(List<Product> products) {
        return products.stream()
                .mapToInt(Product::getPrice)
                .sum();
    }

그리고 이를 테스트 하더라도 똑같이 초록불(그린)이 그려진다.
그리고 Order 생성자의 orderStatus(초기상태 INIT으로 지정)도 확인해보는 테스트를 작성하면 다음과 같다.

    @Test
    @DisplayName("주문 생성시 주문 상태는 INIT이다.")
    void init() {
        // given
        List<Product> products = List.of(
                createProduct("001", 1000),
                createProduct("002", 2000)
        );

        // when
        Order order = Order.create(products);

        // then
        Assertions.assertThat(order.getOrderStatus()).isEqualByComparingTo(OrderStatus.INIT);

    }

enum에 대한 값은 isEqualTo()도 있고, isEqualByComparingTo()로도 할 수 있다.

그리고 LocalDateTime 타입 같은 경우는 외부의 파라미터를 통해서 받도록 한다.

public Order(List<Product> products, LocalDateTime registeredDateTime) {
   this.orderStatus = OrderStatus.INIT;
   this.totalPrice = calculateTotalPrice(products);
   this.registeredDateTime = registeredDateTime;
}

public static Order create(List<Product> products, LocalDateTime registeredDateTme) {
   return new Order(products, registeredDateTme);
}

registeredDateTme를 외부로부터 받도록 했다.
그리고 이 create 메서드는 서비스 클래스로도 이어지는데, Service 코드에서도 다음과 같이 LocalDateTiem을 외부로 받도록 하였다.

    public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTme) {
        List<String> productNumbers = request.getProductNumbers();

        // Product를 찾는다. ProductRepository가 필요하다.
        List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);

        // Order 생성
        Order order = Order.create(products, registeredDateTme);

        return null;
    }

그래서 이 LocalDateTime을 컨트롤러로부터 받도록 한다.

OrderController의 코드는 다음과 같다.

@PostMapping("/api/v1/orders/new")
public void createOrder(@RequestBody OrderCreateRequest request) {
LocalDateTime registeredDateTme = LocalDateTime.now();
orderService.createOrder(request, registeredDateTme);
}

그리고 등록 시간이 잘 들어가는지 확인할 수 있다.

// OrderTest
    @Test
    @DisplayName("주문 생성시 등록 시간을 기록한다.")
    void registeredDateTme() {
        // given
        LocalDateTime registeredDateTme = LocalDateTime.now();

        List<Product> products = List.of(
                createProduct("001", 1000),
                createProduct("002", 2000)
        );

        // when
        Order order = Order.create(products, registeredDateTme);

        // then
        Assertions.assertThat(order.getRegisteredDateTime()).isEqualTo(registeredDateTme);
    }

결과는 다음과 같다.

다음으로 Order 생성자의 마지막 부분 orderProducts를 집어넣는 방법이다.

this.orderProducts = products.stream()
      .map(product -> new OrderProduct(this, product))
      .collect(Collectors.toList());

이와 같이 만들어주면 된다.
따라서 Order 생성자는 다음과 같다.

public Order(List<Product> products, LocalDateTime registeredDateTime) {
   this.orderStatus = OrderStatus.INIT;
   this.totalPrice = calculateTotalPrice(products);
   this.registeredDateTime = registeredDateTime;
   this.orderProducts = products.stream()
            .map(product -> new OrderProduct(this, product))
            .collect(Collectors.toList());
}

그리고 OrderService에서 createOrder 메서드를 완성하면 다음과 같다.

public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTme) {
   List<String> productNumbers = request.getProductNumbers();

   // Product를 찾는다. ProductRepository가 필요하다.
   List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);

   // Order 생성
   Order order = Order.create(products, registeredDateTme);

   return OrderResponse.of(order);
}

마지막에 return OrderResponse.of(order); 을 완성시키기 위해서 OrderResponse 클래스의 of 메서드를 구현해준다.

구현한 OrderResponse 클래스의 코드는 다음과 같다.

import lombok.Builder;
import lombok.Getter;
import sample.cafekiosk.spring.api.service.product.response.ProductResponse;
import sample.cafekiosk.spring.domain.order.Order;

import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;

@Getter
public class OrderResponse {

    private Long id;
    private int totalPrice;
    private LocalDateTime registeredDateTime;
    private List<ProductResponse> products;


    @Builder
    public OrderResponse(Long id, int totalPrice, LocalDateTime registeredDateTime, List<ProductResponse> products) {
        this.id = id;
        this.totalPrice = totalPrice;
        this.registeredDateTime = registeredDateTime;
        this.products = products;
    }

    public static OrderResponse of(Order order) {
        return OrderResponse.builder()
                .id(order.getId())
                .totalPrice(order.getTotalPrice())
                .registeredDateTime(order.getRegisteredDateTime())
                .products(order.getOrderProducts().stream()
                        .map(orderProduct -> ProductResponse.of(orderProduct.getProduct()))
                        .collect(Collectors.toList()))
                .build();
    }
}

참고로 OrderService에서 생성된 Order를 DB에 넣어주는 것이 없다.
그리고 OrderService에서 현재 OrderRepository도 빠졌다.

OrderRepository이다.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
}

최종 OrderService 코드는 다음과 같다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;


    public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTme) {
        List<String> productNumbers = request.getProductNumbers();

        // Product를 찾는다. ProductRepository가 필요하다.
        List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);

        // Order 생성
        Order order = Order.create(products, registeredDateTme);
        Order savedOrder = orderRepository.save(order);

        return OrderResponse.of(savedOrder);
    }

}

그리고 서비스 테스트를 실행시키면, 테스트가 실패한다.

왜냐하면 사실 LocalDateTime을 when에서나, then에서나 now()로 주었기 때문이다.
LocalDateTime을 추출해서 동일한 시간을 받도록 해야한다.
(변수명 오타 주의..!)

최종 ServiceTest 코드는 다음과 같다.

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import sample.cafekiosk.spring.api.controller.order.request.OrderCreateRequest;
import sample.cafekiosk.spring.api.controller.order.response.OrderResponse;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.ProductRepository;
import sample.cafekiosk.spring.domain.ProductType;

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

import static org.assertj.core.api.AssertionsForClassTypes.tuple;
import static sample.cafekiosk.spring.domain.ProductSellingStatus.*;
import static sample.cafekiosk.spring.domain.ProductType.HANDMADE;

@ActiveProfiles("test")
@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private ProductRepository productRepository;


    @Test
    @DisplayName("주문 번호 리스트를 받아 주문을 생성한다.")
    void createOrder() {
        // given
        LocalDateTime registeredDateTime = LocalDateTime.now();

        Product product1 = createProduct(HANDMADE, "001", 1000);
        Product product2 = createProduct(HANDMADE, "002", 3000);
        Product product3 = createProduct(HANDMADE, "003", 5000);

        productRepository.saveAll(List.of(product1, product2, product3));

        OrderCreateRequest request = OrderCreateRequest.builder()
                .productNumbers(List.of("001", "002"))
                .build();

        // when
        OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);

        // then
        Assertions.assertThat(orderResponse.getId()).isNotNull();
        Assertions.assertThat(orderResponse)
                .extracting("registeredDateTime", "totalPrice")
                .contains(registeredDateTime, 4000);

        Assertions.assertThat(orderResponse.getProducts()).hasSize(2)
                .extracting("productNumber", "price")
                .containsExactlyInAnyOrder(
                        tuple("001", 1000),
                        tuple("002", 3000)
                );
    }

    private Product createProduct(ProductType type, String productNumber, int price) {
        return Product.builder()
                .type(type)
                .productNumber(productNumber)
                .price(price)
                .sellingStatus(SELLING)
                .name("메뉴 이름") // 이름이 같아도 상관없음.
                .build();
    }
}

하나의 시나리오가 더 있다.
바로 중복되는 상품번호 리스트로 주문을 생성하는 시나리오이다.

OrderServiceTest 클래스에서 다음과 같은 테스트를 해본다.

@Test
@DisplayName("중복되는 상품번호 리스트로 주문을 생성할 수 있다.")
void createOrderWithDuplicateProductNumbers() {
    // given
    LocalDateTime registeredDateTime = LocalDateTime.now();

    Product product1 = createProduct(HANDMADE, "001", 1000);
    Product product2 = createProduct(HANDMADE, "002", 3000);
    Product product3 = createProduct(HANDMADE, "003", 5000);

    productRepository.saveAll(List.of(product1, product2, product3));

    OrderCreateRequest request = OrderCreateRequest.builder()
            .productNumbers(List.of("001", "001"))
            .build();

    // when
    OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);

    // then
    Assertions.assertThat(orderResponse.getId()).isNotNull();
    Assertions.assertThat(orderResponse)
            .extracting("registeredDateTime", "totalPrice")
            .contains(registeredDateTime, 2000);

    Assertions.assertThat(orderResponse.getProducts()).hasSize(2)
            .extracting("productNumber", "price")
            .containsExactlyInAnyOrder(
                    tuple("001", 1000),
                    tuple("001", 1000)
            );

}

결과는 실패다.
왜냐하면 중복에 대한 처리를 하지 않았기 때문이다.

OrderService 클래스에서


public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTme) {
    List<String> productNumbers = request.getProductNumbers();

    // Product를 찾는다. ProductRepository가 필요하다.
    List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);

    // Order 생성
    Order order = Order.create(products, registeredDateTme);
    Order savedOrder = orderRepository.save(order);

    return OrderResponse.of(savedOrder);
}

해당 메서드에서 문제가 되는 부분은

List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);

이 부분이다.
findAllByProductNumberIn 메서드가 In절로 받는데,
In 절로 받다보니 알아서 중복제거가 된다.
따라서 제품 번호 001에 대한 product가 하나만 나오게 된다.

따라서 여기서 데이터의 가공이 필요하다.

수정한 메서드는 다음과 같다.

public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTme) {
    List<String> productNumbers = request.getProductNumbers();

    // Product를 찾는다. ProductRepository가 필요하다.
    List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);

    // 프로덕트 넘버에 맞는 제품들을 가지고 온다.
    Map<String, Product> productMap = products.stream()
            .collect(Collectors.toMap(Product::getProductNumber, p -> p));

    // productNumbers 를 순회하면서 제품번호에 맞는 객체들을 List로 뽑아낸다.
    List<Product> duplicateProducts = productNumbers.stream()
            .map(productMap::get)
            .collect(Collectors.toList());

    // Order 생성
    Order order = Order.create(duplicateProducts, registeredDateTme);
    Order savedOrder = orderRepository.save(order);

    return OrderResponse.of(savedOrder);
}

추가된 부분은 다음과 같다.


...

// 프로덕트 넘버에 맞는 제품들을 가지고 온다.
Map<String, Product> productMap = products.stream()
        .collect(Collectors.toMap(Product::getProductNumber, p -> p));

// productNumbers 를 순회하면서 제품번호에 맞는 객체들을 List로 뽑아낸다.
List<Product> duplicateProducts = productNumbers.stream()
        .map(productMap::get)
        .collect(Collectors.toList());
...

중복 제거된 product가 조회될 것이고,
productNumber를 기반으로 product를 빨리 찾을 수 있는 map을 하나 만들어 product 객체들을 조회했다.

결과는 다음과 같다.

지금은 TDD 과정 중 레드-그린 과정을 보았기 떄문에 이제 리팩터링을 해보도록 한다.

Service의 createOrder메서드에서 product를 찾고, product를 반환하는 부분을 메서드화 시켰다.
결과는 다음 코드와 같다.

    public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTme) {
        List<String> productNumbers = request.getProductNumbers();

        List<Product> duplicateProducts = findProductsBy(productNumbers);

        // Order 생성
        Order order = Order.create(duplicateProducts, registeredDateTme);
        Order savedOrder = orderRepository.save(order);

        return OrderResponse.of(savedOrder);
    }

    private List<Product> findProductsBy(List<String> productNumbers) {
        // Product를 찾는다. ProductRepository가 필요하다.
        List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);

        // 프로덕트 넘버에 맞는 제품들을 가지고 온다.
        Map<String, Product> productMap = products.stream()
                .collect(Collectors.toMap(Product::getProductNumber, p -> p));

        // productNumbers 를 순회하면서 제품번호에 맞는 객체들을 List로 뽑아낸다.
        return productNumbers.stream()
                .map(productMap::get)
                .collect(Collectors.toList());
    }

테스트 또한 성공했다.

그러나 문제가 발생했다....
한 번에 테스트를 하면 테스트가 실패한다.

이유는 다음과 같다.

서로의 테스트가 서로에게 영향을 준다.
그러다보니 OrderService에서 map을 만드는 부분에서 key의 중복이 발생했기 때문에 다음과 같은 오류가 발생했다.

ava.lang.IllegalStateException: Duplicate key 001 (attempted merging values sample.cafekiosk.spring.domain.Product@7976d382 and sample.cafekiosk.spring.domain.Product@4feea86f)

즉, 1번 테스트가 성공하고, map에 대한 key가 이미 생겼고,
2번 테스를 진행할 때 map에 대한 key를 만들려고 하는데, 이미 있기 때문에 중복이 발생하여 문제가 발생한 것이다.

따라서 Generate 기능에서 제공하는 TearDown 메서드를 통해서 데이터를 비워주는 작업이 필요하다.

각 Entity를 저장하는 Repository를 생성하고, 주입받는다.

@Autowired
private OrderService orderService;

@Autowired
private OrderRepository orderRepository;

@Autowired
private OrderProductRepository orderProductRepository;

@Autowired
private ProductRepository productRepository;

@AfterEach
void tearDown() {
    orderProductRepository.deleteAllInBatch();
    productRepository.deleteAllInBatch();
    orderRepository.deleteAllInBatch();
}

@AfterEach

  • 테스트가 끝난 후의 동작을 정의한다.
    • 여기선 모든 데이터들을 비우게 했다.

다음은 생성한 OrderProductRepository 클래스이다.

// OrderProductRepository
@Repository
public interface OrderProductRepository extends JpaRepository<OrderProduct, Long> {
}

드디어 테스트가 성공한다.

저 초록색 체크가 여러 개 있는 게 너무 이쁘다..

이처럼 데이터가 클렌징하지 않아서 생긴 문제였다.

그런데, 서비스는 이처럼 데이터를 클렌징 해줬어야 테스트가 다 성공하는데,
이전에 Persistence계층에서 했던 테스트는 데이터를 클랜징하지 않더라도 테스트가 성공한다.

왜 그럴까?
Repository 계층의 테스트에 선언한 @DataJpaTest 덕분이다.

이 애너테이션에 내부적으로 가보면, @Transactional이 있음을 알 수 있다.

그렇지만, @SpringBootTest에는 @Transactional이 없기 때문에, 데이터를 따로 클랜징해주어야 한다.

그러면 "@SpringBootTest@Transactional을 붙이면 되지않는가?" 생각할수도 있다.
그래서 TearDown Method를 주석처리하고, 테스트를 진행하면 테스트가 성공한다.

그런데 이렇게 @Tractional을 붙이게 되면 생기는 문제가 있는데, 이는 추후에 다룬다.

그렇다면 실제 동작은 하는가..?

컨트롤러를 다음과 같이 완성해준다.

@PostMapping("/api/v1/orders/new")
public OrderResponse createOrder(@RequestBody OrderCreateRequest request) {
    LocalDateTime registeredDateTme = LocalDateTime.now();
    return orderService.createOrder(request, registeredDateTme);
}

인텔리제이에서 다음과 같이 프로젝트 최상위 디렉터리에 http라는 디렉터리를 만들고, order.http를 생성해준다.

인텔리제이가 REST API를 빠르고 쉽게 쓸 수 있도록 지원을 해준다.

그리고 서버를 Debug 모드로 띄우고,
HTTP를 쏴주면 된다.

product.http파일을 만들어서 다음과 같이 작성하고 쏴준다.

쏴주면 아래와 같이 나온다.

다음은 Order에 대한 POST를 쏘자.

ㅋㅋ 500 에러 발생했다.
에러가 발생했는데, 로그를 보면,

... (no Creators, like default constructor ...

이렇게 나오는데, 결론은 OrderCreateRequest 클래스에 기본생성자가 없어서 그렇다.

다음과 같이 기본생성자를 추가해주고, 서버를 다시 띄운다.

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;

@Getter
@NoArgsConstructor
public class OrderCreateRequest {

    private List<String> productNumbers;

    @Builder
    public OrderCreateRequest(List<String> productNumbers) {
        this.productNumbers = productNumbers;
    }
}

POST 통신이 다음과 같이 나온다.

H2에서도 확인해보면 다음과 같아 잘 나온다.

방금 전 컨트롤러 테스트가 없는 상태여서 기본생성자가 없는 경우의 이슈가 발생했다.
컨트롤러 테스트는 다음 포스팅 쪽에서 다룬다.

계속 해서 다음 사항으로 넘어가보면,
요구사항이 더 생겼다고 가정해보자.

요구사항

  • 주문 생성 시 재고 확인 및 개수 차감 후 생성하기
  • 재고는 상품번호를 가진다.
  • 재고와 관련 있는 상품 타입은 병 음로, 베이커리다.

이와 같은 요구사항이 있다.

이제 재고에 대한 기능을 TDD로 만들어볼 것이다.
Teardown Method를 주석처리하고, @SpringBootTest@Transactional 애너테이션을 추가선언해준다.

Test 코드를 다음과 같이 작성하다보면, 재고(Stock) Entity가 필요함을 알게 된다.

 @Test
@DisplayName("재고와 관련된 상품이 포함되어 있는 주문번호 리스트를 받아 주문을 생성한다.")
void createOrderWithStock() {
    // given
    LocalDateTime registeredDateTime = LocalDateTime.now();

    Product product1 = createProduct(BOTTLE, "001", 1000);
    Product product2 = createProduct(BAKERY, "002", 3000);
    Product product3 = createProduct(HANDMADE, "003", 5000);

    productRepository.saveAll(List.of(product1, product2, product3));

    OrderCreateRequest request = OrderCreateRequest.builder()
            .productNumbers(List.of("001", "001","002","003"))
            .build();

다음은 Stock ENtity 이다.

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Builder;
import sample.cafekiosk.spring.domain.BaseEntity;

@Entity
public class Stock extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String productNumber;

    private int quantity;

    @Builder
    public Stock(String productNumber, int quantity) {
        this.productNumber = productNumber;
        this.quantity = quantity;
    }


}

다시 테스트 코드로 간다.

Stock를 엔티티를 생성하려고 하는데, 제품번호와 수량으로 다음과 같이 생성햐려한다.

Stock stock = Stock.create("001",2);

따라서 Stock에 create() 메서들르 작성해야한다.

    public static Stock create(String productNumber, int quantity) {
        return Stock.builder()
                .productNumber(productNumber)
                .quantity(quantity)
                .build();

    }

이와 같이 하면 된다.

다시 테스트 코드로 가서 001번과 002 에 대한 제품을 두 개를 생성했다.
이제 save를 하려니깐 Stock에 대한 Repository가 없다.
생성해주자.

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
}

그리고 테스트 클래스에서 주입받도록 한다.

@ActiveProfiles("test")
@SpringBootTest
@Transactional
class OrderServiceTest {

    ...

    @Autowired
    private StockRepository stockRepository;
    ...

@Test
@DisplayName("재고와 관련된 상품이 포함되어 있는 주문번호 리스트를 받아 주문을 생성한다.")
void createOrderWithStock() {
    // given
    LocalDateTime registeredDateTime = LocalDateTime.now();

    Product product1 = createProduct(BOTTLE, "001", 1000);
    Product product2 = createProduct(BAKERY, "002", 3000);
    Product product3 = createProduct(HANDMADE, "003", 5000);
    productRepository.saveAll(List.of(product1, product2, product3));

    Stock stock1 = Stock.create("001",2);
    Stock stock2 = Stock.create("002",2);
    stockRepository.saveAll(List.of(stock1, stock2));

    OrderCreateRequest request = OrderCreateRequest.builder()
            .productNumbers(List.of("001", "001","002","003"))
            .build();

    // when
    OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);

    // then
    Assertions.assertThat(orderResponse.getId()).isNotNull();
    Assertions.assertThat(orderResponse)
            .extracting("registeredDateTime", "totalPrice")
            .contains(registeredDateTime, 10000);

    Assertions.assertThat(orderResponse.getProducts()).hasSize(4)
            .extracting("productNumber", "price")
            .containsExactlyInAnyOrder(
                    tuple("001", 1000),
                    tuple("001", 1000),
                    tuple("002", 3000),
                    tuple("003", 5000)
            );

이전 테스트와 별 다른 건 없지만, 이제는 재고와 관련된 테스트가 진행되어야 한다.

001에 대한 제품은 2개, 002에 대한 재품은 1개 주문했으므로, 테스트는 다음과 같이 진행되어야 한다.

List<Stock> stocks = stockRepository.findAll();
Assertions.assertThat(stocks).hasSize(2)
        .extracting("productNumber", "quantity")
        .containsExactlyInAnyOrder(
            tuple("001",0),
            tuple("002",1)
        );

지금은 실패한다.

왜냐하면 재고와 관련된 기능이 추가 되지 않았기 때문이다.

서비스에서 재고 차감과 관련된 로직이 생성되어야 하는데, 다음과 같은 과정이 필요하다.

// 재고 차감 체크가 필요한 상품들 filter
// 재고 Entity 조회
// 샹품별 counting
// 재고 차감 시도

다음과 같이 로직 작성 중에
요구 사항에서 재고차감이 필요한 타입(병 음료, 베이커리)인지를 확인하는 과정이 필요했다.

    // 재고 차감 체크가 필요한 상품들 filter
    products.stream()
            .filter(product -> ProductType.containsStockType(product.getType())
    // 재고 Entity 조회
    // 샹품별 counting
    // 재고 차감 시도

그 과정에서 ProductType.containsStockType 메서드가 필요하고, 다음과 같이 작성해줬다.

@Getter
@RequiredArgsConstructor
public enum ProductType {   // 제품 타입

    HANDMADE("제조 음료"),
    BOTTLE("병 음료"),
    BAKERY("베이커리");

    private final String text;

    public static boolean containsStockType(ProductType type) {
        return List.of(BOTTLE, BAKERY).contains(type);
    }
}

containsStockType에 대한 단위 테스트가 필요하다.

class ProductTypeTest {

    @Test
    @DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
    void containsStockTypeTest() {
        // given
        ProductType givenType = ProductType.HANDMADE;

        // when
        boolean result = ProductType.containsStockType(givenType);

        // then
        assertThat(result).isFalse();
    }

}

결과는 성공이다.
간혹 "이렇게 간단한 테스트도 코드를 작성해야되냐?" 고 말하는 사람이 있다고 하는데, 당연하다
왜냐하면 지금 ProductType의 요구가 언제 바뀔수도 있다.
그래서 그러한 미래 시점을 대비하기 위해서는 필수다.

단위테스트 코드는 다음과 같다.

class ProductTypeTest {

    @Test
    @DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
    void containsStockTypeTest() {
        // given
        ProductType givenType = ProductType.HANDMADE;

        // when
        boolean result = ProductType.containsStockType(givenType);

        // then
        assertThat(result).isFalse();
    }

    @Test
    @DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
    void containsStockTypeTest2() {
        // given
        ProductType givenType = ProductType.BAKERY;

        // when
        boolean result = ProductType.containsStockType(givenType);

        // then
        assertThat(result).isTrue();
    }

}

결과는 다음과 같다.

이제 다시 Service로 가보자.

// 재고 차감 체크가 필요한 상품들 filter
List<String> stockProductNumbers = products.stream()
        .filter(product -> ProductType.containsStockType(product.getType()))
        .map(Product::getProductNumber)
        .collect(Collectors.toList());

해당 단계는 다 작성되었다.
filter과정에서 재고 관련 타입인지를 거르고,
걸러진 결과에서 product의 productNumber를 가지고 List를 작성한다.

그리고 이제 재고 엔티티를 조회해야 하는데,
StockRepository를 DI 해준다.

@Service
@RequiredArgsConstructor
public class OrderService {

    ...

    private final StockRepository stockRepository;

    ...

그리고 엔티티 조회 메서드를 StockRepository 에서 작성한다.

@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {

    List<Stock> findAllByProductNumberIn(List<String> productNumbers);
}

이렇게 작성한다.
그리고 Repository의 Test를 작성해야 한다.

참고로 앞으로 AssertJ의 Assertions는 static import 할 것이다.

@DataJpaTest
class StockRepositoryTest {

    @Autowired
    private StockRepository stockRepository;

    @Test
    @DisplayName("상품번호 리스트로 재고를 조회한다.")
    void findAllByProductNumberIn() {
        // given
        Stock stock1 = Stock.create("001", 1);
        Stock stock2 = Stock.create("002", 2);
        Stock stock3 = Stock.create("003", 3);
        stockRepository.saveAll(List.of(stock1, stock2, stock3));

        // when
        List<Stock> stocks = stockRepository.findAllByProductNumberIn(List.of("001", "002"));

        // then
        assertThat(stocks).hasSize(2)
                .extracting("productNumber", "quantity")
                .containsExactlyInAnyOrder(
                        tuple("001", 1),
                        tuple("002", 2)
                );
    }
}

결과는 다음과 같다.

이제 조회를 할 수 있게 되었다.
서비스를 계속 작성하면,

// 재고 차감 체크가 필요한 상품들 filter
List<String> stockProductNumbers = products.stream()
        .filter(product -> ProductType.containsStockType(product.getType()))
        .map(Product::getProductNumber)
        .collect(Collectors.toList());

// 재고 Entity 조회
List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers);

// 샹품별 counting

상품별 counting은 다음과 같이 한다.

// 샹품별 counting
Map<String, Long> productCountingMap = stockProductNumbers.stream()
        .collect(Collectors.groupingBy(p -> p, Collectors.counting()));

이렇게 하면, productNumber 가 key가 되고, 그에 따른 수량이 value가 된다.
이제 재고 차감을 시도하면 된다.

// 재고 차감 시도
for(String stockProductNumber : stockProductNumbers) {

여기까지 작성했지만, 조회한 재고 Entity를 돌면서 재고를 차감해주어야 하는데, List를 돌기보단, Map을 돌도록 하기 위해 다음과 같이 코드를 작성했다.

// 재고 Entity 조회
List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers);
Map<String, Stock> stockMap = stocks.stream()
        .collect(Collectors.toMap(Stock::getProductNumber, s -> s));

이렇게 하면, Stock의 ProductNumber가 키가 도고, stock이 value가 된다.

다시 계속 코드를 작성해보면,


// 재고 차감 시도
for(String stockProductNumber : stockProductNumbers) {
    Stock stock = stockMap.get(stockProductNumber);
    int quantity = productCountingMap.get(stockProductNumber).intValue();

    if(stock.isQuantityLessThan(quantity)) // 재고가 차감이 가능한지 여부
}

코드를 작성하다가 재고가 차감이 가능한지 여부에 대한 메서드가 있어야 될 거 같았다.
그래서 그 메서드를 작성해야한다. 다음과 같다.

public boolean isQuantityLessThan(int quantity) {
    return this.quantity < quantity;
}

현재의 재고보다, 더 많이 주문되면 안 된다.
그리고 이 메서드 또한 테스트 해준다.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

class StockTest {

    @Test
    @DisplayName("재고의 수량이 제공된 수량보다 적은지 확인한다.")
    void isQuantityLessThan() {
        // given
        Stock stock = Stock.create("001", 1);
        int quantity = 2;

        // when
        boolean result = stock.isQuantityLessThan(quantity);

        // then
        assertThat(result).isTrue();
    }
}

결과는 다음과 같다.

그리고 현재 재고보다 많은 수량의 요청이 올 경우 예외처리를 해줘야 한다.

if(stock.isQuantityLessThan(quantity)) {
    throw new IllegalArgumentException("재고가 부족한 상품이 있습니다.");
}

이 if문이 통과되었으면, 이제 재고를 차감한다.

stock.deductQuantity(quantity);

이 메서드 또한 작성해야하고, 테스트를 돌려줘야 한다.

public void deductQuantity(int quantity) {

}

일단 이 상태에서 테스트를 돌린다.

@Test
@DisplayName("주어진 개수만큼 재고를 차감할 수 있다.")
void deductQuantity() {
    // given
    Stock stock = Stock.create("001", 1);
    int quantity = 1;

    // when
    stock.deductQuantity(quantity);

    // then
    assertThat(stock.getQuantity()).isZero(0);
}

결과는 다음과 같다.

메서드를 제대로 작성한다.(간단하다.)

public void deductQuantity(int quantity) {
    this.quantity -= quantity;
}

이제 성공한다.

또 테스트 해볼 것은 주어진 재고보다 더 차감하게 되면 어떻게 되냐는 것이다.

테스트를 다음과 같이 작성했다.

@Test
@DisplayName("재고보다 많은 수의 수량으로 차감 시도하는 경우 예외가 발생한다.")
void Test() {
    // given
    Stock stock = Stock.create("001", 1);
    int quantity = 2;

    // when // then
    assertThatThrownBy(() -> stock.deductQuantity(quantity))
            .isInstanceOf(IllegalArgumentException.class)       // 어떤 예외인지
            .hasMessage("차감할 재고 수량이 없습니다.");     // 어떤 메세지인지
}

결과는 다음과 같다.

예외 처리가 되어있지 않기 떼문에 예외처리를 하면 된다.

public void deductQuantity(int quantity) {
    if(isQuantityLessThan(quantity)) {
        throw new IllegalArgumentException("차감할 재고 수량이 없습니다.");
    }
    this.quantity -= quantity;
}

테스트는 통과다.

한 번 전체 테스트를 돌려보면,

기분이 좋다.

간혹 이렇게 생각할 수 있다.

Service에서 isQuantityLessThan을 통해서 재고 수량 체크를 하는데, Stock의 deductQuantity 메서드에서 또 재고수량 체크를 하는지에 대한 생각이 들 수 있지만,

관점을 다르게 봐야 한다.

서비스 로직에서의 체크는 주문 생성 로직을 수행하다가 Stock에 대한 재고차감을 시도하는 과정이고,
Stock 자체의 deductQuantity 메서드는 Stock은 밖에 서비스가 어떻게 되어있는지 전혀 모른다.
따라서 수량을 차감할 때 올바른 수량 차감 로직이 수행되었다는 것을 Stock 자체만으로 보장을 해줘야 한다.
그렇기 때문에, 서비스 클래스에서의 체크 후 예외를 던지는 것과, Entity 자체 메서드에서 체크 후 예외를 던지는 것은 아주 다른 상황이다.

그리고 deductQuantity 메서드를 다른 곳에서도 사용할 가능성이 있다.
따라서 Stock Entity 자체에서 올바른 로직이 보장되어야 한다.

현재까지 완성된 메서드는 다음과 같다.

public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTme) {
    List<String> productNumbers = request.getProductNumbers();

    List<Product> products = findProductsBy(productNumbers);

    // 재고 차감 체크가 필요한 상품들 filter
    List<String> stockProductNumbers = products.stream()
            .filter(product -> ProductType.containsStockType(product.getType()))
            .map(Product::getProductNumber)
            .collect(Collectors.toList());

    // 재고 Entity 조회
    List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers);
    Map<String, Stock> stockMap = stocks.stream()
            .collect(Collectors.toMap(Stock::getProductNumber, s -> s));

    // 샹품별 counting
    Map<String, Long> productCountingMap = stockProductNumbers.stream()
            .collect(Collectors.groupingBy(p -> p, Collectors.counting()));

    // 재고 차감 시도
    for(String stockProductNumber : stockProductNumbers) {
        Stock stock = stockMap.get(stockProductNumber);
        int quantity = productCountingMap.get(stockProductNumber).intValue();

        if(stock.isQuantityLessThan(quantity)) {
            throw new IllegalArgumentException("재고가 부족한 상품이 있습니다.");
        }
        stock.deductQuantity(quantity);
    }

    // Order 생성
    Order order = Order.create(products, registeredDateTme);
    Order savedOrder = orderRepository.save(order);

    return OrderResponse.of(savedOrder);
}

private List<Product> findProductsBy(List<String> productNumbers) {
    // Product를 찾는다. ProductRepository가 필요하다.
    List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);

    // 프로덕트 넘버에 맞는 제품들을 가지고 온다.
    Map<String, Product> productMap = products.stream()
            .collect(Collectors.toMap(Product::getProductNumber, p -> p));

    // productNumbers 를 순회하면서 제품번호에 맞는 객체들을 List로 뽑아낸다.
    return productNumbers.stream()
            .map(productMap::get)
            .collect(Collectors.toList());
}

아까 작성했던 Test를 돌려보면, 결과는 실패다.

문제는 이 부분이다.

// 재고 차감 시도
for(String stockProductNumber : stockProductNumbers) {
    Stock stock = stockMap.get(stockProductNumber);
    int quantity = productCountingMap.get(stockProductNumber).intValue();

    if(stock.isQuantityLessThan(quantity)) {
        throw new IllegalArgumentException("재고가 부족한 상품이 있습니다.");
    }
    stock.deductQuantity(quantity);
}

순회를 돌고 있는 stockProductNumbers에서 중복이 제거되지 않은 상태에서 순회를 하다보니, 제품 번호 "001"에 대한 재고가 잘 처리된 상황인데, 또 재고를 차감하려니 재고가 0개라서 실패한 것이다.

따라서 중복이 제거된 상태에서 순회하도록 해야한다.

for문을 다음과 같이 HashSet으로 만들어서 중복을 제거한 상태에서 순회하도록 해준다.

for (String stockProductNumber : new HashSet<>(stockProductNumbers)) {
    ...

이제 테스트는 성공한다.

다음으로 예외 케이스에 대한 테스트를 작성한다.

001의 제품번호를 가진 product의 재고가 1개인데, 2개를 주문하는 경우다.

@Test
@DisplayName("재고가 부족한 상품으로 주문을 생성하려는 경우 예외가 발생한다.")
void createOrderWithNoStock() {
    // given
    LocalDateTime registeredDateTime = LocalDateTime.now();

    Product product1 = createProduct(BOTTLE, "001", 1000);
    Product product2 = createProduct(BAKERY, "002", 3000);
    Product product3 = createProduct(HANDMADE, "003", 5000);
    productRepository.saveAll(List.of(product1, product2, product3));

    Stock stock1 = Stock.create("001",1);
    Stock stock2 = Stock.create("002",1);
    stockRepository.saveAll(List.of(stock1, stock2));

    OrderCreateRequest request = OrderCreateRequest.builder()
            .productNumbers(List.of("001","001","002","003"))
            .build();

    // when // then
    assertThatThrownBy(() -> orderService.createOrder(request, registeredDateTime))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("재고가 부족한 상품이 있습니다.");
}

결과는 다음과 같다.

중간에 given 쪽에서 다음과 같이 해도 테스트가 통과한다.

Stock stock1 = Stock.create("001",2);
Stock stock2 = Stock.create("002",2);
stock1.deductQuantity(1);
stockRepository.saveAll(List.of(stock1, stock2));

사실 지금 이렇게 하면 안 된다...!!
이와 관련된 얘기는 다음에 한다.

그런데 앞에서 @Transactional이 아니라, 데이터를 비워주면서 전체 테스트를 진행했었는데,
이번에 @Transactional을 주석처리하고, Teardown 메서드를 활성화 한 상태에서 전체 테스트를 진행해보겠다.

참고로 @Transactional을 사용하면 전체 테스트는 성공한다.

@ActiveProfiles("test")
@SpringBootTest
//@Transactional
class OrderServiceTest {

   ...

    @AfterEach
    void tearDown() {
        orderProductRepository.deleteAllInBatch();
        productRepository.deleteAllInBatch();
        orderRepository.deleteAllInBatch();
        stockRepository.deleteAllInBatch();
    }

이 상태에서는 테스트가 깨진다.

JPA의 쿼리를 살펴보면 다음과 같다.

Hibernate:
    insert
    into
        product
        (created_date_time, modified_date_time, name, price, product_number, selling_status, type, id)
    values
        (?, ?, ?, ?, ?, ?, ?, default)
Hibernate:
    insert
    into
        product
        (created_date_time, modified_date_time, name, price, product_number, selling_status, type, id)
    values
        (?, ?, ?, ?, ?, ?, ?, default)
Hibernate:
    insert
    into
        product
        (created_date_time, modified_date_time, name, price, product_number, selling_status, type, id)
    values
        (?, ?, ?, ?, ?, ?, ?, default)
Hibernate:
    insert
    into
        stock
        (created_date_time, modified_date_time, product_number, quantity, id)
    values
        (?, ?, ?, ?, default)
Hibernate:
    insert
    into
        stock
        (created_date_time, modified_date_time, product_number, quantity, id)
    values
        (?, ?, ?, ?, default)
Hibernate:
    select
        p1_0.id,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.name,
        p1_0.price,
        p1_0.product_number,
        p1_0.selling_status,
        p1_0.type
    from
        product p1_0
    where
        p1_0.product_number in (?, ?, ?, ?)
Hibernate:
    select
        s1_0.id,
        s1_0.created_date_time,
        s1_0.modified_date_time,
        s1_0.product_number,
        s1_0.quantity
    from
        stock s1_0
    where
        s1_0.product_number in (?, ?, ?)
Hibernate:
    insert
    into
        orders
        (created_date_time, modified_date_time, order_status, registered_date_time, total_price, id)
    values
        (?, ?, ?, ?, ?, default)
Hibernate:
    insert
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id)
    values
        (?, ?, ?, ?, default)
Hibernate:
    insert
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id)
    values
        (?, ?, ?, ?, default)
Hibernate:
    insert
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id)
    values
        (?, ?, ?, ?, default)
Hibernate:
    insert
    into
        order_product
        (created_date_time, modified_date_time, order_id, product_id, id)
    values
        (?, ?, ?, ?, default)
Hibernate:
    select
        s1_0.id,
        s1_0.created_date_time,
        s1_0.modified_date_time,
        s1_0.product_number,
        s1_0.quantity
    from
        stock s1_0
Hibernate:
    delete
    from
        order_product
Hibernate:
    delete
    from
        product
Hibernate:
    delete
    from
        orders
Hibernate:
    delete
    from
        stock

select 절이 나오기 전인 5개의 insert문은 테스트에서 given 에 대한 내용이다.

그리고 product와 stock에 대해 in 절을 통해서 select를 하고 있다.
그리고 order에 대한 insert도 잘 날라갔다.
그리고 orderProduct까지 잘 날라갔다.
그리고 테스트 마지막에 stock에서 findAll() 메서드를 호출하기때문에 select 절이 잘 들어갔다.
그런데, 테스트가 통과할 땐 보였던 update 쿼리문이 나오지 않았다.

그리고 마지막에 @AfterEach에서 데이터를 비워주기 위한 delete 문들이 나갔다.

왜 실패했을까?
사실 지금 Service 클래스에 @Transactional을 선언하지 않았다.

JPA는 트랜잭션 단위 안에서 동작하는데, 트랜잭션이 없기 때문에 변경감지가 동작하지 않는 것이다.

서비스 클래스에 @Transactional을 선언해주면 다시 동작할 것이다.

@Service
@Transactional
@RequiredArgsConstructor
public class OrderService {
    ...

서비스 테스트에서 @Transactional을 활용해서 롤백 테스트를 할 때 이런 부작용을 잘 알고 써야한다.
편하긴 하다. 그렇기 때문에 TearDown메서드로 작성했던, @AfterEach 없이도 알아서 데이터를 롤백해준다.

그렇지만, 주의해야할 점은 테스트 클래스에서 선언한 @Transactional이 마치 서비스 클래스에서도 트랜잭션이 설정되어 있는 것처럼 보인다.

그래서 테스트가 진행되었지만, 실제 배포해야하는 프로덕션 코드에서는 동작하지 않을 수 있다.
그래서 문제가 뒤늦게 문제가 발생할 수 있기 때문에 잘 유의해서 사용해야 한다.

어쨋든 전체 테스트의 그린을 봤기 때문에 리팩터링 과정을 거치면 된다.

리팩터링 된 코드는 다음과 같다.

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import sample.cafekiosk.spring.api.controller.order.request.OrderCreateRequest;
import sample.cafekiosk.spring.api.controller.order.response.OrderResponse;
import sample.cafekiosk.spring.domain.Product;
import sample.cafekiosk.spring.domain.ProductRepository;
import sample.cafekiosk.spring.domain.ProductType;
import sample.cafekiosk.spring.domain.order.Order;
import sample.cafekiosk.spring.domain.order.OrderRepository;
import sample.cafekiosk.spring.domain.stock.Stock;
import sample.cafekiosk.spring.domain.stock.StockRepository;

import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.stream.Collector;
import java.util.stream.Collectors;

@Service
@Transactional
@RequiredArgsConstructor
public class OrderService {

    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final StockRepository stockRepository;


    public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTme) {
        List<String> productNumbers = request.getProductNumbers();

        List<Product> products = findProductsBy(productNumbers);

        deductStockQuantities(products);

        // Order 생성
        Order order = Order.create(products, registeredDateTme);
        Order savedOrder = orderRepository.save(order);

        return OrderResponse.of(savedOrder);
    }

    private void deductStockQuantities(List<Product> products) {
        List<String> stockProductNumbers = extractStockProductNumbers(products);

        Map<String, Stock> stockMap = createStockMapBy(stockProductNumbers);
        Map<String, Long> productCountingMap = createCountingMapBy(stockProductNumbers);

        for (String stockProductNumber : new HashSet<>(stockProductNumbers)) {
            Stock stock = stockMap.get(stockProductNumber);
            int quantity = productCountingMap.get(stockProductNumber).intValue();

            if(stock.isQuantityLessThan(quantity))

            {
                throw new IllegalArgumentException("재고가 부족한 상품이 있습니다.");
            }
            stock.deductQuantity(quantity);
        }
    }



    private List<Product> findProductsBy(List<String> productNumbers) {
        // Product를 찾는다. ProductRepository가 필요하다.
        List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);

        // 프로덕트 넘버에 맞는 제품들을 가지고 온다.
        Map<String, Product> productMap = products.stream()
                .collect(Collectors.toMap(Product::getProductNumber, p -> p));

        // productNumbers 를 순회하면서 제품번호에 맞는 객체들을 List로 뽑아낸다.
        return productNumbers.stream()
                .map(productMap::get)
                .collect(Collectors.toList());
    }

    private List<String> extractStockProductNumbers(List<Product> products) {
        return products.stream()
                .filter(product -> ProductType.containsStockType(product.getType()))
                .map(Product::getProductNumber)
                .collect(Collectors.toList());

    }
    private Map<String, Stock> createStockMapBy(List<String> stockProductNumbers) {
        List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers);
        return stocks.stream()
                .collect(Collectors.toMap(Stock::getProductNumber, s -> s));
    }

    private Map<String, Long> createCountingMapBy(List<String> stockProductNumbers) {
        return stockProductNumbers.stream()
                .collect(Collectors.groupingBy(p -> p, Collectors.counting()));
    }

}

테스트도 통과한다.

"사실 재고 감소가 간단한 것이 아니다.
동시성 문제의 대표적인 문제다.
락에 대한 개념을 알고 가면 좋을 거 같다." 고 함.

3. 요약

요구사항에 따라 서비스 클래스에서 주문 기능을 TDD를 통해서 만들어봤다.
중간에 요구사항이 또 변경되기도 한다. 하나의 기능을 완성하기 위해 많은 메서드가 필요했는데,
그 메서드마저 테스트를 진행했다.

프로덕션 코드만 만들기에는 뚝딱이겠지만, TDD로 하려해서 그런가, 코드를 미친듯이 작성했다.

하지만, 게속 이 로직과 작성한 메서드가 잘 동작하는지 확인하는 과정에서
지금 작성되고 있는 코드가 신뢰까지는 잘 아직 모르겠지만,
안전성은 보장된다는 것을 알게되었다.

다음은 Controller쪽인 Presentation 계층 테스트를 진행할 예정인데,
기대가 된다. ^^

개인적으로 알아야될 것 : 람다, 스트림, 기타 처음 본 애너테이션 및 메서드... 등등

728x90
Comments