~ / posts / developer

DDD 애그리거트 경계 정하는 법, 판단표와 낙관적 잠금 충돌률 계산

애그리거트 경계는 '커밋 순간 맞아야 하는 규칙'과 '같은 인스턴스에 몰리는 쓰기 수'로 정해요. 경계 판단표, 잠금 모드·버전 타입 선택표, 직접 계산한 충돌률과 처리량 상한, 증상별 확인 순서를 정리했어요.

주문 화면에서 상품을 담는 요청이 가끔 OptimisticLockException으로 실패하는데 재현이 안 되거나, 반대로 두 사람이 서로 다른 주문 줄을 고쳤더니 합계 한도가 조용히 깨진 적 있으시죠. 결론부터 말하면 애그리거트 경계는 "커밋하는 순간 반드시 맞아야 하는 규칙"으로 긋고, 그 경계 하나에 초당 몇 번 쓰기가 몰리는지로 검증해요. 같은 인스턴스에 쓰기가 초당 10번 몰리면 트랜잭션이 50ms만 걸려도 재시도 없이 세 건 중 한 건이 실패해요.

이 글은 설계 원리보다 실무 판단에 집중해요. 무엇을 같은 애그리거트에 넣을지 고르는 판단표, 잠금 모드와 버전 컬럼 설정값, 직접 계산한 충돌률·처리량 숫자, 운영에서 자주 나는 문제의 확인 순서, 도입 전에 따질 것을 차례로 정리했어요.

광고

애그리거트 경계 판단표 — 무엇을 같이 묶을까

Vaughn Vernon의 「Effective Aggregate Design」 2부에는 Eric Evans와의 대화에서 나온 판단 기준이 있어요. 유스케이스를 실행하는 사용자 본인이 데이터를 맞춰야 하는 일이면 트랜잭션 일관성, 다른 사용자나 시스템의 일이면 결과적 일관성을 택하라는 것이에요. 여기에 운영 숫자 두 가지(자식 수, 쓰기 빈도)를 더하면 아래 순서도가 돼요.

커밋 순간 규칙 여부, 사용자 몫 여부, 자식 수, 동시 쓰기 빈도를 차례로 물어 같은 애그리거트에 둘지, 이벤트로 나눌지, 규칙을 다시 모델링할지 고르는 순서도
네 질문을 모두 통과한 것만 루트 버전 하나 아래에 둬요

자주 나오는 경우를 표로 정리하면 이래요.

상황판단이유
주문 줄 합계가 주문 승인 한도를 넘으면 안 됨주문과 주문 줄을 한 애그리거트로줄을 추가하는 사람이 그 자리에서 한도를 지켜야 해요
주문하면 재고가 줄어야 함주문·재고를 나누고 이벤트로 연결재고는 수많은 주문이 함께 쓰는 자원이라 묶으면 주문 전체가 재고 경합에 걸려요
주문하면 포인트 적립나누고 이벤트로몇 초 늦게 적립돼도 업무상 문제가 없어요
게시글과 댓글 수천 개나누고 게시글에 댓글 수만 집계댓글 하나 달자고 수천 개를 메모리에 올리게 돼요
화면 한 장에 고객·상품·주문이 함께 나옴묶지 않음화면 조합은 조회 모델이 맡아요. 경계는 쓰기 규칙으로만 정해요
같은 날 같은 회의실 예약이 겹치면 안 됨"날짜별 회의실 일정"을 루트로예약끼리의 규칙이라 예약 하나를 루트로 하면 지킬 곳이 없어요

마지막 줄처럼 규칙이 여러 인스턴스에 걸치면, 그 규칙을 소유할 새 개념(일정, 한도 계좌, 재고 버킷)이 필요하다는 신호예요.

다른 애그리거트는 객체 대신 ID로 참조해요. 이벤트에 담아 보낼 ID를 저장 전에 만들 수 있어야 해서, 자동 증가 키보다 애플리케이션에서 만드는 UUID가 편한 경우가 많아요. 두 방식의 인덱스 차이는 DB 기본키 UUID vs 자동증가 정수에, 테스트용 ID는 UUID 생성기로 바로 만들 수 있어요.

잠금 모드와 버전 컬럼 설정값 고르기

JPA에서 고를 값은 버전 컬럼 타입, 잠금 모드, 버전 증가에서 뺄 필드 세 가지예요.

