Spring Data Envers로 엔티티 변경 이력 관리하기

honey·2024년 10월 5일

왜 엔티티 이력이 필요할까?

운영 환경에서는 데이터가 어떻게 변경되었는지 추적해야 하는 상황이 종종 발생합니다.
예를 들어

  • 특정 값이 언제 변경되었는지 확인해야 할 때
  • 수정/삭제된 데이터의 내용을 복원해야 할 때
  • 변경 사항을 기반으로 감사 로그를 작성해야 할 때

JPA 기반의 애플리케이션에서 이러한 요구사항을 만족시키기 위해 Spring Data Envers를 도입할 수 있습니다.

Spring Data Envers

Spring Data Envers 는 Hibernate Envers와 통합된 모듈로, JPA 엔티티의 변경 이력을 자동으로 기록합니다.
수정, 삭제뿐 아니라 생성까지 모든 변경을 추적하며, 리비전 번호를 기준으로 과거 데이터를 조회할 수 있습니다.

implementation 'org.springframework.data:spring-data-envers'

Envers가 관리하는 기본 테이블 구조

  1. revinfo
    히스토리 리비전(Revision)에 대한 메타 정보를 저장하는 테이블
CREATE TABLE revinfo (
   rev BIGINT AUTO_INCREMENT PRIMARY KEY,
   revtstmp BIGINT
);
  1. entity_aud 테이블 (예: member_aud)
    변경된 엔티티 데이터를 저장하는 테이블
    기본 규칙: 원본 테이블명 + _aud
    revtype: 0 = 추가, 1 = 수정, 2 = 삭제
CREATE TABLE member_aud (
    id VARCHAR(36) NOT NULL,
    rev BIGINT NOT NULL,
    revtype TINYINT NOT NULL,
    password VARCHAR(255),
    name VARCHAR(100),
    age INT,
    phone_number VARCHAR(20),
    PRIMARY KEY (id, rev),
    FOREIGN KEY (rev) REFERENCES revinfo(rev)
);

기본 사용법

@Audited를 필드나 클래스에 붙여 변경 이력을 추적할 수 있습니다.

@Entity
@Audited
public class Member {
    @Id
    private String id;
    private String password;
    private String name;
    private String phoneNumber;
    private int age;
}

엔티티 생성

@Rollback(value = false)
@Test
public void insertMember() {
    Member member = Member.builder()
            .id("user1")
            .password("pw1")
            .name("foo")
            .phoneNumber("010-1234-1234")
            .age(20)
            .role(Role.USER)
            .build();

    memberRepository.save(member);
}

revinfo 테이블

  • 새로운 리비전 번호와 타임스탬프가 기록됨

member_aud 테이블

  • revtype = 0 (생성)

엔티티 수정

  • 직전에 저장한 Member를 조회한 후 수정.
@Test
@Transactional
@Rollback(false)
public void updateMember() {
    Member member = memberRepository.findById("user1").get();
    member.setName("bar");
}

revinfo 테이블

member_aud 테이블

  • 변경 전 값과 변경 후 값 모두 기록
  • revtype = 1 (수정)

엔티티 삭제

삭제 직전 히스토리를 남기려면 application.yml에 설정을 해줘야합니다. 이 설정이 없으면 히스토리의 모든 컬럼에 null값이 들어갑니다.

# application.yml
spring.jpa.properties.org.hibernate.envers.store_data_at_delete: true
@Test
@Transactional
@Rollback(false)
public void deleteMember() {
    memberRepository.deleteById("user1");
}

revinfo

member_aud

  • 삭제 직전의 히스토리 저장
  • revtype = 2 (삭제)

고급 설정

@Audited를 붙이면 기본적으로 생성, 수정, 삭제 이력이 추적됩니다.
하지만 실무에서는 연관 관계 추적, 감사 정보 커스터마이징, 필드 변경 여부 등 더 세밀한 제어가 필요할 수 있습니다.

Spring Data Envers는 이런 요구를 충족할 수 있는 다양한 옵션을 제공합니다.
아래는 자주 사용하는 확장 기능들 입니다.

targetAuditMode = RelationTargetAuditMode.NOT_AUDITED

  • 연관된 엔티티가 감사되지 않도록 지정하는 옵션
  • Member의 Team이 바뀌는 것은 audit하지만 Team의 필드가 바뀌는 것은 추적하고 싶지 않다.
@Entity
public class Member {

    @Id
    private String id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    @Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
    private Team team;
		
		// 생략
}

revinfo 테이블 커스텀

  • @RevisionEntity를 사용해서 필드명을 바꾸거나, 새로운 필드를 추가할 수도 있습니다.
  • 커스텀한 필드에 맞게 DDL을 수정해주면 됩니다.
@Getter
@Table(name = "revinfo")
@Entity
@RevisionEntity
public class Revinfo {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @RevisionNumber
    private Long rev;

    @RevisionTimestamp
    private Long revtstmp;

    @Setter
    private String updatedBy;

