🍃SpringBoot

Spring Boot: Spring Data JPA With Auditing

limdaeil 2025. 10. 23. 21:09

🔖Contents

 

질문의 배경

최근까지 프로젝트에서 항상 Spring Data Jpa에서 제공하는 Auditing 기능을 사용하지 않고, 각 엔티티 안에서 직접 관리했습니다. 그 이유는 새로운 생성을 하면 @CreatedBy(Date), @LastModifiedBy(Date)가 함께 정보가 저장되는 것이 싫었습니다. 새로운 생성은 @LastModifiedBy(Date) 필드가 null 이어야만 하고, 수정할 때만 반영되는 것이 올바르다고 생각했기 때문이에요. 하지만 정책 관점에서의 차이이므로 이번 기회에 다시 오랜만에 Auditing 기능을 학습과 함께 다른 멀티 모듈 환경에서 Auditing 구성을 어떻게 하는지 학습하기 위한 목적으로 정리한 글 입니다.

1. Auditing 이해하기

Auditing은 엔티티에 누가, 언제 만들고 수정했는지 자동으로 기록합니다. Spring Data JPA에서 제공하며, 데이터베이스에 대한 변경 내역을 쉽게 추적하고 관리할 수 있게 해줍니다. 이를 통해 코드의 중복을 줄이고 데이터의 생성 및 수정 시점을 효과적으로 기록할 수 있습니다.

Spring Data JPA에서 제공하는 Auditing 기능

1.1 Auditing 특징

  • 자동 기록: 엔티티의 생성, 수정 시점에 맞춰 관련 정보를 자동으로 기록합니다.
  • 데이터 추적: 데이터가 어떻게, 언제 변경되었는지 추적할 수 있어 서비스 운영에 중요한 정보를 제공합니다.
  • 코드 간소화: @CreatedDate, @LastModifiedDate와 같은 어노테이션을 통해 간편하게 적용할 수 있습니다.
  • 생성자/수정자 기록: 데이터 생성 및 수정 주체를 함께 기록하여 누가, 언제 데이터를 변경했는지 파악할 수 있습니다.
Annotation 설명
@CreatedDate (org.springframework.data) 생성일자
@LastModifiedDate (org.springframework.data) 수정일자
@CreatedBy (org.springframework.data) 생성자
@LastModifiedBy (org.springframework.data) 수정자

Auditing 구동 요소 네 가지

  1. @EnableJpaAuditing: 감사 기능 활성화 ON
  2. AuditorAware<T>: 현재 사용자 ID 제공
  3. DateTimeProvider: 현재 시각 제공
  4. AuditingEntityListener: 엔티티 리스너

1.2 Auditing 값 주입

스프링 부트는 Transaction, Flush 타이밍에 AuditingHandler를 통해 값을 주입합니다.

기준 Annotation
persist @CreatedDate, @CreatedBy
update @LastModifiedDate, @LastModifiedBy

누가, 언제 만들고 수정했는지 값을 주입하기 위해서는 반드시 필요한 조건 세 가지가 있어요.

1. 🔥 JPA 활성화 + Spring Data JPA 의존성 = spring-boot-starter-data-jpa
JPA 활성화를 위해서는 spring-boot-starter-data-jpa 의존성을 추가하고, @EnableJpaAuditing 어노테이션을 설정 클래스에 추가해야 합니다. spring-boot-starter-data-jpa는 Spring Data JPA와 JPA를 위한 의존성을 자동으로 구성해주며, 이를 통해 Spring Data JPA의 모든 기능을 사용할 수 있습니다.

 

2. 🔥 @EnableJpaAuditing 또는 @EntityListeners(AuditingEntityListener.class)

// Application.java 메인 클래스
@EnableJpaAuditing // <- 추가하면 끝!
@SpringBootApplication
public class CatalogApplication {
    public static void main(String[] args) {
        SpringApplication.run(CatalogApplication.class, args);
    }
}

