소프트웨어에서 일관성은 중요하다. 주문이 생성되었는데 결제 정보가 없거나, 구독이 갱신되었는데 이용 권한이 부여되지 않거나, 환불이 완료되었는데 정산 금액이 그대로라면 문제가 된다.
그래서 우리는 모든 것을 한 트랜잭션 안에서 처리하고 싶어진다. 하나의 요청 안에서 구독을 갱신하고, 결제를 저장하고, 권한을 부여하고, 알림을 보내고, 정산 데이터를 만들면 마음이 편하다.
하지만 모든 것을 한 번에 처리하려는 순간 모델은 커지고 시스템은 단단하게 묶인다. DDD에서 Aggregate를 이야기할 때 트랜잭션과 일관성을 함께 고민해야 하는 이유가 여기에 있다.
중요한 질문은 “일관성이 필요한가?”가 아니다. “무엇을 반드시 한 번에 일관되게 지켜야 하는가?”다.
일관성에는 종류가 있다
일관성이라고 하면 흔히 하나의 트랜잭션 안에서 모든 변경이 동시에 성공하거나 실패하는 것을 떠올린다. 이것을 강한 일관성이라고 볼 수 있다.
예를 들어 구독의 상태와 구독 기간은 함께 변경되어야 할 수 있다. 구독이 갱신되었는데 만료일이 그대로라면 이상하다. 이런 규칙은 같은 Aggregate 안에서 한 트랜잭션으로 지키는 것이 자연스럽다.
subscription.renew(paymentResult, now);
subscriptionRepository.save(subscription);
반면 알림 발송은 조금 다르다. 구독이 갱신된 뒤 알림이 몇 초 늦게 발송되어도 도메인적으로 큰 문제가 없을 수 있다. 정산 데이터 생성도 실시간으로 완벽히 맞아야 하는 것이 아니라면 배치나 이벤트로 처리할 수 있다.
이런 경우에는 결과적 일관성을 사용할 수 있다. 즉, 즉시 일관되지는 않지만 시간이 지나면 일관된 상태에 도달한다.
Aggregate는 강한 일관성의 경계다
Aggregate를 설계할 때 중요한 기준은 불변식이다. 반드시 지켜야 하는 규칙이 무엇인지, 그 규칙이 한 트랜잭션 안에서 보장되어야 하는지 생각해야 한다.
구독 Aggregate 안에서 다음 규칙을 지켜야 한다고 해보자.
만료일은 시작일보다 빠를 수 없다.
해지된 구독은 다시 해지할 수 없다.
무료 체험 구독은 체험 종료일을 가져야 한다.
유예 기간 중인 구독은 갱신 실패 사유를 가져야 한다.
이 규칙들은 구독 자신의 상태에 대한 불변식이다. 따라서 Subscription Aggregate 안에서 강하게 지키는 것이 자연스럽다.
반면 다음 규칙은 다른 Aggregate나 시스템과 관련될 수 있다.
결제가 성공하면 구독이 갱신되어야 한다.
구독이 갱신되면 이용 권한이 부여되어야 한다.
구독이 해지되면 알림이 발송되어야 한다.
구독 변경 내역은 정산 시스템에 반영되어야 한다.
이 규칙들은 중요하지만 반드시 같은 트랜잭션 안에 있어야 하는지는 따져봐야 한다. 모두 같은 Aggregate에 넣으면 Subscription이 결제, 권한, 알림, 정산까지 책임지는 거대한 객체가 될 수 있다.
모든 것을 한 트랜잭션에 넣는 비용
하나의 트랜잭션 안에서 여러 Aggregate와 외부 시스템을 함께 처리하면 여러 문제가 생긴다.
첫째, 트랜잭션이 길어진다. 데이터베이스 락이 오래 유지되고, 성능과 동시성에 영향을 준다.
둘째, 외부 시스템 호출과 DB 트랜잭션이 섞인다. 결제 API 호출은 성공했는데 DB 저장은 실패할 수 있다. 반대로 DB 저장 후 메시지 발행이 실패할 수도 있다.
셋째, 모델이 커진다. 한 번에 일관성을 보장하려면 관련 객체를 모두 같은 경계 안에 넣고 싶어진다. 그러면 Aggregate가 커지고 변경 충돌이 늘어난다.
넷째, 장애가 전파된다. 알림 시스템 장애 때문에 구독 갱신 자체가 실패해야 하는지 고민해야 한다.
일관성을 강하게 지키는 것은 비용이 있다. 그 비용을 지불할 만큼 중요한 규칙인지 판단해야 한다.
결과적 일관성은 포기가 아니다
결과적 일관성을 선택한다고 해서 일관성을 포기하는 것은 아니다. 단지 모든 것을 즉시 맞추는 대신, 시간과 경계를 나누어 맞추는 것이다.
예를 들어 구독 갱신이 성공하면 SubscriptionRenewed 이벤트를 발행할 수 있다.
subscription.renew(paymentResult, now);
subscriptionRepository.save(subscription);
eventPublisher.publishAll(subscription.pullEvents());
이 이벤트를 권한 컨텍스트와 알림 컨텍스트가 받아 처리한다.
SubscriptionRenewed
→ 권한 컨텍스트: 이용 권한 부여
→ 알림 컨텍스트: 갱신 완료 알림 발송
→ 정산 컨텍스트: 매출 반영 대상 기록
구독 갱신 자체는 구독 컨텍스트의 트랜잭션 안에서 강하게 처리한다. 권한 부여와 알림 발송은 이벤트를 통해 나중에 처리한다. 이 방식은 각 컨텍스트의 모델을 분리하면서도 전체 시스템이 결국 일관된 상태에 도달하게 한다.
물론 결과적 일관성에는 운영 고려가 필요하다. 이벤트 발행 실패, 중복 처리, 재시도, 순서 문제, 보상 처리 등을 설계해야 한다. 쉬운 선택은 아니다. 하지만 모든 것을 한 트랜잭션에 넣는 것 역시 쉬운 선택이 아니다.
무엇을 한 번에 지켜야 하는가
트랜잭션 경계를 정할 때 다음 질문을 해볼 수 있다.
이 규칙이 깨진 상태가 잠시라도 허용되지 않는가?
이 객체들은 항상 함께 변경되어야 하는가?
이 규칙은 하나의 Aggregate 내부 불변식인가?
실패했을 때 되돌릴 수 있는가?
몇 초 또는 몇 분 후에 맞춰져도 괜찮은가?
다른 컨텍스트가 독립적으로 처리해도 되는가?
예를 들어 “구독 기간의 종료일은 시작일보다 빠를 수 없다”는 규칙은 잠시도 깨지면 안 된다. Aggregate 내부에서 즉시 보장해야 한다.
반면 “구독 갱신 후 이메일 알림이 발송되어야 한다”는 규칙은 몇 초 늦어져도 괜찮을 수 있다. 이벤트 기반 후속 처리가 적절할 수 있다.
“결제 성공 후 구독 갱신”은 더 신중하다. 결제가 성공했는데 구독 갱신이 실패하면 고객에게 큰 문제가 된다. 하지만 외부 결제 시스템과 DB를 하나의 로컬 트랜잭션으로 묶을 수는 없다. 이 경우 멱등성, 재시도, 결제 결과 조회, 보상 처리 같은 설계가 필요하다.
도메인 이벤트와 트랜잭션
Domain Event는 트랜잭션 경계를 넘는 후속 처리를 표현하는 데 유용하다. Aggregate 안에서 중요한 사건이 발생하면 이벤트로 기록하고, 트랜잭션이 성공한 뒤 발행할 수 있다.
중요한 것은 이벤트가 언제 발행되는지다. DB 트랜잭션이 커밋되기 전에 외부 메시지를 발행하면, DB 저장은 실패했는데 외부 시스템은 이벤트를 받은 상태가 될 수 있다. 반대로 커밋 후 발행이 실패하면 상태는 바뀌었지만 후속 처리가 누락될 수 있다.
이 문제를 다루기 위해 Outbox 패턴 같은 방법을 사용할 수 있다. 핵심은 이벤트 발행도 일관성 설계의 일부라는 점이다.
이벤트를 쓴다고 자동으로 안전해지는 것은 아니다. 하지만 이벤트는 “무엇은 지금 지키고, 무엇은 나중에 맞출 것인가”를 표현하는 좋은 도구가 될 수 있다.
마치며
트랜잭션은 기술적 기능이지만, 트랜잭션 경계는 도메인 설계의 문제다. 무엇을 한 번에 지켜야 하는지, 무엇을 나중에 맞춰도 되는지 판단해야 하기 때문이다.
Aggregate는 강한 일관성을 지키는 경계다. 모든 관련 객체를 모으는 단위가 아니라, 반드시 함께 지켜야 하는 불변식의 경계다.
결과적 일관성은 일관성의 포기가 아니다. 경계를 나누고, 이벤트와 재시도를 통해 시간이 지나면 올바른 상태에 도달하게 하는 방식이다. 물론 그만큼 운영과 설계의 책임이 따른다.
DDD에서 중요한 것은 무조건 큰 트랜잭션을 만드는 것도, 무조건 이벤트로 쪼개는 것도 아니다. 중요한 것은 도메인 규칙을 기준으로 일관성의 종류를 구분하는 것이다.
무엇을 반드시 지금 지켜야 하는가? 무엇은 나중에 맞춰도 되는가? 이 질문에 답할 때 Aggregate와 트랜잭션 경계가 선명해진다.