Bluetape4k JaVers Part 2: Exposed, Redis, Kafka persistence 선택

Part 1에서는 JaVers의 commit, snapshot, diff 모델을 봤습니다. 이제 조금 더 운영적인 질문으로 넘어가겠습니다.
snapshot을 어디에 둘 것인가?
bluetape4k-javers에는 Exposed JDBC, Redis, Kafka repository가 들어 있습니다. 이름만 보면 모두 persistence처럼
보이지만, 의도한 역할은 다릅니다. Exposed는 SQL에 남기는 durable history, Redis는 빠른 snapshot read, Kafka는
query repository가 아니라 snapshot event stream입니다. 이 구분을 놓치면 Kafka로 history query를 하려 하거나,
Redis를 감사 원장처럼 운영하는 식의 어색한 구조가 나오기 쉽습니다.

그림을 글로 풀면 이렇게 됩니다. JaVers commit은 하나지만, 그 commit이 가야 할 곳은 운영 목적에 따라 달라집니다. 나중에 사람이 history를 조회해야 하면 durable store가 필요합니다. 화면이 자주 최신 snapshot을 읽으면 빠른 read store가 필요합니다. 외부 감사 파이프라인이나 projection이 필요하면 stream이 필요합니다. 한 저장소가 이 세 역할을 모두 잘 해내길 기대하면 설계가 금방 흐려집니다.
Exposed JDBC: SQL에 남기는 durable history
섹션 제목: “Exposed JDBC: SQL에 남기는 durable history”ExposedCdoSnapshotRepository는 JaVers snapshot과 commit metadata를 Exposed table에 저장합니다. 구현은
CommitTable과 CdoSnapshotTable을 만들고, snapshot마다 global id, commit id, version, type, state,
changed properties, managed type을 저장합니다.
val snapshotRepository = ExposedCdoSnapshotRepository(database)snapshotRepository.ensureSchema()
val javers = JaversBuilder.javers() .registerJaversRepository(snapshotRepository) .registerEntity(Order::class.java) .build()이 방식은 SQL transaction과 운영 DB backup/restore 흐름에 audit history를 같이 두고 싶을 때 맞습니다. 현재 row를 Exposed로 관리하고 있다면, audit snapshot도 같은 JDBC 경계에 두는 편이 운영자가 이해하기 쉽습니다.
| 장점 | 주의점 |
|---|---|
| durable 저장, backup/restore가 명확함 | history table이 커질 수 있으므로 query shape를 봐야 함 |
| transaction 경계가 익숙함 | 대량 history 조회는 memory materialization을 조심해야 함 |
| SQL로 상태 확인이 가능함 | snapshot JSON codec과 schema 변경 정책을 관리해야 함 |
Redis: snapshot read를 빠르게
섹션 제목: “Redis: snapshot read를 빠르게”Redis repository는 두 가지 구현을 제공합니다. Lettuce 구현은 global id별 LIST에 snapshot을 최신순으로
넣고, Redis HASH로 global id index와 commit sequence를 관리합니다. Redisson 구현은 RListMultimap과 RMap을
사용합니다. 둘 다 기본 codec은 LZ4 + Fory 조합입니다.
val repo = LettuceCdoSnapshotRepository("order", redisClient)val javers = JaversBuilder.javers() .registerJaversRepository(repo) .build()Redis는 “감사 원장”이라기보다 빠른 snapshot read 경로로 보는 편이 자연스럽습니다. 최신 snapshot이나 짧은 history를 자주 읽고, durable source는 다른 곳에 둔다면 Redis adapter가 잘 맞습니다. 저는 Redis를 단독 원장으로 쓰기보다는 읽기 지연을 줄이는 쪽에 두는 편이 더 안전하다고 봅니다.
Lettuce 구현에는 한 가지 중요한 세부가 있습니다. saveSnapshot은 MULTI/EXEC로 LIST push와 index update를 묶고,
shared synchronous connection의 transaction interleaving을 막기 위해 lock을 둡니다. 이건 성능보다 정확성 문제입니다.
snapshot은 들어갔는데 index가 빠지는 식의 상태는 audit 쪽에서 특히 피해야 합니다.
Kafka: query repository가 아니라 stream
섹션 제목: “Kafka: query repository가 아니라 stream”KafkaCdoSnapshotRepository는 의도적으로 write-only입니다. saveSnapshot은 snapshot을 Kafka default topic으로
보내지만, getKeys, contains, getSeq, loadSnapshots 같은 read method는 empty/false/0을 반환합니다. 첫
read call에는 warning을 남겨 contract를 드러냅니다.
val repo = KafkaCdoSnapshotRepository(kafkaTemplate)val javers = JaversBuilder.javers() .registerJaversRepository(repo) .build()
// commit -> snapshot event is published to Kafkajavers.commit("system", order)이 설계는 일부러 이렇게 잡았습니다. Kafka topic은 downstream consumer, projection, 외부 감사 파이프라인으로 snapshot event를 흘리는 데 좋습니다. 하지만 “이 객체의 최신 snapshot을 지금 읽어줘”라는 질문에는 Kafka repository만으로 답하지 않습니다. 그 질문은 Exposed나 Redis 같은 read-side repository가 맡아야 합니다.
조합해서 쓰는 경우
섹션 제목: “조합해서 쓰는 경우”실제 운영에서는 하나만 고르는 것보다 조합이 더 자연스러운 경우가 있습니다. 대표적인 조합은 Exposed + Kafka입니다. Exposed에는 query 가능한 durable history를 남기고, Kafka로는 같은 snapshot event를 downstream consumer에 흘립니다. 또 다른 조합은 Exposed + Redis입니다. durable history는 SQL에 두고, 자주 읽는 latest/history view는 Redis로 빠르게 서비스합니다.