@EnableJpaAuditing가 Mock 객체를 사용하는 테스트에서 문제를 일으키는 주된 이유는 테스트 환경이 전체 애플리케이션 컨텍스트를 로드하지 않아 JPA Auditing 기능이 제대로 활성화되지 않기 때문입니다. 크게 두 개의 문제가 발생하게 됩니다.

  • 부분 컨텍스트 로드: @DataJpaTest@WebMvcTest와 같이 특정 계층만 테스트하는 어노테이션은 JPA 관련 빈을 최소한으로 로드합니다. 이 경우 @EnableJpaAuditing 어노테이션이 무시되거나 관련 빈이 누락되어 Auditing 기능이 동작하지 않습니다.
  • 메인 클래스의 어노테이션: @SpringBootApplication이 있는 메인 클래스에 @EnableJpaAuditing을 선언하면, 부분적인 테스트를 실행할 때도 이 설정이 로드되어 의도치 않은 JPA 빈을 생성하려 시도하고 오류를 발생시킬 수 있습니다.

@EnableJpaAuditing은 Spring Data JPA의 Auditing 기능을 활성화하는 편리한 방법이지만, 특정 상황(예: 단위 테스트 시 불필요한 의존성 로딩 방지)에서는 다른 설정 방식을 사용할 수 있습니다. 가장 간단한 방법은 스프링 부트 메인 클래스에 작성하면 끝 입니다.

이 문제를 극복하기 위해서는 메인 클래스에서 @EnableJpaAuditing를 제거해야만 합니다. 대신 Auditing 설정 파일로 관리하면 가능하고, Mock을 통한 컨트롤러 테스트가 가능합니다.

   // 권장 방식
   @Configuration
   @EnableJpaAuditing
   public class JpaAuditingConfig {  
   }
  • 통합 테스트: @SpringBootTest를 사용하면 별도의 설정 없이 자동으로 인식됩니다.(컨트롤러 테스트)
  • 계층별 테스트: @DataJpaTest를 사용한다면, @Import 어노테이션으로 분리된 설정 파일을 가져옵니다.(리포지토리 테스트)

리포지토리 테스트도 주의해야 합니다. @Import 존재 여부에 따라서 반환되는 Auditing에 값이 없을 수도 있습니다. 

   @WebMvcTest(ArticleController.java)
   @MockBean(JpaMetamodelMappingContext.class)
   class ArticleControllerTest {
       ...
   }

@Import가 존재하는 경우

@Import가 존재하는 경우

@Import가 존재하지 않는 경우

@Import가 존재하지 않는 경우

 

3. 🔥 BaseEntity에서 감사 수행

@EntityListeners(AuditingEntityListener.class)는 JPA 엔터티의 생명 주기 이벤트를 감지하여 Auditing(감사) 기능을 적용하는 역할을 합니다. 이 어노테이션을 사용하면@CreatedDate, @LastModifiedDate 같은 어노테이션과 함께 엔터티의 생성 시간, 수정 시간 등을 자동으로 기록할 수 있습니다.

   // @EntityListeners 예시 코드 
   @MappedSuperclass
   @EntityListeners(AuditingEntityListener.class)
   public abstract class BaseEntity {
       // 공통 Auditing 필드 (@CreatedDate, @LastModifiedDate 등)
       // ...
   }

   @Entity
   public class YourEntity extends BaseEntity {
       // ...
   }

💡@MappedSuperclass를 함께 선언하는 이유

@MappedSuperclassJPA에서 테이블에 매핑되지 않는 추상 클래스에 붙이는 어노테이션입니다.

이 클래스를 상속받는 엔티티 클래스는 추상 클래스의 필드(예: 생성일, 수정일)를 그대로 자신의 테이블 컬럼으로 사용할 수 있게 합니다. @EntityListeners와 함께 사용하면 이 추상 클래스의 필드들을 자동으로 관리할 수 있어 중복 코드를 줄여줍니다.

