콘텐츠로 이동

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

작은 로봇 작업자들이 JaVers snapshot을 Exposed, Redis, Kafka 저장소로 분류하는 3D 작업대 일러스트
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에서 Exposed, Redis, Kafka persistence 역할로 나뉘는 선택 지도
저장소 선택은 module 이름보다 읽기/쓰기 역할과 복구 모델로 결정하는 편이 안전합니다.

그림을 글로 풀면 이렇게 됩니다. 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에 저장합니다. 구현은 CommitTableCdoSnapshotTable을 만들고, 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 repository는 두 가지 구현을 제공합니다. Lettuce 구현은 global id별 LIST에 snapshot을 최신순으로 넣고, Redis HASH로 global id index와 commit sequence를 관리합니다. Redisson 구현은 RListMultimapRMap을 사용합니다. 둘 다 기본 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 구현에는 한 가지 중요한 세부가 있습니다. saveSnapshotMULTI/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 Kafka
javers.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로 빠르게 서비스합니다.

Exposed, Redis, Kafka persistence 조합과 향후 composite repository 방향을 설명하는 diagram
현재 adapter들은 역할별로 제공됩니다. Exposed + Kafka 같은 fan-out 조합은 향후 composite repository로 제공할 예정입니다.

다만 현재 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 JDBCRedisKafka
주 역할durable snapshot historycache-friendly snapshot readsnapshot event stream
읽기JaVers query 가능빠른 latest/history read항상 empty/false/0
쓰기Exposed transaction 안에서 row 저장LIST/multimap + index updateKafka publish ack까지 대기
복구DB backup/restoreRedis persistence/재구성 정책 필요consumer replay/projection 정책 필요
운영 복잡도DB schema와 용량 관리key 설계, TTL/persistence 정책topic, consumer, replay 관리
추천 사용audit history를 DB와 함께 운영read-heavy history/projectiondownstream audit/event pipeline

가장 피해야 할 선택은 “Kafka도 persistence니까 Kafka만 쓰자”입니다. 제가 만든 Kafka adapter는 그렇게 쓰라고 만든 것이 아닙니다. query가 필요하면 Kafka stream과 별도의 read store를 같이 둡니다.

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