📡 백엔드/🌱 Spring Boot

[Spring] spring-data-envers 를 이용한 엔티티 변경 이력 관리

gengminy 2024. 3. 10. 20:16

들어가며

시스템 운영 단계에서 중요 데이터의 변경 이력을 저장하고 관리해야 하는 경우가 있다.

 

보통 history 테이블을 따로 만들어 이것들을 관리하곤 하는데

JPA 를 사용중이라면 spring-data-envers 를 통해 이를 편리하게 설정하고 관리할 수 있다.

 

spring-data-envers 는 hibernate-envers 의 wrapping 프로젝트로

envers 를 편리하게 사용할 수 있는 기능(RevisionRepository, 메타데이터 조회)을 제공한다.

 

1. Dependency (gradle)

implementation("org.springframework.data:spring-data-envers")

2. Auditing 활성화

감사를 진행할 엔티티에 @Audited 를 추가한다

@Getter
@Entity
@Audited
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String email;
}

 

전체 테이블에 대해서 이력을 남기고 싶다면 클래스에 @Audited 를 붙이고,

정해진 필드만 관리하고 싶다면 각 필드에 대해 @Audited 어노테이션을 붙이면 된다.

또 전체 필드 중 제외하고 싶은 필드가 있다면 @NotAudited 를 붙이면 감사 대상에서 제외된다.

 

@Audited
private String name;

@NotAudited
private String email;

 

이력 조회 대상에 대해 변경되었는지 여부를 flag 를 통해 관리할 수도 있다.

이 flag 를 조회해서 해당 버전에서 어떤 엔티티가 바뀌었는지 boolean 값으로 관리할 수 있다.

 

@Audited(withModifiedFlag = true, modifiedColumnName = "nameChanged")
private String name;

 

 

연관관계 대상 테이블에 대한 이력 관리 전략을 바꿀 수도 있다.

원래는 연관관계 테이블도 반드시 @Audited 를 통해 이력 관리 대상으로 추가해야 한다.

 

이를 @NotAudited 로 아예 제외시키거나

targetAuditMode 를 NOT_AUDITED 로 설정하여 fk 값만 저장하도록 할 수도 있다.

 

// FK 만 이력 조회 대상으로 한다
@Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
@ManyToOne(fetch=FetchType.LAZY)
private Department department;

// 이력 관리 대상에서 제외한다
@NotAudited
private Department department;

 

3. RevInfo 테이블

hibernate.ddl-auto 옵션이 create 또는 update 일 때

envers 는 자동으로 REVINFO 테이블과 ***_AUD 테이블을 생성한다

 

 

create table REVINFO (
    REV integer not null auto_increment, 
    REVTSTMP bigint, 
    primary key (REV)
) engine=InnoDB

create table User_AUD (
    REV integer not null, 
    REVTYPE tinyint, 
    nameChanged bit, 
    id bigint not null, 
    name varchar(255), 
    primary key (REV, id)
) engine=InnoDB

alter table User_AUD 
    add constraint FKilft2rdosb65jocpcoan7xnjq 
        foreign key (REV) references REVINFO (REV)

REVINFO 는 envers 의 버전 관리 정보로 버전 번호인 REV, 그리고 수정 시간을 REVSTMP 으로 기록한다.

 

이력 관리에 대한 테이블은 ****_AUD 로 생성되는데

REVINFO 에 대한 FK 로 REV 를 가지고 있으며 원래 엔티티의 PK 와 복합키 쌍으로 관리된다.

 

REVTYPE 은 수정 타입으로 다음과 같이 관리된다.

 

public enum RevisionType {
    ADD((byte)0), // 추가
    MOD((byte)1), // 수정
    DEL((byte)2); // 삭제

    ...
}

만약 네이밍 방식을 바꾸고 싶거나 REVINFO 엔티티를 변경하고 싶다면 커스텀이 가능하다.

 

envers 를 사용한다면 REVINFO 엔티티의 커스텀은 거의 필수적인데

자세히 보면 REVINFO 엔티티의 PK 인 REV 의 타입은 Integer 이기 때문이다.

 

이력 조회에 경우 실제 운영단계에서는 잦은 추가가 발생하기 때문에 금방 INT_MAX 에 도달하게 되고,

더이상 이력을 생성할 수 없는 이슈가 발생할 수 있다.

 

@Getter
@Entity
@RevisionEntity
@Table(name = "RevisionHistory")
public class CustomRevisionEntity {
    @Id
    @Column
    @RevisionNumber
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long revisionId;

