Published on

DDD Aggregate 경계 실수로 락·데드락 터질 때

Authors

서버가 느려지고, 간헐적으로 Lock wait timeout 또는 deadlock detected가 터지고, 애플리케이션은 재시도 로직으로 버티지만 결국 DB 커넥션 풀까지 고갈되는 상황. 이런 장애는 “쿼리 튜닝”만으로는 잘 안 끝납니다. 특히 DDD를 도입한 팀에서 Aggregate 경계를 넓게 잡거나, 교차 Aggregate 불변조건을 한 트랜잭션에 우겨 넣을 때 락 경합이 구조적으로 발생합니다.

이 글은 “Aggregate 경계 실수 → 트랜잭션 확대 → 락/데드락”의 연결고리를 실제 운영 관점에서 해부하고, 경계를 다시 잡는 방법(모델/DB/트랜잭션 설계)을 단계적으로 정리합니다. 락이 길어지면 결국 앱 레벨에서는 풀 고갈/타임아웃으로 번지므로, 증상 연쇄까지 같이 봐야 합니다. (관련해서는 Spring Boot HikariCP 풀 고갈·DB 타임아웃 10분 진단도 함께 참고하면 좋습니다.)

1) Aggregate 경계가 락을 키우는 메커니즘

Aggregate의 목적을 다시 확인

Aggregate는 단순히 “연관 엔티티 묶음”이 아니라:

  • 동일 트랜잭션에서 강제해야 하는 불변조건(invariant)의 범위
  • 동시성 제어(락/버전)의 단위
  • 외부에서 참조 가능한 일관된 변경의 최소 단위

입니다. 즉, Aggregate가 커질수록 “한 번의 명령(Command)”이 건드리는 row가 많아지고, 그만큼 락이 오래/넓게 잡힙니다.

락이 커지는 흔한 설계 흐름

  1. “주문 생성”에 재고 차감, 쿠폰 사용, 포인트 차감, 결제 승인까지 한 트랜잭션에 넣음
  2. 주문 Aggregate 안에 OrderItem, Coupon, PointWallet, Inventory를 사실상 다 끌어옴(혹은 DB 레벨에서 조인/갱신)
  3. 트랜잭션이 길어지고, 여러 테이블/행을 업데이트
  4. 동시 주문이 몰리면 서로가 서로의 락을 기다림 → 데드락

이때 데드락은 ‘운이 나빠서’가 아니라, 서로 다른 트랜잭션이 서로 다른 순서로 리소스를 잠그는 구조에서 필연적으로 발생합니다.

2) 대표적인 실패 패턴 5가지

패턴 A: “하나의 거대한 Aggregate”

예: Order가 모든 것을 소유하고, 변경 시 연관 객체들을 전부 변경.

  • 장점: 코드가 단순해 보임
  • 단점: 쓰기 경합이 폭발, 트랜잭션이 길어짐, 장애 시 영향 범위가 큼

특히 Order에 “결제상태/배송상태/재고상태/쿠폰상태”를 모두 넣으면, 상태 머신이 꼬이고 업데이트가 잦아져 락을 더 오래 잡습니다.

패턴 B: 교차 Aggregate 불변조건을 동기 트랜잭션으로 강제

예: “주문 생성 시 쿠폰은 반드시 1회만 사용된다”를 주문 트랜잭션에서 쿠폰 row를 잠그고 처리.

이 불변조건이 정말로 강한 일관성이 필요한지부터 의심해야 합니다. 대부분은 “중복 사용 방지”가 목표인데, 이는 낙관적 동시성 + 유니크 제약 + 재시도로도 달성 가능합니다.

패턴 C: 저장소(Repository)에서 조인으로 여러 Aggregate를 한 번에 로드/갱신

ORM에서 흔한 실수입니다.

  • findOrderWithEverythingForUpdate() 같은 메서드가 생김
  • SELECT ... FOR UPDATE가 조인된 다수 테이블에 걸리면서 락 범위가 넓어짐

특히 PostgreSQL에서는 FOR UPDATE가 조인 결과의 대상 행을 잠그며, 설계에 따라 생각보다 많은 행이 잠길 수 있습니다.

패턴 D: 일관된 락 획득 순서가 없다

데드락의 정석 원인입니다.

  • 트랜잭션 1: inventorycoupon 순서로 업데이트
  • 트랜잭션 2: couponinventory 순서로 업데이트

동시에 들어오면 서로 상대가 잡은 락을 기다리며 교착 상태가 됩니다.

패턴 E: “읽기”가 쓰기 락으로 승격되는 설계

예:

  • 존재 확인을 SELECT ... FOR UPDATE로 처리
  • 혹은 조건부 업데이트를 위해 불필요하게 넓은 범위를 잠금

읽기 트래픽이 많은 도메인에서 이 패턴은 병목을 급격히 키웁니다.

3) 락/데드락이 터질 때의 증상 연쇄(운영 관점)

락 경합이 커지면 단순히 “DB에서 데드락 로그가 찍힌다”로 끝나지 않습니다.

  1. 특정 API의 P95/P99 지연 증가
  2. DB 커넥션이 오래 점유됨(트랜잭션이 락 대기)
  3. 애플리케이션 커넥션 풀 고갈 → 타임아웃/504/5xx
  4. 재시도 로직이 있으면 트래픽이 증폭되어 더 악화

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는 “불변조건”으로 자른다

예시 도메인: 주문/재고/쿠폰/결제

  • Order Aggregate: 주문 라인, 주문 상태 전이(주문 생성/취소/결제대기 등)
  • InventoryItem Aggregate: SKU별 가용 재고, 예약/차감 규칙
  • Coupon Aggregate: 쿠폰 사용 가능 여부, 사용 처리(1회성)
  • Payment Aggregate: 결제 승인/취소 상태

이렇게 분리하면 “주문 생성”이 모든 것을 한 트랜잭션에서 끝내지 않아도 됩니다.

5.2 Outbox + 도메인 이벤트로 교차 Aggregate를 연결

주문 생성 시:

  1. Order만 저장(짧은 트랜잭션)
  2. Outbox에 OrderCreated 이벤트 기록(같은 트랜잭션)
  3. 비동기로 재고 예약/쿠폰 사용/결제 승인 프로세스 진행

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를 한 트랜잭션에서 다뤄야 한다면(레거시/규제/정산 등), 최소한 순서를 고정하세요.

예시 규칙:

  1. 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 작업을 이벤트/사가로 분리할 수 있는가?
  • 불가피하게 한 트랜잭션이면 락 순서가 전역적으로 고정되어 있는가?

이 질문에 답하면서 경계를 다시 잡으면, 데드락은 ‘장애’가 아니라 ‘가끔 발생하는 충돌’로 격하되고, 시스템은 훨씬 예측 가능해집니다.