	// timestamp -> LocalDateTime
    public LocalDateTime getUpdatedAt() {
        return Instant.ofEpochMilli(revtstmp)
                .atZone(ZoneId.systemDefault())
                .toLocalDateTime();
    }
}
  • 수정된 DDL
CREATE TABLE `revinfo` (
    `rev` BIGINT AUTO_INCREMENT NOT NULL,
    `revtstmp` BIGINT NULL,
    `updatedBy` VARCHAR(36) NULL,  -- updatedBy 필드 추가
    PRIMARY KEY (`rev`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

RevisionListener

  • 엔티티에 쓰기 작업이 발생할 때마다 특정 동작을 수행하는 콜백 인터페이스
  • Revinfo 테이블에 수정자같은 메타데이터를 기록하거나 로그를 남길 때 주로 사용함.
public class CustomRevisionListener implements RevisionListener {

	// 인증 객체의 수정자 정보(username, 권한)를 revinfo에 저장
    @Override
    public void newRevision(Object o) {
        Revinfo revinfo = (Revinfo) o;

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (Objects.nonNull(authentication) && authentication.getPrincipal() instanceof CustomUserDetails) {
            CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
            revinfo.setModifiedBy(customUserDetails.getUsername());
            revinfo.setAuthorities(
                    customUserDetails.getAuthorities().stream()
                            .map(GrantedAuthority::getAuthority)
                            .collect(Collectors.joining(","))
            );
        }
    }
}
  • @RevisionEntity에 커스텀 리스너 지정
@Entity
@RevisionEntity(CustomRevisionListener.class) // 리스너 지정
public class Revision {
    //생략
}

withModifiedFlag = true 필드 변경 여부 관리

  • @Audited(withModifiedFlag = true)
  • 엔티티의 필드가 변경 여부를 나타내는 필드 추가.
  • 엔티티 필드가 변경될 때 히스토리를 남기면서 어떤 필드가 바뀌었는지 확인할 수 있습니다.
@Entity
public class Member {
    @Id
    private String id;

    @Audited(withModifiedFlag = true)
    private String password;

    @Audited(withModifiedFlag = true)
    private String name;
    
    @Audited(withModifiedFlag = true)
    private Integer age
}
  • 수정된 DDL
CREATE TABLE `member_aud` (
    `id` VARCHAR(36) NOT NULL,
    `rev` BIGINT NOT NULL,
    `revtype` TINYINT NOT NULL,

    `password` VARCHAR(255),
    `password_mod` TINYINT(1), -- mod 컬럼 추가
    `name` VARCHAR(100),
    `name_mod` TINYINT(1), -- mod 컬럼 추가
    `age` INT,
    `age_mod` TINYINT(1), -- mod 컬럼 추가

    PRIMARY KEY (`id`, `rev`),
    CONSTRAINT `fk_member_aud_revinfo` FOREIGN KEY (`rev`) REFERENCES `revinfo`(`rev`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • member_aud 테이블 조회

엔티티 히스토리 조회하기

Spring Data Envers는 엔티티의 히스토리를 조회할 수 있는 다양한 방법을 제공합니다.
복잡한 SQL 없이 히스토리 테이블을 탐색할 수 있도록 AuditReader, RevisionRepository 같은 도구들이 준비되어 있습니다.

조회 방식은 크게 세 가지.

• 직접 쿼리: aud 테이블을 native SQL이나 MyBatis로 직접 조회
• AuditReader: 조건 검색, 페이징, 정렬 등 유연한 조회 가능
• RevisionRepository: Spring Data 방식으로 간단하게 조회

AuditReader

Spring Data Envers는 AuditReader를 통해 엔티티 히스토리를 조건에 맞게 조회할 수 있도록 지원합니다.
정렬, 페이징, 필터링, 수정 필드 여부 등 다양한 조건을 조합해 조회할 수 있습니다.

AuditReader Bean 등록

@Configuration
public class AuditReaderConfig {
    @Bean
    public AuditReader auditReader(EntityManagerFactory entityManagerFactory) {
        return AuditReaderFactory.get(entityManagerFactory.createEntityManager());
    }
}

전체 히스토리 조회

@Repository
@RequiredArgsConstructor
public class MemberRevisionRepository {
    private final AuditReader auditReader;

    public List<Member> findAll() {
        return auditReader.createQuery()
                .forRevisionsOfEntity(Member.class, true, true) // true, true -> 삭제 엔티티 포함 여부, 중복 제거 여부
                .getResultList();
    }
}

조건 검색 (id, name 등)

public List<Member> findAllById(String id) {
    return auditReader.createQuery()
            .forRevisionsOfEntity(Member.class, true, true)
            .add(AuditEntity.id().eq(id))
            .getResultList();
}

public List<Member> findAllByName(String name) {
    return auditReader.createQuery()
            .forRevisionsOfEntity(Member.class, true, true)
            .add(AuditEntity.property("name").eq(name))
            .getResultList();
}

조건 + 페이징 + 정렬 + RevisionType

public List<Member> findAllByCondition(String id) {
    return auditReader.createQuery()
            .forRevisionsOfEntity(Member.class, true, true)
            .add(AuditEntity.id().eq(id))
            .add(AuditEntity.revisionType().eq(RevisionType.MOD)) // 수정 이력만
            .setFirstResult(0) // 페이징
            .setMaxResults(2)
            .addOrder(AuditEntity.id().asc()) // 정렬
            .getResultList();
}

필드 변경 여부 조건 조회

public List<Member> findByIdHasChanges(String id) {
    return auditReader.createQuery()
            .forRevisionsOfEntity(Member.class, true, true)
            .add(AuditEntity.id().eq(id))
            .add(AuditEntity.property("name").hasChanged())     // name이 수정된 히스토리
            .add(AuditEntity.property("age").hasNotChanged())   // age는 그대로인 히스토리
            .getResultList();
}

메타데이터 함께 조회

public List<Object[]> findByIdWithMetadata(String id) {
    return auditReader.createQuery()
            .forRevisionsOfEntity(Member.class, false, true) // 메타데이터 포함
            .add(AuditEntity.id().eq(id))
            .getResultList();
}

반환 타입 Object[]
•[0]: Member
•[1]: Revinfo
•[2]: RevisionType

수정된 필드 정보 포함 조회

public List<RevisionWithMetadata<Member>> findByIdWithMetadata(String id) {
    List<Object[]> result = auditReader.createQuery()
            .forRevisionsOfEntityWithChanges(Member.class, true)
            .add(AuditEntity.id().eq(id))
            .getResultList();

    return result.stream()
            .map(RevisionWithMetadata::<Member>from)
            .toList();
}

커스텀 DTO

@Getter
public class RevisionWithMetadata<T> {
    private final T entity;
    private final Revinfo revinfo;
    private final RevisionType revisionType;
    private final Set<String> modifiedFields;

    @Builder
    public RevisionWithMetadata(T entity, Revinfo revinfo, RevisionType revisionType, Set<String> modifiedFields) {
        this.entity = entity;
        this.revinfo = revinfo;
        this.revisionType = revisionType;
        this.modifiedFields = modifiedFields;
    }

    public static <T> RevisionWithMetadata<T> from(Object[] objects) {
        return RevisionWithMetadata.<T>builder()
                .entity((T) objects[0])
                .revinfo((Revinfo) objects[1])
                .revisionType((RevisionType) objects[2])
                .modifiedFields((Set<String>) objects[3])
                .build();
    }
}

AuditReader는 조건 기반 조회에 유리하며, 메타데이터와 수정 필드까지 확인할 수 있습니다.
복잡한 조건 조회가 필요한 경우 유용하고, 단순한 히스토리 조회는 RevisionRepository로도 충분합니다.


RevisionRepository

RevisionRepository는 Spring Data Envers에서 제공하는 인터페이스로,
엔티티 히스토리를 간단하게 조회할 수 있는 메서드들을 기본으로 제공합니다.

복잡한 조건 검색은 어렵지만, 최근 이력 조회나 페이징 기반 히스토리 조회에 적합합니다.

인터페이스

@NoRepositoryBean
public interface RevisionRepository<T, ID, N extends Number & Comparable<N>> extends Repository<T, ID> {
    Optional<Revision<N, T>> findLastChangeRevision(ID id);       // 최근 리비전 1건

    Revisions<N, T> findRevisions(ID id);                          // 전체 리비전 목록

    Page<Revision<N, T>> findRevisions(ID id, Pageable pageable); // 페이징 조회

    Optional<Revision<N, T>> findRevision(ID id, N revisionNumber); // 리비전 번호로 조회
}

설정 - Envers 레포지토리 사용 등록

@EnableJpaRepositories(repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean.class)
@SpringBootApplication
public class MemberHistoryManagementApplication {
    public static void main(String[] args) {
        SpringApplication.run(MemberHistoryManagementApplication.class, args);
    }
}

JPA 레포지토리에 추가 RevisionRepository<엔티티 타입, 엔티티 Id 타입, Revinfo Id 타입>

public interface MemberRepository extends JpaRepository<Member, String>,
                                          RevisionRepository<Member, String, Long> {
}

사용 예시

// 최근 리비전 1건
memberRepository.findLastChangeRevision("user1");

// 전체 리비전 리스트
memberRepository.findRevisions("user1");

// 페이징 처리
memberRepository.findRevisions("user1", PageRequest.of(0, 10));

// 특정 리비전 번호로 조회
memberRepository.findRevision("user1", 3L);

정리

Spring Data Envers는 설정만 잘 해두면 별다른 비즈니스 로직 수정 없이도 변경 이력을 자동으로 관리해줍니다. 운영 중 발생하는 데이터 변경 추적 요구사항을 안정적으로 해결할 수 있으니, 감사 로깅이나 변경 추적이 필요한 프로젝트에서 적극적으로 고려해볼 만한 기능입니다.

Github

사용자 별 히스토리를 조회할 수 있는 간단한 관리자 기능 예제

https://github.com/hingjae/member-history-management

0개의 댓글