Bluetape4k JaVers Part 3: DDD와 workshop audit 예제

Part 1에서는 JaVers의 commit/snapshot/diff 모델을 잡았고, Part 2에서는 Exposed, Redis, Kafka persistence의 역할을 나눴습니다. 이제 예제로 내려가 보겠습니다. 제가 이 글에서 보여주고 싶은 것은 “audit를 어디에 붙이고, 어느 시점에 commit해야 하는가”입니다.
| 예제 | 확인할 흐름 |
|---|---|
bluetape4k-workshop/exposed/javers-audit | product row를 Exposed에 저장하고 JaVers로 history/diff를 읽는 작은 예제 |
examples/javers-exposed-ddd | Order aggregate, Exposed source of truth, JaVers audit, domain event, Redis read model |
이 글은 깊이 파고드는 구현 안내가 아니라 전체적인 적용 지도에 가깝습니다. 처음 붙일 때는 workshop 예제처럼 작게 시작하고, command/event 흐름이 필요해지면 DDD 예제로 확장하면 됩니다. 세부 구현은 링크된 예제 코드를 따라가면 됩니다.
한 가지 현실도 같이 보겠습니다. Java/Kotlin 개발자라면 DDD라는 말은 익숙해도, 실제 서비스에서 aggregate, command handler, domain event, projection을 한 흐름으로 오래 운영해 본 경험은 생각보다 많지 않습니다. JaVers까지 함께 쓰는 경우는 더 드뭅니다. 그래서 이 글은 “DDD를 잘 아는 사람만 이해하는 구조”로 쓰지 않겠습니다. 먼저 작은 workshop 예제로 감을 잡고, 그 다음 DDD 예제를 command side와 read side로 나누어 보겠습니다.

manual audit table에서 시작해 보자
섹션 제목: “manual audit table에서 시작해 보자”처음 audit를 붙일 때 저도 가장 먼저 보는 것은 “현재 row와 history를 어디서 나눌 것인가”입니다. 대개는 table을
직접 만듭니다. product_history에 이전 가격, 새 가격, 변경자, 변경 시각을 넣는 식입니다. 작은 업무에서는 이 방식이
제일 빠릅니다. 문제는 객체가 조금만 복잡해져도 같은 코드가 계속 늘어난다는 점입니다.

workshop의 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는 현재 product row를 저장합니다. JaVers는 변경 history를 저장합니다. 둘을 섞으면 현재 상태와
변경 이력을 같은 table 설계 문제로 보게 됩니다. 현재 값을 읽을 때는 Exposed table을 보고, 변경 이력을 읽을 때는 findSnapshots를 사용합니다.
최신 snapshot은 helper로 가져오면 됩니다.
fun getHistory(productId: Long): List<CdoSnapshot> { val query = QueryBuilder.byInstanceId(productId, Product::class.java).build() return javers.findSnapshots(query) .sortedBy { it.commitMetadata.commitDate }}삭제도 그냥 row delete로 끝내지 않습니다. commitShallowDelete로 terminal snapshot을 남기고, 그 다음 Exposed row를
삭제합니다. 테스트에서는 INITIAL, UPDATE, TERMINAL snapshot type을 확인합니다. audit는 저장 코드보다 테스트가
더 중요할 때가 많습니다. 나중에 깨지면 “언제부터 history가 비었지?”를 찾는 일이 꽤 피곤해집니다.
DDD 예제에서는 순서가 더 중요하다
섹션 제목: “DDD 예제에서는 순서가 더 중요하다”examples/javers-exposed-ddd는 command side 예제입니다. OrderCommandHandler는 command를 받아 aggregate를 만들거나
상태를 바꾸고, repository에 저장합니다.
시나리오는 일부러 작게 잡았습니다. 사용자가 주문을 만들고, 결제가 완료되면 주문 상태가 PAID로 바뀝니다. command
side는 현재 주문 상태를 Exposed table에 저장하고, 같은 변경을 JaVers snapshot으로 남긴 뒤, OrderPlaced나
OrderMarkedPaid event를 발행합니다. query side는 그 event를 받아 Redis의 OrderSummary를 갱신합니다. 이 흐름에서
Redis는 audit history가 아니라 화면/조회용 read model입니다.

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, ), )}제가 이 예제에서 지키고 싶은 순서는 명확합니다. source of truth는 OrdersTable입니다. OrderRepository는 먼저
Exposed table에 aggregate를 저장하고, base class인 AggregateRepository가 JaVers commit과 event publish를 이어갑니다.
fun save(aggregate: T, author: String, events: Collection<DomainEvent>): T { val saved = persist(aggregate) javers.commit(author, saved, events.toJaversProperties()) eventPublisher.publishAll(events) return saved}이 순서를 바꾸면 바로 문제가 생깁니다. aggregate persistence가 실패했는데 audit commit이나 event publish가 먼저 나가면, history와 현재 row가 서로 다른 이야기를 하게 됩니다. 이 예제는 “저장 성공 -> JaVers commit -> domain event” 순서를 명시합니다. DDD helper를 둔 이유도 여기에 있습니다. 매 command handler마다 이 순서를 다시 작성하면 언젠가는 하나쯤 빠집니다.
read model은 audit history와 다르다
섹션 제목: “read model은 audit history와 다르다”예제에는 Redis 기반 OrderSummary projection도 있습니다. 이것은 audit history가 아니라 query-side read model입니다.
Kafka event consumer가 event를 받아 summary를 갱신하고, query service는 Redis에서 summary를 읽습니다.

