배경
사이드 프로젝트나 실제 서비스에서 데이터 변경 이력 관리가 필요할 때가 많다. “누가, 언제, 무엇을, 어떻게 바꿨는지” 추적이 필요하다면, Spring Data Envers가 딱이다. 이번 글에서는 Spring Data Envers를 실제로 적용하는 방법을, 내가 직접 해보면서 겪은 시행착오와 함께 정리해본다.
Envers란?
Hibernate Envers는 JPA 엔티티의 변경 이력을 자동으로 관리해준다. Spring Data Envers는 이를 Spring Data JPA와 자연스럽게 통합해주기 때문에, 기존 Repository 패턴을 그대로 쓰면서도 이력 관리가 가능하다
프로젝트에 Envers 적용하기
의존성 추가
build.gradle에 아래 의존성을 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.data:spring-data-envers'
엔터티 설정
변경 이력을 관리하고 싶은 엔터티에 @Audited 어노테이션만 붙이면 된다.
클래스에 @Audited를 사용하면 엔티티 전체 이력을 관리하게 되고, 특정 필드에 사용하면 해당 필드의 변경 이력만 관리하게 된다.
특정 필드에 @NotAudited를 붙이면 해당 필드는 이력 관리에서 제외된다. 보통 민감 정보, 빈번히 변경되는 값 등을 이력 관리에서 제외할 때 사용한다.
| 어노테이션 | 적용 대상 | 효과 |
| @Audited | 엔티티/필드 | 변경 이력 관리 대상에 포함 |
| @NotAudited | 필드 | 해당 필드만 이력 관리에서 제외 |
@Getter
@Builder
@Entity
@Audited // 엔티티 전체 이력 관리
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@OneToMany(mappedBy = "post")
private List<Comment> comments;
public void addComment(Comment comment) {
if (comments == null) {
comments = new ArrayList<>();
}
if (!comments.contains(comment)) {
comments.add(comment);
}
}
public void removeComment(Comment comment) {
if (comments != null) {
comments.remove(comment);
}
}
}
@Getter
@Builder
@Entity
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Audited // 특정 필드의 이력 관리
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;
public void setPost(Post post) {
this.post = post;
post.addComment(this);
}
public void removePost() {
if (this.post != null) {
this.post.removeComment(this);
this.post = null;
}
}
}
❓연관관계 필드에서 @Audited를 붙이면 어떻게 될까?
연관관계(@ManyToOne, @OneToMany) 필드에 @Audited를 붙이면 해당 연관관계 엔티티의 변경 이력까지 기록하게 된다. 다만, 해당 엔티티에도 @Audited가 붙어 있어야 한다.
만약 연관관계 엔티티의 변경 이력까지는 기록하지 않고, 연관 관계의 현재 FK(id) 값만 이력 테이블에 저장하고 싶다면 @Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)으로 옵션을 붙여서 사용하면 된다. FK까지도 기록하고싶지 않으면 @NotAudited를 붙이면 된다.
데이터베이스에 테이블 자동 생성
이제 애플리케이션을 ddl-auto를 사용해 테이블이 생성해보면 COMMENT, POST 테이블 외에 COMMNET_HISTORY, POST_HISTORY, REVINFO 테이블이 생긴 것을 볼 수 있다.

기본적으로 Hibernate Envers는 엔티티 이력 테이블에 _AUD 접미사를 사용한다. 예를 들어, Post 엔티티라면 post_AUD 테이블이 생성된다. 이 접미사를 바꾸고 싶다면, application.yml 또는 application.properties에서 설정할 수 있다. 나는 _history로 변경했다.
spring:
jpa:
properties:
org:
hibernate:
envers:
audit_table_suffix: _history # 이력 테이블 접미사 변경
엔티티 변경
이제 엔티티가 변경되었을 때 어떻게 기록되는지 확인해보자. 게시판 생성, 수정, 삭제를 순차적으로 진행했다.
PostController
@RestController
@RequestMapping("/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@PostMapping
public void createPost() {
postService.createPost();
}
@PatchMapping
public void updatePost() {
postService.updatePost();
}
@DeleteMapping
public void deletePost() {
postService.deletePost();
}
}
PostService
@Service
@Transactional
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public void createPost() {
Post post = Post.builder().title("아 덥다").content("더워 죽겠다. 너무너무 덥다...").build();
postRepository.save(post);
}
public void updatePost() {
postRepository.findById(1L)
.ifPresent(post -> post.updateContent("에어컨 틀었더니 조금 시원하다. 에어컨 최고!"));
}
public void deletePost() {
postRepository.deleteById(1L);
}
}
PostRepository
public interface PostRepository extends JpaRepository<Post, Long> {
}