@MappedSuperclass의 특징을 정리하면 아래와 같습니다.

  1. 엔티티 매핑을 하지 않습니다.
    @MappedSuperclass가 있는 클래스 자체는 데이터베이스의 테이블로 생성되지 않습니다.
  2. 상속을 통해 필드 재사용이 가능합니다.
    상속받는 엔티티 클래스는 @MappedSuperclass 클래스의 모든 필드를 상속받아 사용합니다.
  3. 상속받는 엔티티에서 테이블 컬럼으로 사용됩니다.
    @MappedSuperclass의 필드는 상속받은 엔티티의 테이블에 그대로 컬럼으로 추가됩니다.
    예를 들어, BaseEntitycreatedAt 필드가 YourEntitycreatedAt 컬럼으로 매핑됩니다.
  4. Auditing 기능과 함께 사용합니다.
    @EntityListeners(AuditingEntityListener.class)와 함께 사용하면, createdAt과 같은 필드에 @CreatedDate@LastModifiedDate 어노테이션을 붙여 자동으로 생성 및 수정 시간을 관리할 수 있습니다.
  1. AuditorAware, DateTimeProvider 빈 등록
    1. AuditorAware를 구현하는 클래스를 작성합니다.
    2. @Bean 어노테이션을 사용하여 스프링 컨텍스트에 등록합니다.
    @Configuration
    @EnableJpaAuditing
    public class JpaConfig {
    
        @Bean
        public AuditorAware<String> auditorAware() {
            return () -> Optional.of("system"); // 또는 Spring Security에서 사용자 정보 가져오기
        }
    }
  2. @CreatedBy 또는 @LastModifiedBy 필드를 자동으로 채우려면 AuditorAware<T> 인터페이스를 구현하는 빈을 제공해야 합니다.
    T는 Auditing 정보를 나타내는 타입입니다. (예: String 또는 Long)

1.3 예제 코드

지금까지 학습한 내용으로 단일 애플리케이션에서 충분히 Auditing 기능을 코드 구현할 수 있습니다.

1. spring-boot-starter-data-jpa 추가

JPA 활성화 + Spring Data JPA 의존성은 Spring Data JPA 라이브러리 한 줄 작성으로 가능해요.

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
...
}

2. BaseEntity 생성

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
import lombok.Getter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
public abstract class BaseEntity {

    @CreatedDate // 생성일자
    private LocalDateTime createdAt;

    @LastModifiedDate // 수정일자
    private LocalDateTime updatedAt;

    @CreatedBy // 생성자
    private Long createdBy;

    @LastModifiedBy // 수정자
    private Long updatedBy;
}

3. Auditing Config 파일 생성

@Configuration
@EnableJpaAuditing
public class JpaAuditConfig {

    @Bean
    public AuditorAware<Long> auditorProvider() {
        return new AuditorAwareImpl(); // 현재 사용자 정보
    }

}

4. AuditorAware 구현체 구현

AuditorAware 구현체를 구현하는 방법은 크게 Spring Security와 연동하는 방법직접 사용자가 사용자 정보를 요청 헤더나 쿠키 등에서 사용자 정보를 추출하는 방법이 있습니다.

public class AuditorAwareImpl implements AuditorAware<Long> {

    @Override
    public Optional<Long> getCurrentAuditor() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if(null == authentication || !authentication.isAuthenticated()) {
            return null;
        }

        //사용자 환경에 맞게 로그인한 사용자의 정보를 불러온다. 
        CustomUserDetails userDetails = (CustomUserDetails)authentication.getPrincipal();

        return Optional.of(userDetails.getId());
    }
}

AuditorAwareImpl implements AuditorAware<Long> 클래스는 Spring Data JPA Auditing 기능에서 엔티티 생성자/수정자 정보를 자동으로 채워주기 위해 사용됩니다. 이 인터페이스는 현재 인증된 사용자의 ID(Long 타입)를 제공하는 getCurrentAuditor() 메서드를 구현해야 합니다.

가장 일반적인 구현 방법은 Spring Security와 연동하여 현재 로그인한 사용자의 정보를 가져오는 것입니다.
Spring Security를 사용하는 경우, AuditorAware<Long> 구현체를 생성하기 위해 다음 단계를 따릅니다.

  1. AuditorAware 구현체 생성:
    SecurityContextHolder를 사용하여 현재 인증 정보를 가져오고, 인증된 사용자(예: CustomUserDetails)의 ID를 Optional<Long> 형태로 반환하는 AuditorAware 구현 클래스를 작성합니다. 익명 사용자는 빈 Optional을 반환합니다. 전체 코드는 Tistory에서 확인할 수 있습니다.
  2. JPA Auditing 활성화:
    설정 클래스에 @EnableJpaAuditing를 추가하고, 위에서 만든 AuditorAware 구현체를 빈으로 등록합니다. 코드는 Tistory에서 확인할 수 있습니다.
  3. Auditing 적용할 엔티티 생성:
    엔티티 필드에 @CreatedBy@LastModifiedBy 어노테이션을 추가하고, 엔티티 클래스에 @EntityListeners(AuditingEntityListener.class)를 적용합니다.

2. 멀티 모듈에서 Auditing 설정