이 구분을 놓치면 JaVers snapshot을 모든 read model 대체재로 쓰려고 할 가능성이 높습니다. JaVers는 “어떻게 변했나”를 설명하는 데 강합니다. read model은 “지금 화면에 무엇을 빠르게 보여줄까”를 담당합니다. 둘은 함께 쓸 수 있지만 역할은 다릅니다.
CDC, Envers, JaVers를 어떻게 나눌까
섹션 제목: “CDC, Envers, JaVers를 어떻게 나눌까”CDC, Hibernate Envers, JaVers를 섞어 보면 판단이 흐려지기 쉽습니다. 세 도구는 모두 변경 이력 근처에 있지만 출발점이 다릅니다. 저는 아래처럼 나누어 생각하는 편이 가장 안전하다고 봅니다.
| 선택지 | 출발점 | 잘 맞는 경우 |
|---|---|---|
| CDC | database log | DB 변경을 외부 시스템으로 빠짐없이 흘려야 한다 |
| Hibernate Envers | JPA entity revision | JPA entity revision table과 빠른 entity-level audit가 필요하다 |
| JaVers | domain object commit | 객체 diff, commit metadata, DDD command/event 흐름과 audit를 묶고 싶다 |
성능 이야기도 솔직하게 적어두는 편이 맞습니다. examples/javers-exposed-ddd의 H2 문서 benchmark에서는 Hibernate
Envers가 더 빠릅니다.
| Scenario | Hibernate Envers ms/op | JaVers + Exposed ms/op |
|---|---|---|
| insert | 1.049 | 3.627 |
| update | 1.383 | 2.848 |
| audit-query | 8.010 | 105.339 |
저는 이 숫자를 “JaVers를 쓰면 안 된다”는 결론으로 보지는 않습니다. 이 benchmark는 좁은 persistence benchmark입니다. JaVers + Exposed 예제는 명시적인 aggregate commit, commit metadata, domain event integration, CQRS projection 흐름이 필요할 때 의미가 있습니다. 하지만 latency가 높은 것도 사실입니다. 특히 audit-query 차이는 개선해야 할 문제입니다. repository query, snapshot materialization, codec, index 설계를 따로 점검하겠습니다.
다음 계획
섹션 제목: “다음 계획”bluetape4k-javers는 아직 끝난 라이브러리가 아닙니다. 이번 예제에서 드러난 성능, persistence 조합, Spring Boot
연동 지점을 다음 순서로 개선해 나가겠습니다.
| 계획 | 기대효과 |
|---|---|
| JaVers + Exposed latency 분석과 benchmark 모듈 분리 | Envers 대비 느린 지점을 숨기지 않고, query/codec/index 개선 대상을 명확히 합니다 |
| Exposed JQL filter의 SQL pushdown | snapshot을 많이 읽는 audit-query 비용을 줄입니다 |
| Exposed + Kafka composite repository | SQL에는 query 가능한 history를 남기고, Kafka에는 같은 snapshot event를 흘릴 수 있습니다 |
| Kafka snapshot publisher와 pluggable event pipeline | Kafka 외에도 Spring event, NATS 같은 event 경로를 붙이기 쉬워집니다 |
| read 가능한 Kafka audit projection 경로 | Kafka를 단순 write-only stream에서 projection 기반 조회 흐름으로 확장할 수 있습니다 |
| Redis repository parity와 Redisson near-cache 전략 | Lettuce/Redisson 선택 기준을 분명히 하고, hot snapshot read latency를 낮춥니다 |
| R2DBC persistence 검토 | coroutine/R2DBC 기반 서비스에서도 blocking JDBC 없이 audit store를 붙일 가능성을 확인합니다 |
| Spring Boot auto-configuration | repository와 JaVers wiring을 Spring Boot 서비스에서 더 적은 설정으로 시작할 수 있습니다 |
| Exposed schema mapping 설정화 | 운영 DB naming rule, schema 분리, multi-tenant 환경에 맞추기 쉬워집니다 |
| codec compatibility/security 점검 | snapshot 저장 형식의 호환성과 JDK serialization 의존을 더 안전하게 관리합니다 |
| Spring transaction 기반 DDD event publishing 테스트 | aggregate 저장, JaVers commit, event 발행 순서의 transaction 경계를 더 신뢰할 수 있게 만듭니다 |
새 aggregate에 붙이는 checklist
섹션 제목: “새 aggregate에 붙이는 checklist”새 Exposed aggregate에 JaVers audit를 붙일 때 저는 아래 순서로 확인합니다.
| 단계 | 확인할 것 |
|---|---|
| 1. identity | aggregate id가 안정적인가, JaVers @Id가 명확한가 |
| 2. source of truth | 현재 상태는 어느 table/repository가 책임지는가 |
| 3. commit point | command handler에서 state transition 뒤 어디서 commit할 것인가 |
| 4. metadata | author, domain event type, aggregate id를 commit property로 남길 것인가 |
| 5. persistence | Exposed, Redis, Kafka 중 history/query/stream 역할을 어떻게 나눌 것인가 |
| 6. tests | INITIAL/UPDATE/TERMINAL, diff, latest snapshot, event projection을 확인하는가 |
작은 서비스에서는 workshop 방식처럼 현재 row + JaVers history만으로 충분합니다. command/event 흐름이 커지면 DDD helper와 event publisher를 붙입니다. downstream projection이 필요하면 Kafka/Redis를 더합니다. 처음부터 모든 것을 붙이면 구조가 좋아지는 것이 아니라, 디버깅할 곳만 늘어납니다.
Source Links
섹션 제목: “Source Links”- Workshop audit example: exposed/javers-audit
- Product audit service:
ProductAuditService.kt - DDD example: examples/javers-exposed-ddd
- Command handler:
OrderCommandHandler.kt - Aggregate repository:
AggregateRepository.kt
마무리
섹션 제목: “마무리”bluetape4k-javers를 붙이는 핵심은 “history table을 없애자”가 아닙니다. 현재 상태와 변경 이력을 다른 책임으로
나누자는 것입니다. Exposed table은 현재 aggregate를 저장하고, JaVers는 object history와 diff를 맡고, event
publisher와 read model은 query-side 흐름을 만듭니다.
이 구분이 있으면 audit code가 entity마다 반복되는 일을 줄이고, 나중에 reader가 변경 이력을 이해할 수 있는 형태로
남길 수 있습니다. 저는 bluetape4k-javers를 이 경계를 코드로 고정해 주는 도구로 키워갈 생각입니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.