데이터의 변경사항마다 이력을 관리하는 것은 반복적이고 지루한 작업이다.
이를 해결해 줄 수 있는 강력한 라이브러리인 Envers가 있다.
JPA를 사용하는 환경에서는 이력 관리를 손쉽게 자동화할 수 있게 지원하고 있다.
이번 글에서는 Spring Data Envers를 실무에 적용하면서 겪었던 경험과 문제 해결 방안을 공유하고자 한다.
Spring Data Envers란?
- 소개
- Hibernate Envers의 확장으로, Spring Data와 통합된 감사 라이브러리
- 데이터 변경 이력을 자동으로 관리하는 기능 제공
- 주요 특징
- 엔터티의 Create, Update, Delete 이력 관리
- 간단한 설정으로 엔티티 버전 관리 활성화
- 이력 조회 API 제공(이전 상태와 비교 가능)
- 기본 아키텍처
- 이력 대상 _AUD 테이블 생성 (엔티티 이력 저장)
- RevisionEntity를 중심으로 변경 정보 관리
의존성 추가 및 이력 설정
스프링부트를 사용하고 있다면 의존성을 쉽게 추가할 수 있다.
implementation("org.springframework.data:spring-data-envers")
spring:
jpa:
org.hibernate.envers.audit_table_suffix: _AUD # 이력 테이블의 Suffix (기본값: _AUD)
org.hibernate.envers.store_data_at_delete: true # Entity 삭제에도 이력 남기는 것을 활성화한다.
application.yaml 에 원하는 옵션을 설정한다.
Revision Entity 설정
이력 관리의 기반이 되는 Revision Entity를 정의해야 한다.
이 엔티티는 데이터 변경 시점을 기록하며, @RevisionEntity와 @RevisionNumber 를 정의해야한다.
@RevisionEntity
@Entity
class Revision(
@Column(name = "REV")
@RevisionNumber
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long,
@Column(name = "REVTSMTP")
@RevisionTimestamp
val timestamp: Long,
)
- @RevisionNumber: 이력마다 남겨질 번호이다. 순차적으로 채번되며 같은 트랜잭션에서 변화가 생긴 이력들은 같은 REV 번호를 공유한다.
- @RevisionNumber: 이력이 저장된 시간을 기록한다.
이력 관리 대상 엔티티 정의
이력 관리를 적용할 엔티티에 @Audited 애너테이션을 추가하면 자동으로 이력을 관리할 수 있다.
@Audited
@Entity
class Team(
val name: String,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Int = 0
)
위 설정만으로 이력 자동 관리는 끝이다.
설정이 매우 간단하며, 추가적인 로직 없이도 기본적인 이력 관리를 시작할 수 있다.
테스트 환경이므로 Entity만을 정의했다. 실무 환경이라면 이력 테이블도 DDL로 관리하는 것을 추천한다.
연관 테이블 이력 조회 관련 요구사항
실제 실무에서 요구된 요구사항을 예제를 위해 쉽게 풀어봤다.
- 하나의 Team은 여러 명의 Member를 포함할 수 있다.
- 각 Member는 여러 개의 Item을 가질 수 있다.
- Team을 상세 조회할 때 소속된 Member와 Member가 가진 Item까지 함께 조회할 수 있어야 한다.
- Team의 정보를 수정하면 이력을 남긴다.
- Team에 소속된 Member의 정보가 수정되어도 Team 이력을 남겨야 한다.
- Member가 가진 Item이 변경되어도 Team 이력에서 확인할 수 있어야 한다.
JPA 연관관계가 정의되어있다면 하위 테이블에 대한 이력을 쉽게 조회할 수 있으나, 객체 탐색이 깊어지는 문제로 연관관계를 맺지 않았다.
이력 조회 기능 구현
Team 엔티티
@Audited
@Entity
class Team(
val name: String,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Int = 0
)
Member 엔티티
@Audited
@Entity
class Member(
val name: String,
val teamId: Int,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Int = 0
)
Item 엔티티
@Audited
@Entity
class Item(
val name: String,
val memberId: Int,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Int = 0
)
위와 같이 간단히 설정하면 각 엔티티의 변경 이력은 _AUD 테이블에 자동으로 저장된다.
이력이 활성화되고 쌓인 이력들이다. 어떻게 조회하면 좋을지 내용을 살펴보자.
Team_AUD 이력 테이블
| REV | TYPE | id | name |
| 1 | ADD | 1 | 축구 |
| 2 | MOD | 1 | 농구 |
| 3 | MOD | 1 | 야구 |
| 6 | ADD | 2 | 공부 |
Member_AUD 이력 테이블
| REV | TYPE | id | name | team_id |
| 3 | ADD | 1 | noose | 1 |
| 3 | ADD | 2 | park | 1 |
| 4 | ADD | 3 | kim | 1 |
| 5 | MOD | 2 | parking | 1 |
| 7 | ADD | 4 | choi | 2 |
Item_AUD 이력 테이블
| REV | TYPE | id | name | member_id |
| 8 | ADD | 1 | 가방 | 1 |
| 9 | ADD | 2 | 아이폰 | 2 |
Revision 테이블
| REV | 시간 |
| 1 | 2024-01-01 10:00:00 |
| 2 | 2024-01-01 10:05:00 |
| 3 | 2024-01-02 10:00:00 |
| 4 | 2024-01-03 10:00:00 |
| 5 | 2024-01-04 10:00:00 |
| 6 | 2024-01-05 10:00:00 |
| 7 | 2024-01-06 10:00:00 |
| 8 | 2024-01-07 10:00:00 |
| 9 | 2024-01-07 20:00:00 |
각 테이블의 이력은 쌓이나 하위 테이블의 변경 이력이 부모 테이블에서 바로 확인되지 않아 추가적인 로직이 필요하다.
가령 Member나 Item이 변경되었을 때 Team에서 해당 시점을 알 수 없는 상황이다.
이를 해결하기 위해 하위 테이블의 변경 사항을 부모 테이블에 전파하여 기록하는 방식을 도입하게 되었다.
같은 이력 버전으로 기록하자
요구사항에 따라 하위 엔터티(Member 또는 Item)가 변경되었을 때 부모 엔터티(Team)도 변경되었다는 사실을 기록해야 한다.
이를 효율적으로 해결하기 위해 같은 REV 값을 공유하는 방식 매커니즘과 스프링 이벤트 기능을 결합하여 이력을 관리하도록 했다.
앞에서도 설명했듯이 JPA 연관관계가 정의되지 않은 상황이다.
Envers의 특징 중 하나는 한 트랜잭션에서 수정된 모든 엔터티가 동일한 REV 버전 값을 공유한다는 점이다.
이 점을 활용하여 하위 엔터티가 변경될 때 부모 엔터티에도 변경을 가하면 동일한 REV 값으로 변경 이력을 남길 수 있었다.
fun updateMember(teamId, command: MemberUpdateCommand) {
// 하위 멤버 변경 로직
Events.publish(TeamChangedEvent(teamId = teamId)) // 이벤트 발행
}
fun updateItem(memberId, command: ItemUpdateCommand) {
// 하위 아이템 변경 로직
Events.publish(TeamChangedEvent(teamId = teamId)) // 이벤트 발행
}
@Audited
@Entity
class Team(
...
) {
var version: Int = 1
protected set
// 하위 엔터티의 변경에 따라 자체적인 버전을 갱신하도록 함수를 만들었다.
fun updateVersion() {
this.version += 1
}
}
@Component
class TeamVersioning(
private val teamRepository: TeamRepository,
) {
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
fun handle(event: TeamChangedEvent) {
val team = teamRepository.findByIdOrNull(event.teamId) ?: return
team.updateVersion()
teamRepository.save(team)
}
}
이벤트를 통해 하위 엔터티의 변경이 부모 엔터티의 이력으로 자동 기록되도록 개선되었다.
변경된 Team_AUD 이력 테이블
| REV | TYPE | id | name | version |
| 1 | ADD | 1 | 축구 | 1 |
| 2 | MOD | 1 | 농구 | 2 |
| 3 | MOD | 1 | 야구 | 3 |
| 4 | MOD | 1 | 야구 | 4 |
| 5 | MOD | 1 | 야구 | 5 |
| 6 | ADD | 2 | 공부 | 1 |
| 7 | MOD | 1 | 야구 | 6 |
| 8 | MOD | 1 | 야구 | 7 |
| 9 | MOD | 1 | 야구 | 8 |
| 10 | MOD | 2 | 공부 | 2 |
| 11 | MOD | 1 | 야구 | 9 |
Member_AUD 이력 테이블
| REV | TYPE | id | name | team_id |
| 3 | ADD | 1 | noose | 1 |
| 3 | ADD | 2 | park | 1 |
| 4 | ADD | 3 | kim | 1 |
| 5 | MOD | 2 | parking | 1 |
| 6 | ADD | 4 | bus | 2 |
| 7 | ADD | 5 | choi | 2 |
Item_AUD 이력 테이블
| REV | TYPE | id | name | member_id |
| 8 | ADD | 1 | 가방 | 1 |
| 9 | ADD | 2 | 아이폰 | 2 |
| 10 | ADD | 3 | 우유 | 4 |
| 11 | ADD | 2 | 딸기 | 2 |
Team REV 번호를 따라가다보면 각각의 이력에 접근할 수 있게 되었다.
이력 조회를 편하게
REV 기준 이력 조회를 위해 연관된 엔터티의 이력 중, 특정 기준 엔터티의 REV 버전과 가장 근접한(같거나 작은) 이력을 조회해야 한다.
이력 조회를 간편하게 하기 위해 제공하는 AuditReader를 확장하여 공통 기능을 만들었다.
const val REVISION_ENTITY_INDEX = 0
const val REVISION_NUMBER_INDEX = 1
const val REVISION_TYPE_INDEX = 2
// 단건 이력 조회
inline fun <reified T : Any> AuditReader.record(baseRevisionNumbers: List<Long>): T? {
val latestRevisionNumber = latestRevisionNumber<T>(baseRevisionNumbers) ?: return null
val revision = this.createQuery()
.forRevisionsOfEntity(T::class.java, false, true)
.add(AuditEntity.revisionNumber().eq(latestRevisionNumber))
.addOrder(AuditEntity.revisionNumber().desc())
.setMaxResults(1)
.resultList
.firstOrNull()
return revision?.let {
val type = (it as Array<*>)[REVISION_TYPE_INDEX] as RevisionType
if (type == RevisionType.DEL) {
null
} else {
it[REVISION_ENTITY_INDEX] as T
}
}
}
// 이력 목록 조회
inline fun <reified T : Any> AuditReader.records(baseRevisionNumbers: List<Long>): List<T> {
val latestRevisionNumber = latestRevisionNumber<T>(baseRevisionNumbers) ?: return emptyList()
return this.createQuery()
.forRevisionsOfEntity(T::class.java, true, false)
.add(AuditEntity.revisionNumber().eq(latestRevisionNumber))
.addOrder(AuditEntity.revisionNumber().desc())
.resultList
.map { it as T }
}
inline fun <reified T : Any> AuditReader.latestRevisionNumber(revisionNumbers: List<Long>): Long? {
return this.createQuery()
.forRevisionsOfEntity(T::class.java, false, true)
.add(AuditEntity.revisionNumber().`in`(revisionNumbers))
.addOrder(AuditEntity.revisionNumber().desc())
.setMaxResults(1)
.resultList
.mapNotNull {
val type = (it as Array<*>)[REVISION_TYPE_INDEX] as RevisionType
if (type == RevisionType.DEL) {
null
} else {
it[REVISION_NUMBER_INDEX] as Revision
}
}.map { it.id }
.firstOrNull()
}
- record: 단일 엔터티의 최신 이력을 가져오는 함수, 삭제된(DEL) 이력은 제외하며, 지정된 REV 번호에 해당하는 엔터티를 반환한다.
- records: 지정된 REV 번호에 따라 엔터티의 이력 목록을 반환
연관된 엔터티의 이력을 조회할 때 revisionNumbers를 리스트로 전달하는 이유는 다음과 같다.
특정 기준 엔터티의 REV 번호보다 작은 리비전 번호 중 가장 최근 값이 있다고 하더라도,
이는 다른 자식 엔터티의 변경 사항에 의해 생성된 리비전일 수 있다.
따라서, 기준 엔터티와 연관된 이력을 명확히 구분하기 위해 모든 관련 리비전 번호를 리스트로 제공해야 한다.
물론 하위 테이블이나 손자 테이블에도 부모/조상 엔터티의 ID를 참조로 가지고 있어도 되지만,
연관 테이블에 모두 부모 ID를 기록해야하므로 이력을 관리할 테이블이 많아진다면 귀찮은 일이 되어버린다.
이력 조회 예제
fun getRevision(teamId: Int, version: Int): TeamDetailResponse {
val allRevisions = teamRepository.findRevisions(teamId) // ID의 모든 이력 목록 조회
val revision = allRevisions.find { it.entity.version == version }
?: throw NotFoundException("$version 이력을 찾을 수 없습니다.") // 이력들 중 관리 버전과 일치하는 이력 찾기
val revisionEntity = revision.entity
val revisionNumbers = allRevisions.filter { it.metadata.requiredRevisionNumber <= revision.requiredRevisionNumber }
.map { it.metadata.requiredRevisionNumber }
.toList() // Revision 테이블에 기록되는 번호리스트 추출
val auditReader = AuditReaderFactory.get(entityManager)
val members = auditReader.records<Member>(revisionNumbers) // 관련 Member 이력 조회
val items = auditReader.records<Item>(revisionNumbers) // 관련 Item 이력 조회
return TeamDetailResponse(
team = revisionEntity,
members = members.map { MemberResponse.from(it) },
items = items.map { ItemResponse.from(it) }
)
}
이력 하나를 상세 조회할 때 해당 엔터티의 이력을 메모리로 불러와야 하는 문제가 있었다.
그러나 실무에서는 다음과 같은 판단으로 수용하기로 결정했다.
- 이력 변경 횟수
엔터티의 평균 이력 변경 횟수가 2~3회 수준으로 크지 않기 때문에 성능 부담이 크지 않는다. - 백오피스 용도
주로 관리자용 백오피스에서 사용되는 기능이므로, 실시간 처리보다 기능의 간결성과 유지보수성이 더 중요하다.
Envers 도입 후 얻은 효과와 고려사항
- 자동화로 개발 효율성 증대
Envers를 도입하면서 많은 복잡한 로직을 줄일 수 있었다.
추가 이력이 필요할 경우 테이블 추가 및 애너테이션 적용만으로 간단히 확장할 수 있어 유연한 이력 관리가 가능해짐 - 이력 테이블 관리의 번거로움
테이블의 변경 사항에 따라 이력 테이블도 수정해야 하는 번거로움이 존재하지만,
이로 인한 단점보다 효율적인 이력 관리가 주는 이점이 훨씬 크다고 판단했다. - 성능에 대한 고려
하위 엔터티 변경 시 부모 엔터티까지 갱신되는 구조로 인해,
대규모 트랜잭션 환경에서는 성능 문제가 발생할 수 있다. 어마어마한 대용량 트래픽과 데이터라면 RDB가 아닌 다른 솔루션을 고려해야 할 것 같고, RDB를 선택했다면 이력 리드 모델에 자식/손자 테이블에 부모 ID를 참조로 가지게 끔 만드는 것도 고려해야 할 것 같다.
참고
https://docs.spring.io/spring-data/envers/docs/current/reference/html/