콘텐츠로 이동

Bluetape4k JaVers Part 1: 감사와 객체 차이 비교의 기본 모델

작은 로봇 작업자들이 JaVers 스냅숏과 객체 차이 카드를 점검하는 3D 작업대 일러스트
감사는 저장만의 문제가 아닙니다. 나중에 사람이 읽을 수 있어야 합니다.

이 글은 bluetape4k-javers 시리즈의 1편입니다. 먼저 JaVers가 감사 이력과 객체 차이를 다루는 방식을 살펴보고, bluetape4k-javers가 어떤 기능을 보완하는지 설명합니다. Part 2에서는 Exposed, Redis, Kafka 저장 방식의 선택 기준을 다루고, Part 3에서는 워크숍과 DDD/CQRS 예제로 실제 적용 흐름을 따라갑니다.

서비스에 변경 이력을 추가하는 가장 단순한 방법은 엔티티마다 이력 테이블을 만드는 것입니다. product_history, order_history, member_history에 변경 전후 값을 복사하면 작은 업무에는 충분합니다. 그러나 필드와 중첩 객체가 늘고, 변경한 사용자와 사유까지 추적하기 시작하면 같은 코드가 여러 곳에 반복됩니다.

bluetape4k-javers는 이 반복을 줄이기 위해 만들었습니다. JaVers는 객체를 커밋하고 그 시점의 상태를 스냅숏으로 남기며, 두 객체나 두 스냅숏 사이의 차이를 계산합니다. 이 모델을 Kotlin/JVM 서비스에 적용하려면 저장소, 코덱, 프레임워크 구성, DDD 흐름 같은 작업이 추가로 필요합니다. bluetape4k-javers는 이러한 공통 작업을 모은 라이브러리입니다.

JaVers는 Hibernate Envers처럼 JPA 엔티티의 리비전 테이블에 결합된 도구가 아닙니다. 도메인 객체를 기준으로 변경 이력을 기록하고 차이를 계산하는 라이브러리입니다. 객체를 commit()하면 객체 그래프를 분석해 변경된 속성을 계산하고, 그 결과를 스냅숏 이력으로 남깁니다. 따라서 JPA를 사용하지 않는 Kotlin/JVM 서비스에서도 “이 객체가 언제, 누가, 어떻게 바뀌었는가”를 같은 모델로 설명할 수 있습니다.

대표 기능은 세 가지로 보면 됩니다.

기능설명
객체 감사객체 상태를 커밋 단위의 스냅숏으로 남깁니다
객체 차이 비교두 객체 또는 두 버전 사이에서 속성 단위의 차이를 계산합니다
JQL 조회특정 인스턴스, 클래스, 커밋 범위의 스냅숏과 이력을 조회합니다

CDC와도 역할이 다릅니다. Debezium 같은 CDC는 데이터베이스 행의 변경을 로그 스트림으로 전달하는 데 적합합니다. 반면 JaVers는 애플리케이션의 도메인 객체와 커밋 메타데이터를 중심에 둡니다. 데이터베이스 변경을 빠짐없이 외부로 전달해야 한다면 CDC가 적합하고, 사용자가 이해할 수 있는 객체 변경 이력과 차이가 필요하다면 JaVers가 적합합니다.

도메인 객체가 JaVers 커밋을 거쳐 스냅숏 저장소와 이력 조회 및 객체 차이 비교로 이어지는 흐름
애플리케이션은 애그리거트를 커밋하고, 조회자는 스냅숏 이력과 객체 차이를 읽습니다.

JaVers의 핵심 개념은 커밋과 스냅숏입니다.

개념의미
Commit작성자, 커밋 ID, 커밋 속성과 함께 저장하는 변경 단위
CDO Snapshot특정 객체의 특정 버전 상태
GlobalIdOrder/1처럼 객체의 식별 정보를 표현하는 JaVers ID
Diff두 객체 또는 스냅숏 사이의 속성 단위 변경
Shadow스냅숏 이력에서 객체 형태로 복원한 결과

이 모델을 사용하면 엔티티마다 감사 코드를 새로 만들 필요가 없습니다. 객체의 식별 정보와 현재 상태를 JaVers에 전달하면 스냅숏이 생성됩니다. 이후 JQL로 이력을 조회하거나 compare로 객체 차이를 계산할 수 있습니다.

val product = Product(1L, "Widget", BigDecimal("9.99"), "Tools")
javers.commit("alice", product)
val snapshots = javers.findSnapshots(
QueryBuilder.byInstanceId(1L, Product::class.java).build()
)

commit은 단순한 로그 한 줄이 아닙니다. 스냅숏에는 객체 상태와 변경된 속성이 들어가고, 커밋에는 작성자와 커밋 속성 같은 메타데이터가 포함됩니다. 따라서 “누가 변경했는가”와 “무엇이 변경되었는가”를 하나의 감사 흐름에서 확인할 수 있습니다.

bluetape4k-javers는 JaVers를 다시 구현하지 않습니다. JaVers의 감사와 차이 비교 모델은 그대로 사용하고, bluetape4k 기반 서비스에서 반복해서 작성하는 기능을 모듈로 제공합니다.

