본문 바로가기

프로젝트

Spring Data Envers를 활용하여 Entity의 변경 이력을 관리하기

지스트 청원 프로젝트의 실제 서비스 운영을 위해서 청원과 답변 Entity에 대한 변경 이력이 관리되어 문제 상황에 대비해야 한다는 팀적으로 요구사항이 생겼고, 이를 Hibernate의 Envers기능을 이용하여 손쉽게 구현할 수 있었다.

 

요구사항

사용자가 청원을 등록했을 때, 관리자(학생회 측)는 해당 내용 중 부적절한 정보의 숨김 처리를 진행한다. 악의적으로 글의 내용을 수정하는 트롤 행위가 충분히 발생할 수 있지만 이를 막을 방법이 없다. 또한, 어떤 관리자가 작업을 진행했는지의 이력을 확인할 수 없고, 원본 글의 정보 또한 찾을 수 없게 되는 구조였다. (숨김 처리 작업을 진행하면, Entity 테이블의 해당 칼럼의 수정을 진행한다.) 이러한 문제들을 해결하기 위한 요구사항을 정리하면 다음과 같다.

- 어떤 관리자가 작업을 진행했는지 확인할 수 있어야 한다.

- 문제가 발생했을 때, 기존의 정보를 다시 확인해 원복할 수 있어야 한다.

- 해당 글이 삭제되더라도, 이력을 남길 수 있어야 한다.

 

이를 해결하기 위해 해결할 수 있는 방법론은 다음 두가지 정도로 생각해봤다.

1. Insert-Only 방식: UPDATE로직이 발생했을 때에도, INSERT로 처리를 하고 가장 마지막의 추가된 값을 사용하는 방법

2. History 테이블 방식: 별도의 Entity 이력 관리 테이블을 사용하는 방법

Insert-Only 방식

청원에 대해 학교 측의 입장을 답변을 단다. 이 로직도 수정의 기능을 열어두기로 비지니스 로직을 설정했고 이력의 관리도 필요했다. (비지니스 로직상 답변은 한 개만 존재할 수 있기에 지금의 방식을 고려해볼 수 있었다.)

아래와 같은 방식으로 Answer테이블의 createAt, validUntil column을 두고 유효한 row를 조회하는 방식으로 로직을 구성할 수 있었다.

Insert-Only 방식 예시 - 생성
Insert-Only 방식 예시 - 수정 2번 진행
Insert-Only 방식 예시 - 삭제

수정을 진행했을 때에는 마지막 값의 validUntil값을 수정 시간으로 update해주고 새로운 값을 추가하고, 삭제를 진행했을 때에는 마지막 값의 validUntil값을 삭제 시간으로 update해 주는 방식으로 진행할 수 있다.

하지만 이 방식은 삭제한 사람의 정보를 알 수 는 없으며, Answer를 조회할 때 필요 없는 이전 정보들이 테이블에 남게되어 조회시 오버헤드로 작용할 수 있다.

History 테이블 방식

Entity의 정보가 수정될 때마다 이를 별도의 Entity를 만들고 직접 작업할 수 있으나, Hibernate에서 제공하는 Envers는 이러한 기능들을 쉽게 처리해준다. 또한 Spring에서는 이를 한번 더 추상화한 Spring Data Envers를 제공하고 있으며 이를 사용하기로 한다.

 

Spring Data Envers 적용

`implementation 'org.springframework.data:spring-data-envers:2.6.1'` 의존성을 주입하고 @Audited를 이력을 남기고 싶은 Entity에 추가하기만 하면 된다. Spring Data Envershibernate orm envers 의 공식 문서를 참고하면 쉽게 사용할 수 있다.

DefaultRevisionEntity

별도의 RevisionEntity를 정의하지 않고 사용하면 위의 DefaultRevisionEntity를 사용하게 되는데, id와 timestamp의 정보만을 기록하고 id의 경우에는 int의 범위만을 제공한다. 작성자에 대한 정보를 담고, id의 범위를 Long으로 수정을 진행하면 다음과 같이 진행할 수 있다.

@Getter
@Setter
@NoArgsConstructor
@Entity
@RevisionEntity(CustomRevisionListener.class)
@Proxy(lazy = false)
@Table(name = "REVINFO")
public class CustomRevisionEntity implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @RevisionNumber
    @Column(name = "REV")
    private Long id;

    @RevisionTimestamp
    @Column(name = "REVTSTMP")
    private Long timestamp;

    private Long userId;

    @Transient
    public Date getRevisionDate() {
        return new Date(timestamp);
    }

    @Override
    public String toString() {
        return String.format("CustomRevisionEntity(id = %d, revisionDate = %s, userId = %d)",
                id, DateFormat.getDateTimeInstance().format(getRevisionDate()), userId);
    }
}

 

@RequiredArgsConstructor
@Component
public class CustomRevisionListener implements RevisionListener {

    private final HttpSession httpSession;

    @Override
    public void newRevision(Object revisionEntity) {
        CustomRevisionEntity customRevisionEntity = (CustomRevisionEntity) revisionEntity;
        customRevisionEntity.setUserId(extractUserId());
    }

    private Long extractUserId() {
        SimpleUser user = (SimpleUser) httpSession.getAttribute(SESSION_KEY);
        return Objects.isNull(user) ? null : user.getId();
    }
}

세션을 사용하는 우리 서비스는 해당 세션의 사용자의 id를 userId에 작성하도록 진행했다. 이 방식을 사용하게 되었을 때, 처음의 요구사항을 모두 만족할 수 있었다.