콘텐츠로 이동

Bluetape4k JaVers Part 3: DDD와 워크숍 감사 예제

작은 로봇 작업자들이 DDD 명령 흐름과 JaVers 감사 보관소를 연결하는 3D 작업대 일러스트
감사 기록은 명령 처리 시점에 남겨야 합니다. 나중에 기억으로 복원할 수는 없습니다.

Part 1에서는 JaVers의 커밋, 스냅숏, 차이 모델을 살펴봤고, Part 2에서는 Exposed, Redis, Kafka 영속 저장소의 역할을 나눴습니다. 이제 실제 예제에서 감사 경계를 어디에 두고 어느 시점에 커밋하는지 살펴보겠습니다.

예제확인할 흐름
bluetape4k-workshop/exposed/javers-audit상품의 현재 행을 Exposed에 저장하고 JaVers로 이력과 차이를 읽는 작은 예제
examples/javers-exposed-dddOrder 애그리거트, Exposed 원본 저장소, JaVers 감사, 도메인 이벤트, Redis 조회 모델

이 글은 세부 API를 모두 설명하기보다 적용 순서와 책임 경계를 보여 주는 안내서입니다. 처음에는 워크숍 예제처럼 현재 상태와 감사 이력만 분리하고, 명령과 이벤트 흐름이 필요해지면 DDD 예제의 명령 측과 조회 측을 차례로 살펴보면 됩니다. 단, 두 예제 모두 운영 환경의 장애 복구와 분산 트랜잭션까지 제공하지는 않습니다.

명령 처리기가 Order 애그리거트를 저장한 뒤 JaVers 감사와 이벤트 발행, Redis 조회 모델로 이어지는 흐름
명령 측에서는 애그리거트 저장, JaVers 커밋, 도메인 이벤트 발행 순서와 각 실패 지점을 구분해야 합니다.

수동 감사 테이블에서 시작하기

섹션 제목: “수동 감사 테이블에서 시작하기”

감사를 처음 붙일 때는 현재 행과 변경 이력을 어디서 나눌지 결정해야 합니다. 흔히 product_history 같은 테이블을 만들어 이전 가격, 새 가격, 변경자, 변경 시각을 저장합니다. 작은 업무에서는 빠르게 적용할 수 있지만, 객체와 변경 규칙이 늘어나면 복사 코드도 함께 증가합니다.

수동 감사 테이블과 JaVers 스냅숏 흐름을 비교한 다이어그램
Exposed는 현재 행을 저장하고, JaVers는 변경 이력과 객체 차이의 의미를 맡습니다.

워크숍의 ProductAuditService는 이 책임 분리를 보여 주는 가장 작은 예제입니다.

fun save(author: String, product: Product) {
author.requireNotBlank("author")
javers.commit(author, product)
transaction {
ProductTable.upsert {
it[id] = product.id
it[name] = product.name
it[price] = product.price
it[category] = product.category
}
}
}

Exposed는 상품의 현재 행을 저장하고 JaVers는 변경 이력을 저장합니다. 현재 값은 Exposed 테이블에서 읽고, 변경 이력은 findSnapshots로 조회합니다. 최신 스냅숏은 latestSnapshotOrNull 도우미로 가져옵니다.

fun getHistory(productId: Long): List<CdoSnapshot> {
val query = QueryBuilder.byInstanceId(productId, Product::class.java).build()
return javers.findSnapshots(query)
.sortedBy { it.commitMetadata.commitDate }
}

삭제할 때도 commitShallowDeleteTERMINAL 스냅숏을 남긴 뒤 Exposed 행을 삭제합니다. 테스트는 INITIAL, UPDATE, TERMINAL 스냅숏 유형과 최신 스냅숏, 값 차이를 검증합니다.

여기에는 중요한 제한이 있습니다. save는 JaVers에 먼저 커밋한 뒤 별도 Exposed 트랜잭션에서 행을 저장합니다. delete도 JaVers 삭제 커밋과 Exposed 행 삭제를 하나의 트랜잭션으로 묶지 않습니다. 따라서 이 예제가 증명하는 것은 저장소의 책임 분리와 정상 실행 순서이며, 둘 중 하나가 실패했을 때의 원자성이나 복구 절차는 아닙니다.

DDD 예제에서는 순서가 더 중요하다

섹션 제목: “DDD 예제에서는 순서가 더 중요하다”

examples/javers-exposed-ddd는 명령 측 예제입니다. OrderCommandHandler는 명령을 받아 애그리거트를 만들거나 상태를 바꾼 뒤 저장소에 저장합니다.

시나리오는 작습니다. 사용자가 주문을 만들고 결제를 완료하면 주문 상태가 PAID로 바뀝니다. 명령 측은 현재 주문 상태를 Exposed 테이블에 저장하고, 같은 변경을 JaVers 스냅숏으로 남긴 뒤 OrderPlacedOrderMarkedPaid 이벤트를 발행합니다. 조회 측은 Kafka에서 이벤트를 읽어 Redis의 OrderSummary를 갱신합니다. Redis는 감사 이력이 아니라 화면과 조회를 위한 모델입니다.

javers-exposed-ddd 예제의 명령 처리와 프로젝션 실행 순서
명령 처리, 감사 커밋, 이벤트 발행, Redis 프로젝션은 순서대로 실행되지만 하나의 원자적 작업은 아닙니다.
fun handle(command: MarkOrderPaidCommand): Order {
val current = requireNotNull(repository.load(command.orderId)) {
"Order not found: ${command.orderId.value}"
}
val now = clock.instant()
val paid = current.markPaid(now)
return repository.save(
aggregate = paid,
author = command.author,
event = OrderMarkedPaid(
aggregateId = paid.id,
occurredOn = now,
),
)
}

원본 저장소는 OrdersTable입니다. OrderRepository는 Exposed 테이블에 애그리거트를 저장하고, 상위 클래스인 AggregateRepository는 같은 저장 경계 안에서 JaVers 커밋을 실행한 뒤 이벤트를 발행합니다.

fun save(aggregate: T, author: String, events: Collection<DomainEvent>): T {
val saved = saveAuditBoundary {
val persisted = persist(aggregate)
javers.commit(author, persisted, events.toJaversProperties())
persisted
}
eventPublisher.publishAll(events)
return saved
}

OrderRepository.saveAuditBoundary는 원본 행 저장과 Exposed 기반 JaVers 커밋을 같은 데이터베이스 트랜잭션에서 실행하도록 경계를 재정의합니다. 하지만 eventPublisher.publishAll(events)는 이 경계가 끝난 뒤 실행됩니다. 따라서 Kafka 발행이 실패하면 주문과 감사 이력은 남아도 조회 모델로 전달할 이벤트가 없을 수 있습니다. 이 예제는 원본 저장 -> 감사 커밋 -> 이벤트 발행의 정상 순서를 고정하지만, 트랜잭셔널 아웃박스나 자동 복구까지 제공하지는 않습니다.

조회 모델은 감사 이력과 다르다

섹션 제목: “조회 모델은 감사 이력과 다르다”

예제에는 Redis 기반 OrderSummary 프로젝션도 있습니다. Kafka 소비자가 이벤트를 받아 요약 정보를 갱신하고, OrderQueryService는 Redis에서 그 결과를 읽습니다.

javers-exposed-ddd 예제에서 명령 저장소, 감사 저장소, Kafka, Redis의 책임을 나눈 CQRS 흐름
명령 측 원본 저장소, JaVers 감사 저장소, Kafka 이벤트 스트림, Redis 조회 모델은 서로 다른 책임을 가집니다.

JaVers는 객체가 어떻게 변했는지 설명하고, 조회 모델은 현재 화면에 필요한 값을 빠르게 제공합니다. 둘은 함께 쓸 수 있지만 서로를 대체하지 않습니다. 현재 프로젝션 구현은 OrderPlaced가 먼저 적용됐다고 가정합니다. OrderMarkedPaid가 먼저 도착하거나 선행 이벤트가 빠지면 기존 OrderSummary를 찾지 못해 실패합니다. 이벤트 중복, 순서 변경, 재시작 뒤 오프셋 복구까지 처리하는 운영 소비자도 이 예제의 범위 밖입니다.

CDC, Envers, JaVers를 어떻게 나눌까