    @Column @RevisionTimestamp
    private Long updatedAt;

    public LocalDateTime getUpdatedAt() {
        return LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), ZoneId.systemDefault());
    }
}

 

REVINFO 를 @RevisionEntity 를 통해 재정의할 수 있다.

REV 는 @RevisionNumber , REVTSTMP 는 @RevisionTimestamp 로 재정의한다.

 

# custom revision entity 로 변경된 쿼리

create table RevisionHistory (
    revisionId bigint not null auto_increment, 
    updatedAt bigint, primary key (revisionId)
) engine=InnoDB

create table User_AUD (
    REVTYPE tinyint, 
    REV bigint not null, 
    id bigint not null, 
    email varchar(255), 
    name varchar(255), 
primary key (REV, id)) engine=InnoDB

alter table User_AUD 
    add constraint FK7gw0sgfolgrr3a6xju5tvqxxp 
        foreign key (REV) references RevisionHistory (revisionId)

 

다만 생성 쿼리를 보면 알 수 있다시피 User_AUD 쪽의 정보는 변경되지 않았는데

이는 application.yml 의 추가 설정을 통해 변경할 수 있다.

 

spring:
    jpa:
    properties:
      org.hibernate.envers:
        audit_table_suffix: History # 이력 테이블 이름의 _AUD 를 History 로 변경
        revision_field_name: revisionId # 이력 테이블 REV 컬럼명
        revision_type_field_name: revisionType # 이력 테이블 REVTYPE 컬럼명
        store_data_at_delete: true # 삭제시 저장 여부, 기본은 false 이다

revision_field_name 을 변경하면 REV ⇒ 해당 이름의 컬럼명으로 변경된다

여러 유용한 추가 설정이 있는데 검색 또는 envers 패키지를 찾아보면 좋을 듯 싶다.

 

https://docs.jboss.org/hibernate/envers/3.6/reference/en-US/html/configuration.html

 

Chapter 3. Configuration

Important The following configuration options have been added recently and should be regarded as experimental: org.hibernate.envers.audit_strategy org.hibernate.envers.audit_strategy_validity_end_rev_field_name org.hibernate.envers.audit_strategy_validity_

docs.jboss.org

 

 

# yml 설정으로 변경된 쿼리

create table RevisionHistory (
    revisionId bigint not null auto_increment, 
    updatedAt bigint, primary key (revisionId)
) engine=InnoDB

create table UserHistory (
    revisionType tinyint, 
    id bigint not null, 
    revisionId bigint not null, 
    email varchar(255), 
    name varchar(255), 
    primary key (id, revisionId)
) engine=InnoDB

alter table UserHistory 
    add constraint FKtqxax72rt5cgosfu35jtcghu7 
        foreign key (revisionId) references RevisionHistory (revisionId)

이제 컬럼명이 이쁘게 수정되었다.

 

여기서 마지막으로 소개할 기능은 RevisionListener 이다.

REVINFO 생성 단계에서 추가적인 정보를 부여하고 싶을 때 사용하는 기능이다.

 

예를 들면 중요한 데이터라 누가 해당 버전에서 데이터를 수정했는지 관리할 필요가 있다 하자.

수정한 사람을 기록하기 위해 RevisionListener 와 interceptor 를 적절하게 활용할 수 있다.

 

public class CustomRevisionEntityListener implements RevisionListener {
    @Override
    public void newRevision(Object o) {
        final Admin currentAdmin = AuditThreadContext.getCurrentAdmin();
        CustomRevisionEntity customRevisionEntity = (CustomRevisionEntity) o;
        if (currentAdmin != null) {
            customRevisionEntity.setAdminId(currentAdmin.getAdminId());
        }
    }
}

 

인터셉터에서 AuditThreadContext 라는 임의의 ThreadLocal 에 어드민 정보를 저장하고,

Revision 엔티티를 생성할 때 이를 꺼내와서 저장하는 방식으로 수정한 어드민 정보를 관리한다.

 

이를 통해 데이터를 수정한 사람이 누구인지 History 생성 단계에서 지정해줄 수 있다.

 

@Getter
@Entity
@Table(name = "RevisionHistory")
@RevisionEntity(CustomRevisionEntityListener.class)
public class CustomRevisionEntity {
    @Id
    @Column
    @RevisionNumber
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long revisionId;

    @Column @RevisionTimestamp
    private Long updatedAt;

    @Column private Long adminId;

