Spring JPA Auditing 심화

JPA Auditing이란?

엔티티의 생성일시, 수정일시, 생성자, 수정자를 자동으로 기록하는 것이 JPA Auditing입니다. Spring Data JPA는 @EnableJpaAuditing@EntityListeners로 이 기능을 추상화합니다. 하지만 실전에서는 기본 설정만으로 부족합니다. 이 글에서는 Auditing 설정부터 커스텀 AuditorAware, Soft Delete 연동, Envers 히스토리 테이블까지 심화 패턴을 다룹니다.

기본 Auditing 설정

4개의 핵심 어노테이션으로 자동 감사 필드를 구성합니다.

// 1. Auditing 활성화
@Configuration
@EnableJpaAuditing
public class JpaConfig {
}

// 2. BaseEntity에 감사 필드 정의
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false, nullable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(updatable = false, length = 100)
    private String createdBy;

    @LastModifiedBy
    @Column(length = 100)
    private String updatedBy;
}

// 3. 엔티티에서 상속
@Entity
@Table(name = "users")
public class User extends BaseEntity {

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

    @Column(nullable = false, length = 100)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;
}

AuditorAware 구현

@CreatedBy@LastModifiedByAuditorAware 인터페이스를 통해 현재 사용자를 결정합니다. Spring Security와 연동하는 것이 일반적입니다.

// Spring Security 연동
@Component
public class SecurityAuditorAware implements AuditorAware<String> {

    @Override
    public Optional<String> getCurrentAuditor() {
        return Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .filter(Authentication::isAuthenticated)
            .filter(auth -> !(auth instanceof AnonymousAuthenticationToken))
            .map(Authentication::getName);
    }
}

// 테스트 환경에서는 고정값 반환
@TestConfiguration
public class TestAuditConfig {
    @Bean
    public AuditorAware<String> auditorAware() {
        return () -> Optional.of("test-user");
    }
}

엔티티 ID 기반 AuditorAware

사용자 이름 대신 ID를 저장하면 이름 변경에 영향받지 않습니다. Spring Method Security의 인증 객체에서 ID를 추출합니다.

// Long ID 기반 AuditorAware
@Component
public class UserIdAuditorAware implements AuditorAware<Long> {

    @Override
    public Optional<Long> getCurrentAuditor() {
        return Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .filter(Authentication::isAuthenticated)
            .map(auth -> {
                Object principal = auth.getPrincipal();
                if (principal instanceof CustomUserDetails details) {
                    return details.getId();
                }
                return null;
            });
    }
}

// BaseEntity도 Long 타입으로 변경
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false, nullable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(updatable = false)
    private Long createdBy;

    @LastModifiedBy
    private Long updatedBy;

    // JPA Configuration에서 타입 명시
}

// EnableJpaAuditing에 auditorAwareRef 지정
@Configuration
@EnableJpaAuditing(auditorAwareRef = "userIdAuditorAware")
public class JpaConfig {
}

계층적 BaseEntity 설계

모든 엔티티가 동일한 감사 필드를 필요로 하지 않습니다. 시간만 필요한 경우와 사용자까지 필요한 경우를 분리합니다.

// 시간만 기록하는 베이스
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false, nullable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime updatedAt;
}

// 시간 + 사용자 기록하는 베이스
@MappedSuperclass
@Getter
public abstract class BaseEntity extends BaseTimeEntity {

    @CreatedBy
    @Column(updatable = false, length = 100)
    private String createdBy;

    @LastModifiedBy
    @Column(length = 100)
    private String updatedBy;
}

// Soft Delete까지 포함하는 베이스
@MappedSuperclass
@Getter
@SQLRestriction("deleted_at IS NULL")
public abstract class SoftDeletableEntity extends BaseEntity {

    @Column
    private LocalDateTime deletedAt;

    @Column(length = 100)
    private String deletedBy;

    public void softDelete(String deletedBy) {
        this.deletedAt = LocalDateTime.now();
        this.deletedBy = deletedBy;
    }

