- Published on
DDD Aggregate 경계 실수로 락·데드락 터질 때
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느려지고, 간헐적으로 Lock wait timeout 또는 deadlock detected가 터지고, 애플리케이션은 재시도 로직으로 버티지만 결국 DB 커넥션 풀까지 고갈되는 상황. 이런 장애는 “쿼리 튜닝”만으로는 잘 안 끝납니다. 특히 DDD를 도입한 팀에서 Aggregate 경계를 넓게 잡거나, 교차 Aggregate 불변조건을 한 트랜잭션에 우겨 넣을 때 락 경합이 구조적으로 발생합니다.
이 글은 “Aggregate 경계 실수 → 트랜잭션 확대 → 락/데드락”의 연결고리를 실제 운영 관점에서 해부하고, 경계를 다시 잡는 방법(모델/DB/트랜잭션 설계)을 단계적으로 정리합니다. 락이 길어지면 결국 앱 레벨에서는 풀 고갈/타임아웃으로 번지므로, 증상 연쇄까지 같이 봐야 합니다. (관련해서는 Spring Boot HikariCP 풀 고갈·DB 타임아웃 10분 진단도 함께 참고하면 좋습니다.)
1) Aggregate 경계가 락을 키우는 메커니즘
Aggregate의 목적을 다시 확인
Aggregate는 단순히 “연관 엔티티 묶음”이 아니라:
- 동일 트랜잭션에서 강제해야 하는 불변조건(invariant)의 범위
- 동시성 제어(락/버전)의 단위
- 외부에서 참조 가능한 일관된 변경의 최소 단위
입니다. 즉, Aggregate가 커질수록 “한 번의 명령(Command)”이 건드리는 row가 많아지고, 그만큼 락이 오래/넓게 잡힙니다.
락이 커지는 흔한 설계 흐름
- “주문 생성”에 재고 차감, 쿠폰 사용, 포인트 차감, 결제 승인까지 한 트랜잭션에 넣음
- 주문 Aggregate 안에
OrderItem,Coupon,PointWallet,Inventory를 사실상 다 끌어옴(혹은 DB 레벨에서 조인/갱신) - 트랜잭션이 길어지고, 여러 테이블/행을 업데이트
- 동시 주문이 몰리면 서로가 서로의 락을 기다림 → 데드락
이때 데드락은 ‘운이 나빠서’가 아니라, 서로 다른 트랜잭션이 서로 다른 순서로 리소스를 잠그는 구조에서 필연적으로 발생합니다.
2) 대표적인 실패 패턴 5가지
패턴 A: “하나의 거대한 Aggregate”
예: Order가 모든 것을 소유하고, 변경 시 연관 객체들을 전부 변경.
- 장점: 코드가 단순해 보임
- 단점: 쓰기 경합이 폭발, 트랜잭션이 길어짐, 장애 시 영향 범위가 큼
특히 Order에 “결제상태/배송상태/재고상태/쿠폰상태”를 모두 넣으면, 상태 머신이 꼬이고 업데이트가 잦아져 락을 더 오래 잡습니다.
패턴 B: 교차 Aggregate 불변조건을 동기 트랜잭션으로 강제
예: “주문 생성 시 쿠폰은 반드시 1회만 사용된다”를 주문 트랜잭션에서 쿠폰 row를 잠그고 처리.
이 불변조건이 정말로 강한 일관성이 필요한지부터 의심해야 합니다. 대부분은 “중복 사용 방지”가 목표인데, 이는 낙관적 동시성 + 유니크 제약 + 재시도로도 달성 가능합니다.
패턴 C: 저장소(Repository)에서 조인으로 여러 Aggregate를 한 번에 로드/갱신
ORM에서 흔한 실수입니다.
findOrderWithEverythingForUpdate()같은 메서드가 생김SELECT ... FOR UPDATE가 조인된 다수 테이블에 걸리면서 락 범위가 넓어짐
특히 PostgreSQL에서는 FOR UPDATE가 조인 결과의 대상 행을 잠그며, 설계에 따라 생각보다 많은 행이 잠길 수 있습니다.
패턴 D: 일관된 락 획득 순서가 없다
데드락의 정석 원인입니다.
- 트랜잭션 1:
inventory→coupon순서로 업데이트 - 트랜잭션 2:
coupon→inventory순서로 업데이트
동시에 들어오면 서로 상대가 잡은 락을 기다리며 교착 상태가 됩니다.
패턴 E: “읽기”가 쓰기 락으로 승격되는 설계
예:
- 존재 확인을
SELECT ... FOR UPDATE로 처리 - 혹은 조건부 업데이트를 위해 불필요하게 넓은 범위를 잠금
읽기 트래픽이 많은 도메인에서 이 패턴은 병목을 급격히 키웁니다.
3) 락/데드락이 터질 때의 증상 연쇄(운영 관점)
락 경합이 커지면 단순히 “DB에서 데드락 로그가 찍힌다”로 끝나지 않습니다.
- 특정 API의 P95/P99 지연 증가
- DB 커넥션이 오래 점유됨(트랜잭션이 락 대기)
- 애플리케이션 커넥션 풀 고갈 → 타임아웃/504/5xx
- 재시도 로직이 있으면 트래픽이 증폭되어 더 악화
PostgreSQL/RDS 환경이라면 커넥션 수/대기 이벤트가 같이 튀는 경우가 많습니다. 증상이 커넥션 문제로 보일 때도 실제 원인은 락 경합인 경우가 많으니, RDS PostgreSQL too many connections 원인·해결 같은 관점(커넥션/풀/대기)으로 함께 진단해야 합니다.
4) 빠르게 진단하는 체크리스트
4.1 “어떤 명령이 어떤 리소스를 어떤 순서로 잠그는가?”를 먼저 그리기
- 문제 API별로 “업데이트하는 테이블/행” 목록화
- 락 획득 순서가 요청마다 달라지는지 확인
4.2 DB에서 락 대기/데드락 원인 쿼리 확인
PostgreSQL 예시(락 대기 중인 세션 확인):
SELECT
a.pid,
a.usename,
a.state,
a.wait_event_type,
a.wait_event,
now() - a.query_start AS running_for,
a.query
FROM pg_stat_activity a
WHERE a.state <> 'idle'
ORDER BY running_for DESC;
락 충돌 관계를 더 직접적으로 보려면 pg_locks를 조합합니다(운영 환경에서는 뷰 쿼리 템플릿을 미리 준비해두는 게 좋습니다).
4.3 애플리케이션에서 “트랜잭션 시간”을 먼저 측정
- APM에서 Transaction trace로 DB 호출 구간이 늘어나는지
@Transactional범위가 과도하게 넓지 않은지- 외부 API 호출(결제 승인 등)이 트랜잭션 안에 들어가 있지 않은지
5) 경계 재설정: 락을 줄이는 DDD 리팩터링 전략
핵심은 “강한 일관성이 필요한 불변조건만 Aggregate 내부로” 넣고, 나머지는 결과적 일관성(eventual consistency) 및 프로세스 매니저(Saga) 로 분리하는 것입니다.
5.1 Aggregate는 “불변조건”으로 자른다
예시 도메인: 주문/재고/쿠폰/결제
OrderAggregate: 주문 라인, 주문 상태 전이(주문 생성/취소/결제대기 등)InventoryItemAggregate: SKU별 가용 재고, 예약/차감 규칙CouponAggregate: 쿠폰 사용 가능 여부, 사용 처리(1회성)PaymentAggregate: 결제 승인/취소 상태
이렇게 분리하면 “주문 생성”이 모든 것을 한 트랜잭션에서 끝내지 않아도 됩니다.
5.2 Outbox + 도메인 이벤트로 교차 Aggregate를 연결
주문 생성 시:
Order만 저장(짧은 트랜잭션)- Outbox에
OrderCreated이벤트 기록(같은 트랜잭션) - 비동기로 재고 예약/쿠폰 사용/결제 승인 프로세스 진행
Outbox 테이블 예시:
CREATE TABLE outbox_event (
id bigserial PRIMARY KEY,
aggregate_type text NOT NULL,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX idx_outbox_unpublished
ON outbox_event (published_at)
WHERE published_at IS NULL;
이 방식은 “트랜잭션을 짧게” 유지하면서도 이벤트 유실을 막습니다.
5.3 동시성 제어는 낙관적 락을 기본값으로
DB 락으로 막기보다, 버전 컬럼으로 충돌을 감지하고 재시도하는 편이 확장성에 유리합니다.
JPA 예시:
@Entity
public class InventoryItem {
@Id
private Long id;
@Version
private Long version;
private String sku;
private long available;
public void reserve(long qty) {
if (available < qty) throw new IllegalStateException("out of stock");
available -= qty;
}
}
재고 예약 커맨드 처리에서 OptimisticLockException이 나면 짧게 재시도(backoff 포함)합니다. 이렇게 하면 “긴 락 대기” 대신 “짧은 충돌 + 재시도”로 바뀌어 시스템이 예측 가능해집니다.
5.4 정말로 필요한 곳에만 비관적 락(SELECT FOR UPDATE)
예: “쿠폰 1회 사용”이 강한 일관성이 꼭 필요하고, 충돌 비용이 큰 경우.
다만 이때도:
- 잠그는 행 수를 최소화
- 트랜잭션을 극단적으로 짧게
- 잠금 순서를 전역적으로 통일
해야 합니다.
5.5 락 획득 순서를 ‘규칙’으로 고정
교차 Aggregate를 한 트랜잭션에서 다뤄야 한다면(레거시/규제/정산 등), 최소한 순서를 고정하세요.
예시 규칙:
coupon→ 2)inventory→ 3)order→ 4)payment
그리고 코드 리뷰 체크리스트로 강제합니다.
6) “한 트랜잭션에서 다 처리”를 걷어내는 실전 예시
문제 코드(트랜잭션이 너무 큼)
@Transactional
public Order placeOrder(PlaceOrderCommand cmd) {
Coupon coupon = couponRepo.findByIdForUpdate(cmd.couponId());
InventoryItem item = inventoryRepo.findBySkuForUpdate(cmd.sku());
item.reserve(cmd.qty());
coupon.use();
Order order = Order.create(cmd.userId(), cmd.sku(), cmd.qty(), coupon.getId());
orderRepo.save(order);
// 외부 결제 승인까지 같은 트랜잭션에서 수행 (최악)
paymentGateway.approve(order.getId(), cmd.paymentToken());
order.markPaid();
return order;
}
- 외부 호출이 트랜잭션 안에 있어 락 유지 시간이 폭발
- 쿠폰/재고 락 획득 순서가 다른 유스케이스에서 뒤집히면 데드락
개선 방향(주문 트랜잭션 축소 + 비동기 오케스트레이션)
@Transactional
public Long placeOrder(PlaceOrderCommand cmd) {
Order order = Order.createPending(cmd.userId(), cmd.sku(), cmd.qty(), cmd.couponId());
orderRepo.save(order);
outboxRepo.save(OutboxEvent.of(
"Order", order.getId().toString(),
"OrderCreated",
Map.of("orderId", order.getId(), "sku", cmd.sku(), "qty", cmd.qty(), "couponId", cmd.couponId())
));
return order.getId();
}
이후 컨슈머(프로세스 매니저)가:
- 재고 예약 시도(낙관적 락 + 재시도)
- 쿠폰 사용 처리(필요 시 짧은 비관적 락)
- 결제 승인(외부 호출)
- 성공/실패에 따라
Order상태 전이
로 분리합니다. 이렇게 하면 주문 API는 빠르게 응답하고, 락은 각 Aggregate의 짧은 트랜잭션으로 분산됩니다.
7) 데드락을 “없애는” 게 아니라 “관리 가능한 실패”로 바꾸기
완벽히 데드락이 0이 되기는 어렵습니다. 중요한 건:
- 데드락/락타임아웃이 발생해도 영향이 국소적일 것
- 재시도가 짧고 제한적일 것(무한 재시도 금지)
- 실패가 사용자/업무적으로 정합한 상태로 수렴할 것(보상 트랜잭션)
또한 락 대기가 길어지면 DB 커넥션 점유 시간이 늘고, 결국 애플리케이션 풀 고갈로 번질 수 있으니(증상은 “DB 타임아웃”으로 보임), 앞서 언급한 풀 진단 글과 함께 트랜잭션 시간을 반드시 관측하세요.
8) 마무리: Aggregate 경계는 ‘성능 튜닝 포인트’다
Aggregate 경계는 코드 구조의 미학이 아니라 동시성/락/트랜잭션의 물리적 비용을 결정합니다. 락·데드락이 반복된다면 “인덱스”나 “격리수준”부터 보기 전에 아래 질문부터 다시 던져야 합니다.
- 이 불변조건은 정말로 한 트랜잭션에서 강제해야 하는가?
- 한 명령이 변경해야 하는 최소 일관 범위는 어디까지인가?
- 교차 Aggregate 작업을 이벤트/사가로 분리할 수 있는가?
- 불가피하게 한 트랜잭션이면 락 순서가 전역적으로 고정되어 있는가?
이 질문에 답하면서 경계를 다시 잡으면, 데드락은 ‘장애’가 아니라 ‘가끔 발생하는 충돌’로 격하되고, 시스템은 훨씬 예측 가능해집니다.