본문 바로가기

백엔드 엔지니어링 일지

마켓 백엔드 엔진 15 : 주문 시스템 개발 (트랜잭션, JUnit)

주문 시스템을 개발합니다.

 

먼저 Order 도메인, 로직을 개발하고

Product 재고 차감과 연동하여

정합성 확인 단위 테스트 및 통합 테스트를 진행합니다.

 

k6 테스트 이전에

Junit 테스트는 로컬에서 진행하겠습니다.

 

Order.java

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

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

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    @Column(nullable = false)
    private int quantity;

    @Column(name = "unit_price", nullable = false, precision = 19, scale = 4)
    private BigDecimal unitPrice;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 32)
    private OrderStatus status;

    @Column(name = "total_amount", nullable = false, precision = 19, scale = 4)
    private BigDecimal totalAmount;

    @Column(name = "created_at", nullable = false, updatable = false)
    private OffsetDateTime createdAt;

    public Order(Member member, Product product, int quantity, BigDecimal unitPrice, OrderStatus status) {
        this.member = member;
        this.product = product;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
        this.status = status;
        this.totalAmount = unitPrice.multiply(BigDecimal.valueOf(quantity));
    }

    @PrePersist
    void assignCreatedAt() {
        if (createdAt == null) {
            createdAt = OffsetDateTime.now();
        }
    }

    public void changeStatus(OrderStatus status) {
        this.status = status;
    }

    public void changeQuantity(int quantity) {
        this.quantity = quantity;
        this.totalAmount = unitPrice.multiply(BigDecimal.valueOf(quantity));
    }
}
  • id: 주문 ID
  • member_id: 주문자
  • product_id: 주문 상품
  • quantity: 주문 수량
  • unit_price: 주문 시점 단가 스냅샷
  • status: 주문 상태 (CREATED, CONFIRMED, CANCELLED)
  • total_amount: unit_price * quantity 
  • created_at: 생성 시각

 

OrderService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ProductRepository productRepository;

    @Transactional
    public OrderResponse create(CreateOrderRequest request) {
        Member member = memberRepository.findById(request.memberId())
                .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Member not found"));
        Product product = productRepository.findById(request.productId())
                .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Product not found"));

        product.decreaseStock(request.quantity());

        Order saved = orderRepository.save(
                new Order(member, product, request.quantity(), product.getPriceAmount(), OrderStatus.CREATED)
        );
        return OrderResponse.from(saved);
    }

    public OrderResponse get(Long orderId) {
        return OrderResponse.from(findOrder(orderId));
    }

    public List<OrderResponse> list() {
        return orderRepository.findAll().stream().map(OrderResponse::from).toList();
    }

    @Transactional
    public OrderResponse update(Long orderId, UpdateOrderRequest request) {
        Order order = findOrder(orderId);
        order.changeStatus(request.status());
        order.changeQuantity(request.quantity());
        return OrderResponse.from(order);
    }

    @Transactional
    public void delete(Long orderId) {
        Order order = findOrder(orderId);
        orderRepository.delete(order);
    }

    private Order findOrder(Long orderId) {
        return orderRepository.findById(orderId)
                .orElseThrow(() -> new BusinessException(ErrorCode.RESOURCE_NOT_FOUND, "Order not found"));
    }
}

 

 

OrderService 클래스에 @Transactional(readOnly = true)

  • 기본값이 읽기 전용 트랜잭션입니다.

create() 메소드에 @Transactional 

  • member 조회 > product 조회 > product 재고 감소 > order 저장
  • 따라서 재고 감소 + 주문 저장까지 같은 트랜잭션에서 실행됩니다.
  • 중간에 예외가 발생하면 전체 롤백되어 원자성을 보장합니다.

 

domain/Product.java - decreaseStock() 함수 추가

public void decreaseStock(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive");
        }
        if (stockQuantity < quantity) {
            throw new BusinessException(ErrorCode.INSUFFICIENT_STOCK, "Insufficient stock");
        }
        stockQuantity -= quantity;
    }

이제 order는 stock을 감소시킵니다.