다만 현재 bluetape4k-javers에는 Exposed에 쓰면서 동시에 Kafka로 fan-out하는 CompositeCdoSnapshotRepository가
아직 없습니다. 가능성은 충분합니다. 읽기는 primary repository인 Exposed나 Redis에 위임하고, 쓰기는 Kafka 같은
secondary writer에 fan-out하면 됩니다. 다만 실패 정책을 먼저 정해야 합니다. Kafka publish 실패 시 전체 commit을
실패로 볼지, secondary write를 retry queue로 넘길지, 애플리케이션이 선택할 수 있어야 합니다.
그래서 이 글에서는 오늘 바로 쓸 수 있는 기능과 앞으로 추가할 기능을 분리해서 설명하겠습니다. 지금은 Exposed, Redis, Kafka 각각의 adapter를 바로 씁니다. Exposed + Kafka를 하나의 JaVers repository처럼 쓰는 기능은 후속 작업으로 제공할 예정입니다.
선택 기준
섹션 제목: “선택 기준”| 기준 | Exposed JDBC | Redis | Kafka |
|---|---|---|---|
| 주 역할 | durable snapshot history | cache-friendly snapshot read | snapshot event stream |
| 읽기 | JaVers query 가능 | 빠른 latest/history read | 항상 empty/false/0 |
| 쓰기 | Exposed transaction 안에서 row 저장 | LIST/multimap + index update | Kafka publish ack까지 대기 |
| 복구 | DB backup/restore | Redis persistence/재구성 정책 필요 | consumer replay/projection 정책 필요 |
| 운영 복잡도 | DB schema와 용량 관리 | key 설계, TTL/persistence 정책 | topic, consumer, replay 관리 |
| 추천 사용 | audit history를 DB와 함께 운영 | read-heavy history/projection | downstream audit/event pipeline |
가장 피해야 할 선택은 “Kafka도 persistence니까 Kafka만 쓰자”입니다. 제가 만든 Kafka adapter는 그렇게 쓰라고 만든 것이 아닙니다. query가 필요하면 Kafka stream과 별도의 read store를 같이 둡니다.
Source Links
섹션 제목: “Source Links”- Repository: bluetape4k-javers
- Exposed repository:
ExposedCdoSnapshotRepository.kt - Lettuce repository:
LettuceCdoSnapshotRepository.kt - Redisson repository:
RedissonCdoSnapshotRepository.kt - Kafka repository:
KafkaCdoSnapshotRepository.kt
마무리
섹션 제목: “마무리”bluetape4k-javers의 persistence 선택은 “어떤 저장소가 더 멋진가”가 아닙니다. audit history를 SQL과 함께
운영하려면 Exposed, snapshot read를 빠르게 만들려면 Redis, snapshot event를 흘리려면 Kafka입니다. 특히 Kafka는
write-only contract를 일부러 드러냅니다. 이름이 persistence라도 query store가 아니라는 점을 코드에서 숨기지
않았습니다.
다음 글에서는 이 선택지를 DDD command flow와 workshop 예제에 얹어 봅니다. aggregate를 저장하고, JaVers에 commit하고, domain event를 발행하는 순서가 왜 중요한지 보겠습니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.