    public boolean isDeleted() {
        return deletedAt != null;
    }

    public void restore() {
        this.deletedAt = null;
        this.deletedBy = null;
    }
}

// 사용: 엔티티별 필요한 레벨 선택
@Entity
public class SystemLog extends BaseTimeEntity { ... }  // 시간만

@Entity
public class Order extends BaseEntity { ... }  // 시간+사용자

@Entity
public class Product extends SoftDeletableEntity { ... }  // 소프트 삭제

Hibernate Envers로 변경 이력 추적

Auditing은 “누가, 언제” 수정했는지만 알려줍니다. “무엇을” 변경했는지까지 추적하려면 Hibernate Envers를 사용합니다.

// build.gradle
dependencies {
    implementation 'org.springframework.data:spring-data-envers'
}

// Envers 활성화
@Configuration
@EnableJpaRepositories(repositoryFactoryBeanClass =
    EnversRevisionRepositoryFactoryBean.class)
public class EnversConfig {
}

// 엔티티에 @Audited 추가
@Entity
@Audited
public class Product extends BaseEntity {

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

    private String name;

    private BigDecimal price;

    @NotAudited  // 변경 이력 제외할 필드
    private int viewCount;
}

// 자동 생성되는 테이블:
// products_aud — 변경 이력 (revtype: 0=INSERT, 1=UPDATE, 2=DELETE)
// revinfo — 리비전 정보 (rev, revtstmp)

커스텀 RevisionEntity

기본 revinfo 테이블에는 타임스탬프만 저장됩니다. 커스텀 RevisionEntity로 사용자 정보를 추가합니다.

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

    @Column(length = 100)
    private String username;

    @Column(length = 50)
    private String ipAddress;

    @Column(length = 200)
    private String userAgent;
}

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.setUsername(auth.getName());
        }

        // HTTP 요청 정보 (RequestContextHolder)
        try {
            ServletRequestAttributes attrs =
                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            if (attrs != null) {
                HttpServletRequest request = attrs.getRequest();
                rev.setIpAddress(request.getRemoteAddr());
                rev.setUserAgent(request.getHeader("User-Agent"));
            }
        } catch (Exception ignored) {}
    }
}

변경 이력 조회 API

Envers의 이력 데이터를 조회하는 서비스와 API를 구현합니다. JPA N+1 문제에 유의하며 쿼리를 설계합니다.

// Repository에 RevisionRepository 확장
public interface ProductRepository extends
        JpaRepository<Product, Long>,
        RevisionRepository<Product, Long, Long> {
}

// 서비스: 이력 조회
@Service
@RequiredArgsConstructor
public class ProductAuditService {
    private final ProductRepository productRepository;

    // 특정 엔티티의 전체 변경 이력
    public List<RevisionDto> getRevisions(Long productId) {
        Revisions<Long, Product> revisions =
            productRepository.findRevisions(productId);

        return revisions.stream()
            .map(rev -> RevisionDto.builder()
                .revisionNumber(rev.getRevisionNumber().orElse(null))
                .revisionType(rev.getMetadata().getRevisionType().name())
                .entity(ProductDto.from(rev.getEntity()))
                .timestamp(rev.getRevisionInstant().orElse(null))
                .build())
            .toList();
    }

    // 특정 시점의 엔티티 상태
    public ProductDto getAtRevision(Long productId, Long revisionNumber) {
        Revision<Long, Product> revision =
            productRepository.findRevision(productId, revisionNumber)
                .orElseThrow(() -> new NotFoundException("Revision not found"));
        return ProductDto.from(revision.getEntity());
    }

    // AuditReader로 복잡한 쿼리
    public List<ProductDto> getDeletedProducts() {
        AuditReader reader = AuditReaderFactory.get(entityManager);
        List<Object[]> results = reader.createQuery()
            .forRevisionsOfEntity(Product.class, false, true)
            .add(AuditEntity.revisionType().eq(RevisionType.DEL))
            .getResultList();

        return results.stream()
            .map(row -> ProductDto.from((Product) row[0]))
            .toList();
    }
}