설정고를 값언제 다른 값을 쓰나
@Version 타입longJakarta Persistence @Version은 정수·Timestamp·Instant·LocalDateTime을 허용하지만, Hibernate 사용자 가이드는 타임스탬프가 버전 번호보다 덜 믿을 만하다고 적어요.
루트 필드만 바꾸는 명령기본(@Version만)루트 행이 갱신되니 버전이 저절로 올라요
자식 엔티티만 바꾸는 명령OPTIMISTIC_FORCE_INCREMENT로 루트 읽기자식 행만 갱신되면 루트 버전이 그대로라 불변식이 새요
읽기만 하고 판단에 쓰는 다른 객체OPTIMISTIC커밋 때 그 객체 버전이 그대로인지 확인만 해요
초당 수십 번 몰리는 행(재고 수량)PESSIMISTIC_WRITE 또는 조건부 UPDATE낙관적 잠금은 충돌이 잦으면 실패와 재시도가 쌓여요
조회수·마지막 접속 시각처럼 규칙과 무관한 필드Hibernate @OptimisticLock(excluded = true)이 필드 변경이 다른 사용자의 수정을 실패시키지 않게 해요

LockModeType 명세는 READ·WRITE가 각각 OPTIMISTIC·OPTIMISTIC_FORCE_INCREMENT의 옛 이름이니 새 코드에는 뒤쪽 이름을 쓰라고 해요.

int 버전도 허용되지만 계산해 보면 long이 마음 편해요. int 최댓값은 2,147,483,647이에요. 한 행이 초당 100번 갱신되면 2,147,483,647 ÷ 100 ÷ 86,400 ≈ 248일이면 바닥나요. long이면 같은 속도로 약 29억 년이라 고민할 이유가 없어요.

재고처럼 경합이 심한 숫자는 조건부 갱신 한 문장으로 끝내는 방법도 있어요. UPDATE stock SET qty = qty - ? WHERE sku = ? AND qty >= ?의 영향받은 행이 0이면 재고 부족이에요. 읽기와 쓰기 사이에 틈이 없어 재시도가 필요 없지만, 규칙이 SQL 한 줄로 표현될 때만 써요.

숫자로 보는 충돌률 — 직접 계산

낙관적 잠금에서 실패는 "내가 읽은 뒤 커밋하기 전에 누가 먼저 커밋했는가"로 정해져요. 한 인스턴스에 요청이 초당 λ번 무작위로 들어오고 읽기부터 커밋까지 w초가 걸린다고 해 볼게요. 재시도가 없으면 성공한 커밋 하나가 뒤따르는 w초 동안의 요청을 모두 실패시키므로, 실패율은 λw ÷ (1 + λw)가 되고 성공 처리량은 λ ÷ (1 + λw)예요. λ를 아무리 키워도 처리량은 1/w을 넘지 못해요.

이 식이 맞는지 이벤트 시뮬레이션(파이썬, 요청 5,000초 분량)으로 확인했어요. w = 50ms, 재시도는 실패 즉시 다시 읽어서 최대 3번이에요.

한 인스턴스에 몰리는 요청 수별 최종 실패율 (트랜잭션 50ms)
재시도 없음즉시 재시도 3번
020406080초당 1건 · 재시도 없음 5.1%초당 1건 · 즉시 재시도 3번 0%초당 1건초당 5건 · 재시도 없음 20.3%초당 5건 · 즉시 재시도 3번 0.4%초당 5건초당 10건 · 재시도 없음 33.4%초당 10건 · 즉시 재시도 3번 4%초당 10건초당 20건 · 재시도 없음 50%초당 20건 · 즉시 재시도 3번 24.1%초당 20건초당 40건 · 재시도 없음 66.6%초당 40건 · 즉시 재시도 3번 55.5%초당 40건

재시도 없음 값은 식 λw ÷ (1 + λw)와 일치해요. 초당 20건을 넘으면 재시도로도 못 버텨요.

단위: % · 자료: 포아송 도착·고정 50ms 트랜잭션 이벤트 시뮬레이션으로 직접 계산
표로 보기
구분재시도 없음즉시 재시도 3번
초당 1건5.10
초당 5건20.30.4
초당 10건33.44
초당 20건5024.1
초당 40건66.655.5

재시도는 초당 10건 아래에서 효과적이에요(초당 5건이면 20% → 0.4%). 초당 20건부터는 재시도 요청끼리 다시 충돌해 24~56%가 남아요.