모듈맡는 일
javers-core확장 함수, 코덱, 캐시 기반 저장소의 공통 구현
javers-exposedExposed JDBC 기반 CDO 스냅숏 저장소
javers-persistence-redisLettuce/Redisson 기반 Redis 스냅숏 저장소
javers-persistence-kafka스냅숏을 Kafka 토픽으로 발행하는 쓰기 전용 저장소
javers-ddd애그리거트, 도메인 이벤트, 저장소, 발행 도우미
examples/javers-exposed-dddExposed, JaVers, 이벤트 프로젝션을 조합한 예제

예를 들어 javers-coreAbstractCdoSnapshotRepository는 스냅숏 인코딩과 디코딩, 커밋 헤드와 순번 관리, 조회 조건 필터링을 공통으로 처리합니다. 하위 저장소는 saveSnapshot, loadSnapshots, getKeys뿐 아니라 키 존재 여부, 커밋 순번, 스냅숏 수와 같은 저장소별 동작을 구현합니다. 커밋 단위의 원자적 쓰기를 지원하는 저장소는 persistCommit을 재정의할 수 있습니다.

abstract class AbstractCdoSnapshotRepository<T : Any>(
protected val codec: JaversCodec<T>,
) : CdoSnapshotRepository {
override fun persist(commit: Commit?) {
// commit snapshots -> repository-specific saveSnapshot(...)
}
}

이 구조 덕분에 Part 2에서 다룰 Exposed, Redis, Kafka 어댑터가 같은 JaVers 저장소 계약을 공유합니다. 다만 역할은 서로 다릅니다. Exposed는 SQL에 영구 이력을 저장하고, Redis는 스냅숏 조회 지연을 줄이며, Kafka 저장소는 하위 소비자에게 스냅숏 이벤트를 전달합니다.

워크숍의 ProductAuditService는 상품을 저장할 때 먼저 JaVers에 커밋하고, Exposed 테이블의 현재 행을 upsert합니다. 변경 이력은 JaVers 스냅숏에서 읽고 현재 상태는 Exposed에서 읽습니다. 따라서 현재 상태와 변경 이력을 하나의 테이블에 함께 저장하지 않아도 됩니다.

ProductAuditService가 JaVers 커밋과 스냅숏 저장 후 ProductTable의 현재 행을 갱신하는 시퀀스 다이어그램
작은 예제에서도 현재 행과 감사 이력의 책임을 분리하면 처리 흐름이 명확해집니다.

이 예제의 JaVers 인스턴스는 기본 메모리 저장소를 사용하며, JaVers 커밋과 Exposed 쓰기를 하나의 원자적 트랜잭션으로 묶지 않습니다. 두 저장 중 하나만 실패하면 현재 상태와 감사 이력이 어긋날 수 있으므로, 운영 서비스에서는 트랜잭션 경계나 아웃박스, 재처리 정책을 별도로 설계해야 합니다. 이 예제의 목적은 저장 책임의 분리를 보여주는 데 있습니다.

fun save(author: String, product: Product) {
javers.commit(author, product)
transaction {
ProductTable.upsert {
it[id] = product.id
it[name] = product.name
it[price] = product.price
it[category] = product.category
}
}
}

객체 차이는 저장하지 않고도 계산할 수 있습니다.

val diff = javers.compare(
oldProduct,
oldProduct.copy(price = BigDecimal("7.50")),
)

테스트에서는 가격과 분류 변경을 ValueChange로 확인합니다. 삭제할 때는 데이터베이스의 현재 행을 다시 읽은 뒤 commitShallowDeleteTERMINAL 스냅숏을 남깁니다. 이 예제로 변경 이력 기록과 변경 전후 비교를 같은 모델에서 다루는 방법을 확인할 수 있습니다.

JaVers 방식은 아래 조건에서 특히 자연스럽습니다.

상황이유
객체 단위 변경 이력이 필요하다스냅숏과 차이가 도메인 객체를 기준으로 남습니다
변경된 속성을 보여줘야 한다ValueChange, ListChange 같은 차이 유형을 사용할 수 있습니다
감사 메타데이터가 중요하다작성자와 커밋 속성을 커밋 단위로 저장합니다
현재 상태와 이력을 분리하고 싶다현재 행과 JaVers 이력의 책임을 분리할 수 있습니다
DDD 명령 흐름과 연결하고 싶다애그리거트 저장, JaVers 커밋, 도메인 이벤트 발행의 순서를 명시할 수 있습니다

모든 경우에 JaVers가 필요한 것은 아닙니다. 추가만 가능한 단순 로그로 충분한 업무도 있습니다. 상태 변경 시각과 작업자만 기록하면 된다면 직접 만든 감사 테이블이 더 작고 명확합니다. 변경된 객체 상태를 다시 읽고 비교해 도메인 이력으로 제공해야 할 때 JaVers의 장점이 분명해집니다.

감사 이력은 나중에 읽고 변경 전후를 설명할 수 있어야 합니다. 또한 해당 도메인 객체와 연결되어야 합니다. bluetape4k-javers는 이 흐름을 Kotlin/JVM 서비스에 적용할 때 반복되는 작업을 줄입니다.

다음 글에서는 스냅숏을 어디에 저장할지 살펴봅니다. Exposed, Redis, Kafka는 모두 영속화와 관련된 모듈이지만 각각의 역할은 다릅니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.