Bluetape4k JaVers Part 2: Exposed, Redis, Kafka 영속 저장소 선택

Part 1에서는 JaVers의 커밋, 스냅숏, 차이 모델을 살펴봤습니다. 이제 운영 환경에서 마주치는 질문으로 넘어가겠습니다.
스냅숏을 어디에 저장할 것인가?
bluetape4k-javers에는 Exposed JDBC, Redis, Kafka 저장소가 있습니다. 모두 영속화 모듈이지만 역할은 다릅니다.
Exposed는 SQL에 남기는 영속 이력이고, Redis는 빠른 스냅숏 저장소이며, Kafka는 조회 저장소가 아닌 스냅숏 이벤트
스트림입니다. 이 구분을 놓치면 Kafka에서 이력을 직접 조회하거나, 영속성 정책을 정하지 않은 Redis를 감사 원장으로
운영하는 구조가 나오기 쉽습니다.

JaVers 커밋은 하나지만 저장 대상은 운영 목적에 따라 달라집니다. 사람이 이력을 조회해야 한다면 영속 저장소가 필요합니다. 화면에서 최신 스냅숏을 자주 읽는다면 빠른 조회 저장소가 필요합니다. 외부 감사 파이프라인이나 프로젝션을 구성한다면 이벤트 스트림이 필요합니다. 한 저장소에 세 역할을 모두 맡기면 읽기 경로와 복구 정책이 불분명해집니다.
Exposed JDBC: SQL에 남기는 영속 이력
섹션 제목: “Exposed JDBC: SQL에 남기는 영속 이력”ExposedCdoSnapshotRepository는 JaVers 스냅숏과 커밋 메타데이터를 Exposed 테이블에 저장합니다. 기본적으로
CommitTable과 CdoSnapshotTable을 만들고, 각 스냅숏의 전역 ID, 커밋 ID, 버전, 유형, 상태,
변경 속성, 관리 유형을 저장합니다. 외부 마이그레이션 도구가 스키마를 관리한다면
createSchemaOnEnsure = false로 자동 생성을 끌 수 있습니다.
val snapshotRepository = ExposedCdoSnapshotRepository(database)snapshotRepository.ensureSchema()
val javers = JaversBuilder.javers() .registerJaversRepository(snapshotRepository) .registerEntity(Order::class.java) .build()이 방식은 감사 이력을 SQL 백업·복구 체계와 함께 운영할 때 적합합니다. 저장소의 각 작업은 Exposed
transaction {} 경계에서 실행됩니다. 다만 애플리케이션 데이터 변경과 JaVers 커밋이 자동으로 하나의 분산
트랜잭션이 되는 것은 아니므로, 호출 계층에서 트랜잭션 경계를 별도로 검토해야 합니다.
| 장점 | 주의점 |
|---|---|
| 영속 저장과 백업·복구 절차가 명확함 | 이력 테이블이 커질 수 있으므로 쿼리 형태를 검토해야 함 |
| Exposed 트랜잭션 경계를 사용함 | 대량 이력 조회 시 메모리 구체화 비용을 주의해야 함 |
| SQL로 상태를 확인할 수 있음 | 스냅숏 JSON 코덱과 스키마 변경 정책을 관리해야 함 |
Redis: 스냅숏을 빠르게 읽기
섹션 제목: “Redis: 스냅숏을 빠르게 읽기”Redis 모듈은 Lettuce와 Redisson 구현을 제공합니다. Lettuce 구현은 전역 ID별 LIST에 스냅숏을 최신순으로
저장하고, Redis HASH로 전역 ID 인덱스와 커밋 시퀀스를 관리합니다. Redisson 구현은 RListMultimap과 RMap을
사용합니다. 두 구현의 기본 코덱은 LZ4 + Fory 조합입니다.
val repo = LettuceCdoSnapshotRepository("order", redisClient)val javers = JaversBuilder.javers() .registerJaversRepository(repo) .build()Redis는 최신 스냅숏이나 짧은 이력을 자주 읽는 경로에 적합합니다. Redis를 주 저장소로 사용할 수도 있지만, 그 경우에는 AOF/RDB, 복제, 백업, 복구 목표를 감사 데이터의 보존 요구에 맞춰야 합니다. SQL을 기준 저장소로 두고 Redis를 명시적인 프로젝션이나 캐시 저장소로 사용하는 구성이 운영 책임을 구분하기 쉽습니다.
Lettuce 구현은 saveSnapshot에서 LIST 추가와 인덱스 갱신을 MULTI/EXEC로 묶습니다. 커밋 단위 persist도
모든 스냅숏과 커밋 시퀀스 갱신을 하나의 MULTI/EXEC 경계에서 처리합니다. 공유 동기 연결에서 트랜잭션 명령이
뒤섞이지 않도록 별도 쓰기 연결과 잠금을 사용합니다. 반면 Redisson 구현은 여러 자료 구조를 갱신하는 과정에서
부분 저장이 발생할 수 있으므로, 커밋 단위 원자성이 필요하면 Lettuce 구현을 선택해야 합니다.
Kafka: 조회 저장소가 아니라 이벤트 스트림
섹션 제목: “Kafka: 조회 저장소가 아니라 이벤트 스트림”KafkaCdoSnapshotRepository는 의도적으로 쓰기 전용입니다. saveSnapshot은 스냅숏 이벤트를 지정한 토픽이나
KafkaTemplate 기본 토픽으로 발행하고, 전역 ID를 레코드 키로 사용합니다. getKeys, contains, getSeq,
loadSnapshots 같은 읽기 메서드는 빈 값, false, 0을 반환합니다. 첫 읽기 호출에는 경고를 남겨 계약을
명시적으로 드러냅니다.
val repo = KafkaCdoSnapshotRepository(kafkaTemplate)val javers = JaversBuilder.javers() .registerJaversRepository(repo) .build()
// 커밋하면 스냅숏 이벤트를 Kafka에 발행한다.javers.commit("system", order)Kafka 토픽은 하위 소비자, 프로젝션, 외부 감사 파이프라인으로 스냅숏 이벤트를 전달하는 데 적합합니다. 하지만
특정 객체의 최신 스냅숏을 조회하는 요청에는 Kafka 저장소만으로 응답할 수 없습니다. 이 조회는 Exposed나 Redis 같은
읽기 저장소가 맡아야 합니다. Kafka 레코드를 읽기 가능한 저장소로 재생성할 때는
KafkaCdoSnapshotProjector를 사용할 수 있습니다.
조합해서 쓰는 경우
섹션 제목: “조합해서 쓰는 경우”실제 운영에서는 여러 역할을 조합하기도 합니다. CompositeCdoSnapshotRepository는 읽기 기준이 되는 주 저장소
하나와, 순서대로 쓰기를 전달할 보조 저장소 목록을 받습니다. Exposed를 주 저장소로, Kafka를 보조 저장소로 두면
SQL에서 이력을 조회하면서 같은 커밋을 이벤트 스트림으로 발행할 수 있습니다. Redis와 Kafka를 함께 보조 저장소로
두면 한 번의 JaVers persist 호출에서 조회 프로젝션과 이벤트 발행을 순서대로 실행할 수 있습니다.

