도메인이 협력할 때 진짜 문제는 시작된다
이번 Loopers 과제의 요구사항은 동시성 관리였다.
사용자는 상품을 주문할 수 있어야 한다.
주문 시 사용자의 포인트가 차감되고, 쿠폰을 사용하면 할인이 적용되며, 상품 재고도 줄어야 한다.
단순한 흐름 같지만, 이걸 도메인 책임을 분리하면서 트랜잭션 정합성까지 보장하려면 이야기가 달라진다.
1. 할인 정책은 주문(Order)의 책임이 아니다
왜 이게 문제였는가?
처음엔 Order 도메인에서 할인 금액 계산까지 전부 처리하고 있었다.
public static Order create(UserId userId, List<OrderItem> items, UserCoupon coupon) {
validate(userId, items);
OrderAmount originalAmount = OrderAmount.from(items);
OrderAmount finalAmount = (coupon != null)
? OrderAmount.of(coupon.apply(originalAmount.value()))
: originalAmount;
return new Order(null, userId, items, finalAmount, OrderStatus.PENDING);
}
이 구조의 문제는 다음과 같다:
- Order가 UserCoupon을 직접 참조 → 도메인 침범
- 쿠폰은 Order의 하위 도메인이 아님
- 할인 정책이 바뀌면 Order 도메인이 영향을 받는다
- Order의 단위 테스트가 쿠폰에 의존하게 된다
어떻게 해결했는가?
Order는 계산된 최종 금액만 받도록 설계를 변경했다.
public static Order create(UserId userId, List<OrderItem> items, OrderAmount amount) {
validate(userId, items);
return new Order(null, userId, items, amount, OrderStatus.PENDING);
}
그리고 할인은 외부 서비스에서 처리하고, Order는 결과만 받는다:
BigDecimal originalAmount = OrderAmount.from(items).value();
BigDecimal discounted = couponService.apply(userId, couponId, originalAmount);
Order order = orderService.createOrder(userId, items, OrderAmount.of(discounted));
무엇이 바뀌었는가?
항목 | Before | After |
할인 계산 | Order 내부에서 처리 | 외부에서 처리 후 금액만 전달 |
도메인 의존성 | Order가 쿠폰을 참조 | 쿠폰과 분리 |
테스트 | 쿠폰이 있어야 테스트 가능 | 순수한 Order 테스트 가능 |
2. 뚱뚱해진 퍼사드는 Processor로 위임한다
왜 이게 문제였는가?
주문 흐름이 복잡해지면서 OrderFacade가 다음과 같은 로직을 모두 담당하게 되었다:
@Transactional
public OrderResponse order(OrderCommand command) {
Optional<Long> existingOrderId = orderRequestHistoryService.findOrderIdByIdempotencyKey(command.idempotencyKey());
if (existingOrderId.isPresent()) {
Order order = orderService.getOrder(existingOrderId.get());
return new OrderResponse(order.getId(), order.getAmount().value(), order.getStatus());
}
productService.checkAndDeduct(command.items());
UserCoupon coupon = (command.couponId() != null)
? couponService.getCouponByUserId(command.couponId(), command.userId().value())
: null;
Order order = orderService.createOrder(command.userId(), command.items(), coupon);
orderRequestHistoryService.savePending(...);
paymentService.pay(...);
orderService.completeOrder(order);
orderRequestHistoryService.markSuccess(...);
return new OrderResponse(...);
}
역할이 다음과 같이 지나치게 많았다:
- 멱등성 검사
- 재고 확인 및 차감
- 쿠폰 조회 및 적용
- 주문 생성
- 결제 처리
- 요청 기록 저장
어떻게 해결했는가?
Facade는 입구 역할만 하고, 상세 흐름은 Processor로 위임했다.
@Transactional
public OrderResponse order(OrderCommand command) {
if (isDuplicate(command)) return getExistingResponse(command);
orderProcessor.process(command);
return new OrderResponse(...);
}
무엇이 바뀌었는가?
항목 | Before | After |
로직 분산 | 퍼사드에 집중 | Processor로 분리 |
테스트 | 테스트 어렵고 복잡 | Processor 단위로 명확히 분리 |
변경 시 영향 범위 | 퍼사드 전체 | Processor 일부만 수정 가능 |
3. 멱등성 처리: 동시성 제어의 첫 단추
왜 이게 문제였는가?
“동시에 같은 요청이 두 번 들어오면 어떻게 처리해야 할까?”
락을 걸어야 하는지부터 고민했는데, 그 전에 먼저 중복 요청인지 아닌지 구분할 수 있어야 했다.
만약 네트워크 재시도 같은 상황이라면, 처리 중복을 막기 위한 선행 조건은 멱등성 처리다.
어떻게 해결했는가?
- 프론트에서 idempotencyKey를 필수로 전달하도록 정의
- 백엔드에서는 해당 키로 OrderRequestHistory 조회
- 이전에 처리한 키라면 주문을 새로 생성하지 않고 기존 주문을 그대로 반환
Optional<Long> existingOrderId =
orderRequestHistoryService.findOrderIdByIdempotencyKey(command.idempotencyKey());
if (existingOrderId.isPresent()) {
Order existingOrder = orderService.getOrder(existingOrderId.get());
return new OrderResponse(existingOrder.getId(), ...);
}
무엇이 바뀌었는가?
- 중복 요청 시 락을 걸 필요가 없어짐
- 네트워크 재시도에도 안정적
- 실수로 같은 요청을 여러 번 보내도 단일 처리 보장
4. 동시성 제어: 비관적 락 vs 낙관적 락, 어떤 전략을 선택할 것인가?
멱등성으로 중복 요청은 걸렀다. 하지만 여전히 남은 문제가 있었다.
정말 동시에 들어온 서로 다른 요청은 어떻게 막을 것인가?
하나는 성공하고, 하나는 실패해야 한다. 그렇지 않으면 데이터는 망가진다.
이제부터는 진짜 동시성 제어의 영역이다. 나는 여기서 두 가지 락 전략 중 선택을 해야 했다:
비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)
비관적 락(Pessimistic Lock)
조회 시점부터 선제적으로 락을 건다.
다른 트랜잭션은 해당 데이터를 읽지도, 수정하지도 못하도록 막는다.
동작 방식
- 트랜잭션 A가 SELECT ... FOR UPDATE로 데이터 조회
- 해당 row에 쓰기 락이 걸림
- 트랜잭션 B는 같은 데이터를 조회하거나 수정하려고 하면 대기하거나 예외 발생
- A가 커밋되면 락이 해제됨
예시 (JPA 기준)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findWithPessimisticLockById(Long id);
특징 요약
충돌 감지 시점 | 조회 시점 |
락 방식 | DB Row-level Lock |
장점 | 정합성 보장 확실, 동시 요청 제어 가능 |
단점 | 성능 저하, 데드락 위험, 락 대기 발생 |
적합한 상황
- 재고처럼 자원이 제한적이고
- 동시에 여러 사용자가 접근할 수 있으며
- 정합성 보장이 최우선인 도메인
낙관적 락(Optimistic Lock)
조회할 때는 락을 걸지 않는다.
대신, 수정 시점에 버전을 비교해서 충돌을 감지한다.
동작 방식
- 트랜잭션 A, B가 동시에 같은 데이터를 조회
- A, B가 각각 데이터를 수정
- A가 먼저 커밋하여 version 증가
- B가 커밋할 때 version mismatch 발생 → 예외
예시 (JPA 기준)
@Entity
public class Point {
@Version
private Long version;
public void charge(BigDecimal amount) {
this.balance = this.balance.add(amount);
}
}
특징 요약
충돌 감지 시점 | 커밋 시점 (데이터 저장 시점) |
락 방식 | DB 락 없음, 대신 @Version 사용 |
장점 | 성능 유리, 데드락 없음 |
단점 | 충돌 시 예외 발생, 재시도 로직 필요 |
적합한 상황
- 포인트, 쿠폰처럼 개인 단위 자원이고
- 충돌 확률이 낮으며
- 실패 시 재시도가 가능한 도메인
비교 요약
항목 | 비관적 락 | 낙관적 락 |
충돌 감지 | 조회 시점 | 커밋 시점 |
락 방식 | DB Row-level Lock | 버전 필드 비교 |
성능 | 낮음 (락 경쟁 발생) | 높음 (락 없음) |
데드락 위험 | 있음 | 없음 |
재시도 필요 | 거의 없음 | 필요함 |
적합 도메인 | 재고, 계좌 | 포인트, 쿠폰 |
그리고 나는 이렇게 선택했다
재고 (Product) | 비관적 락 | 재고는 수량이 한정된 자원이라 즉시 충돌 감지가 필수 |
포인트 (Point) | 낙관적 락 | 유저 단위 자원이므로 충돌 확률 낮고 성능이 더 중요 |
쿠폰 (Coupon) | 낙관적 락 | 유저 단위이며, 실패 시 재시도 가능 |
5. AOP 기반 예외 처리
왜 이게 문제였는가?
낙관적 락을 쓰기 시작하면서 OptimisticLockingFailureException을 여러 곳에서 잡아야 했다.
try {
point.charge(amount);
repository.save(point);
} catch (OptimisticLockingFailureException e) {
throw new CoreException(CONFLICT, "포인트 충전 중 충돌이 발생했습니다. 다시 시도해주세요.");
}
중복된 try-catch가 늘어나면서, 핵심 로직이 가려지고 유지보수성도 떨어졌다.
어떻게 해결했는가?
AOP + 애너테이션 방식으로 전환:
@HandleConcurrency(message = "포인트 충전 중 충돌이 발생했습니다. 다시 시도해주세요.")
public void chargePoint(...) {
...
}
@Around("@annotation(handleConcurrency)")
public Object handleConcurrency(ProceedingJoinPoint joinPoint) throws Throwable {
try {
return joinPoint.proceed();
} catch (OptimisticLockingFailureException e) {
throw new CoreException(CONFLICT, handleConcurrency.message());
}
}
무엇이 바뀌었는가?
- 중복된 try-catch 제거
- 예외 메시지 관리 일원화
- 코드로 의도가 드러남 (@HandleConcurrency 붙어 있으면 낙관적 락 제어 대상임을 알 수 있음)
6. 동시성 테스트: 전략 검증
실제 시나리오를 검증하기 위해 모든 도메인에 대해 ExecutorService + CountDownLatch 기반으로 테스트를 작성했다.
포인트 도메인 (낙관적 락)
차감 시나리오
@Test
@DisplayName("동시 차감 - 최대 5건만 성공하고 나머지는 낙관적 락으로 실패한다")
void deduct_concurrent_singleUsePerBalance() throws InterruptedException {
// 사전 충전
pointService.charge(new AddPointCommand(USER_ID, BigDecimal.valueOf(5000)));
int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger conflictCount = new AtomicInteger();
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
pointService.deduct(
UserId.of(USER_ID),
PaymentAmount.from(OrderAmount.of(BigDecimal.valueOf(1000)))
);
successCount.incrementAndGet();
} catch (CoreException e) {
conflictCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
Point saved = pointService.findByUserId(USER_ID);
BigDecimal remaining = saved.getBalance().value();
assertThat(successCount.get()).isLessThanOrEqualTo(5);
assertThat(successCount.get() + conflictCount.get()).isEqualTo(threadCount);
assertThat(remaining.compareTo(BigDecimal.ZERO)).isGreaterThanOrEqualTo(0);
}
- 잔액 5000원 상태에서 10개 스레드가 1000원씩 차감
- 최대 5개만 성공
충전 시나리오
- 일부 성공, 일부 OptimisticLockingFailureException 발생
- 성공한 만큼만 충전됨
쿠폰 도메인 (낙관적 락)
- 하나의 쿠폰에 대해 10개 스레드가 동시에 사용 요청
- 하나만 성공
재고 도메인 (비관적 락)
- 재고 10개 상품에 대해 15개 스레드가 동시 요청
- 정확히 10건만 성공
- 그 외는 대기 → 실패
도메인을 분리하면 협력의 구조는 명확해지지만,
정합성을 지키는 책임은 더욱 무거워진다.
이번 글은 각 도메인의 성격에 맞는 락 전략 선택과 테스트,
그리고 그 기반이 되는 설계 흐름을 돌아본 기록이다.
'동시성 제어는 인프라나 설정이 아니라, 도메인 설계의 문제'라는 걸 체감했다.
예제 전체 코드는 아래 저장소에서 확인할 수 있다.
https://github.com/xogns4909/loopers_commerce
'Loopers' 카테고리의 다른 글
인덱스를 걸었는데… 더 느려졌다? 🤯 (4) | 2025.08.15 |
---|---|
락은 왜 느리고, MVCC는 무엇을 바꾸었나 (8) | 2025.08.10 |
설계는 정답이 없다고 했지만, 그래도 너무 어렵잖아 (3) | 2025.08.01 |
Loopers WIL – 2주차 (0) | 2025.07.24 |
Loopers요구사항 정의서 없이 만드는 개발은 결국 돌고 돌아 제자리로 돌아온다 (0) | 2025.07.24 |