- 상품에 대한 주문 및 환불, 그리고 재고량 조정 시 동시성을 고려하여 안전하게 처리하는 주문 시스템.
- 재고의 권위(authority)는 Redis가 가지며(원자 연산), DB 재고는 아웃박스(Outbox) 패턴으로 사후 동기화됩니다.
주문 생성은 재고를 검증하지 않고 즉시
PENDING으로 응답한 뒤, 접수 및 완료·취소는 커밋 이후 비동기 방식으로 처리됩니다.
- Java 17, Spring Boot (Web, Data JPA, Data Redis)
- Querydsl (동적 조회), H2 (인메모리,
MODE=MySQL), Redis (재고 권위 저장소) - p6spy (SQL 로깅), springdoc-openapi (Swagger)
- k6 (부하 테스트)
docker compose -f src/main/resources/docker/docker-compose.yml up -d redis./mvnw spring-boot:run- Swagger UI: http://localhost:8080/swagger-ui/index.html
- H2 Console: http://localhost:8080/h2-console
| 항목 | 값 |
|---|---|
| JDBC URL | jdbc:h2:mem:ordering;DB_CLOSE_DELAY=-1;MODE=MySQL |
| User Name | sa |
DB는
create-drop+data.sql로 매 기동 시 시드값이 초기화되지만 Redis 재고 키는 이전 상태로 유지되어있으므로, 재실행 시 수동으로 초기화해야합니다.
docker exec redis redis-cli FLUSHALLdocker compose -f src/main/resources/docker/docker-compose.yml down -v여러 주문이 동시에 발생해도 상품 재고가 정확하게 차감되는 지 확인합니다.
k6 run k6/order-load.js
[POST /api/orders]
└─ createOrder (TX) : 주문을 PENDING 으로 저장만 한다 (재고 미검증) → 즉시 201 응답
└─ commit 후 OrderCreatedEvent 발행 (AFTER_COMMIT, @Async 백그라운드)
├─ acceptOrder (REQUIRES_NEW) : PENDING → ACCEPTED (취소 가능 상태로 먼저 커밋)
└─ processOrder (REQUIRES_NEW) : 레디스 재고량 차감 (reserve)
├─ 성공 → ACCEPTED → COMPLETED, 아웃박스에 DB 재고 -delta 기록
└─ 실패(재고 부족) → 롤백 → cancelOrder(REQUIRES_NEW) 로 ACCEPTED → CANCELLED
[POST /api/orders/{id}/refund] : COMPLETED → CANCELLED, 재고 복구(+delta), 아웃박스 +delta
[POST /api/products/{id}/stock-adjust] : 재고 델타 조정(+보충/−보정), Redis 즉시 반영 + 아웃박스 동기화
상태 전이 규칙: PENDING → ACCEPTED → (COMPLETED | CANCELLED), COMPLETED → CANCELLED(환불).
재고의 동시성 정합성은 전적으로 Redis 원자 연산으로 보장합니다. 한 주문에 여러 상품이 있을 때, 모든 상품의 재고를 한 번에 검사하고 차감해야 일부만 차감되는 부분 실패가 없습니다.
주문 생성 트랜잭션은 주문 상태를 PENDING 으로 저장만 하고 커밋합니다. 재고 차감과 완료/취소 확정은 커밋 이후로 미룹니다(@TransactionalEventListener(AFTER_COMMIT) + @Async).
이렇게 한 이유:
- 응답 지연 최소화: 무거운 재고 처리를 응답 로직에서 분리해 주문 생성 요청은 즉시 반환할 수 있습니다.
- 롤백된 주문 처리 방지: 실제로 커밋된(
AFTER_COMMIT) 주문만 후속 처리할 수 있습니다.
후속 처리에서 접수/완료/취소를 각각 별도 트랜잭션(REQUIRES_NEW)으로 나눈 이유:
- 한 트랜잭션이 롤백되면 그 안의 상태 변경도 함께 롤백되므로, 취소는 처리 트랜잭션과 분리되어야 합니다.
- 규칙상
PENDING에서는 바로 취소할 수 없으므로, 취소가 가능한ACCEPTED상태로 먼저 커밋해둡니다. 그래야 처리가 재고 부족으로 롤백돼도ACCEPTED → CANCELLED로 취소할 수 있습니다.
Redis 차감은 트랜잭션과 무관하게 즉시 반영되므로, JPA 트랜잭션의 롤백/커밋과 어긋날 수 있습니다. 이를 Transaction Synchronization Hook 으로 정확히 1회만 보정합니다.
- 주문 처리(
processOrder): 재고를 먼저 예약(차감)한 뒤afterCompletion에 보상(복원) 을 등록합니다. 트랜잭션이 롤백되면(예외 또는 커밋 단계 실패) Redis 예약을 복원합니다. 단순 try/catch는 커밋 단계에서의 실패를 알지 못하므로afterCompletion(STATUS_ROLLED_BACK)을 사용합니다. - 환불(
refundOrder): 재고 복구를afterCommit으로 미룹니다. 커밋이 성공했을 때만 복구하여, 커밋 단계 실패로 주문 취소가 롤백됐는데 Redis만 과다 복구되는(초과 판매) 상황을 막습니다.
DB의 stockQuantity는 Redis 재고량을 아웃박스 이벤트로 뒤따라 반영합니다.
| # | 설계 | 내용 |
|---|---|---|
| 1 | DB 행 락 경합 제거 | 재고를 처리하는데 있어서 RDB 행 락(SELECT ... FOR UPDATE)으로 다투지 않고 Redis 인메모리 원자 연산으로 처리합니다. 특정 상품에 주문이 몰려도 DB 락 직렬화 병목이 없습니다. |
| 2 | 선 응답 + 비동기 처리 | 주문 생성은 PENDING으로 즉시 반환하고, 재고 차감·완료 확정은 백그라운드(@Async)에서 처리합니다. |
| 3 | N+1 쿼리 방지 | 단건 조회는 fetchJoin(findByIdWithProducts)으로 주문–주문상품–상품을 한 번에 로딩하고, 컬렉션은 default_batch_fetch_size=100으로 IN 절을 사용하여 배치 로딩합니다. |
| 4 | 재고 배치 조회 (MGET) | 상품 목록 응답에서 항목마다 Redis를 호출하지 않고, 페이지 내 모든 상품 ID를 모아 StockService.getStocks(Redis MGET)로 한 번에 조회합니다. 상품 수에 비례하던 Redis 왕복(N회)을 1회+@로 줄입니다. |
| 문제점 | 개선 방안 |
|---|---|
@Async 풀 한계 — 이벤트 처리 풀이 버티지 못할 정도로 주문이 폭주하면 큐 포화로 후속 처리(완료 확정)가 지연될 수 있습니다. |
워크로드에 맞춰 풀과 큐를 튜닝하고, 큐가 가득 차면 거부정책을 AbortPolicy가 아닌 CallerRunsPolicy로 두어 유실 없이 역압을 겁니다 (단, 포화 시 제출 스레드=요청 스레드가 직접 처리하므로 생성 응답은 느려집니다). 인메모리 큐의 유실 위험을 없애고 수평 확장·내구성이 필요하다면 외부 큐(Kafka 등)로 위임합니다. |