본문 바로가기

devlog/JAVA

Hibernate ORM 이력관리 모듈 Envers 사용 가이드

개요

Envers 모듈은 Hibernate 및 JPA와 함께 작동하는 핵심 Hibernate 모델입니다. 사실 독립형이든, WildFly이든 JBoss AS, Spring, Grails 등이든 Hibernate가 작동하는 모든 곳에서 Envers를 사용할 수 있습니다.

Envers 모듈은 엔티티 클래스를위한 쉬운 감사 / 버전 관리 솔루션을 제공하는 것을 목표로합니다.

  • 하이버네이트 핵심 모듈
  • JPA 스펙에 정의된 모든 매핑 감사
  • 엔티티의 변경 이력을 자동 관리
  • 트랜잭션 단위의 통합 Revision 관리 (Snapshot)
    • REVINFO 테이블은 revision_id 와 Timestamp만 가지고 있고, 이력 테이블은 별도로 존재함. 한 트랜잭션 내에서 발생한 변경사항을 revision_id 1이라는 이력으로 여러 이력테이블에 각각 저장, revision_id 1인 항목의 해당 트랜잭션에서 변경된 모든 이력을 감사(audit)가능함

빠른 시작

  1. 프로젝트에 hibernate-envers 종속성 추가
  2. 이력관리가 필요한 엔티티 또는 프로퍼티에 @Audited 추가
  3. JpaRepository 상속한 인퍼테이스에 RevisionRepository<T, ID, N> 상속하여 사용
  4. RevisionRepository 사용을 위한 설정 추가
@EnableJpaRepositories(repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean.class) // 설정 추가
@SpringBootApplication
public class Application {

  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

예시

@Entity
public class Person {
    @Id
    @GeneratedValue
    private Integer id;

    @Audited
    private String name;

    @Audited
    private String surname;

    @Audited
    @ManyToOne
    private Address address;
}

Enver 엔티티 맵핑

  • hibernate.hbm2ddl.auto 옵션이 create, create-drop, update 면 audit 테이블이 자동생성됨
@Audited
@Entity(name = "Customer")
public static class Customer {

    @Id
    private Long id;

    private String firstName;

    private String lastName;

    @Temporal( TemporalType.TIMESTAMP )
    @Column(name = "created_on")
    @CreationTimestamp
    private Date createdOn;

    //Getters, setters 생략

}

생성된 SQL

create table Customer (
    id bigint not null,
    created_on timestamp,
    firstName varchar(255),
    lastName varchar(255),
    primary key (id)
)

create table Customer_AUD (
    id bigint not null,
    REV integer not null,
    REVTYPE tinyint,
    created_on timestamp,
    firstName varchar(255),
    lastName varchar(255),
    primary key (id, REV)
)

create table REVINFO (
    REV integer generated by default as identity,
    REVTSTMP bigint,
    primary key (REV)
)

alter table Customer_AUD
   add constraint FK5ecvi1a0ykunrriib7j28vpdj
   foreign key (REV)
   references REVINFO

REVTYPE 이란?

RevisionType Enum

  • 0: (ADD) 행 추가
  • 1: (MOD) 행 수정
  • 2: (DEL) 행 삭제

Insert 해보기

  • 코드
Customer customer = new Customer();
customer.setId( 1L );
customer.setFirstName( "존" );
customer.setLastName( "찰스" );

entityManager.persist( customer );
  • 결과
insert
into
    Customer
    (created_on, firstName, lastName, id)
values
    (?, ?, ?, ?)

-- binding parameter [1] as [TIMESTAMP] - [THU Aug 06 17:21:32 EEST 2020]
-- binding parameter [2] as [VARCHAR]   - [존]
-- binding parameter [3] as [VARCHAR]   - [찰스]
-- binding parameter [4] as [BIGINT]    - [1]

insert
into
    REVINFO
    (REV, REVTSTMP)
values
    (?, ?)

-- binding parameter [1] as [BIGINT]    - [1]
-- binding parameter [2] as [BIGINT]    - [1500906092803]

insert
into
    Customer_AUD
    (REVTYPE, created_on, firstName, lastName, id, REV)
values
    (?, ?, ?, ?, ?, ?)

-- binding parameter [1] as [INTEGER]   - [0]
-- binding parameter [2] as [TIMESTAMP] - [THU Aug 06 17:21:32 EEST 2020]
-- binding parameter [3] as [VARCHAR]   - [존]
-- binding parameter [4] as [VARCHAR]   - [찰스]
-- binding parameter [5] as [BIGINT]    - [1]
-- binding parameter [6] as [INTEGER]   - [1]

Update 해보기

  • 코드
Customer customer = entityManager.find( Customer.class, 1L );
customer.setLastName( "존 Jr." );
  • 결과
update
    Customer
set
    created_on=?,
    firstName=?,
    lastName=?
where
    id=?

-- binding parameter [1] as [TIMESTAMP] - [2020-08-06 17:21:32.757]
-- binding parameter [2] as [VARCHAR]   - [존]
-- binding parameter [3] as [VARCHAR]   - [존 Jr.]
-- binding parameter [4] as [BIGINT]    - [1]

insert
into
    REVINFO
    (REV, REVTSTMP)
values
    (?, ?)

-- binding parameter [1] as [BIGINT]    - [2]
-- binding parameter [2] as [BIGINT]    - [1500906092853]

insert
into
    Customer_AUD
    (REVTYPE, created_on, firstName, lastName, id, REV)
values
    (?, ?, ?, ?, ?, ?)

-- binding parameter [1] as [INTEGER]   - [1]
-- binding parameter [2] as [TIMESTAMP] - [2020-08-06 17:21:32.757]
-- binding parameter [3] as [VARCHAR]   - [존]
-- binding parameter [4] as [VARCHAR]   - [존 Jr.]
-- binding parameter [5] as [BIGINT]    - [1]
-- binding parameter [6] as [INTEGER]   - [2]

Delete 해보기

  • 코드
Customer customer = entityManager.getReference( Customer.class, 1L );
entityManager.remove( customer );
  • 결과
delete
from
    Customer
where
    id = ?

-- binding parameter [1] as [BIGINT]    - [1]

insert
into
    REVINFO
    (REV, REVTSTMP)
values
    (?, ?)

-- binding parameter [1] as [BIGINT]    - [3]
-- binding parameter [2] as [BIGINT]    - [1500906092876]

insert
into
    Customer_AUD
    (REVTYPE, created_on, firstName, lastName, id, REV)
values
    (?, ?, ?, ?, ?, ?)

-- binding parameter [1] as [INTEGER]   - [2]
-- binding parameter [2] as [TIMESTAMP] - [null]
-- binding parameter [3] as [VARCHAR]   - [null]
-- binding parameter [4] as [VARCHAR]   - [null]
-- binding parameter [5] as [BIGINT]    - [1]
-- binding parameter [6] as [INTEGER]   - [3]

@OneToMany + @JoinColumn 이력관리

OneToMany나 JoinColumn으로 설정된 연관관계의 주인이 아닌 엔티티 컬렉션이 바뀔 때도@AuditMappedBy를 사용하여 이력을 관리할 수 있습니다.

RevisionRepository 인터페이스로 이력 조회하기

Spring data envers라는 스프링 데이터 JPA의 확장 모듈

이력관리 조회는 JpaRepository처럼 RevisionRepository 인터페이스를 상속하여 사용하면 됩니다.

@NoRepositoryBean
public interface RevisionRepository extends Repository<T, ID> {

    //최근 리비전 조회
    Revision<N, T> findLastChangeRevision(ID id);

    //id를 사용하여 해당 id의 모든 리비전 조회
    Revisions<N, T> findRevisions(ID id);

    //리비전을 페이징 처리하여 조회
    Page<Revision<N, T>> findRevisions(ID id, Pageable pageable);

    //특정 리비전 조회
    Revision<N, T> findRevision(ID id, N revisionNumber);
}

EnversRevisionRepository 사용하기 위한 설정

@EnableJpaRepositories(repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean.class)
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

CustomerRepository 만들기

public interface CustomerRepository extends JpaRepository<Customer, Long>, RevisionRepository<Customer, Long, Integer> {
}
  • 단순 조회
Revision<Long, IotManagementDevice> revision = repository.findRevision(iotManagementDevice.getId(), 1);

IotManagementDevice entity = revision.getEntity();   // 엔티티
Long revisionNumber = revision.getRevisionNumber();  // 리비전
DateTime dateTime = revision.getRevisionDate();      // 변경 날짜
  • 페이징, 정렬조회
Page<Revision<Long, IotManagementDevice>> result = repository.findRevisions(iotManagementDevice.getId(), new PageRequest(**0, 10**, **RevisionSort.desc()**));

result.**getTotalElements**();  // 전체 수
result.**getContent**();        // 내용

AuditReader 인터페이스로 이력 조회하기

Hibernate가 지원하는 native 이력조회 인터페이스

복잡한 조건을 포함하여 이력을 검색하거나 페이징이 필요한 경우 AuditReader를 사용하여 QueryDSL과 유사한 방식으로 조회할 수 있습니다.

  • 과거 특정시점의 Revision을 조회
  • 단건 조회
  • 리스트 조회
public List<IotManagementDevice> findRevisions(String id) {
    return auditReader.createQuery()
        .forRevisionsOfEntity(IotManagementDevice.class, true, false)
        .add(AuditEntity.id().eq(id))
        .getResultList();
}
  • 검색 조건 조회
public List<IotManagementDevice> findRevisionsWithWhere() {
    int pageSize = 10;
    AuditReader auditReader = AuditReaderFactory.get(getEntityManager());
    return auditReader.createQuery()
        .forRevisionsOfEntity(IotManagementDevice.class, true, true)
        // 설치일자 검색
        .add(AuditEntity.property("instl_de").eq("2020-06-01"))
        // 수정 항목만 검색
        .add(AuditEntity.revisionType().eq(RevisionType.MOD))
        // 정렬 조건
        .addOrder(AuditEntity.revisionNumber().desc())
        .getResultList();
}
  • 페이징
public List<IotManagementDevice> findRevisionsWithWhere() {
    int pageSize = 10;
    AuditReader auditReader = AuditReaderFactory.get(getEntityManager());
    return auditReader.createQuery()
        .forRevisionsOfEntity(IotManagementDevice.class, true, true)
        // 페이징 조건
        .setFirstResult(0)
        .setMaxResults(pageSize)
        .getResultList();
}
  • 리비전 메타 데이터와 함께 이력 조회
@Builder
@Getter
@Setter
public class IotManagementDeviceAuditDto {

    IotManagementDevice iotManagementDevice;
    CustomRevisionEntity revisionEntity;
    RevisionType revisionType;
}
public List<IotManagementDeviceAuditDto> findRevisionsWithMetadata(String id) {
    AuditReader auditReader = AuditReaderFactory.get(getEntityManager());
    AuditQuery auditQuery = auditReader.createQuery()
        // 2번째 파라미터를 false로 설정 시 반환타입이 Object 배열로 바뀌고, Revision number와 revision type이 같이 반환됨
        .forRevisionsOfEntity(IotManagementDevice.class, false, true)
        .add(AuditEntity.id().eq(id))
        .addOrder(AuditEntity.revisionNumber().desc());

    // Object 배열의 List로 넘어오기 때문엥 convert가 필요함 ;;
    List<Object[]> revisions = auditQuery.getResultList();
    return revisions.stream()
    .map(rev -> IotManagementDeviceAuditDto.builder()
        .iotManagementDevice((IotManagementDevice) rev[0])  // 0번째는 저장된 이력 엔티티
        .revisionEntity((CustomRevisionEntity) rev[1])  // 1번째는 revision 정보
        .revisionType((RevisionType) rev[2]).build())  // 2번째는 구분(생성, 수정, 삭제)
    .collect(Collectors.toList());
}

Property 설정

Envers Configuration Property 문서

스키마 이름 바꾸기

  • org.hibernate.envers.default_schema (default: null)
  • @AuditTable( schema="…" )

이력테이블 접두어, 접미어

  • org.hibernate.envers.audit_table_prefix
  • org.hibernate.envers.audit_table_suffix (default: _AUD)

이력테이블 컬럼 이름

  • org.hibernate.envers.revision_field_name (default: REV)
  • org.hibernate.envers.revision_type_field_name (default: REVTYPE)

주의⚠

The use of JPA’s CriteriaUpdate and CriteriaDelete bulk operations are not currently supported by Envers due to how an entity’s lifecycle events are dispatched. Such operations should be avoided as they’re not captured by Envers and leads to incomplete audit history.

고려해야 할 점

  • 이력이 대용량일 때는 차라리 log를 쌓고 Hadoop과 같은 시스템에서 던져서 검색하는 방식으로 사용하는게 낫다

참고

Hibernate_User_Guide#envers

Hibernate Enver 적용 블로그 (2019.03)

Springboot + Envers로 엔티티 이력 관리하기 2017.09

엔티티 히스토리를 편리하게 관리해주는 Spring Data Envers - 김영한

스프링캠프 2017 [Day2 A5] : 엔티티 히스토리를 편리하게 관리해주는 스프링 데이터 Envers