개발일지/SPRINGBOOT

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

recording or reCoding 2025. 10. 8. 15:10

애플리케이션을 개발하다 보면 엔티티의 변경 이력을 추적해야 하는 경우가 종종 있습니다. 누가 언제 데이터를 변경했는지, 어떤 내용이 변경되었는지 알아야 할 필요가 있을 때가 있죠. 이런 요구사항을 Spring Data Envers와 RevisionRepository를 사용하여 간단하게 해결할 수 있습니다.

Spring Data Envers란?

Spring Data Envers는 Hibernate Envers를 Spring Data JPA와 통합하여 엔티티의 변경 이력을 자동으로 관리해주는 라이브러리입니다. 별도의 로직 구현 없이 애노테이션만으로 엔티티의 생성, 수정, 삭제 이력을 데이터베이스에 기록할 수 있습니다.

RevisionRepository 살펴보기

RevisionRepository는 Spring Data Envers가 제공하는 인터페이스로, 엔티티의 변경 이력을 조회하는 메서드들을 제공합니다. 우리가 직접 이력을 조회하는 복잡한 쿼리를 작성할 필요 없이, 이 인터페이스를 상속받는 것만으로 편리하게 이력 데이터를 다룰 수 있습니다.

RevisionRepository 인터페이스 정의

먼저 Book 엔티티와 그 변경 이력을 관리할 BookRepository를 정의해봅시다.

package com.spring.jpa.envers.repository;

import com.spring.jpa.envers.entity.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.history.RevisionRepository;

public interface BookRepository extends RevisionRepository<Book, Integer, Integer>, JpaRepository<Book, Integer> {
}

여기서 BookRepository JpaRepository와 함께 RevisionRepository<Book, Integer, Integer>를 상속받습니다.

  • 첫 번째 Integer는 엔티티의 ID 타입 (여기서는 Book 엔티티의 id 필드)을 나타냅니다.
  • 두 번째 Integer는 리비전 엔티티의 ID 타입 (보통 리비전 번호)을 나타냅니다.

이제 BookRepository는 기본적인 CRUD 기능뿐만 아니라 엔티티의 변경 이력을 조회하는 기능도 갖게 됩니다.

Book 엔티티 정의 (예시)

Book 엔티티는 다음과 같이 @Audited 애노테이션을 사용하여 Envers의 추적 대상임을 명시해야 합니다.

package com.spring.jpa.envers.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.envers.Audited;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Audited // 이 엔티티의 변경 이력을 추적합니다.
public class Book {
    @Id
    @GeneratedValue
    private int id;
    private String name;
    private int pages;
}

RevisionRepository를 활용한 엔티티 이력 관리 예제

이제 RevisionRepository를 이용하여 Book 엔티티의 변경 이력을 어떻게 관리하고 조회하는지 간단한 REST 컨트롤러를 통해 살펴보겠습니다.

package com.spring.jpa.envers.controller;

import com.spring.jpa.envers.entity.Book;
import com.spring.jpa.envers.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.data.history.Revision;
import org.springframework.data.history.Revisions;

import java.util.Optional;

@RestController
@RequestMapping("/books") // 경로를 명확하게 하기 위해 추가
public class SpringDataEnversApplication
{

    @Autowired
    private BookRepository repository;

    @PostMapping
    public Book saveBook(@RequestBody Book book) {
        return repository.save(book);
    }

    @PutMapping("/{id}/{pages}")
    public String updateBook(@PathVariable int id, @PathVariable int pages) {
        Optional<Book> optionalBook = repository.findById(id);
        if (optionalBook.isPresent()) {
            Book book = optionalBook.get();
            book.setPages(pages);
            repository.save(book);
            return "Book updated";
        }
        return "Book not found";
    }

    @DeleteMapping("/{id}")
    public String deleteBook(@PathVariable int id) {
        repository.deleteById(id);
        return "Book deleted";
    }

    // 특정 ID의 엔티티에 대한 마지막 변경 이력 조회
    @GetMapping("/last-revision/{id}")
    public Revision<Integer, Book> getLastRevision(@PathVariable  int id){
        // findLastChangeRevision은 Optional<Revision<...>>을 반환합니다.
        Optional<Revision<Integer, Book>> lastRevision = repository.findLastChangeRevision(id);
        if (lastRevision.isPresent()) {
            System.out.println("Last Change Revision: " + lastRevision.get());
            System.out.println("Revision Number: " + lastRevision.get().getRevisionNumber());
            System.out.println("Revision Type: " + lastRevision.get().getMetadata().getRevisionType());
            System.out.println("Entity at this revision: " + lastRevision.get().getEntity());
            return lastRevision.get();
        }
        return null; // 또는 적절한 예외 처리
    }

    // 특정 ID의 엔티티에 대한 모든 변경 이력 조회
    @GetMapping("/revisions/{id}")
    public Revisions<Integer, Book> getAllRevisions(@PathVariable int id) {
        Revisions<Integer, Book> revisions = repository.findRevisions(id);
        revisions.forEach(revision -> {
            System.out.println("Revision: " + revision.getRevisionNumber() + ", Type: " + revision.getMetadata().getRevisionType() + ", Entity: " + revision.getEntity());
        });
        return revisions;
    }

    // 특정 리비전 번호로 엔티티 조회
    @GetMapping("/revision/{id}/{revisionNumber}")
    public Revision<Integer, Book> getRevision(@PathVariable int id, @PathVariable int revisionNumber) {
        Optional<Revision<Integer, Book>> revision = repository.findRevision(id, revisionNumber);
        if (revision.isPresent()) {
            System.out.println("Revision " + revisionNumber + ": " + revision.get().getEntity());
            return revision.get();
        }
        return null;
    }
}

동작 흐름 설명:

  1. /books (POST): 새 Book 엔티티를 저장합니다. 이때 Envers는 book_aud 테이블에 리비전 1 (REVTYPE 0 - ADD)로 이력을 기록합니다.
  2. /books/{id}/{pages} (PUT): 특정 Book pages를 업데이트합니다. Envers는 book_aud 테이블에 새로운 리비전 (REVTYPE 1 - MOD)으로 변경 이력을 기록합니다.
  3. /books/{id} (DELETE): 특정 Book을 삭제합니다. Envers는 book_aud 테이블에 또 다른 리비전 (REVTYPE 2 - DEL)으로 삭제 이력을 기록합니다.
  4. /books/last-revision/{id} (GET): repository.findLastChangeRevision(id)를 사용하여 특정 Book의 가장 최근 변경 이력을 조회합니다. 이 메서드는 Optional<Revision<Integer, Book>>을 반환합니다.
    • Revision 객체는 리비전 번호 (getRevisionNumber()), 변경 타입 (getMetadata().getRevisionType()), 해당 리비전 시점의 엔티티 상태 (getEntity()) 등의 정보를 포함합니다.
    • getMetadata().getRevisionType() ADD, MOD, DEL 등의 RevisionType enum 값을 반환합니다.
  5. /books/revisions/{id} (GET): repository.findRevisions(id)를 사용하여 특정 Book의 모든 변경 이력을 Revisions 객체로 조회합니다. Revisions Iterable 인터페이스를 구현하여 모든 리비전을 순회할 수 있습니다.
  6. /books/revision/{id}/{revisionNumber} (GET): repository.findRevision(id, revisionNumber)를 사용하여 특정 Book의 특정 리비전 시점의 상태를 조회합니다.

실행 예시 및 결과

위 컨트롤러와 H2 데이터베이스를 사용하여 테스트해보면, 엔티티를 생성, 수정, 삭제할 때마다 book_aud 테이블에 다음과 유사한 이력 데이터가 쌓이는 것을 확인할 수 있습니다.

book_aud 테이블 (DB 툴에서 확인):

다음은 제공된 이미지와 유사한 데이터가 생성되는 예시입니다. (실제 리비전 번호, 타임스탬프 등은 다를 수 있습니다.)

id rev revtype name pages
1 1 0 Spring in Action 350
1 2 1 Spring in Action 400
2 3 0 Spring 444
1 4 2 [NULL] [NULL]
  • rev: 리비전 번호 (변경이 발생할 때마다 증가)
  • revtype: 변경 타입 (0: 생성, 1: 수정, 2: 삭제)
  • id, name, pages: 해당 리비전 시점의 Book 엔티티 데이터 (삭제 시에는 name, pages가 NULL이 될 수 있음)

/books/last-revision/{id} 호출 결과 예시 (JSON):

JSON
 
{
    "metadata": {
        "revisionNumber": 2,
        "revisionInstant": "2023-10-27T10:00:00Z", // 실제 시간
        "revisionType": "MOD"
    },
    "entity": {
        "id": 1,
        "name": "Spring in Action",
        "pages": 400
    }
}

이 결과는 ID가 1인 Book 엔티티의 마지막 변경이 리비전 2에서 발생했으며, 이때 pages가 400으로 수정되었다는 것을 보여줍니다.

결론

Spring Data Envers와 RevisionRepository를 사용하면 엔티티의 변경 이력 관리를 매우 효율적이고 간단하게 구현할 수 있습니다. 복잡한 로직을 직접 작성할 필요 없이, 몇 가지 설정과 인터페이스 상속만으로 강력한 이력 추적 기능을 애플리케이션에 추가할 수 있습니다. 이는 감사(audit) 기능이나 데이터 복구, 특정 시점의 데이터 조회 등 다양한 요구사항에 유용하게 활용될 수 있습니다.


관련 이미지

이해를 돕기 위해, book_aud 테이블에 데이터가 쌓이는 과정을 시각적으로 표현한 이미지입니다.
book_aud 테이블의 데이터는 다음과 같은 흐름으로 기록됩니다:

  1. 새로운 책이 추가됩니다 (REVTYPE 0).
  2. 기존 책의 페이지 수가 업데이트됩니다 (REVTYPE 1).
  3. 다른 책이 추가됩니다 (REVTYPE 0).
  4. 첫 번째 책이 삭제됩니다 (REVTYPE 2).

book_aud 테이블의 모습: