주문 시스템을 개발합니다.
먼저 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
- create(quantity=1):

테스트 통과
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 동시 주문 테스트를 진행해보겠습니다.
'백엔드 엔지니어링 일지' 카테고리의 다른 글
| 마켓 백엔드 엔진 14 : k6 검색 로드 테스트 - 2 (0) | 2026.05.29 |
|---|---|
| 마켓 백엔드 엔진 13 : ElasticSearch - 2 / Redis 캐싱 (0) | 2026.05.28 |
| 마켓 백엔드 엔진 12 : ElasticSearch - 1 (0) | 2026.05.26 |
| 마켓 백엔드 엔진 11 : k6 검색 로드 테스트 - 1 (0) | 2026.05.22 |
| 마켓 백엔드 엔진 10 : 키워드 조합/유사도 검색 - pg_trgm (0) | 2026.05.18 |