들어온 요청 대비 실제로 커밋된 건수 (트랜잭션 50ms)
들어온 요청재시도 3번 커밋재시도 없음 커밋
010203040151020401 · 들어온 요청 1건/초5 · 들어온 요청 5건/초10 · 들어온 요청 10건/초20 · 들어온 요청 20건/초40 · 들어온 요청 40건/초401 · 재시도 3번 커밋 1건/초5 · 재시도 3번 커밋 5건/초10 · 재시도 3번 커밋 9.6건/초20 · 재시도 3번 커밋 15.1건/초40 · 재시도 3번 커밋 17.7건/초17.71 · 재시도 없음 커밋 0.9건/초5 · 재시도 없음 커밋 4건/초10 · 재시도 없음 커밋 6.6건/초20 · 재시도 없음 커밋 10건/초40 · 재시도 없음 커밋 13.3건/초13.3

커밋 수는 1 ÷ 0.05초 = 초당 20건에 가까워질 뿐 넘지 못해요.

단위: 건/초 · 자료: 같은 시뮬레이션으로 직접 계산 (가로축은 초당 들어온 요청 수)
표로 보기
구분들어온 요청재시도 3번 커밋재시도 없음 커밋
1110.9
5554
10109.66.6
202015.110
404017.713.3

트랜잭션 시간을 바꿔도 같은 결과였어요. 10ms면 상한이 초당 100건(시뮬레이션 94.1건), 200ms면 초당 5건(4.7건)이에요. 그래서 한 인스턴스에 몰리는 쓰기가 상한의 절반을 넘는다면 재시도 설정이 아니라 경계나 쓰기 방식을 바꿔야 해요.

이 계산으로 "한 트랜잭션에 두 애그리거트를 고치면 안 되는 이유"도 숫자로 볼 수 있어요. 주문 하나에는 쓰기가 거의 없어서(초당 0.1건) 실패율이 0.56%예요. 여기에 초당 10건이 몰리는 인기 상품 재고를 같은 트랜잭션에 넣으면 실패율은 1 − (1 − 0.0056) × (1 − 0.334) ≈ 33.7%가 돼요. 주문 쪽에는 아무 문제가 없는데 주문 세 건 중 한 건이 재고 때문에 롤백돼요.

화면에 오래 머무는 수정과 큰 애그리거트의 거짓 충돌

버전 번호를 화면까지 들고 가면 계산이 크게 달라져요. 사용자가 수정 화면을 열 때 읽은 버전으로 저장을 검사하면, w가 50ms가 아니라 사람이 화면에 머문 시간이 되기 때문이에요. 다른 사람의 저장이 평균 1분에 1번 오는 문서를 30초 동안 고치면 실패 확률은 1 − e^(−30/60) ≈ 39%, 2분 동안 고치면 86%예요.

이때 애그리거트 크기가 결정적이에요. Vernon 1부의 사례처럼 제품 하나가 백로그 항목·릴리스·스프린트를 모두 품으면, 각자 다른 항목을 고쳐도 같은 버전을 두고 다퉈요. 팀원 n명이 각자 1분에 한 번 저장하고 한 번 고치는 데 30초가 걸린다고 해 볼게요. 작은 애그리거트로 나눈 쪽은 항목 50개에 수정이 고르게 퍼진다고 가정한 예시 계산이에요.

30초 동안 고친 내용이 저장 때 거절될 확률 (예시 계산)
제품 하나를 큰 애그리거트로항목 50개를 각각 애그리거트로
02550751002명 · 제품 하나를 큰 애그리거트로 39.3%2명 · 항목 50개를 각각 애그리거트로 1%2명5명 · 제품 하나를 큰 애그리거트로 86.5%5명 · 항목 50개를 각각 애그리거트로 3.9%5명10명 · 제품 하나를 큰 애그리거트로 98.9%10명 · 항목 50개를 각각 애그리거트로 8.6%10명20명 · 제품 하나를 큰 애그리거트로 100%20명 · 항목 50개를 각각 애그리거트로 17.3%20명

팀원 각자 1분에 1번 저장한다고 가정했어요. 20명 큰 애그리거트는 99.99%예요.