만약 재고보다 많은 order가 들어온다면 재고 부족 BusinessException을 던집니다.


JUnit 테스트 진행 - 로직 단위 테스트 

 

ProductStockTest

class ProductStockTest {

    @Test
    void decreaseStock_reducesQuantity() {
        Product product = sampleProduct(10);

        product.decreaseStock(1);

        assertThat(product.getStockQuantity()).isEqualTo(9);
    }

    @Test
    void decreaseStock_throwsWhenInsufficient() {
        Product product = sampleProduct(1);

        assertThatThrownBy(() -> product.decreaseStock(2))
                .isInstanceOf(BusinessException.class)
                .satisfies(ex -> assertThat(((BusinessException) ex).errorCode()).isEqualTo(ErrorCode.INSUFFICIENT_STOCK));
        assertThat(product.getStockQuantity()).isEqualTo(1);
    }

    private static Product sampleProduct(int stockQuantity) {
        return new Product(
                "sample",
                new BigDecimal("10.00"),
                stockQuantity,
                "detail",
                ProductCategory.TOP,
                "BRAND",
                "BLACK",
                "UNISEX",
                "ACTIVE",
                0
        );
    }
}
  • 생성 데이터
    • Product 객체 직접 생성
    • 케이스1: stock=10, decreaseStock(1)
    • 케이스2: stock=1, decreaseStock(2)
  • 예상 결과
    • 케이스1: stock 9
    • 케이스2: BusinessException(INSUFFICIENT_STOCK), stock 1 유지

테스트 통과


OrderServiceTest 

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private MemberRepository memberRepository;

    @Mock
    private ProductRepository productRepository;

    @InjectMocks
    private OrderService orderService;

    @Test
    void create_buildsOrderWithProductPrice() {
        Member member = new Member("user@test.com", "pw", "user");
        Product product = new Product(
                "book",
                new BigDecimal("30.00"),
                5,
                "book detail",
                ProductCategory.TOP,
                "CORE",
                "BLACK",
                "UNISEX",
                "ACTIVE",
                100
        );

        when(memberRepository.findById(1L)).thenReturn(Optional.of(member));
        when(productRepository.findById(2L)).thenReturn(Optional.of(product));
        when(orderRepository.save(any(Order.class))).thenAnswer(invocation -> invocation.getArgument(0));

        OrderResponse response = orderService.create(new CreateOrderRequest(1L, 2L, 3));

        assertThat(response.unitPrice()).isEqualByComparingTo("30.00");
        assertThat(response.totalAmount()).isEqualByComparingTo("90.00");
        assertThat(response.status()).isEqualTo(OrderStatus.CREATED);
        assertThat(product.getStockQuantity()).isEqualTo(2);
    }

    @Test
    void create_throwsInsufficientStockWhenQuantityExceedsStock() {
        Member member = new Member("user@test.com", "pw", "user");
        Product product = new Product(
                "book",
                new BigDecimal("30.00"),
                5,
                "book detail",
                ProductCategory.TOP,
                "CORE",
                "BLACK",
                "UNISEX",
                "ACTIVE",
                100
        );

        when(memberRepository.findById(1L)).thenReturn(Optional.of(member));
        when(productRepository.findById(2L)).thenReturn(Optional.of(product));

        assertThatThrownBy(() -> orderService.create(new CreateOrderRequest(1L, 2L, 6)))
                .isInstanceOf(BusinessException.class)
                .satisfies(ex -> assertThat(((BusinessException) ex).errorCode()).isEqualTo(ErrorCode.INSUFFICIENT_STOCK));
        assertThat(product.getStockQuantity()).isEqualTo(5);
    }

    @Test
    void create_throwsNotFoundWhenMemberMissing() {
        when(memberRepository.findById(1L)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> orderService.create(new CreateOrderRequest(1L, 2L, 1)))
                .isInstanceOf(BusinessException.class)
                .satisfies(ex -> assertThat(((BusinessException) ex).errorCode()).isEqualTo(ErrorCode.RESOURCE_NOT_FOUND));
    }

    @Test
    void update_changesStatusAndQuantity() {
        Member member = new Member("user@test.com", "pw", "user");
        Product product = new Product(
                "book",
                new BigDecimal("30.00"),
                5,
                "book detail",
                ProductCategory.TOP,
                "CORE",
                "BLACK",
                "UNISEX",
                "ACTIVE",
                100
        );
        Order order = new Order(member, product, 1, new BigDecimal("30.00"), OrderStatus.CREATED);
        when(orderRepository.findById(10L)).thenReturn(Optional.of(order));

        OrderResponse response = orderService.update(10L, new UpdateOrderRequest(OrderStatus.CONFIRMED, 2));

        assertThat(response.status()).isEqualTo(OrderStatus.CONFIRMED);
        assertThat(response.quantity()).isEqualTo(2);
        assertThat(response.totalAmount()).isEqualByComparingTo("60.00");
    }
}

 

  • 생성 데이터
    • Member: 1명 (mock repo)
    • Product: price 30.00, stock 5
    • 요청: memberId=1, productId=2, quantity=3 (성공 케이스)
    • 요청: quantity=6 (재고 부족 케이스)
    • 요청: member 미존재 (findById -> empty)
  • 예상 결과
    • 성공: unitPrice=30.00, totalAmount=90.00, status=CREATED, stock=2
    • 재고 부족: BusinessException(INSUFFICIENT_STOCK), stock=5 유지
    • member 없음: BusinessException(RESOURCE_NOT_FOUND)