섹션 제목: “CDC, Envers, JaVers를 어떻게 나눌까”

CDC, Hibernate Envers, JaVers는 모두 변경 이력을 다루지만 출발점이 다릅니다. 다음 기준으로 책임을 구분할 수 있습니다.

선택지출발점잘 맞는 경우
CDC데이터베이스 로그DB 변경을 외부 시스템으로 전달해야 한다
Hibernate EnversJPA 엔티티 리비전JPA 엔티티 리비전 테이블을 중심으로 감사한다
JaVers도메인 객체 커밋객체 차이, 커밋 메타데이터, DDD 명령 흐름을 감사와 연결한다

초기 문서용 벤치마크에서는 JaVers + Exposed 감사 조회에 큰 이상치가 있었지만, 후속 실행에서는 재현되지 않았습니다. 따라서 오래된 수치 하나로 우열을 일반화할 수 없습니다. 현재 벤치마크의 실행 경로, 완료 경계, 환경, 감사 범위는 Part 4에서 따로 설명합니다.

예제가 확인하는 것운영에서 추가해야 할 것
명령 처리 뒤 원본 상태와 JaVers 스냅숏이 남는 정상 흐름실제 PostgreSQL에서의 스키마, 잠금, 격리 수준 검증
주문 ID를 Kafka 키로 사용한 이벤트 발행과 Redis 프로젝션트랜잭셔널 아웃박스 또는 동등한 복구 기록
OrderPlaced 다음 OrderMarkedPaid 순서의 정상 처리이벤트 ID, 멱등 처리, 재시도, 격리, 오프셋 정책
Redis에서 최신 OrderSummary 조회프로젝션 재구축과 정합성 점검 절차

테스트는 H2의 PostgreSQL 호환 모드와 Kafka·Redis Testcontainers로 정상 흐름을 검증합니다. 실제 PostgreSQL, 프로세스 재시작, 브로커 장애, 동시 명령, 중복·역순 이벤트, 프로젝션 재구축은 검증하지 않습니다.

새 애그리거트에 적용할 때 확인할 사항

섹션 제목: “새 애그리거트에 적용할 때 확인할 사항”

새 Exposed 애그리거트에 JaVers 감사를 적용할 때는 아래 순서로 확인합니다.

단계확인할 것
1. 식별자애그리거트 ID가 안정적이고 JaVers @Id가 명확한가
2. 원본 저장소현재 상태를 어느 테이블과 저장소가 책임지는가
3. 커밋 지점상태 전이 뒤 어느 트랜잭션 경계에서 감사 커밋을 남기는가
4. 메타데이터작성자, 이벤트 유형, 애그리거트 ID를 커밋 속성으로 남기는가
5. 저장소 역할Exposed, Redis, Kafka가 각각 이력, 조회, 스트림 중 무엇을 맡는가
6. 실패 계약DB 저장, 감사 커밋, 이벤트 발행 중 하나가 실패하면 어떻게 복구하는가
7. 테스트INITIAL/UPDATE/TERMINAL, 차이, 최신 스냅숏, 프로젝션과 실패 경계를 검증하는가

작은 서비스라면 워크숍 방식처럼 현재 행과 JaVers 이력을 분리하는 것부터 시작할 수 있습니다. 명령과 이벤트가 필요해지면 DDD 저장소와 이벤트 발행기를 추가하고, 조회 프로젝션이 필요할 때 Kafka와 Redis를 도입합니다. 각 자원을 추가할 때는 정상 흐름뿐 아니라 부분 실패와 복구 책임도 함께 정해야 합니다.

bluetape4k-javers를 적용하는 목적은 모든 감사 테이블을 없애는 데 있지 않습니다. Exposed는 현재 애그리거트 상태를 저장하고, JaVers는 객체 이력과 차이를 기록하며, 이벤트 발행기와 조회 모델은 조회 측 흐름을 구성합니다.

이 책임 분리는 정상 실행 순서를 이해하는 출발점입니다. 운영에 적용할 때는 DB와 감사 저장소의 트랜잭션 참여 여부, 이벤트 발행 실패의 복구 기록, 프로젝션의 멱등성과 재구축 절차까지 별도로 검증해야 합니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.