위 이미지를 보면 이력이 자동 생성된 걸 확인할 수 있다. 이제 이 이력에 대한 정보가 무엇을 나타내는걸까?
이력 테이블(_AUD지만 내가 별도 설정해두어서 현재는 _HISTORY가 붙은 테이블)과 REVINFO 테이블에 대해 알아보자
이력 테이블(_AUD 테이블)
Envers를 적용한 엔티티마다 생성되는 이력 테이블은 아래와 같은 구조를 갖는다.
| 컬럼명 | 설명 |
| 엔티티 PK (ID) | 엔티티의 기본키(PK). 복합키라면 여러 컬럼이 될 수 있다. |
| 엔티티 필드(CONTENT, TITLE) | 엔티티의 이력 관리 대상 필드 값 |
| REV | 해당 변경 이력이 속한 리비전 번호. REVINFO 테이블의 REV와 FK로 연결된다. |
| REVTYPE | 변경 유형 - 0 : 추가(INSERT) - 1 : 수정(UPDATE) - 2 : 삭제(DELETE) |
REVINFO 테이블
REVINFO 테이블은 리비전(변경 이력의 묶음)을 관리한다.
| 컬럼명 | 설명 |
| REV | 리비전 번호(순차 증가 PK) |
| REVSTMP | 리비전 생성 시각(Unix timestamp, ms) |
REVINFO는 @RevisionEntity를 사용해 커스텀할 수 있다.
이 정보를 바탕으로 이미지를 보면 각 변경 타입(REVTYPE)에 맞게 잘 저장된걸 확인할 수 있다. 이렇게 설정만 해두면 이렇게 편하게 자동으로 이력을 관리해준다.
REVINFO 커스텀
지금도 충분히 편할 수 있지만 뭔가 아쉽다. 예를 들어, 변경 요청을 한 IP를 저장하고 싶을 수도 있다.
이럴 때는@RevisionEntity와 RevisionListener를 사용해서 원하는대로 Revinfo(리비전 엔티티)를 커스텀할 수 있다.
변경 이력에 변경 요청을 한 IP를 자동으로 저장할 수 있도록 해보자
CustomRevisionEntity
DefaultRevisionEntity를 상속 받아 IP 컬럼을 추가할 커스텀 엔티티를 작성한다.
/**
* Envers에서 사용하는 커스텀 리비전 엔티티.
* 기본 리비전 엔티티에 IP 주소 필드를 추가한다.
*/
@Getter
@Setter
@Entity
@Table(name = "revinfo") // Envers에서 사용하는 기본 테이블 이름
@RevisionEntity(CustomRevisionListener.class) // 리스너 연결
public class CustomRevisionEntity extends DefaultRevisionEntity {
private String ipAddress;
}
CustomRevisionListener
Revision이 생성될 때마다 현재 요청의 IP 주소를 커스텀 리비전 엔티티에 세팅한다.
public class CustomRevisionListener implements RevisionListener {
/**
* Envers에서 새로운 리비전이 생성될 때 호출되는 메소드.
* 현재 요청의 IP 주소를 CustomRevisionEntity에 설정한다.
*
* @param revisionEntity 새로 생성된 리비전 엔티티
*/
@Override
public void newRevision(Object revisionEntity) {
CustomRevisionEntity rev = (CustomRevisionEntity) revisionEntity;
String ip = "unknown";
// 현재 요청의 HttpServletRequest에서 IP 추출
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
HttpServletRequest request = attrs.getRequest();
ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty()) {
ip = request.getRemoteAddr();
}
}
rev.setIpAddress(ip);
}
}

REVINFO에 IP_ADDRESS가 추가된 것을 확인할 수 있다. 이제 우리는 매번 직접 IP 주소를 저장할 필요가 없어졌다. envers가 알아서 다 해주니까!
마무리
Envers는 이번에 우리 팀에서 도입하게 되면서 처음 알게 되었다.
Envers는 설정이 간단하지만, 실제로 써보면 “이렇게까지 자동화가 되나?” 싶을 정도로 강력하다.
사이드 프로젝트든, 실무든 데이터 이력 관리가 필요하다면 한 번쯤 꼭 도입해보길 추천한다.
다만, 이력 테이블도 결국 DB 리소스를 잡아먹기 때문에 꼭 필요한 엔티티/필드에만 @Audited를 적용하자. 그리고 DB 마이그레이션 시 REVINFO 및 _AUD 테이블 구조도 함께 변경해줘야 할 수 있다.
참조
https://www.baeldung.com/java-hibernate-envers-extending-revision-custom-fields
https://docs.spring.io/spring-data/envers/docs/current/reference/html/
Spring Data Envers - Reference Documentation
Example 10. Repository definitions using domain classes with annotations interface PersonRepository extends Repository { … } @Entity class Person { … } interface UserRepository extends Repository { … } @Document class User { … } PersonRepository re
docs.spring.io
'Spring' 카테고리의 다른 글
| Spring - 딸깍으로 쉽게 Request 로그 남기기(CommonsRequestLoggingFilter) (0) | 2025.07.16 |
|---|---|
| Spring Kafka - 여러 타입의 메시지를 하나의 Topic에 발행하고 수신하는 방법 (MessageConverter) (0) | 2025.04.15 |
| Spring Kafka - Spring Boot에서 Kafka 사용하기 (2) | 2025.04.09 |
| Spring - Spring Boot 3.x에서 View 렌더링되지 않는 원인과 해결 방법 (0) | 2025.01.16 |
| Spring Boot - Repository 단위 테스트하기(JPA, Querydsl, Mybatis) (1) | 2024.12.31 |