주문은 단순한 INSERT가 아닙니다. 장바구니에서 아이템을 꺼내고 각 상품의 재고를 차감하면서 주문 시점 가격을 스냅샷으로 저장하고 주문을 생성하고 장바구니를 비우고 후속 처리를 위한 이벤트를 저장합니다. 이 모든 것이 하나의 트랜잭션 안에서 일어나야 합니다. 한 단계라도 실패하면 전부 롤백되어야 하기 때문입니다.
주문이 생성된 이후에도 처리할 일이 남습니다. 결제 요청, 결제 성공, 결제 실패, 배송 준비, 배송 완료, 취소. 주문 상태는 여러 갈래로 전이하고 어떤 상태에서는 취소가 가능하고 어떤 상태에서는 불가능합니다. 이 전이 규칙을 누가 갖고 있느냐에 따라 코드의 유지보수성이 크게 달라집니다.
이 글에서 사용하는 용어는 다음 뜻으로 읽으면 됩니다.
| 용어 | 이 글에서의 의미 |
|---|---|
| 애그리거트 (Aggregate) | 하나의 트랜잭션으로 일관성을 보장하는 엔티티 묶음. Order가 OrderItem을 포함하는 것이 예시 |
| 상태 전이 (State Transition) | 주문 상태가 한 값에서 다른 값으로 바뀌는 것. 모든 전이가 허용되지는 않는다 |
| 스냅샷 (Snapshot) | 특정 시점의 값을 복사해 보존하는 것. 주문 시점의 상품 가격을 저장하는 것이 예시 |
| 보상 트랜잭션 (Compensating Transaction) | 이미 커밋된 변경을 되돌리기 위한 별도 트랜잭션. 결제 실패 시 재고를 복구하는 것이 예시 |
| Outbox 이벤트 | DB 트랜잭션과 함께 저장되어 이벤트 유실을 방지하는 패턴. 뒤의 Outbox 글에서 상세히 다룰 예정 |
이 연재는 PeekCart를 네 단계로 진행했습니다. Phase 1은 단일 Spring Boot 모놀리스로 도메인을 구현한 단계, Phase 2는 캐시, 분산 락, Kafka와 Outbox로 성능과 정합성을 보강한 단계, Phase 3은 CI, Kubernetes, 관측성, 부하 테스트로 운영 환경을 검증한 단계, Phase 4는 서비스별로 나눈 MSA 단계입니다.
이번 학습에서는 다음 질문을 확인하려고 합니다.
- 장바구니에서 주문까지의 전체 흐름은 어떻게 이어질까?
- 주문 생성 트랜잭션 안에서 어떤 일이 함께 일어날까?
- 주문 시점 가격 스냅샷은 왜 필요할까?
- 주문 상태 전이 규칙은 어디에 있어야 유지보수하기 쉬울까?
- 주문 취소에는 몇 가지 경로가 있고 각각 어떻게 다를까?
- 결제 타임아웃은 어떻게 처리할까?
- Phase 4 MSA에서 Order Service의 경계는 어떻게 바뀔까?
장바구니: 주문의 출발점
주문 생성은 장바구니에서 시작합니다. 사용자가 상품을 장바구니에 담고 수량을 조정하고 주문 버튼을 누르면 장바구니 내용이 주문으로 변환됩니다.
Cart 애그리거트
@Entity
@Table(name = "carts")
public class Cart extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
private Long userId;
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true)
private List<CartItem> items = new ArrayList<>();
public void addItem(Long productId, int quantity) {
items.stream()
.filter(item -> item.getProductId().equals(productId))
.findFirst()
.ifPresentOrElse(
item -> item.addQuantity(quantity),
() -> items.add(CartItem.create(this, productId, quantity))
);
}
public void clear() {
items.clear();
}
public boolean isEmpty() {
return items.isEmpty();
}
}
Cart는 CartItem을 포함하는 애그리거트 루트입니다. 여기에 비즈니스 규칙이 하나 있습니다. addItem()은 동일 상품이 이미 장바구니에 있으면 수량을 합산하고 없으면 새 항목을 추가합니다. 이 분기는 Application Service에 두지 않고 Cart 엔티티 안에 두었습니다.
// CartTest.java
@Test
void addItem_existingProduct_mergesQuantity() {
Cart cart = OrderFixture.cart();
cart.addItem(10L, 2);
cart.addItem(10L, 3);
assertThat(cart.getItems()).hasSize(1);
assertThat(cart.getItems().get(0).getQuantity()).isEqualTo(5);
}
장바구니에는 가격 정보가 없습니다. cart_items에는 product_id와 quantity만 있고 unit_price는 없습니다. 장바구니는 “무엇을 얼마나 담았는가”만 기억하고 “얼마인가”는 주문 시점에 Product 도메인에서 가져옵니다. 이 설계는 상품 가격이 변경되었을 때 장바구니에 stale 가격이 남지 않게 합니다.
CartCommandService: Application이 흐름을 조율한다
@Service
@Transactional
@RequiredArgsConstructor
public class CartCommandService {
private final CartRepository cartRepository;
private final ProductPort productPort;
public CartDetailDto addItem(Long userId, AddCartItemCommand command) {
productPort.verifyProductExists(command.productId());
Cart cart = cartRepository.findByUserId(userId)
.orElseGet(() -> cartRepository.save(Cart.create(userId)));
cart.addItem(command.productId(), command.quantity());
return CartDetailDto.from(cart);
}
}
장바구니에 상품을 담을 때 productPort.verifyProductExists()로 상품 존재 여부를 먼저 확인합니다. 상품과 재고 글에서 본 ProductPort가 여기서도 도메인 간 경계를 만들고 있습니다. CartCommandService는 Product 엔티티를 직접 알지 못하고 “이 상품이 존재하는가?”라는 질문만 인터페이스를 통해 던집니다.
장바구니가 없으면 자동으로 생성합니다(orElseGet). 사용자가 처음 상품을 담을 때 별도로 “장바구니 생성” API를 호출할 필요가 없습니다.
ProductPort: Order에서 Product로
CartCommandService와 뒤에서 볼 OrderCommandService는 둘 다 ProductPort를 주입받습니다. 인터페이스는 이렇게 생겼습니다.
// Order 도메인의 application/port에 정의
public interface ProductPort {
void verifyProductExists(Long productId);
long decreaseStockAndGetUnitPrice(Long productId, int quantity);
void restoreStock(Long productId, int quantity);
}
아래 “도메인 간 경계” 절에서 이 Port가 만드는 의존 방향을 다시 봅니다.
주문 생성: 하나의 트랜잭션 안에서 일어나는 일
주문 생성은 Order 도메인에서 가장 복잡한 UseCase입니다. 하나의 트랜잭션 안에서 다섯 가지 일이 일어납니다.
flowchart TD
Start["주문 생성 요청"]
Cart["1. 장바구니 조회"]
Stock["2. 상품별 재고 차감 + 가격 스냅샷"]
Order["3. Order 엔티티 생성 + 저장"]
Clear["4. 장바구니 비우기"]
Outbox["5. Outbox 이벤트 저장"]
End["트랜잭션 커밋"]
Start --> Cart
Cart --> Stock
Stock --> Order
Order --> Clear
Clear --> Outbox
Outbox --> End
style Start fill:#e8f5e9
style End fill:#e8f5e9
OrderCommandService.createOrder()
@Service
@Transactional
@RequiredArgsConstructor
public class OrderCommandService {
private final OrderRepository orderRepository;
private final CartRepository cartRepository;
private final ProductPort productPort;
private final OrderOutboxEventPublisher outboxEventPublisher;
public OrderDetailDto createOrder(Long userId, CreateOrderCommand command) {
// 1. 장바구니 조회
Cart cart = cartRepository.findByUserId(userId)
.orElseThrow(() -> new OrderException(ErrorCode.ORD_006));
if (cart.isEmpty()) {
throw new OrderException(ErrorCode.ORD_004);
}
// 2. 상품별 재고 차감 + 가격 스냅샷
List<OrderItemData> itemDataList = cart.getItems().stream()
.map(cartItem -> {
long unitPrice = productPort.decreaseStockAndGetUnitPrice(
cartItem.getProductId(), cartItem.getQuantity());
return new OrderItemData(
cartItem.getProductId(), cartItem.getQuantity(), unitPrice);
})
.toList();
// 3. Order 엔티티 생성 + 저장
Order order = Order.create(
userId, generateOrderNumber(),
command.receiverName(), command.phone(),
command.zipcode(), command.address(),
itemDataList);
orderRepository.save(order);
// 4. 장바구니 비우기
cart.clear();
// 5. Outbox 이벤트 저장
outboxEventPublisher.publishOrderCreated(order);
return OrderDetailDto.from(order);
}
}
각 단계를 하나씩 보겠습니다.
1단계: 장바구니 조회와 검증. 장바구니가 없거나 비어 있으면 주문을 만들 수 없습니다. 이 검증은 UseCase 흐름에 가깝기 때문에 Application Service에 있습니다.
2단계: 재고 차감과 가격 스냅샷. 장바구니의 각 아이템마다 productPort.decreaseStockAndGetUnitPrice()를 호출합니다. 내부에서는 InventoryLockFacade가 Redis 분산 락을 잡고 InventoryService.decreaseStock()을 호출합니다. 이때 InventoryService의 @Transactional이 기본 Propagation.REQUIRED이므로 createOrder()의 외부 트랜잭션에 참여합니다.
분산 락은 InventoryService 반환 후 해제되지만 실제 DB 커밋은 createOrder() 전체가 끝나야 일어납니다. 따라서 분산 락 해제와 커밋 사이에 다른 스레드가 실행될 수 있고 이때 @Version 낙관적 락이 최종 방어선이 됩니다(상품과 재고 글에서 상세히 다뤘습니다). 동시에 이 호출은 주문 시점의 상품 가격을 반환합니다.
가격은 장바구니에 담을 때 저장한 값을 쓰지 않고, 이 시점에 가져옵니다. 사용자가 장바구니에 상품을 담은 지 3일이 지났다면 그 사이에 가격이 바뀌었을 수 있습니다. 주문 시점의 최신 가격을 가져와야 정확합니다.
3단계: Order 엔티티 생성. Order.create()는 OrderItemData 리스트를 받아 Order와 OrderItem들을 한 번에 만듭니다. 총액은 엔티티 생성 시점에 아이템 소계의 합으로 계산됩니다.
4단계: 장바구니 비우기. 주문이 성공적으로 만들어졌으므로 장바구니를 비웁니다. orphanRemoval = true가 설정돼 있어 clear() 호출 시 DB에서도 cart_items가 삭제됩니다.
5단계: Outbox 이벤트 저장. order.created 이벤트를 outbox_events 테이블에 저장합니다. 이 이벤트는 별도 폴링 스케줄러가 Kafka로 발행합니다. DB 저장과 이벤트 저장이 같은 트랜잭션이므로, 주문은 저장됐는데 이벤트는 유실되는 상황이 방지됩니다.
이 다섯 단계가 모두 하나의 @Transactional 안에 있습니다. 2단계에서 재고 부족으로 예외가 발생하면 3~5단계는 실행되지 않습니다. 재고 차감이 같은 트랜잭션에 참여하는 구조라면 이미 차감된 다른 상품의 재고도 롤백으로 함께 복원됩니다. 반대로 재고 차감을 별도 트랜잭션으로 분리한다면, 주문 저장 실패 시 보상 복구가 필요합니다. 이 차이는 상품과 재고 글에서 본 락 범위와 트랜잭션 범위 문제와 연결됩니다.
주문 시점 가격 스냅샷은 왜 필요한가
order_items 테이블에는 unit_price 컬럼이 있습니다. 이 값은 주문 생성 시점에 Product에서 가져온 가격이 복사된 것입니다.
// OrderItem.java
@Column(name = "unit_price", nullable = false)
private long unitPrice;
왜 product_id만 저장하고 조회할 때 Product 테이블을 조인하지 않을까요?
| 전략 | 동작 | 장점 | 단점 |
|---|---|---|---|
| FK 조인 | 주문 조회 시 products.price를 조인 | 항상 최신 가격 | 가격 변경 시 과거 주문 금액이 바뀜 |
| 스냅샷 저장 | 주문 시점 가격을 order_items.unit_price에 복사 | 과거 주문 금액 불변 | 저장 공간 약간 증가 |
이커머스에서 “주문 당시 10,000원에 샀는데, 가격이 8,000원으로 내려갔더니 주문 내역에도 8,000원으로 보인다”는 결함이 됩니다. 반대로 가격이 올라가면 사용자에게 더 많은 금액을 청구한 것처럼 보입니다. 주문 시점의 가격을 스냅샷으로 보존하는 것이 비즈니스적으로 맞습니다.
이 패턴은 Phase 4에서도 중요해집니다. MSA에서 Order Service와 Product Service가 별도 DB를 갖게 되면, FK 조인 자체가 불가능합니다. 스냅샷이 이미 저장되어 있으므로 Order Service는 Product Service 없이도 주문 금액을 조회할 수 있습니다.
장바구니에 가격이 없는 이유는 앞의 Cart 절에서 설명했습니다.
Order 애그리거트: 생성 규칙
@Entity
@Table(name = "orders")
public class Order {
private Order(Long userId, String orderNumber, String receiverName, String phone,
String zipcode, String address, List<OrderItemData> itemDataList) {
if (itemDataList == null || itemDataList.isEmpty()) {
throw new OrderException(ErrorCode.ORD_004);
}
this.userId = userId;
this.orderNumber = orderNumber;
this.status = OrderStatus.PENDING;
this.receiverName = receiverName;
this.phone = phone;
this.zipcode = zipcode;
this.address = address;
this.orderedAt = LocalDateTime.now();
for (OrderItemData data : itemDataList) {
this.orderItems.add(
new OrderItem(this, data.productId(), data.quantity(), data.unitPrice()));
}
this.totalAmount = this.orderItems.stream()
.mapToLong(OrderItem::getSubtotal)
.sum();
}
}
Order.create()에서 일어나는 일을 정리하면 이렇습니다.
- 아이템 목록이 비어 있으면 ORD-004 예외를 던집니다.
- 초기 상태를
PENDING으로 설정합니다. - 각
OrderItemData로OrderItem을 생성해 내부 리스트에 추가합니다. - 총액을
OrderItem.getSubtotal()의 합으로 계산합니다.
여기서 OrderItemData라는 record가 중간 전달 객체 역할을 합니다.
public record OrderItemData(Long productId, int quantity, long unitPrice) {}
이 record가 없으면 Order.create()가 CartItem이나 ProductPort를 직접 알아야 합니다. OrderItemData가 있어서 Order 도메인은 “상품 ID, 수량, 단가”라는 순수한 값만 받고 그 값이 어디서 왔는지는 모릅니다.
OrderItem의 생성자는 package-private입니다.
OrderItem(Order order, Long productId, int quantity, long unitPrice) {
if (quantity < 1) throw new OrderException(ErrorCode.ORD_005);
if (unitPrice < 0) throw new IllegalArgumentException("단가는 0 이상이어야 합니다.");
this.order = order;
this.productId = productId;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
외부에서 new OrderItem()을 호출할 수 없습니다. OrderItem은 반드시 Order.create() 내부에서만 만들어집니다. 이것이 Order를 애그리거트 루트로 만드는 장치입니다. 아이템이 주문 없이 독립적으로 존재하는 것은 비즈니스적으로 의미가 없기 때문입니다.
주문 상태 전이: 규칙은 어디에 있어야 하는가
이 질문은 계층 구조 글에서도 다뤘지만 이번 글에서는 더 자세히 살펴봅니다. 실제 전이 규칙과 그것을 사용하는 코드를 함께 보겠습니다.
상태 전이도
stateDiagram-v2
[*] --> PENDING : 주문 생성
PENDING --> PAYMENT_REQUESTED : 결제 요청
PENDING --> CANCELLED : 사용자 취소
PAYMENT_REQUESTED --> PAYMENT_COMPLETED : 결제 성공
PAYMENT_REQUESTED --> PAYMENT_FAILED : 결제 실패
PAYMENT_REQUESTED --> CANCELLED : 사용자 취소 / 타임아웃
PAYMENT_FAILED --> CANCELLED : 자동 취소
PAYMENT_COMPLETED --> PREPARING : 상품 준비
PREPARING --> SHIPPED : 배송 시작
SHIPPED --> DELIVERED : 배송 완료
DELIVERED --> [*]
CANCELLED --> [*]
8개 상태, 11개 전이. 터미널 상태는 DELIVERED와 CANCELLED 두 가지입니다.
OrderStatus: enum이 전이 규칙을 갖는다
public enum OrderStatus {
PENDING {
@Override
public boolean canTransitionTo(OrderStatus target) {
return target == PAYMENT_REQUESTED || target == CANCELLED;
}
},
PAYMENT_REQUESTED {
@Override
public boolean canTransitionTo(OrderStatus target) {
return target == PAYMENT_COMPLETED
|| target == PAYMENT_FAILED
|| target == CANCELLED;
}
},
PAYMENT_COMPLETED {
@Override
public boolean canTransitionTo(OrderStatus target) {
return target == PREPARING;
}
},
PAYMENT_FAILED {
@Override
public boolean canTransitionTo(OrderStatus target) {
return target == CANCELLED;
}
},
// ... PREPARING, SHIPPED, DELIVERED, CANCELLED
DELIVERED {
@Override
public boolean canTransitionTo(OrderStatus target) {
return false;
}
},
CANCELLED {
@Override
public boolean canTransitionTo(OrderStatus target) {
return false;
}
};
public abstract boolean canTransitionTo(OrderStatus target);
}
각 상태가 canTransitionTo()를 직접 구현합니다. 이 설계의 장점은 전이 규칙이 흩어지지 않는다는 것입니다. “PAYMENT_REQUESTED에서 어디로 갈 수 있는가?”라는 질문에 답하려면 OrderStatus.PAYMENT_REQUESTED만 보면 됩니다.
전이 규칙을 다른 곳에 둔다면?
| 위치 | 구현 방식 | 장점 | 단점 |
|---|---|---|---|
| enum 내부 (현재) | canTransitionTo() 추상 메서드 | 상태별 규칙이 응집, 간결 | 상태가 많아지면 enum이 비대 |
| 별도 Map/Table | Map<OrderStatus, Set<OrderStatus>> | 외부 설정 가능, 테스트 쉬움 | 규칙과 상태가 분리되어 추적 어려움 |
| State Machine 라이브러리 | Spring Statemachine 등 | 복잡한 전이/가드/액션 지원 | 학습 비용, 프레임워크 의존 |
| Application Service | if-else로 전이 검사 | 없음 | 여러 Service에 규칙이 흩어짐 |
PeekCart에서는 현재 8개 상태와 11개 전이 정도라 enum 내부가 가장 간결합니다. 전이가 30개 이상으로 늘어나거나 전이 시 조건(가드)이 복잡해지면 State Machine이나 별도 정책 객체로 분리할 수 있습니다.
Order.cancel()과 Order.transitionTo()
Order에는 두 가지 상태 변경 메서드가 있습니다.
public void cancel() {
if (this.status == OrderStatus.CANCELLED) {
throw new OrderException(ErrorCode.ORD_002);
}
if (!this.status.canTransitionTo(OrderStatus.CANCELLED)) {
throw new OrderException(ErrorCode.ORD_003);
}
this.status = OrderStatus.CANCELLED;
}
public void transitionTo(OrderStatus target) {
if (!this.status.canTransitionTo(target)) {
throw new OrderException(ErrorCode.ORD_003);
}
this.status = target;
}
cancel()은 “이미 취소된 주문을 다시 취소”하는 경우(ORD-002)와 “취소 불가 상태에서 취소”하는 경우(ORD-003)를 구분해 다른 에러 코드를 던집니다. transitionTo()는 범용적이라 어떤 상태로든 전이를 시도할 수 있지만 canTransitionTo()가 거부하면 ORD-003을 던집니다.
계층 구조 글에서 언급했던 것처럼, transitionTo()보다 도메인 언어에 가까운 메서드(requestPayment(), completePayment() 등)로 바꾸면 호출부의 의도가 더 명확해질 수 있습니다. 현재는 상태 전이가 단순하므로 transitionTo() + cancel()로 충분하지만 개선 후보로 남겨둡니다.
테스트: 모든 전이를 검증한다
// OrderStatusTest.java
@Test
void pending_toPaymentRequested_allowed() {
assertThat(OrderStatus.PENDING.canTransitionTo(OrderStatus.PAYMENT_REQUESTED))
.isTrue();
}
@Test
void paymentCompleted_toCancelled_denied() {
assertThat(OrderStatus.PAYMENT_COMPLETED.canTransitionTo(OrderStatus.CANCELLED))
.isFalse();
}
@ParameterizedTest
@EnumSource(OrderStatus.class)
void delivered_isTerminal(OrderStatus target) {
assertThat(OrderStatus.DELIVERED.canTransitionTo(target)).isFalse();
}
@ParameterizedTest
@EnumSource(OrderStatus.class)
void cancelled_isTerminal(OrderStatus target) {
assertThat(OrderStatus.CANCELLED.canTransitionTo(target)).isFalse();
}
터미널 상태(DELIVERED, CANCELLED)는 @ParameterizedTest로 모든 상태에 대해 전이가 거부됨을 검증합니다. 새 상태가 추가되더라도 이 테스트가 자동으로 커버합니다.
주문 취소: 세 가지 경로
주문 취소는 하나의 결과(CANCELLED)에 도달하지만 경로는 아래처럼 세 가지로 갈립니다.
flowchart LR
A["사용자 수동 취소"] --> Cancel["order.cancel()<br/>+ 재고 복구<br/>+ Outbox 이벤트"]
B["결제 실패<br/>(Kafka Consumer)"] --> Cancel
C["타임아웃 15분 초과<br/>(Scheduler)"] --> Cancel
경로 1: 사용자 수동 취소
// OrderCommandService.java
public void cancelOrder(Long userId, Long orderId) {
Order order = orderRepository.findByIdAndUserId(orderId, userId)
.orElseThrow(() -> new OrderException(ErrorCode.ORD_001));
order.cancel();
for (var item : order.getOrderItems()) {
productPort.restoreStock(item.getProductId(), item.getQuantity());
}
outboxEventPublisher.publishOrderCancelled(order);
}
findByIdAndUserId()로 본인 주문만 취소할 수 있습니다. 인증과 인가 글에서 본 데이터 레벨 인가가 여기서도 적용됩니다. order.cancel()이 상태 전이 규칙을 확인하고 productPort.restoreStock()으로 재고를 복구하고 Outbox 이벤트를 저장합니다.
경로 2: 결제 실패 (Kafka Consumer)
// OrderEventConsumer.java
@KafkaListener(topics = "payment.failed", groupId = GROUP_PAYMENT_FAILED)
@Transactional
public void handlePaymentFailed(String message) {
JsonNode root = kafkaMessageParser.parse(message);
String eventId = root.get("eventId").asText();
JsonNode payload = root.get("payload");
idempotencyChecker.executeIfNew(eventId, GROUP_PAYMENT_FAILED, () -> {
Long orderId = payload.get("orderId").asLong();
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderException(ErrorCode.ORD_001));
order.cancel();
for (var item : order.getOrderItems()) {
productPort.restoreStock(item.getProductId(), item.getQuantity());
}
});
}
Payment 도메인이 결제 실패 이벤트를 Kafka로 발행하면, Order의 Consumer가 이를 소비해 주문을 취소하고 재고를 복구합니다. idempotencyChecker.executeIfNew()로 같은 이벤트가 중복 소비되더라도 한 번만 처리합니다.
여기서 도메인 간 통신 방식이 보입니다. Payment와 Order 사이에 직접 메서드 호출이 없습니다. Kafka 이벤트로 연결되어 있습니다. Payment 도메인은 “결제가 실패했다”는 사실만 발행하고 Order 도메인이 “그러면 주문을 취소해야 한다”는 결정을 독립적으로 내립니다.
경로 3은 결제가 오지 않은 주문의 재고를 푸는 일이라 다음 절에서 따로 봅니다.
결제 타임아웃은 어떻게 처리할까
// OrderTimeoutScheduler.java
@Scheduled(fixedDelay = 60_000)
@SchedulerLock(name = "orderTimeoutCancelJob",
lockAtMostFor = "PT10M", lockAtLeastFor = "PT30S")
public void cancelExpiredOrders() {
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(15);
List<Order> expiredOrders = orderRepository.findByStatusAndOrderedAtBefore(
OrderStatus.PAYMENT_REQUESTED, cutoff);
for (Order order : expiredOrders) {
cancelSafely(order.getId(), order.getOrderNumber());
}
}
private void cancelSafely(Long orderId, String orderNumber) {
try {
orderCommandService.cancelExpiredOrder(orderId);
} catch (OrderException e) {
log.warn("타임아웃 주문 취소 스킵 (상태 경합): orderId={}", orderId);
} catch (Exception e) {
log.error("타임아웃 주문 취소 실패: orderId={}", orderId, e);
}
}
상품과 재고 글에서 다룬 “재고 묶임” 문제의 해결책이 여기 있습니다. 주문 생성 시 즉시 재고를 차감하면 결제가 되지 않아도 재고가 묶여 있습니다. 스케줄러가 60초마다 PAYMENT_REQUESTED 상태가 15분을 초과한 주문을 찾아 자동 취소합니다.
여기서 두 가지 설계 포인트가 있습니다.
건별 독립 트랜잭션: cancelExpiredOrder()는 @Transactional(propagation = Propagation.REQUIRES_NEW)입니다. 한 건의 취소가 실패해도 다른 건에 영향을 주지 않습니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void cancelExpiredOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderException(ErrorCode.ORD_001));
order.cancel();
for (var item : order.getOrderItems()) {
productPort.restoreStock(item.getProductId(), item.getQuantity());
}
outboxEventPublisher.publishOrderCancelled(order);
}
실패 격리: cancelSafely()가 각 건의 예외를 catch합니다. OrderException(상태 경합, 이미 취소됐거나 결제가 완료된 경우)은 warn 로그로 건너뛰고 예상 못한 예외는 error 로그를 남깁니다. 이 방식이 없으면 한 건의 실패가 루프 전체를 멈출 수 있습니다.
ShedLock: @SchedulerLock으로 다중 Pod에서 스케줄러가 동시에 실행되는 것을 방지합니다. 뒤의 ShedLock 글에서 상세히 다룰 예정입니다.
세 취소 경로의 비교
| 경로 | 트리거 | 트랜잭션 | 멱등성 | 재고 복구 |
|---|---|---|---|---|
| 사용자 취소 | HTTP API | 기본 @Transactional | cancel() 중복 호출 시 ORD-002 | 있음 |
| 결제 실패 | Kafka Consumer | Consumer @Transactional | processed_events UK | 있음 |
| 타임아웃 | Scheduler | REQUIRES_NEW (건별) | cancelSafely() catch + 상태 확인 | 있음 |
세 경로 모두 order.cancel()과 productPort.restoreStock()을 호출한다는 점에서 동일합니다. 차이는 트리거와 실패 처리 방식입니다.
도메인 간 경계: Port가 만드는 계약
Order 도메인이 관여하는 Port는 둘입니다. 장바구니 절에서 본 ProductPort는 Order가 Product를 호출할 때 쓰고, OrderPort는 Payment가 Order를 호출할 때 씁니다.
OrderPort: Payment에서 Order로
// Payment 도메인의 application/port에 정의 (구현은 Order infrastructure에)
public interface OrderPort {
void transitionToPaymentRequested(Long orderId);
void verifyOrderOwner(Long userId, Long orderId);
}
// OrderPortAdapter.java
@Component
@RequiredArgsConstructor
public class OrderPortAdapter implements OrderPort {
private final OrderRepository orderRepository;
@Override
public void transitionToPaymentRequested(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderException(ErrorCode.ORD_001));
order.transitionTo(OrderStatus.PAYMENT_REQUESTED);
}
@Override
public void verifyOrderOwner(Long userId, Long orderId) {
orderRepository.findByIdAndUserId(orderId, userId)
.orElseThrow(() -> new OrderException(ErrorCode.ORD_001));
}
}
이 구조를 도식으로 보면 이렇습니다.
flowchart LR
subgraph Order["Order 도메인"]
OCS["OrderCommandService"]
OPort["ProductPort<br/>(interface)"]
OPAdapter["OrderPortAdapter<br/>(impl)"]
end
subgraph Product["Product 도메인"]
PPAdapter["ProductPortAdapter<br/>(impl)"]
ILF["InventoryLockFacade"]
end
subgraph Payment["Payment 도메인"]
PCS["PaymentCommandService"]
OrderPortI["OrderPort<br/>(interface)"]
end
OCS --> OPort
OPort -.-> PPAdapter
PPAdapter --> ILF
PCS --> OrderPortI
OrderPortI -.-> OPAdapter
Port 인터페이스는 호출하는 쪽의 도메인에 정의되고 구현체는 호출받는 쪽의 infrastructure에 위치합니다. Order가 Product를 호출하지만 Order가 Product의 구현에 의존하지 않습니다. Order는 “재고를 차감해주세요”라는 계약만 알고 그것이 분산 락으로 처리되는지, 비관적 락으로 처리되는지는 모릅니다.
한계와 트레이드오프
주문 생성 트랜잭션이 너무 많은 일을 한다
현재 주문 생성 흐름은 재고 차감, 주문 저장, 장바구니 비우기, Outbox 이벤트 저장을 모두 포함합니다. 이 작업들을 하나의 트랜잭션 경계 안에 두면 원자성은 단순해지지만 트랜잭션이 길어질수록 DB 커넥션 점유 시간이 늘어납니다. 장바구니 아이템이 많을수록 상품별 재고 확인과 차감 대기도 누적됩니다.
Phase 4에서 Order Service와 Product Service가 분리되면 이 트랜잭션은 유지될 수 없습니다. 서비스 간 통신(HTTP 또는 이벤트)으로 전환되며 재고 차감이 실패했을 때 이미 차감된 다른 상품의 재고를 복구하는 Saga 보상 로직이 필요해집니다.
장바구니 아이템이 많을 때 부분 실패
장바구니에 5개 상품이 있는데 3번째 상품의 재고가 부족하면 전체 주문이 실패합니다. 같은 트랜잭션 안에서 처리된 1, 2번째 상품의 재고 차감은 롤백되지만 사용자 입장에서는 “어떤 상품이 부족한지” 알기 어렵습니다. 이커머스에서 흔히 보는 “일부 상품이 품절되었습니다”라는 부분 주문 기능은 현재 구현되어 있지 않습니다.
주문번호 생성의 유일성
private String generateOrderNumber() {
String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String suffix = UUID.randomUUID().toString().replace("-", "")
.substring(0, 8).toUpperCase();
return "ORD-" + date + "-" + suffix;
}
UUID의 앞 8자리를 사용하므로 충돌 확률은 매우 낮지만 0은 아닙니다. DB의 order_number UK가 최종 방어선입니다. 충돌 시 DataIntegrityViolationException이 발생하고 트랜잭션이 롤백됩니다. 현재 볼륨에서는 충분하지만 트래픽이 크게 늘면 시퀀스 기반이나 Snowflake ID 같은 유일성이 보장되는 방식으로 전환할 수 있습니다.
의도적으로 안 한 것
- 부분 주문: 일부 상품만 재고가 있을 때 허용하는 기능(위 “부분 실패” 절)
- 주문 수정: 주문 생성 후 수량이나 배송지를 변경하는 기능. 현재는 취소 후 재주문만 가능
- 결제 완료 후 취소:
PAYMENT_COMPLETED이후에는CANCELLED로 전이할 수 없습니다. 환불은 별도 비즈니스 흐름으로, 포트폴리오 범위에서는 미구현 transitionTo()의 도메인 언어화: 상태 전이 절에서 개선 후보로 남긴order.requestPayment(),order.completePayment()같은 명시적 메서드로의 전환
Phase 4 MSA에서는 어떻게 바뀌는가
Phase 4에서 Order Service는 독립 배포 단위가 됩니다. 현재 모놀리스 안에서 ProductPort와 OrderPort로 분리해둔 경계가 서비스 간 통신으로 바뀝니다.
flowchart TD
subgraph OrderSvc["Order Service"]
OCS["OrderCommandService"]
ODB["Order DB<br/>orders · order_items · carts"]
end
subgraph ProductSvc["Product Service"]
IS["InventoryService"]
PDB["Product DB"]
end
subgraph PaymentSvc["Payment Service"]
PCS["PaymentCommandService"]
PayDB["Payment DB"]
end
Kafka["Kafka"]
OCS -->|"HTTP: 재고 차감"| IS
OCS -->|"order.created"| Kafka
Kafka -->|"payment.completed<br/>payment.failed"| OCS
PaymentSvc -->|"payment.completed<br/>payment.failed"| Kafka
Kafka -->|"order.cancelled"| ProductSvc
가장 크게 바뀌는 것: 주문 생성 트랜잭션이 여러 서비스로 분산된다
현재 모놀리스에서는 재고 차감과 주문 저장이 하나의 DB 트랜잭션에 있습니다. MSA에서는 Order DB와 Product DB가 별도이므로 이것이 불가능합니다. 두 가지 접근이 가능합니다.
동기 HTTP 호출: Order Service가 Product Service의 재고 차감 API를 호출한 뒤 주문을 저장합니다. 재고는 차감됐는데 주문 저장이 실패하면 보상 API로 재고를 복구해야 합니다.
비동기 Saga: 주문을 먼저 “예약” 상태로 만들고 이벤트를 발행합니다. Product Service가 재고를 차감하고 결과를 이벤트로 돌려보냅니다. 실패하면 주문을 취소합니다.
현재 코드에서 이미 ProductPort 인터페이스와 Outbox + Kafka Consumer 구조가 갖춰져 있어, 두 접근 모두로 전환할 수 있는 기반이 마련되어 있습니다.
나머지 변화들
- 장바구니 소유권:
carts와cart_items는 Order DB에 남습니다. 장바구니에서 상품 정보(이름, 가격)를 보여주려면 Product Service API를 호출하거나 CQRS 로컬 캐시를 유지해야 합니다. OrderPort를 HTTP API로: Payment Service가 주문 상태를PAYMENT_REQUESTED로 전이할 때,OrderPortAdapter의 메서드 호출이 HTTP API 호출로 바뀝니다.- 이벤트 계약 강화:
order.created,order.cancelled의 payload가 서비스 간 계약이 됩니다. 현재OrderOutboxEventPublisher가 정의하는 payload 구조가 그대로 API 계약의 출발점입니다.
자료는 어떤 질문에 연결해서 읽을까
| 질문 | 같이 읽을 자료 | 이 글에서 연결되는 지점 |
|---|---|---|
| 애그리거트는 어떤 기준으로 나누는가? | Vaughn Vernon, Effective Aggregate Design | Order-OrderItem, Cart-CartItem의 경계를 왜 이렇게 잡았는가 |
| 상태 머신을 도메인에서 어떻게 모델링하는가? | Martin Fowler, State Machine | OrderStatus enum의 상태 전이 설계를 볼 때 |
| 이벤트 기반 보상 트랜잭션은 어떻게 동작하는가? | AWS, Saga Pattern, Chris Richardson, Sagas | 결제 실패에서 재고 복구로 이어지는 흐름을 Saga로 볼 때 |
| Outbox 패턴은 왜 필요한가? | Chris Richardson, Transactional Outbox | 주문 생성과 이벤트 저장이 같은 트랜잭션인 이유 |
| 가격 스냅샷은 언제 필요한가? | Martin Fowler, Temporal Patterns | order_items.unit_price가 왜 products.price를 복사하는가 |
| 스케줄러 중복 실행은 어떻게 막는가? | ShedLock, GitHub README | 타임아웃 스케줄러가 다중 Pod에서 안전하게 동작하는 이유 |