콘텐츠로 이동

Bluetape4k JaVers Part 3: DDD와 workshop audit 예제

작은 로봇 작업자들이 DDD command flow와 JaVers audit archive를 연결하는 3D 작업대 일러스트
감사 기록은 command 흐름 옆에 붙어야지, 나중에 기억으로 복원하면 안 됩니다.

Part 1에서는 JaVers의 commit/snapshot/diff 모델을 잡았고, Part 2에서는 Exposed, Redis, Kafka persistence의 역할을 나눴습니다. 이제 예제로 내려가 보겠습니다. 제가 이 글에서 보여주고 싶은 것은 “audit를 어디에 붙이고, 어느 시점에 commit해야 하는가”입니다.

예제확인할 흐름
bluetape4k-workshop/exposed/javers-auditproduct row를 Exposed에 저장하고 JaVers로 history/diff를 읽는 작은 예제
examples/javers-exposed-dddOrder 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로 나누어 보겠습니다.

Command handler가 Order aggregate를 저장하고 JaVers audit와 event publisher, Redis read model로 이어지는 flow diagram
command side에서는 aggregate 저장, JaVers commit, domain event 발행 순서가 명확해야 합니다.

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

Manual audit table 방식과 JaVers snapshot flow를 비교하는 diagram
Exposed는 현재 row를 저장하고, JaVers는 변경 이력과 object diff semantics를 맡습니다.

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으로 남긴 뒤, OrderPlacedOrderMarkedPaid event를 발행합니다. query side는 그 event를 받아 Redis의 OrderSummary를 갱신합니다. 이 흐름에서 Redis는 audit history가 아니라 화면/조회용 read model입니다.

javers-exposed-ddd 예제의 command와 projection sequence diagram
예제 README의 sequence diagram은 command 처리, audit commit, event publish, Redis projection의 순서를 한 번에 정리합니다.
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마다 이 순서를 다시 작성하면 언젠가는 하나쯤 빠집니다.

예제에는 Redis 기반 OrderSummary projection도 있습니다. 이것은 audit history가 아니라 query-side read model입니다. Kafka event consumer가 event를 받아 summary를 갱신하고, query service는 Redis에서 summary를 읽습니다.

javers-exposed-ddd 예제의 CQRS flow diagram
Command side의 source of truth, JaVers audit store, Kafka event stream, Redis read model은 서로 다른 책임을 가집니다.

이 구분을 놓치면 JaVers snapshot을 모든 read model 대체재로 쓰려고 할 가능성이 높습니다. JaVers는 “어떻게 변했나”를 설명하는 데 강합니다. read model은 “지금 화면에 무엇을 빠르게 보여줄까”를 담당합니다. 둘은 함께 쓸 수 있지만 역할은 다릅니다.

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

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

CDC, Hibernate Envers, JaVers를 섞어 보면 판단이 흐려지기 쉽습니다. 세 도구는 모두 변경 이력 근처에 있지만 출발점이 다릅니다. 저는 아래처럼 나누어 생각하는 편이 가장 안전하다고 봅니다.

선택지출발점잘 맞는 경우
CDCdatabase logDB 변경을 외부 시스템으로 빠짐없이 흘려야 한다
Hibernate EnversJPA entity revisionJPA entity revision table과 빠른 entity-level audit가 필요하다
JaVersdomain object commit객체 diff, commit metadata, DDD command/event 흐름과 audit를 묶고 싶다

성능 이야기도 솔직하게 적어두는 편이 맞습니다. examples/javers-exposed-ddd의 H2 문서 benchmark에서는 Hibernate Envers가 더 빠릅니다.

ScenarioHibernate Envers ms/opJaVers + Exposed ms/op
insert1.0493.627
update1.3832.848
audit-query8.010105.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 pushdownsnapshot을 많이 읽는 audit-query 비용을 줄입니다
Exposed + Kafka composite repositorySQL에는 query 가능한 history를 남기고, Kafka에는 같은 snapshot event를 흘릴 수 있습니다
Kafka snapshot publisher와 pluggable event pipelineKafka 외에도 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-configurationrepository와 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 경계를 더 신뢰할 수 있게 만듭니다

새 Exposed aggregate에 JaVers audit를 붙일 때 저는 아래 순서로 확인합니다.

단계확인할 것
1. identityaggregate id가 안정적인가, JaVers @Id가 명확한가
2. source of truth현재 상태는 어느 table/repository가 책임지는가
3. commit pointcommand handler에서 state transition 뒤 어디서 commit할 것인가
4. metadataauthor, domain event type, aggregate id를 commit property로 남길 것인가
5. persistenceExposed, Redis, Kafka 중 history/query/stream 역할을 어떻게 나눌 것인가
6. testsINITIAL/UPDATE/TERMINAL, diff, latest snapshot, event projection을 확인하는가

작은 서비스에서는 workshop 방식처럼 현재 row + JaVers history만으로 충분합니다. command/event 흐름이 커지면 DDD helper와 event publisher를 붙입니다. downstream projection이 필요하면 Kafka/Redis를 더합니다. 처음부터 모든 것을 붙이면 구조가 좋아지는 것이 아니라, 디버깅할 곳만 늘어납니다.

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을 남길 수 있습니다.