Auditing 기능을 사용하기 위해 spring-boot-starter-data-jpa으로 JPA와 Spring Data JPA 의존성을 자동으로 구성했습니다.
멀티 모듈 환경에서는 어떻게 구성해야 적절한 지 학습합니다.

2.1 앱은 가볍게, Commons에서 Auditing

  • commons: BaseEntity, AuditingAutoConfiguration, AuditingProperties, AutoConfiguration.imports
  • : spring-boot-starter-data-jpa + DB 드라이버만 추가 → 코드/설정 없음
commerce-commons
 ├─ auditing/BaseEntity.java
 ├─ auditing/AuditingAutoConfiguration.java
 ├─ auditing/AuditingProperties.java
 └─ resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
commerce-catalog (앱)
 └─ build.gradle (JPA/DB 의존만)

멀티 모듈에서 Auditing 파일 구조 예

- resources/META-INF/spring 하위 org.springframework.boot... 파일입니다.(spring.org.springframework.boot... ❌)

2.2 시간 타입 정책

  • LocalDateTime (기본값): 현재 시스템 시각 기준으로 MySQL DATETIME과 자연스럽게 사용할 수 있습니다.
  • Zoned/OffsetDateTime: TimeZone, UTC 보존, 여러 국가 혹은 다중 리전 확장에 유리합니다.
// AuditingProperties.java
package auditing;


import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Setter
@Getter
@ConfigurationProperties(prefix = "app.auditing")
class AuditingProperties {

    private String zone = "Asia/Seoul";
    private boolean useLocalDateTime = true; // true: LocalDateTime, false: ZonedDateTime
}

DateTimeProvider가 시간 타입 정책인 AuditingProperties 안의 내용을 반영합니다.
참고로 Auditing 프로퍼티 클래스를 생성한 이유는 시간대 확장성을 고려했기 때문이에요. 반드시 필요한 클래스는 아닙니다.

2.3 소프트 삭제

  • BaseEntity 안에 deletedAt/deletedBy 편의 메서드를 구현했습니다.
public void markDeleted(Long by, LocalDateTime at) { ... }
public void restore() { ... }
public boolean isDeleted() { ... }

💡왜 엔티티가 시간을 만들지 않나요?
엔티티가 KST/UTC/보안 컨텍스트를 모르면 테스트/재사용이 쉬워집니다. 서비스에서 현재 사용자/시간을 넘겨주도록 했습니다. 그래서 markDeleted(Long by, LocalDateTime at) { ... } 메서드의 파라미터에 LocalDateTime 타입이 존재해요.

조회에서 삭제 레코드를 기본 제외하고 싶다면, 구체 엔티티@Where(clause = "deleted_at IS NULL") 를 붙이는 것을 권장합니다.

2.4 전이 의존성 최소화

commons는 라이브러리 모듈이므로 스타터 금지, implementation/compileOnly로 최대한 숨깁니다.
앱마다 필요한 스타터(Spring Data JPA, Spring Web, Redis Driver 등)는 앱이 직접 선언합니다.

// commons: build.gradle
dependencies {
    // JPA/Auditing (전이는 막기 위해 implementation 권장)
    implementation(libs.jakarta.persistence)     // ← api  ➜ implementation
    implementation(libs.spring.data.commons)     // ← api  ➜ implementation
    implementation(libs.spring.data.jpa)         // ← api  ➜ implementation
    implementation(libs.hibernate.core)

      ...

    // AutoConfiguration 지원
    implementation(libs.spring.boot.autoconfigure)
}
# gradle/libs.versions.toml
...
# Spring Data JPA
jakarta-persistence = { module = "jakarta.persistence:jakarta.persistence-api" }
spring-data-commons = { module = "org.springframework.data:spring-data-commons" }
spring-data-jpa = { module = "org.springframework.data:spring-data-jpa" }
hibernate-core = { module = "org.hibernate.orm:hibernate-core" }

# autoConfig
spring-boot-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure" }
...

2.5 BaseEntity

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.Transient;
import java.time.LocalDateTime;
import lombok.Getter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@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)
    private Long createdBy;

    @LastModifiedBy
    private Long updatedBy;

    @Column
    private LocalDateTime deletedAt;

    @Column
    private Long deletedBy;

    public void markDeleted(Long by, LocalDateTime at) {
        this.deletedBy = by;
        this.deletedAt = at;
    }

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

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

    protected void setDeletedAt(LocalDateTime at) {
        // 엔티티가 “KST/UTC” 정책을 몰라야 테스트와 재사용이 쉬운 점을 고려했습니다.
        this.deletedAt = at;
    }

    protected void setDeletedBy(Long by) {
        this.deletedBy = by;
    }
}