단위: % · 자료: 1 − e^(−다른 사람 저장 빈도 × 30초)로 직접 계산
표로 보기
구분제품 하나를 큰 애그리거트로항목 50개를 각각 애그리거트로
2명39.31
5명86.53.9
10명98.98.6
20명10017.3

화면을 오래 여는 수정이라면, HTTP API에서는 버전을 ETag로 내려 주고 저장 요청의 If-Match로 받아, 어긋나면 RFC 9110이 정한 412 Precondition Failed로 돌려줘요. 그리고 그 화면이 고치는 범위를 애그리거트 하나로 좁혀요.

경계 넘는 후속 처리 — 리스너, 아웃박스, 발행 기록 중 고르기

재고 차감이나 포인트 적립처럼 다른 애그리거트를 고치는 일은 별도 트랜잭션으로 넘겨요. 스프링에서 고를 수 있는 방식은 셋이에요.

방식커밋 뒤 프로세스가 죽으면추가 비용맞는 경우
@TransactionalEventListener (기본 AFTER_COMMIT)이벤트 사라짐없음캐시 비우기, 알림처럼 놓쳐도 되는 일
Spring Modulith 이벤트 발행 기록기록이 남아 재발행 가능리스너마다 행 하나 쓰기한 애플리케이션 안의 모듈 사이
트랜잭셔널 아웃박스 + 릴레이테이블에 남아 다시 보냄테이블·릴레이 운영다른 서비스로 메시지 브로커를 거쳐 보낼 때

Spring Modulith 문서는 원래 업무 트랜잭션 안에서 리스너별 발행 기록을 남기고, 리스너가 성공하면 완료로 표시하며, spring.modulith.events.republish-outstanding-events-on-restart로 재시작 때 미완료 건을 다시 보낼 수 있다고 설명해요.

아웃박스는 microservices.io의 Transactional outbox 패턴 설명대로 릴레이가 보낸 뒤 기록하기 전에 죽으면 같은 메시지를 다시 보내요. 그래서 받는 쪽은 처리한 이벤트 ID를 자기 상태를 바꾸는 트랜잭션과 같은 트랜잭션에 기록해서 중복을 걸러요. 이벤트 ID를 시간순으로 정렬되는 UUIDv7로 만들면 기록 테이블 인덱스에도 유리해요. 구조는 UUID v4 v7 차이에 정리해 뒀어요.

아웃박스 비용도 미리 계산해 두세요. 예시로 하루 이벤트 50만 건, 행 하나가 본문 포함 약 1KB라면 하루에 약 0.5GB씩 쌓여요. 지우지 않으면 30일에 약 15GB예요. Spring Modulith도 기본은 완료 표시(UPDATE)라 행이 계속 남고, spring.modulith.events.completion-mode=DELETE로 바꾸면 완료 때 지워요. 폴링 릴레이는 주기의 절반만큼 평균 지연이 더해져요. 1초 주기면 평균 0.5초, 최대 1초 늦게 전달돼요.

운영에서 자주 나는 문제와 확인 순서

낙관적 잠금 예외 급증, 합계 규칙 깨짐, 리스너 저장 누락, 재배포 뒤 후속 처리 누락, 중복 반영, 삭제 이벤트 누락 여섯 가지 증상별 먼저 볼 것과 조치 표
로그나 데이터에서 본 증상으로 줄을 찾고 가운데 칸부터 확인해요

1. 낙관적 잠금 예외가 특정 시간대에만 몰려요. 예외가 난 엔티티 ID를 로그에 남겨 같은 ID가 반복되는지 봐요. 그 인스턴스의 초당 쓰기 수가 상한(1/w)의 절반을 넘으면 재시도로는 안 되니 경계를 나누거나 조건부 갱신으로 바꿔요.

2. 재시도를 걸었는데 재시도마다 똑같이 실패해요. 재시도가 트랜잭션 안쪽에서 돌고 있을 가능성이 커요. 같은 영속성 컨텍스트에는 낡은 엔티티가 남아 있어서 다시 읽어도 같은 버전이 나와요. 재시도는 트랜잭션을 여는 메서드 바깥에서 걸어 매번 새 트랜잭션으로 시작해야 해요. Spring Framework 7의 @Retryable은 @EnableResilientMethods로 켜고, 기본값이 모든 예외에 대해 재시도 3번·1초 간격이에요. 범위를 좁혀 쓰세요.

@Service
class AddLineFacade {
    private final OrderCommandService service;   // 이 안의 메서드가 @Transactional