val repository = CompositeCdoSnapshotRepository( primary = exposedRepository, secondaryRepositories = listOf( redisProjectionRepository, kafkaRepository, ), options = CompositeCdoSnapshotRepositoryOptions( writeFailurePolicy = CompositeCdoSnapshotFailurePolicy.FAIL_FAST, ),)읽기 작업은 모두 주 저장소에 위임합니다. 쓰기는 주 저장소를 먼저 갱신한 뒤 보조 저장소를 순서대로 호출합니다.
기본 FAIL_FAST는 첫 보조 저장소 실패에서 중단하고, BEST_EFFORT는 나머지 보조 저장소까지 모두 시도한 뒤
실패를 모아 예외를 던집니다. BEST_EFFORT도 실패를 무시하거나 자동 재시도하지는 않습니다.
이 구성은 분산 트랜잭션이 아닙니다. 보조 저장소가 실패할 때 주 저장소에는 커밋이 이미 보일 수 있습니다. 따라서 재시도, 중복 이벤트 허용 여부, 운영 알림과 복구 절차는 애플리케이션에서 별도로 정해야 합니다.
선택 기준
섹션 제목: “선택 기준”| 기준 | Exposed JDBC | Redis | Kafka |
|---|---|---|---|
| 주 역할 | 영속 스냅숏 이력 | 빠른 스냅숏 조회·프로젝션 | 스냅숏 이벤트 스트림 |
| 읽기 | JaVers 쿼리 지원 | 최신 스냅숏·이력 조회 | 항상 빈 값/false/0 |
| 쓰기 | Exposed 트랜잭션에서 행 저장 | LIST·멀티맵과 인덱스 갱신 | Kafka 발행 승인까지 대기 |
| 복구 | DB 백업·복구 | Redis 영속화 또는 재구성 정책 필요 | 소비자 재처리·프로젝션 정책 필요 |
| 운영 복잡도 | DB 스키마와 용량 관리 | 키 설계, TTL·영속화 정책 | 토픽, 소비자, 재처리 관리 |
| 권장 용도 | 감사 이력을 DB와 함께 운영 | 조회가 많은 이력·프로젝션 | 하위 감사·이벤트 파이프라인 |
Kafka 모듈이 영속화 패키지에 있다는 이유만으로 Kafka만 선택해서는 안 됩니다. 조회가 필요하면 Kafka 스트림과
별도의 읽기 저장소를 함께 두거나, KafkaCdoSnapshotProjector로 이벤트를 읽기 저장소에 반영해야 합니다.
참고 자료
섹션 제목: “참고 자료”- 저장소: bluetape4k-javers
- Exposed 저장소:
ExposedCdoSnapshotRepository.kt - Lettuce 저장소:
LettuceCdoSnapshotRepository.kt - Redisson 저장소:
RedissonCdoSnapshotRepository.kt - Kafka 저장소:
KafkaCdoSnapshotRepository.kt - 복합 저장소:
CompositeCdoSnapshotRepository.kt - Kafka 프로젝터:
KafkaCdoSnapshotProjector.kt
마무리
섹션 제목: “마무리”bluetape4k-javers의 영속화 저장소는 읽기·쓰기·복구 책임에 맞춰 선택해야 합니다. 감사 이력을 SQL과 함께
운영하려면 Exposed, 스냅숏 조회 지연을 줄이려면 Redis, 스냅숏 이벤트를 전달하려면 Kafka가 적합합니다.
두 역할 이상이 필요하면 CompositeCdoSnapshotRepository로 주 저장소와 보조 저장소를 명시하되,
보조 저장소 실패 이후의 복구 정책까지 함께 설계해야 합니다.
다음 글에서는 이 선택지를 DDD 명령 처리 흐름과 워크숍 예제에 적용합니다. 애그리거트를 저장하고 JaVers에 커밋한 뒤 도메인 이벤트를 발행하는 순서가 왜 중요한지 살펴봅니다.
댓글
GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.