2.6 AutoConfiguration + Properties

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Optional;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@AutoConfiguration
@ConditionalOnClass(DateTimeProvider.class)
@EnableConfigurationProperties(AuditingProperties.class)
@EnableJpaAuditing(auditorAwareRef = "auditorAware",
        dateTimeProviderRef = "auditingDateTimeProvider")
public class AuditingAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    AuditorAware<Long> auditorAware() {
        // TODO: 나중에 공통 필터/시큐리티 컨텍스트에서 가져오도록 교체 가능
        return () -> Optional.of(0L);
    }

    @Bean
    @ConditionalOnMissingBean(name = "auditingDateTimeProvider")
    DateTimeProvider auditingDateTimeProvider(AuditingProperties props) {
        ZoneId zone = ZoneId.of(props.getZone());
        if (props.isUseLocalDateTime()) {
            return () -> Optional.of(LocalDateTime.now(zone));
        } else {
            return () -> Optional.of(ZonedDateTime.now(zone));
        }
    }
}
@ConfigurationProperties(prefix = "app.auditing")
class AuditingProperties {
  private String zone = "Asia/Seoul";
  private boolean useLocalDateTime = true;
  // getter/setter …
}

2.7 AutoConfiguration 등록

스프링 부트 3.0 이상에서 AutoConfiguration은 자동 구성 클래스를 더 효율적으로 관리하기 위해 등장한 최적화 기능입니다.

Spring Boot는 프로젝트에 필요한 자동 구성 클래스만 선택적으로 로드하여 애플리케이션 부팅 속도를 향상시키고, spring-boot-autoconfigure.jar 파일에 포함된 클래스 목록에서 이를 효율적으로 관리합니다.

src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
auditing.AuditingAutoConfiguration

멀티 모듈 프로젝트에서 Auditing 기능을 AutoConfiguration으로 구현하는 것은, 반복적인 설정을 줄이고 개발 생산성을 높이는 효과적인 방법입니다. 다음은 Spring Data JPA Auditing 기능을 멀티 모듈 환경에서 AutoConfiguration으로 설정하는 상세한 방법입니다.

Auditing 사용 시 주의사항

Spring Data JPA의 Auditing 기능으로 자동 시간 기록, 자동 사용자 기록, 변경 이력 추적 등의 이점이 있습니다. 그러나 단점도 명확하게 존재합니다. QueryDSL과 같은 다른 기술과의 비호환성, ZonedDateTime 사용 시 발생하는 시간대 관련 문제, 그리고 Auditing 기능이 적용되지 않는 예외적인 상황(예: 엔티티를 조회하지 않고 직접 업데이트하는 경우)입니다.

  • QueryDSL과의 비호환성
    QueryDSL을 사용해 엔티티를 조회하지 않고 직접 UPDATE 쿼리를 실행하면, Auditing 기능이 자동으로 작동하지 않습니다. Auditing은 엔티티의 생명주기 이벤트를 기반으로 동작하기 때문입니다.
  • 시간대(Timezone) 문제
    ZonedDateTime을 사용할 때, 프로젝트의 다국어 환경이나 요구사항에 따라 시간대 문제가 발생할 수 있습니다. 이는 UTC로 저장하고 클라이언트에서 변환하는 방식과 같은 추가적인 처리가 필요함을 의미합니다.
  • 예외적인 상황
    Auditing은 엔티티를 영속성 컨텍스트에 저장하거나 조회하는 과정을 통해 작동합니다. 따라서 엔티티를 조회하지 않고 직접 UPDATE하는 등, JPA의 생명주기 이벤트가 발생하지 않는 경우에는 Auditing 기능이 적용되지 않습니다.

QueryDSL 사용 시 Auditing을 적용하려면, 엔티티를 먼저 조회한 후 필드를 변경하고 save()하는 방식(엔티티 조회 후 수정)을 사용해야 합니다. 시간대 문제는 UTC로 DB에 저장하고 애플리케이션 레벨에서 시간대를 맞춰 처리하는 방식으로 해결할 수 있습니다.