// 응답 DTO
@Builder
public record RevisionDto(
    Long revisionNumber,
    String revisionType,
    ProductDto entity,
    Instant timestamp
) {}

JPA Callback으로 커스텀 감사

Spring Data Auditing으로 부족한 경우 JPA의 @PrePersist, @PreUpdate 콜백을 직접 사용합니다.

// EntityListener로 분리
public class AuditListener {

    @PrePersist
    public void prePersist(Object entity) {
        if (entity instanceof BaseTimeEntity base) {
            LocalDateTime now = LocalDateTime.now();
            setField(base, "createdAt", now);
            setField(base, "updatedAt", now);
        }
        if (entity instanceof BaseEntity auditable) {
            String user = getCurrentUser();
            setField(auditable, "createdBy", user);
            setField(auditable, "updatedBy", user);
        }
    }

    @PreUpdate
    public void preUpdate(Object entity) {
        if (entity instanceof BaseTimeEntity base) {
            setField(base, "updatedAt", LocalDateTime.now());
        }
        if (entity instanceof BaseEntity auditable) {
            setField(auditable, "updatedBy", getCurrentUser());
        }
    }

    @PreRemove
    public void preRemove(Object entity) {
        // Soft Delete 대신 실제 삭제 시 로깅
        if (entity instanceof BaseEntity auditable) {
            log.info("Entity deleted: {} by {}",
                entity.getClass().getSimpleName(), getCurrentUser());
        }
    }

    private String getCurrentUser() {
        try {
            return SecurityContextHolder.getContext()
                .getAuthentication().getName();
        } catch (Exception e) {
            return "system";
        }
    }
}

테스트 전략

Auditing 기능은 통합 테스트로 검증해야 합니다. @DataJpaTest에서 Auditing을 활성화하는 패턴입니다.

@DataJpaTest
@Import(TestAuditConfig.class)  // 테스트용 AuditorAware
@EnableJpaAuditing
class UserAuditingTest {

    @Autowired
    private UserRepository userRepository;

    @TestConfiguration
    static class TestAuditConfig {
        @Bean
        public AuditorAware<String> auditorAware() {
            return () -> Optional.of("test-user");
        }
    }

    @Test
    void 생성시_감사필드가_자동으로_채워진다() {
        User user = User.builder()
            .name("홍길동")
            .email("hong@example.com")
            .build();

        User saved = userRepository.save(user);

        assertThat(saved.getCreatedAt()).isNotNull();
        assertThat(saved.getUpdatedAt()).isNotNull();
        assertThat(saved.getCreatedBy()).isEqualTo("test-user");
        assertThat(saved.getUpdatedBy()).isEqualTo("test-user");
    }

    @Test
    void 수정시_updatedAt과_updatedBy만_변경된다() {
        User user = userRepository.save(User.builder()
            .name("홍길동").email("hong@example.com").build());

        LocalDateTime originalCreatedAt = user.getCreatedAt();

        user.updateName("김철수");
        User updated = userRepository.saveAndFlush(user);

        assertThat(updated.getCreatedAt()).isEqualTo(originalCreatedAt);
        assertThat(updated.getUpdatedAt()).isAfter(originalCreatedAt);
        assertThat(updated.getCreatedBy()).isEqualTo("test-user");
    }
}

정리

JPA Auditing은 엔티티의 생명주기를 투명하게 추적하는 핵심 인프라입니다. BaseTimeEntity → BaseEntity → SoftDeletableEntity 계층으로 필요한 수준의 감사를 선택하고, AuditorAware로 Spring Security와 연동합니다. “무엇이” 변경되었는지까지 알아야 한다면 Hibernate Envers를 도입하여 변경 이력 테이블을 자동 관리하세요. 감사 기능은 장애 추적, 컴플라이언스, 데이터 복구 모두에서 필수적인 안전망입니다.

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