    public void setAdminId(Long adminId) {
        this.adminId = adminId;
    }

    public LocalDateTime getUpdatedAt() {
        return LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), ZoneId.systemDefault());
    }
}

@RevisionEntity 에 새로운 컬럼을 지정한 후, 리스너 클래스를 등록하는 방식으로 사용한다.

 

4. RevisionRepository

@NoRepositoryBean
public interface RevisionRepository<T, ID, N extends Number & Comparable<N>> extends Repository<T, ID> {
    Optional<Revision<N, T>> findLastChangeRevision(ID id);

    Revisions<N, T> findRevisions(ID id);

    Page<Revision<N, T>> findRevisions(ID id, Pageable pageable);

    Optional<Revision<N, T>> findRevision(ID id, N revisionNumber);
}

 

spring-data-envers 는 기본적인 RevisionRepository 인터페이스를 제공하며 이를 상속받아 사용할 수 있다.

타입 인자는 <엔티티 클래스, 엔티티 ID 타입, REV 타입> 이다.

 

public interface UserRepository
    extends CrudRepository<User, Long>, RevisionRepository<User, Long, Long> {}

 

  • 최신 버전 단건 조회
public User findLatestUserRevisionById(Long id) {
    Revision<Long, User> latestRevision 
        = userRepository.findRevisions(id).getLatestRevision();

    User user = latestRevision.getEntity(); // 감사 엔티티
    Optional<Long> revisionNumber = latestRevision.getRevisionNumber(); // 버전번호
    Optional<Instant> revisionInstant = latestRevision.getRevisionInstant(); // 수정일시

    return user;
}
  • Jpa Pageable 을 이용한 페이징 조회
public Page<User> findUserRevisionPage(Long id, Pageable pageable) {
    Page<Revision<Long, User>> revisionPage = userRepository.findRevisions(id, pageable);

    return revisionPage.map(Revision::getEntity);
}

 

5. 동적 쿼리

RevisionRepository 로 해결할 수 없는 복잡한 동적 쿼리는 AuditReader 를 사용하여 구현할 수 있다.

@Configuration
@RequiredArgsConstructor
public class AuditReaderConfig {
    private final EntityManagerFactory entityManagerFactory;

    @Bean
    public AuditReader auditReader() {
        return AuditReaderFactory.get(entityManagerFactory.createEntityManager());
    }
}
public interface UserHistoryRepositoryCustom {
    Optional<User> findLatestById(Long userId);
}
@RequiredArgsConstructor
public class UserHistoryRepositoryCustomImpl implements UserHistoryRepositoryCustom {
    private final AuditReader auditReader;

    public Optional<User> findLatestById(Long userId) {
        List<?> results =
                    auditReader
                            .createQuery()
                            .forRevisionsOfEntity(User.class, true, true)
                            .add(AuditEntity.id().eq(userId))
                            .addOrder(AuditEntity.revisionNumber().desc())
                            .setMaxResults(1)
                            .getResultList();
        return (results.isEmpty()) ? Optional.empty() : Optional.of((User) results.get(0));
    }
}

AuditReader 의 forRevisionsOfEntity 의 파라미터에 따라 여러 옵션이 오버로딩 되어있는데 필요에 따라 사용할 수 있다.

위의 예시에서는 추가적인 Revision 메타데이터 없이 유저 엔티티만 가져오는 옵션을 주었다.

 

where 절 조건은 .add() 체이닝으로 추가할 수 있고

and 는 AuditConjunction or 은 AuditDisjunction 이라는 타입을 제공해서 추가할 수 있다.

 

private AuditConjunction allMatch(AuditCriterion... criterion) {
    final AuditConjunction auditConjunction = new AuditConjunction();
    Arrays.stream(criterion).forEach(auditConjunction::add);
    return auditConjunction;
}

private AuditDisjunction anyMatch(AuditCriterion... criterion) {
    final AuditDisjunction auditDisjunction = new AuditDisjunction();
    Arrays.stream(criterion).forEach(auditDisjunction::add);
    return auditDisjunction;
}

 

AuditReader 를 사용해서 1개 결과를 조회할 때 List 로 먼저 가져온 후에 처리하는 것이 좋다

getSingleResult() 는 결과가 없을 경우 NoResultException 이 발생하기 때문이다.

 

패치된 결과는 Object[] 형태인데 프로젝션 갯수가 늘어날 때 마다 원소의 개수가 증가한다.

프로젝트에서는 이를 정해진 타입으로 관리하고 싶어 추가 필드를 만들어서 관리 중이다.

 

현재 프로젝트에서는 @Audited 에 수정 플래그를 사용 중인데

수정 플래그도 같이 가져오려면 forRevisionsOfEntityWithChanges 을 사용하면 된다.

 

이를 Object[] 의 3번 인덱스 원소로 수정된 엔티티 컬럼명을 Set 으로 제공한다.

(modifiedFlag 모드를 사용하고 있지 않다면 오류가 발생한다)

 

@Getter
@AllArgsConstructor
public class AuditReaderResult<T> {
    private final T entity;
    private final CustomRevisionEntity revisionEntity;
    private final RevisionType revisionType;
    private final Set<String> revisionFields;

    @SuppressWarnings("unchecked")
    public static <T> AuditReaderResult<T> from(Object[] result) {
        return new AuditReaderResult<>(
                (T) result[0],
                (CustomRevisionEntity) result[1],
                (RevisionType) result[2],
                (Set<String>) result[3]);
    }
}
@RequiredArgsConstructor
public class UserHistoryRepositoryCustomImpl implements UserHistoryRepositoryCustom {
    private final AuditReader auditReader;

    public Optional<User> findLatestById(Long userId) {
        final AuditCriterion predicate = this.allMatch(userIdEq(userId));
        final Optional<AuditReaderResult<User>> user = this.queryOne(predicate);

        return user.map(AuditReaderResult::getEntity);
    }

    private AuditCriterion userIdEq(Long userId) {
        return AuditEntity.property("id").eq(userId);
    }

    private <T> Optional<AuditReaderResult<T>> queryOne(AuditCriterion... auditCriterion) {
        List<?> results;
        synchronized (this) {
            results =
                    auditReader
                            .createQuery()
                            .forRevisionsOfEntityWithChanges(User.class, true)
                            .add(this.allMatch(auditCriterion))
                            .addOrder(AuditEntity.revisionNumber().desc())
                            .setMaxResults(1)
                            .getResultList();
        }
        final List<AuditReaderResult<T>> histories = auditReaderResultOf(results);
        return (histories.isEmpty()) ? Optional.empty() : Optional.of(histories.get(0));
    }

    private <T> List<AuditReaderResult<T>> auditReaderResultOf(List<?> results) {
        return results.stream()
                .map(Object[].class::cast)
                .map(AuditReaderResult::<T>from)
                .collect(Collectors.toList());
    }

    private AuditConjunction allMatch(AuditCriterion... criterion) {
        final AuditConjunction auditConjunction = new AuditConjunction();
        Arrays.stream(criterion).forEach(auditConjunction::add);
        return auditConjunction;
    }
}

 

Pageable 을 이용한 페이지네이션도 setFirstResult()setMaxResults() 를 적절하게 섞어 구현할 수 있을 것이다.

 

@SuppressWarnings("unchecked")
private <T> Page<AuditReaderResult<T>> queryAll(
        Pageable pageable, AuditCriterion... auditCriterion) {
    final List<Long> coveringIndexes = (List<Long>)
            auditReader
                    .createQuery()
                    .forRevisionsOfEntity(User.class, false, true)
                    .add(this.allMatch(auditCriterion))
                    .addProjection(AuditEntity.revisionNumber())
                    .getResultList();

    final List<?> results =
                auditReader
                        .createQuery()
                        .forRevisionsOfEntityWithChanges(User.class, true)
                        .add(revisionIdIn(coveringIndexes))
                        .addOrder(AuditEntity.revisionNumber().desc())
                        .setFirstResult((int) pageable.getOffset())
                        .setMaxResults(pageable.getPageSize())
                        .getResultList();
    final List<AuditReaderResult<T>> histories = auditReaderResultOf(results);
    return PageableExecutionUtils.getPage(histories, pageable, coveringIndexes::size);
}

private AuditCriterion revisionIdIn(List<Long> ids) {
    return AuditEntity.revisionNumber().in(ids);
}

 

마치며

지금까지 spring-data-envers 를 이용해서 이력 조회를 간편하게 설정하는 방법을 알아보았다.

 

envers 를 이용하면 History 테이블을 직접 구현하지 않고도 이력 관리가 가능하기 때문에

운영 단계에서 생산성을 높일 수 있을 것이다.

 

또 생각보다 hibernate 에 많은 기능이 구현되어있고 지원해주니 잘 찾아보면 수고를 덜 수 있을 것 같다.