- Published on
DDD Aggregate 경계 실수로 락 폭증한 장애 해결기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서비스가 성장하면서 트래픽이 늘면, 코드 자체는 멀쩡해 보여도 DB에서 갑자기 lock wait가 늘고 P95/P99 지연이 튀는 순간이 옵니다. 이때 인덱스나 쿼리 튜닝만 붙잡고 있으면 반쪽짜리 처방이 되기 쉽습니다. 특히 DDD를 도입한 시스템에서는 Aggregate 경계를 잘못 잡은 것이 락 폭증의 근본 원인인 경우가 많습니다.
이 글은 “Aggregate를 크게 잡아 한 트랜잭션에 너무 많은 것을 묶어버린” 전형적인 실수를 어떻게 발견하고, 어떻게 경계를 재설계해 락 경합을 줄였는지에 초점을 맞춥니다.
증상: 쿼리는 빠른데 락 대기만 늘어난다
장애 당시 흔히 보이는 지표는 다음과 같습니다.
- 애플리케이션 APM에서 특정 API의 평균 응답은 괜찮은데 P99가 급등
- DB에서 활성 세션 수가 증가하고,
lock wait이벤트가 상위에 랭크 - CPU는 넉넉한데 TPS가 더 이상 늘지 않음(전형적인 경합 병목)
- 동일한 엔티티(예: 주문, 장바구니, 계정 등)에 대한 동시 업데이트가 몰릴수록 악화
여기서 중요한 힌트는 “쿼리 실행 시간”이 아니라 “트랜잭션이 오래 잡고 있는 잠금”입니다. 즉, 빠르게 실행되는 UPDATE라도 같은 행(또는 같은 인덱스 키 범위)을 두고 경쟁하면 대기가 누적됩니다.
PostgreSQL 기준으로는 pg_stat_activity, pg_locks를 보거나, 슬로우 쿼리 분석과 함께 auto_explain을 붙여 실제로 어디서 시간이 소비되는지 확인하는 것이 좋습니다. 관련해서는 PostgreSQL 느린 쿼리 튜닝 - auto_explain+pg_stat_statements 글의 접근을 락 분석에도 응용할 수 있습니다. 실행 계획이 아니라 “대기 이벤트와 트랜잭션 길이”를 같이 보자는 관점이 핵심입니다.
원인: Aggregate를 ‘업무적으로 편해 보이게’ 크게 잡은 경우
DDD에서 Aggregate는 다음을 보장하기 위한 단위입니다.
- 불변식(Invariant)을 트랜잭션 경계 안에서 강제
- 외부는 Aggregate Root를 통해서만 변경
- 한 트랜잭션에서 일관성 있게 바뀌어야 하는 것만 묶음
문제는 “한 번에 같이 보여야 한다” 혹은 “도메인적으로 연관이 있다”는 이유로, 실제로는 즉시 일관성이 필요 없는 것까지 한 Aggregate에 우겨 넣는 순간 발생합니다.
흔한 실수 패턴 3가지
1) 주문 Aggregate에 결제/배송/포인트/쿠폰까지 다 넣기
예를 들어 Order를 저장할 때 아래를 한 트랜잭션에 다 업데이트하는 구조입니다.
- 주문 상태 변경
- 결제 승인 정보 갱신
- 배송지/배송 상태 갱신
- 쿠폰 사용 처리
- 포인트 차감 처리
업무적으로는 “주문 처리”지만, 동시성 관점에서는 서로 다른 경쟁 축이 섞입니다. 특히 포인트/쿠폰은 사용자 단위로 경합이 생기고, 주문은 주문 단위로 경합이 생깁니다. 이를 한 트랜잭션에 묶으면 락 범위와 보유 시간이 커집니다.
2) Root가 아닌 자식 컬렉션을 자주 수정하는데도 한 덩어리로 저장
JPA/Hibernate 같은 ORM을 쓰면 더 자주 터집니다.
Order안에orderItems컬렉션Order안에orderEvents컬렉션(상태 변경 이력)
이때 매 요청마다 이벤트를 append 하거나 아이템을 수정하면, 결국 Order 행을 업데이트하는 트랜잭션이 자주 발생합니다. 그리고 그 업데이트는 같은 주문에 대한 다른 작업(조회 후 수정, 상태 변경 등)과 충돌합니다.
3) “정합성”을 과대해석해서 모든 것을 동기식으로 강제
예: 주문 상태가 PAID로 바뀌는 순간
- 재고 차감
- 포인트 적립
- 추천인 리워드
까지 모두 같은 트랜잭션에서 처리하려고 합니다. 이 설계는 락 경합뿐 아니라 장애 전파(외부 시스템 지연이 곧 DB 락 보유 시간 증가)를 부릅니다.
진단: 락 폭증이 Aggregate 경계 문제인지 확인하는 체크리스트
다음 질문에 “예”가 많을수록 Aggregate 경계가 과도하게 큰 확률이 큽니다.
- 한 API가 서로 다른 테이블을 5개 이상 업데이트하는가?
- 트랜잭션이 외부 I/O(HTTP 호출, 메시지 발행, 파일 업로드)를 포함하는가?
- 같은 Root 행을 업데이트하는 빈도가 높은가? (이력 append 때문에라도)
- 동시 업데이트가 많은 키가 “사용자 단위”인지 “주문 단위”인지 “상품 단위”인지 섞여 있는가?
- 불변식이 실제로는 “즉시” 필요 없는데도 트랜잭션으로 강제하고 있는가?
PostgreSQL에서는 다음처럼 대기 중인 트랜잭션과 블로킹 트랜잭션을 확인해, 어떤 테이블/쿼리가 병목인지 빠르게 찾을 수 있습니다.
-- 대기 중인 세션과 블로킹 세션 확인
SELECT
a.pid AS waiting_pid,
a.query AS waiting_query,
a.wait_event_type,
a.wait_event,
pg_blocking_pids(a.pid) AS blocking_pids
FROM pg_stat_activity a
WHERE a.wait_event_type IS NOT NULL
AND a.state = 'active';
그리고 블로킹 pid를 풀어서 어떤 쿼리가 락을 잡고 있는지 추적합니다.
-- 블로킹 세션의 쿼리 확인
SELECT pid, usename, state, query, xact_start
FROM pg_stat_activity
WHERE pid = ANY (ARRAY[12345, 67890]);
여기서 xact_start가 오래된 트랜잭션이 반복적으로 특정 Root 업데이트를 붙잡고 있으면, “업데이트 자체가 느리다”가 아니라 “트랜잭션이 불필요하게 길다”로 결론이 기웁니다.
해결 전략: Aggregate를 “불변식 단위”로 다시 쪼개기
핵심은 단순합니다.
- 강한 일관성이 반드시 필요한 것만 같은 Aggregate에 둔다
- 나머지는 이벤트 기반으로 분리하거나, 별도 Aggregate로 분리한다
- 자주 쓰는 쓰기 경로에서 Root 업데이트를 최소화한다
1) 주문 처리 예시: Order와 Payment를 분리
잘못된 모델(과도하게 큰 Aggregate)을 단순화하면 아래 느낌입니다.
// 나쁜 예: 한 트랜잭션에서 너무 많은 것을 강제
@Transactional
fun pay(orderId: Long, paymentInfo: PaymentInfo) {
val order = orderRepository.findByIdForUpdate(orderId)
order.markPaid(paymentInfo)
couponService.useCoupon(order.userId, order.couponId)
pointService.deduct(order.userId, order.usedPoints)
shippingService.createShipment(orderId, order.address)
orderRepository.save(order)
}
여기서 락 폭증의 본질은 findByIdForUpdate로 잡은 락을 외부/부가 로직이 끝날 때까지 계속 보유한다는 점입니다.
개선 방향은 다음과 같습니다.
OrderAggregate는 “주문 상태 전이”에만 집중Payment는 별도 Aggregate로 두고 결제 승인/실패를 독립적으로 관리- 쿠폰/포인트/배송은 “주문 결제 완료 이벤트”를 구독해 비동기 처리
// 개선 예: 트랜잭션을 짧게, 불변식만 강제
@Transactional
fun confirmPayment(orderId: Long, paymentId: String) {
val order = orderRepository.findById(orderId)
order.confirmPaid(paymentId)
orderRepository.save(order)
// 트랜잭션 커밋 이후 처리(아웃박스 권장)
outbox.publish(
type = "OrderPaid",
payload = mapOf("orderId" to orderId, "paymentId" to paymentId)
)
}
이 구조에서 중요한 포인트는 “커밋 이후”입니다. 트랜잭션 안에서 메시지 브로커에 바로 발행하면 실패 시 중복/유실 문제가 생기므로, Outbox 패턴을 적용해 일관성을 확보하는 것이 안전합니다.
2) 이벤트 이력 append 때문에 Root가 매번 업데이트되는 문제
상태 변경 이력을 Order 내부 컬렉션으로 관리하면, 이력 추가만으로도 Order가 업데이트되어 경합이 증가합니다. 이력은 강한 일관성이 필요한 불변식이 아니라 “감사 로그/추적” 성격이 강한 경우가 많습니다.
Order는 현재 상태만 들고OrderHistory는 별도 테이블에 append-only로 저장- 가능하면
INSERT만 발생하도록 설계
-- 주문 상태는 주문 테이블에서 업데이트
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE id = $1;
-- 이력은 별도 테이블에 insert-only
INSERT INTO order_history(order_id, event_type, payload, created_at)
VALUES ($1, 'PAID', $2, NOW());
이렇게 하면 같은 주문에 대한 상태 업데이트 경합은 남더라도, “이력 append 때문에 불필요하게 주문 행을 자주 건드리는” 문제를 크게 줄일 수 있습니다.
3) 불변식 재정의: 정말로 즉시 일관성이 필요한가?
Aggregate를 쪼갤 때 가장 어려운 부분은 “정합성 수준 합의”입니다. 다음처럼 정리하면 의사결정이 빨라집니다.
- 즉시 일관성 필요: 재고가 0 이하로 내려가면 안 된다, 결제 승인 없이 배송 생성 금지
- 결과적 일관성 허용: 포인트 적립, 추천 리워드, 알림 발송, 검색 인덱싱
즉시 일관성이 필요한 것만 같은 트랜잭션에 남기면, 락 보유 시간이 짧아지고 경합이 줄어듭니다.
구현 디테일: Outbox로 “분리”의 부작용(유실/중복) 막기
Aggregate 경계를 분리하면 비동기 이벤트가 늘고, 그만큼 “메시지 유실/중복”이 현실 문제가 됩니다. 그래서 실무에서는 Outbox가 사실상 세트로 따라옵니다.
간단한 Outbox 테이블 예시는 다음과 같습니다.
CREATE TABLE outbox (
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 NULL
);
CREATE INDEX outbox_unpublished_idx
ON outbox (published_at)
WHERE published_at IS NULL;
애플리케이션 트랜잭션에서 outbox에 insert 하고, 별도 퍼블리셔가 published_at IS NULL을 폴링하거나 CDC로 브로커에 발행합니다.
이 방식은 “주문 업데이트 커밋은 됐는데 이벤트 발행이 실패” 같은 찢어진 상태를 줄여줍니다.
결과: 락 폭증이 줄어드는 메커니즘
Aggregate 경계를 바로잡으면 다음 변화가 생깁니다.
- 한 트랜잭션이 업데이트하는 행 수가 줄어듦
- 트랜잭션에서 외부 I/O가 빠져 락 보유 시간이 단축
- 경합 키가 분리됨(주문 단위 경합과 사용자 단위 경합이 섞이지 않음)
UPDATE중심에서INSERT(append-only)로 이동하는 부분이 늘어 경합 완화
정리하면, 락 폭증을 “DB 튜닝”으로만 해결하려고 하면 한계가 있고, 도메인 모델(특히 Aggregate 경계)이 락의 형태를 결정한다는 점이 중요합니다.
운영 팁: 재발 방지를 위한 가드레일
- 트랜잭션 안에서 네트워크 호출 금지(코드리뷰 체크리스트화)
- Root 업데이트 빈도 높은 컬렉션(이력, 로그, 이벤트)을 별도 저장소로 분리
- 핫키(특정 사용자/주문/상품) 경합을 부하 테스트에서 의도적으로 재현
- DB에서 락 관련 대기 이벤트를 상시 대시보드화
또한 장애 대응 과정에서 Git으로 핫픽스를 여러 번 쌓다 보면 PR 히스토리가 꼬이기 쉬운데, 리베이스 이후 커밋이 중복되는 이슈를 겪었다면 Git rebase 후 PR에 커밋이 중복될 때 원인·복구도 함께 참고하면 운영 효율이 좋아집니다.
마무리
DDD의 Aggregate는 “도메인 객체를 예쁘게 묶는 단위”가 아니라, 동시성과 트랜잭션 비용을 통제하는 강력한 스위치입니다. 경계를 크게 잡으면 개발 초기에는 편하지만, 트래픽이 오르는 순간 락 경합으로 비용을 치르게 됩니다.
락 폭증이 보인다면 쿼리 튜닝 전에 먼저 다음을 의심해 보세요.
- 불변식이 아닌 것까지 한 트랜잭션에 묶어두지 않았는지
- Root를 너무 자주 업데이트하도록 모델링하지 않았는지
- 서로 다른 경합 축(주문/사용자/상품)을 한 Aggregate로 섞지 않았는지
Aggregate를 불변식 단위로 재정의하고, Outbox를 곁들여 비동기 분리를 안전하게 만들면 “락을 줄이기 위한 구조적 개선”이 가능합니다.