    @Retryable(includes = OptimisticLockingFailureException.class,
               maxRetries = 3, delay = 20, jitter = 10, multiplier = 2)
    public void addLine(long orderId, String sku, long amount) {
        service.addLine(orderId, sku, amount);    // 시도마다 새 트랜잭션
    }
}

3. 서로 다른 주문 줄을 고쳤더니 합계 규칙이 깨졌어요. 자식 엔티티의 필드만 바뀌면 루트 행에는 UPDATE가 나가지 않아 루트 버전이 그대로예요. SQL 로그에서 orders 테이블의 version 갱신이 있는지 확인하고, 자식을 고치는 명령은 루트를 OPTIMISTIC_FORCE_INCREMENT로 읽게 바꿔요.

4. 리스너에서 저장했는데 DB에 남지 않아요. TransactionalEventListener Javadoc은 AFTER_COMMIT 단계의 데이터 접근이 원래 트랜잭션에 "참여"하지만 커밋되지는 않는다고 경고해요. 리스너 메서드에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 붙여 새 트랜잭션을 열어요.

5. 재배포나 장애 뒤에 포인트 적립이 일부 빠졌어요. 이벤트가 메모리에만 있었다면 흔적이 없어요. 아웃박스나 발행 기록으로 옮기고, 이미 빠진 건은 주문 테이블과 적립 내역을 맞대어 찾아요.

6. 재고나 포인트가 두 번 반영됐어요. 처리한 이벤트 ID를 상태 변경과 같은 트랜잭션에 기록하는지 봐요. 따로 커밋하면 그 사이에서 죽었을 때 다시 중복이 생겨요.

7. 삭제했는데 삭제 이벤트가 안 나갔어요. Spring Data 도메인 이벤트 문서는 이벤트가 save·saveAll·delete·deleteAll·deleteAllInBatch·deleteInBatch처럼 루트 인스턴스를 받는 메서드에서만 발행된다고 적어요. deleteById는 루트를 읽지 않고 지울 수 있어 빠져 있어요. 루트를 읽어 delete(order)로 지워요.

도입 전에 따질 것

작게 나누고 이벤트로 잇는 구조는 공짜가 아니에요.

자주 묻는 질문

애그리거트를 여러 개 읽는 것도 안 되나요?

읽는 것은 괜찮아요. 하나로 제한하는 것은 고치는 애그리거트예요. 읽은 다른 애그리거트가 커밋 전에 바뀌면 안 된다면 LockModeType.OPTIMISTIC으로 읽어 커밋 때 버전을 확인하게 할 수 있어요.

OptimisticLockException이 나면 몇 번 재시도하는 게 좋나요?

계산상 같은 인스턴스에 몰리는 요청이 처리량 상한(1/w)의 절반 아래면 2~3번으로 대부분 성공해요. 그 이상이면 재시도 수를 늘려도 실패율이 크게 줄지 않고 DB 부하만 늘어요. 재시도는 반드시 트랜잭션 바깥에서, 짧은 지연과 무작위 흔들림(jitter)을 섞어 걸어요.

비관적 잠금(PESSIMISTIC_WRITE)이 더 안전하지 않나요?

충돌이 잦은 행에서는 실패 대신 대기가 되니 나을 수 있어요. 하지만 잠금을 쥔 동안 다른 요청이 줄을 서서 처리량 상한은 비슷하고, 여러 행을 다른 순서로 잠그면 교착 상태가 생겨요. 경합이 높은 소수의 행에만 써요.

@TransactionalEventListener만으로 충분한 경우는 언제인가요?

놓쳐도 저절로 복구되는 일이에요. 캐시 무효화, 주기적 재색인이 있는 검색 색인 갱신, 알림 같은 것이요. 돈·재고·권한처럼 빠지면 사람이 찾아야 하는 일은 발행 기록이나 아웃박스로 남겨요.

정리

참고한 자료

#DDD 애그리거트 설계#애그리거트 경계#낙관적 잠금 충돌#OptimisticLockException#트랜잭셔널 아웃박스#TransactionalEventListener#JPA 버전 관리#결과적 일관성
← 이전 글CSS clamp() 반응형 글자 크기 공식, 최소·최대 폭으로 값 계산하는 법다음 글 →깃 저장소 관리 판단표, 클론 방식·용량·브랜치 수·gc 설정 고르는 법

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →