들어가며
시스템 운영 단계에서 중요 데이터의 변경 이력을 저장하고 관리해야 하는 경우가 있다.
보통 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
# 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 에 많은 기능이 구현되어있고 지원해주니 잘 찾아보면 수고를 덜 수 있을 것 같다.
'📡 백엔드 > 🌱 Spring Boot' 카테고리의 다른 글
[Spring] Jackson Module 을 이용한 Jackson 확장 (1) | 2024.03.25 |
---|---|
[Spring] Swagger 공통 응답 예시 커스터마이징 (0) | 2024.03.17 |
[Spring] Redisson 분산락 AOP로 동시성 문제 해결하기 (트랜잭션 전파속성 NEVER 사용) (0) | 2023.07.06 |
[Spring] 스프링 소셜 로그인 OIDC 방식으로 구현하기 (OAuth with OpenID Connect) (1) | 2023.03.25 |
[Spring] 스프링 애플 로그인 구현하기 (Sign in with Apple OIDC) (1) | 2023.03.25 |