테스트 통과 

 


통합테스트 진행 

(elasticsearch, redis는 disabled 하고 진행)

 

OrderServiceStockIntegrationTest

@SpringBootTest
@TestPropertySource(properties = {
        "marketengine.elasticsearch.enabled=false",
        "spring.data.elasticsearch.repositories.enabled=false",
        "marketengine.elasticsearch.verify-on-startup=false",
        "marketengine.elasticsearch.index.ensure-on-startup=false",
        "marketengine.cache.redis.enabled=false",
        "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,"
                + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration",
        "spring.cache.type=simple"
})
class OrderServiceStockIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @BeforeEach
    void cleanDatabase() {
        jdbcTemplate.update("DELETE FROM \"orders\"");
        jdbcTemplate.update("DELETE FROM products");
        jdbcTemplate.update("DELETE FROM members");
    }

    @Test
    void create_decreasesStock() {
        long memberId = insertMember("order-it@test.com");
        long productId = insertProduct(10);

        orderService.create(new CreateOrderRequest(memberId, productId, 1));

        assertThat(loadStock(productId)).isEqualTo(9);
        assertThat(orderRepository.count()).isEqualTo(1);
    }

    @Test
    void create_failsWhenInsufficientStock() {
        long memberId = insertMember("order-it-insufficient@test.com");
        long productId = insertProduct(10);

        assertThatThrownBy(() -> orderService.create(new CreateOrderRequest(memberId, productId, 11)))
                .isInstanceOf(BusinessException.class)
                .satisfies(ex -> assertThat(((BusinessException) ex).errorCode()).isEqualTo(ErrorCode.INSUFFICIENT_STOCK));

        assertThat(loadStock(productId)).isEqualTo(10);
        assertThat(orderRepository.count()).isZero();
    }

    private long insertMember(String email) {
        jdbcTemplate.update(
                """
                INSERT INTO members (email, password_hash, name, created_at)
                VALUES (?, ?, ?, CURRENT_TIMESTAMP)
                """,
                email,
                "pw",
                "order-it"
        );
        return jdbcTemplate.queryForObject(
                "SELECT id FROM members WHERE email = ?",
                Long.class,
                email
        );
    }

    private long insertProduct(int stockQuantity) {
        jdbcTemplate.update(
                """
                INSERT INTO products (
                    name, price_amount, stock_quantity, description,
                    category, brand, color, gender, status, popularity_score,
                    created_at, updated_at
                )
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
                """,
                "order-it-product",
                100.00,
                stockQuantity,
                "integration test",
                "TOP",
                "BRAND",
                "BLACK",
                "UNISEX",
                "ACTIVE",
                0
        );
        return jdbcTemplate.queryForObject(
                "SELECT id FROM products WHERE name = ?",
                Long.class,
                "order-it-product"
        );
    }

    private int loadStock(long productId) {
        return productRepository.findById(productId).orElseThrow().getStockQuantity();
    }
}
  • 생성 데이터
    • 테스트마다 DB 초기화 후 JDBC로 삽입
    • members: 1행 (created_at=NOW)
    • products: 1행 (price=100.00, stock=10, category/brand 등 필수 컬럼 포함)
  • 예상 결과
    • create(quantity=1):
      • products.stock_quantity: 10 -> 9
      • orders row count: 1
    • create(quantity=11):
      • BusinessException(INSUFFICIENT_STOCK)
      • stock_quantity 그대로 10
      • orders row count: 0

테스트 통과

 


OrderServiceStockRollBackIntegrationTest

@SpringBootTest
@TestPropertySource(properties = {
        "marketengine.elasticsearch.enabled=false",
        "spring.data.elasticsearch.repositories.enabled=false",
        "marketengine.elasticsearch.verify-on-startup=false",
        "marketengine.elasticsearch.index.ensure-on-startup=false",
        "marketengine.cache.redis.enabled=false",
        "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,"
                + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration",
        "spring.cache.type=simple"
})
class OrderServiceStockRollbackIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private ProductRepository productRepository;

    @SpyBean
    private OrderRepository orderRepository;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @BeforeEach
    void cleanDatabase() {
        jdbcTemplate.update("DELETE FROM \"orders\"");
        jdbcTemplate.update("DELETE FROM products");
        jdbcTemplate.update("DELETE FROM members");
    }

    @AfterEach
    void resetSpies() {
        reset(orderRepository);
    }

    @Test
    void create_rollsBackStockWhenOrderPersistFails() {
        long memberId = insertMember("order-it-rollback@test.com");
        long productId = insertProduct(10);

        doThrow(new RuntimeException("simulate persist failure"))
                .when(orderRepository)
                .save(any(Order.class));

        assertThatThrownBy(() -> orderService.create(new CreateOrderRequest(memberId, productId, 1)))
                .isInstanceOf(RuntimeException.class)
                .hasMessageContaining("simulate persist failure");

        assertThat(loadStock(productId)).isEqualTo(10);
        assertThat(orderRepository.count()).isZero();
    }

    private long insertMember(String email) {
        jdbcTemplate.update(
                """
                INSERT INTO members (email, password_hash, name, created_at)
                VALUES (?, ?, ?, CURRENT_TIMESTAMP)
                """,
                email,
                "pw",
                "order-it"
        );
        return jdbcTemplate.queryForObject(
                "SELECT id FROM members WHERE email = ?",
                Long.class,
                email
        );
    }

    private long insertProduct(int stockQuantity) {
        jdbcTemplate.update(
                """
                INSERT INTO products (
                    name, price_amount, stock_quantity, description,
                    category, brand, color, gender, status, popularity_score,
                    created_at, updated_at
                )
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
                """,
                "order-it-product",
                100.00,
                stockQuantity,
                "integration test",
                "TOP",
                "BRAND",
                "BLACK",
                "UNISEX",
                "ACTIVE",
                0
        );
        return jdbcTemplate.queryForObject(
                "SELECT id FROM products WHERE name = ?",
                Long.class,
                "order-it-product"
        );
    }

    private int loadStock(long productId) {
        return productRepository.findById(productId).orElseThrow().getStockQuantity();
    }
}
  • 생성 데이터
    • 동일하게 member 1, product(stock=10) 삽입
    • OrderRepository.save()를 SpyBean으로 강제 예외 발생 (RuntimeException)
  • 예상 결과
    • 주문 생성 호출 시 예외 발생
    • 트랜잭션 롤백으로:
      • stock_quantity 다시 10 (감소 반영 안 됨)
      • orders row count: 0

테스트 통과

 

1인 주문 단계에서

재고 감소, 재고 부족 실패 처리 및
예외 발생시 rollback하여 원자성 보장하는 테스트를 완료했습니다.

 

이제 k6 동시 주문 테스트를 진행해보겠습니다.