Spring Data Envers 변경 이력

Spring Data Envers란?

엔티티의 변경 이력을 자동으로 추적하고 싶을 때, 직접 이력 테이블을 만들고 트리거나 이벤트 리스너를 작성하는 건 번거롭습니다. Hibernate Envers는 JPA 엔티티의 INSERT·UPDATE·DELETE를 자동으로 감사(audit) 테이블에 기록하는 라이브러리이며, Spring Data Envers는 이를 Spring Data JPA Repository와 통합하여 리비전 조회 API를 제공합니다.

금융, 의료, 커머스 등 데이터 변경 이력이 법적·비즈니스적으로 중요한 도메인에서 특히 유용합니다. Spring JPA Auditing이 “누가, 언제” 수준의 메타데이터를 기록한다면, Envers는 “무엇이 어떻게 바뀌었는지” 전체 스냅샷을 보존합니다.

의존성 추가와 기본 설정

Spring Boot 프로젝트에 다음 의존성을 추가합니다:

<!-- Maven -->
<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-envers</artifactId>
</dependency>

// Gradle
implementation 'org.springframework.data:spring-data-envers'

설정 클래스에서 Envers Repository를 활성화합니다:

@Configuration
@EnableJpaRepositories(
    repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean.class
)
public class JpaConfig {
}

application.yml에서 감사 테이블 생성 전략을 설정합니다:

spring:
  jpa:
    properties:
      org.hibernate.envers:
        audit_table_suffix: _history        # 감사 테이블 접미사
        revision_field_name: revision_id    # 리비전 컬럼명
        revision_type_field_name: revision_type
        store_data_at_delete: true          # DELETE 시에도 데이터 보존
        default_schema: audit               # 감사 테이블 별도 스키마

엔티티에 @Audited 적용

변경 이력을 추적할 엔티티에 @Audited를 붙이면, Hibernate가 자동으로 _history 테이블을 생성합니다:

@Entity
@Audited
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String orderNumber;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(nullable = false)
    private BigDecimal totalAmount;

    @NotAudited   // 이 필드는 이력 추적 제외
    private String internalNote;

    @Audited(targetAuditMode = RelationTargetAuditMode.NOT_AUDITED)
    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;
}

핵심 어노테이션:

  • @Audited — 엔티티 또는 필드 단위로 감사 활성화
  • @NotAudited — 특정 필드를 감사에서 제외
  • targetAuditMode = NOT_AUDITED — 연관 엔티티가 @Audited가 아닐 때 사용

이렇게 설정하면 Hibernate DDL이 orders_history 테이블과 revinfo(리비전 정보) 테이블을 자동 생성합니다.

커스텀 RevisionEntity로 메타데이터 확장

기본 revinfo 테이블은 리비전 번호와 타임스탬프만 저장합니다. 실무에서는 누가 변경했는지도 기록해야 합니다:

@Entity
@RevisionEntity(CustomRevisionListener.class)
@Table(name = "revision_info")
public class CustomRevisionEntity extends DefaultRevisionEntity {

    @Column(name = "modified_by")
    private String modifiedBy;

    @Column(name = "ip_address")
    private String ipAddress;

    // getter, setter
}

public class CustomRevisionListener implements RevisionListener {

    @Override
    public void newRevision(Object revisionEntity) {
        CustomRevisionEntity rev = (CustomRevisionEntity) revisionEntity;

        // Spring Security 컨텍스트에서 사용자 정보 추출
        Authentication auth = SecurityContextHolder
            .getContext().getAuthentication();

        if (auth != null && auth.isAuthenticated()) {
            rev.setModifiedBy(auth.getName());
        } else {
            rev.setModifiedBy("SYSTEM");
        }

        // RequestContextHolder에서 IP 추출
        ServletRequestAttributes attrs =
            (ServletRequestAttributes) RequestContextHolder
                .getRequestAttributes();
        if (attrs != null) {
            rev.setIpAddress(attrs.getRequest().getRemoteAddr());
        }
    }
}

RevisionRepository로 이력 조회

Spring Data Envers의 핵심은 RevisionRepository 인터페이스입니다. 기존 JpaRepository에 상속만 추가하면 됩니다:

public interface OrderRepository
    extends JpaRepository<Order, Long>,
            RevisionRepository<Order, Long, Long> {
    // 3번째 타입 파라미터 = 리비전 번호 타입(Long)
}

제공되는 주요 메서드:

@Service
@RequiredArgsConstructor
public class OrderAuditService {

    private final OrderRepository orderRepository;

    // 특정 주문의 전체 변경 이력
    public List<Revision<Long, Order>> getOrderHistory(Long orderId) {
        Revisions<Long, Order> revisions =
            orderRepository.findRevisions(orderId);
        return revisions.getContent();
    }

    // 최신 리비전 조회
    public Order getLatestRevision(Long orderId) {
        return orderRepository.findLastChangeRevision(orderId)
            .map(Revision::getEntity)
            .orElseThrow(() -> new EntityNotFoundException(
                "Order not found: " + orderId));
    }

    // 페이징 처리된 이력 조회
    public Page<Revision<Long, Order>> getPagedHistory(
            Long orderId, Pageable pageable) {
        return orderRepository.findRevisions(orderId, pageable);
    }

    // 특정 리비전 시점의 스냅샷
    public Order getAtRevision(Long orderId, Long revisionNumber) {
        return orderRepository
            .findRevision(orderId, revisionNumber)
            .map(Revision::getEntity)
            .orElseThrow();
    }
}

AuditReader로 고급 쿼리

Spring Data Envers의 Repository API만으로는 복잡한 조건 쿼리가 어렵습니다. Hibernate Envers의 AuditReader를 직접 사용하면 더 강력한 쿼리가 가능합니다:

@Service
@RequiredArgsConstructor
public class OrderAuditQueryService {

    private final EntityManager entityManager;

    // 특정 시점(날짜)의 주문 상태 조회
    public Order getOrderAtDate(Long orderId, LocalDateTime dateTime) {
        AuditReader reader = AuditReaderFactory.get(entityManager);
        long timestamp = dateTime.atZone(ZoneId.systemDefault())
            .toInstant().toEpochMilli();

        Number revisionAt = reader.getRevisionNumberForDate(
            Date.from(Instant.ofEpochMilli(timestamp)));

        return reader.find(Order.class, orderId, revisionAt);
    }

    // 조건부 이력 검색: 특정 상태로 변경된 모든 리비전
    @SuppressWarnings("unchecked")
    public List<Order> findStatusChanges(
            Long orderId, OrderStatus targetStatus) {
        AuditReader reader = AuditReaderFactory.get(entityManager);

        return reader.createQuery()
            .forRevisionsOfEntity(Order.class, true, true)
            .add(AuditEntity.id().eq(orderId))
            .add(AuditEntity.property("status").eq(targetStatus))
            .getResultList();
    }

    // 변경된 필드만 추출 (diff)
    public Map<String, Object[]> getChangeDiff(
            Long orderId, Long rev1, Long rev2) {
        AuditReader reader = AuditReaderFactory.get(entityManager);
        Order v1 = reader.find(Order.class, orderId, rev1);
        Order v2 = reader.find(Order.class, orderId, rev2);

        Map<String, Object[]> diff = new LinkedHashMap<>();

        if (!Objects.equals(v1.getStatus(), v2.getStatus())) {
            diff.put("status",
                new Object[]{v1.getStatus(), v2.getStatus()});
        }
        if (!Objects.equals(v1.getTotalAmount(), v2.getTotalAmount())) {
            diff.put("totalAmount",
                new Object[]{v1.getTotalAmount(), v2.getTotalAmount()});
        }
        return diff;
    }
}

REST API로 이력 노출

감사 이력을 REST API로 제공하는 컨트롤러 예시:

@RestController
@RequestMapping("/api/orders/{orderId}/history")
@RequiredArgsConstructor
public class OrderHistoryController {

    private final OrderAuditService auditService;

    @GetMapping
    public ResponseEntity<List<OrderRevisionDto>> getHistory(
            @PathVariable Long orderId) {

        List<OrderRevisionDto> history = auditService
            .getOrderHistory(orderId).stream()
            .map(rev -> OrderRevisionDto.builder()
                .revisionNumber(rev.getRevisionNumber().orElse(null))
                .revisionType(rev.getMetadata().getRevisionType().name())
                .timestamp(rev.getMetadata()
                    .getRequiredRevisionInstant())
                .orderNumber(rev.getEntity().getOrderNumber())
                .status(rev.getEntity().getStatus())
                .totalAmount(rev.getEntity().getTotalAmount())
                .build())
            .toList();

        return ResponseEntity.ok(history);
    }

