- Published on
DDD 애그리거트 경계 오류로 트랜잭션 폭발할 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 도메인 규칙을 한 트랜잭션에 욱여넣는 순간, DDD에서 말하는 “애그리거트 경계”는 설계 문서가 아니라 장애의 진원지가 됩니다. 처음에는 편합니다. Order 하나 저장하면 OrderItem, Payment, InventoryReservation, Coupon, Shipment까지 한 번에 “정합하게” 바뀌는 것처럼 보이니까요.
하지만 트래픽이 오르고 동시성이 붙는 순간, 그 편의는 곧바로 비용으로 돌아옵니다.
- 한 요청이 여러 테이블(혹은 컬렉션)을 잠그며 락 경합이 증가
- 트랜잭션 시간이 길어져 타임아웃과 재시도가 폭증
- 교차 업데이트로 데드락이 잦아짐
- 장애 시 롤백 범위가 커져 복구가 느려짐
이 글은 “애그리거트 경계를 잘못 잡아 트랜잭션이 폭발”하는 전형적인 케이스를 기준으로, 어떤 신호를 보고 의심해야 하는지, 그리고 경계를 어떻게 다시 잡고 트랜잭션을 줄이는지까지 단계적으로 정리합니다.
트랜잭션 폭발의 전형적인 증상
1) 유스케이스 하나가 @Transactional 안에서 너무 많은 일을 한다
예를 들어 “주문 생성”이 아래를 모두 포함합니다.
- 주문/주문항목 저장
- 재고 차감 또는 예약
- 쿠폰 사용 처리
- 결제 승인/취소
- 배송 생성
- 포인트 적립
- 알림 발송
이 중 일부는 강한 일관성이 필요해 보이지만, 실제로는 “즉시 일관성”이 아니라 “결과적 일관성”으로 충분한 경우가 많습니다.
2) 락 경합, 데드락, 재시도 폭증
락 경합이 늘면 평균 응답시간이 아니라 p95/p99가 먼저 무너집니다. 그리고 재시도가 붙으면 트랜잭션 수가 더 늘어 악순환이 됩니다.
PostgreSQL을 쓰고 있다면 deadlock_detected 같은 에러가 대표 신호입니다. 원인과 해결 접근은 아래 글도 함께 보면 좋습니다.
3) “정합성”을 이유로 외부 시스템 호출까지 트랜잭션에 포함
결제 PG 승인, 외부 재고 서비스 호출, 메시지 발행을 트랜잭션 안에 넣는 순간, 트랜잭션은 DB가 아니라 네트워크 품질에 종속됩니다.
왜 애그리거트 경계가 트랜잭션 폭발로 이어지나
DDD에서 애그리거트는 “일관성 경계(Consistency Boundary)”입니다. 즉, 한 애그리거트 내부는 단일 트랜잭션에서 즉시 일관성을 보장하고, 애그리거트 간은 도메인 이벤트와 결과적 일관성을 기본으로 합니다.
경계를 잘못 잡는 패턴은 크게 두 가지입니다.
패턴 A: “큰 애그리거트”로 모든 규칙을 한 번에 보장하려 함
Order 애그리거트가 Payment, Inventory, Coupon까지 직접 참조하고 상태를 바꾸면, 주문 한 건이 사실상 “쇼핑몰 전체 상태”를 잠그는 셈이 됩니다.
패턴 B: 애그리거트 간 참조를 ID가 아니라 객체 그래프로 연결
ORM에서 흔합니다. Order가 Payment 엔티티를 직접 들고 있고, Payment가 또 InventoryReservation을 들고… 이런 식으로 변경 감지가 전파되면, 개발자는 “주문만 수정했는데 왜 이렇게 많은 업데이트가 나가지?”를 겪게 됩니다.
경계가 잘못됐다는 판단 기준(체크리스트)
아래 중 2개 이상이면 경계 재검토를 권합니다.
- 단일 유스케이스에서 업데이트하는 애그리거트가 2개 이상이다
- 트랜잭션이 외부 호출(HTTP, gRPC, 메시지 브로커)을 포함한다
Order저장이Inventory행 락까지 유발한다- 데드락 회피를 위해 업데이트 순서를 “규칙”으로 강제하고 있다
- 읽기 모델 최적화가 아니라 “쓰기 트랜잭션 최적화”가 계속 이슈다
잘못된 예시: 한 트랜잭션에 모든 것을 넣는 주문 생성
아래는 흔히 보이는 구조입니다. 핵심 문제는 “주문 생성”이 여러 애그리거트를 직접 변경하고, 트랜잭션이 길어지며, 외부 연동까지 빨려 들어간다는 점입니다.
@Service
public class PlaceOrderService {
@Transactional
public OrderId place(PlaceOrderCommand cmd) {
Order order = orderFactory.create(cmd);
couponService.use(cmd.couponId(), cmd.userId());
inventoryService.reserve(order.items());
Payment payment = paymentService.authorize(cmd.paymentMethod(), order.totalPrice());
order.attachPayment(payment);
orderRepository.save(order);
// 트랜잭션 안에서 외부 메시지 발행까지 해버리는 경우도 흔함
eventPublisher.publish(new OrderPlaced(order.getId()));
return order.getId();
}
}
이 구조는 트래픽이 낮을 때는 “정합하게 보이는” 장점이 있지만, 동시성이 높아지면 다음 비용이 발생합니다.
- 쿠폰/재고/결제 테이블(또는 서비스)까지 한 요청이 묶어서 락을 잡는다
- 결제 승인 지연이 DB 트랜잭션 시간을 늘린다
- 실패 시 롤백 범위가 커져 재시도가 더 비싸진다
경계 재설계의 핵심: “즉시 일관성”이 필요한 것만 남기기
1) 애그리거트 내부 불변식(invariant)만 트랜잭션으로 보장
주문 애그리거트 내부에서 즉시 보장해야 하는 것은 보통 이런 것들입니다.
- 주문 상태 전이 규칙(예:
CREATED에서만CANCELLED가능) - 주문 총액 계산 규칙
- 주문 항목 수량/가격의 정합성
반면 아래는 “주문 애그리거트 불변식”이라기보다 “프로세스 정합성”에 가깝습니다.
- 결제 승인 여부
- 재고 확보 여부
- 쿠폰 사용 확정
- 배송 생성
이들은 애그리거트를 분리하고 이벤트로 연결하는 편이 트랜잭션 폭발을 막습니다.
2) 애그리거트 간 참조는 객체가 아니라 ID로
애그리거트는 다른 애그리거트를 직접 품기보다 식별자만 들고, 필요하면 조회해서 사용합니다(그 조회조차도 보통은 “검증”이 아니라 “정책 결정”에 가깝게).
public class Order {
private OrderId id;
private UserId userId;
private Money total;
private OrderStatus status;
// Payment 엔티티를 직접 들지 않고 식별자만 보관
private PaymentId paymentId;
public void markPaymentAuthorized(PaymentId pid) {
if (this.status != OrderStatus.CREATED) {
throw new IllegalStateException("invalid state");
}
this.paymentId = pid;
this.status = OrderStatus.PAID;
}
}
3) 트랜잭션은 “명령 모델”에서 짧게, 확장은 이벤트로
주문 생성 트랜잭션은 주문 저장까지만 책임지고, 나머지는 이벤트로 분리합니다.
해결 패턴 1: 도메인 이벤트 + Outbox로 안전하게 분리
트랜잭션을 줄이려다 가장 흔히 겪는 문제는 “DB 커밋은 됐는데 이벤트 발행이 실패”하거나 그 반대가 되는 상황입니다. 이때 Outbox 패턴이 실전 해법입니다.
- 주문 저장 트랜잭션 안에서
outbox테이블에 이벤트를 함께 기록 - 별도 퍼블리셔가
outbox를 읽어 Kafka 등으로 발행 - 발행 성공 시
outbox를 처리 완료로 마킹
-- outbox 예시
create table outbox_event (
id bigserial primary key,
aggregate_type varchar(50) not null,
aggregate_id varchar(100) not null,
event_type varchar(100) not null,
payload jsonb not null,
status varchar(20) not null default 'NEW',
created_at timestamptz not null default now()
);
@Transactional
public OrderId place(PlaceOrderCommand cmd) {
Order order = orderFactory.create(cmd);
orderRepository.save(order);
OutboxEvent evt = OutboxEvent.of(
"Order", order.getId().toString(), "OrderPlaced", toJson(order)
);
outboxRepository.save(evt);
return order.getId();
}
Kafka를 쓰고 “Exactly-Once처럼 보이게” 만들려다 깨지는 지점은 결국 멱등성과 Outbox에 모입니다. 아래 글이 같은 맥락에서 도움이 됩니다.
해결 패턴 2: Saga(프로세스 매니저)로 장기 트랜잭션을 모델링
결제 승인, 재고 확보, 쿠폰 확정은 “한 번에 커밋되는 DB 트랜잭션”이 아니라 “단계적 상태 전이”로 다루는 편이 자연스럽습니다. Saga는 이 과정을 명시적으로 모델링합니다.
예: 주문 프로세스
OrderPlaced이벤트 발생- 결제 서비스가 승인 시
PaymentAuthorized발행 - 재고 서비스가 예약 시
InventoryReserved발행 - 둘 다 충족되면 주문을
PAID혹은CONFIRMED로 전이 - 중간 실패 시 보상 트랜잭션(결제 취소, 예약 해제) 수행
public class OrderSaga {
public void onOrderPlaced(OrderPlaced e) {
// 결제/재고를 비동기로 요청
commandBus.send(new AuthorizePayment(e.orderId(), e.total()));
commandBus.send(new ReserveInventory(e.orderId(), e.items()));
}
public void onPaymentAuthorized(PaymentAuthorized e) {
sagaStateStore.markPaymentOk(e.orderId());
tryComplete(e.orderId());
}
public void onInventoryReserved(InventoryReserved e) {
sagaStateStore.markInventoryOk(e.orderId());
tryComplete(e.orderId());
}
private void tryComplete(OrderId orderId) {
if (sagaStateStore.isPaymentOk(orderId) && sagaStateStore.isInventoryOk(orderId)) {
commandBus.send(new MarkOrderConfirmed(orderId));
}
}
}
포인트는 “하나의 거대한 트랜잭션”을 만들지 않고, 각 애그리거트가 자신의 트랜잭션에서 상태를 바꾸도록 만드는 것입니다.
해결 패턴 3: 동시성 제어는 애그리거트 단위로, 필요한 곳에만
애그리거트 경계를 잘 잡으면 락 범위가 자연스럽게 줄어듭니다. 그래도 경합이 큰 자원(대표적으로 재고)은 별도 전략이 필요합니다.
- 낙관적 락: 버전 컬럼으로 충돌 감지 후 재시도
- 비관적 락: 정말 필요한 구간에만 짧게
- 재고를 “예약” 모델로 바꿔 핫 로우 업데이트를 줄이기
데드락이 이미 발생하고 있다면 “업데이트 순서 통일” 같은 응급처치보다, 애초에 한 트랜잭션에 여러 애그리거트를 묶지 않도록 구조를 바꾸는 게 근본 해결입니다.
트랜잭션 폭발을 줄이는 리팩터링 순서(실전)
1) 현재 트랜잭션에서 수정되는 리소스를 목록화
- 어떤 테이블/컬렉션이 같이 업데이트되는지
- 외부 호출이 있는지
- 평균/최대 트랜잭션 시간이 얼마인지
2) “진짜 불변식”과 “프로세스 정책”을 분리
- 불변식: 애그리거트 내부에서 즉시 보장
- 정책: 이벤트 기반으로 이어붙이기
3) 이벤트 발행을 Outbox로 고정
이 단계가 없으면 운영 중 유실/중복 문제가 반드시 터집니다.
4) 소비자 멱등성 보장
- 이벤트 ID 기반 중복 처리 방지
processed_event저장소 운영
5) 읽기 모델은 CQRS로 별도 최적화
쓰기 트랜잭션을 줄이면, 읽기에서 조인이 늘어날 수 있습니다. 그때는 읽기 전용 뷰/머티리얼라이즈드 뷰/서치 인덱스 등으로 보완합니다.
MongoDB를 쓰면서 읽기 성능이 흔들린다면 explain 기반으로 병목을 찾는 접근도 유용합니다.
“경계를 줄이면 정합성이 깨지는 것 아닌가”에 대한 답
정합성은 “항상 한 트랜잭션에 묶는다”로만 달성되지 않습니다. 대신 다음을 명확히 해야 합니다.
- 어떤 정합성이 “즉시” 필요한가
- 어떤 정합성은 “몇 초 내 수렴”이면 되는가
- 실패했을 때 어떤 보상/재처리가 가능한가
예를 들어 “결제 승인 후 1초 내 주문 상태가 PAID로 바뀐다”는 것은 사용자 경험 관점에서 충분할 수 있습니다. 반면 “재고가 없는 상품을 결제까지 완료시키면 안 된다”는 정책은 결제 승인 전에 재고 예약을 먼저 하도록 프로세스를 설계하면 됩니다. 중요한 건 이 정책을 “한 트랜잭션”이 아니라 “프로세스”로 구현하는 것입니다.
마무리: 애그리거트는 트랜잭션을 작게 만드는 설계 장치다
애그리거트 경계를 잘못 잡으면, 서비스가 성장할수록 트랜잭션이 커지고 락과 데드락, 재시도 폭증으로 이어집니다. 반대로 경계를 올바르게 잡으면 다음이 가능해집니다.
- 트랜잭션 범위가 애그리거트 단위로 작아짐
- 동시성 문제가 국소화됨
- 장애 격리와 복구가 쉬워짐
- 비즈니스 확장이 “한 덩어리 수정”이 아니라 “이벤트 추가”로 바뀜
요약하면, 트랜잭션 폭발은 단순 튜닝 이슈가 아니라 경계 설계 이슈인 경우가 많습니다. 지금 시스템이 거대한 @Transactional에 의존하고 있다면, 먼저 “즉시 일관성이 필요한 불변식만 남기고 나머지를 이벤트/사가/아웃박스로 분리”하는 것부터 시작해 보세요.