    @GetMapping("/diff")
    public ResponseEntity<Map<String, Object[]>> getDiff(
            @PathVariable Long orderId,
            @RequestParam Long from,
            @RequestParam Long to) {
        return ResponseEntity.ok(
            auditService.getChangeDiff(orderId, from, to));
    }
}

성능 최적화와 운영 주의사항

1. 감사 테이블 인덱스

이력 조회 성능을 위해 Flyway/Liquibase로 인덱스를 추가하세요:

-- 엔티티 ID + 리비전 번호 복합 인덱스
CREATE INDEX idx_orders_history_id_rev
    ON orders_history (id, revision_id);

-- 리비전 타입별 조회 최적화
CREATE INDEX idx_orders_history_rev_type
    ON orders_history (revision_type, revision_id);

2. 대용량 테이블 파티셔닝

이력 데이터는 계속 쌓이므로, 시간 기반 파티셔닝을 고려하세요:

-- PostgreSQL 파티셔닝 예시
CREATE TABLE orders_history (
    id BIGINT,
    revision_id BIGINT,
    revision_type SMALLINT,
    -- ... 컬럼들
) PARTITION BY RANGE (revision_id);

CREATE TABLE orders_history_2025 PARTITION OF orders_history
    FOR VALUES FROM (1) TO (1000000);
CREATE TABLE orders_history_2026 PARTITION OF orders_history
    FOR VALUES FROM (1000000) TO (2000000);

3. 벌크 연산 주의

Envers는 JPA 엔티티 라이프사이클 이벤트에 의존합니다. @Modifying JPQL이나 네이티브 쿼리로 벌크 업데이트하면 감사 기록이 남지 않습니다:

// ❌ 감사 기록 안 됨
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
void bulkUpdateStatus(@Param("ids") List<Long> ids,
                      @Param("status") OrderStatus status);

// ✅ 감사 기록됨 (개별 엔티티 로드 후 변경)
@Transactional
public void updateStatusWithAudit(List<Long> ids, OrderStatus status) {
    List<Order> orders = orderRepository.findAllById(ids);
    orders.forEach(o -> o.setStatus(status));
    orderRepository.saveAll(orders);
}

4. @Audited vs Spring JPA Auditing

둘은 보완적 관계입니다. Spring JPA Auditing(@CreatedBy, @LastModifiedDate)은 현재 레코드의 메타데이터를, Envers는 전체 변경 히스토리를 담당합니다. 함께 사용하세요:

@Entity
@Audited                    // Envers: 변경 이력 추적
@EntityListeners(AuditingEntityListener.class)  // JPA Auditing
public class Order {

    @CreatedBy              // JPA Auditing
    private String createdBy;

    @LastModifiedDate        // JPA Auditing
    private LocalDateTime updatedAt;

    // ... 비즈니스 필드들 (Envers가 스냅샷 관리)
}

테스트 전략

감사 기록이 올바르게 생성되는지 통합 테스트로 검증하세요:

@SpringBootTest
@Transactional
class OrderAuditTest {

    @Autowired OrderRepository orderRepository;
    @Autowired EntityManager em;

    @Test
    void shouldTrackOrderStatusChange() {
        // given
        Order order = new Order("ORD-001", OrderStatus.CREATED,
            BigDecimal.valueOf(10000));
        orderRepository.save(order);
        em.flush(); em.clear();  // 1st revision

        // when
        Order found = orderRepository.findById(order.getId()).get();
        found.setStatus(OrderStatus.PAID);
        orderRepository.save(found);
        em.flush(); em.clear();  // 2nd revision

        // then
        Revisions<Long, Order> revisions =
            orderRepository.findRevisions(order.getId());

        assertThat(revisions.getContent()).hasSize(2);
        assertThat(revisions.getContent().get(0)
            .getEntity().getStatus()).isEqualTo(OrderStatus.CREATED);
        assertThat(revisions.getContent().get(1)
            .getEntity().getStatus()).isEqualTo(OrderStatus.PAID);
    }
}

마치며

Spring Data Envers는 어노테이션 하나로 엔티티의 전체 변경 이력을 자동 관리합니다. Off 모드의 추천값을 커스텀 RevisionEntity로 “누가, 어디서” 정보를 확장하고, AuditReader로 시점 조회와 조건 검색까지 가능합니다. 다만 벌크 연산 제외, 감사 테이블 인덱싱, 파티셔닝 등 운영 관점의 고려가 필수입니다. JPA Auditing과 함께 사용하면 메타데이터와 변경 히스토리를 모두 완벽하게 커버할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux