
JpaAudit(JPA Auditing) 은 엔티티가 언제 생성되고 수정되었는지, 그리고 누가 만들고 수정했는지를 자동으로 기록해주는 기능이다.
보통 서비스 개발에서는 거의 모든 테이블에 다음 컬럼이 공통으로 존재한다.
이걸 매번 서비스 로직에서 일일이 넣어줬다면, 코드가 지저분해지고 유지보수가 어렵다.
이를 JPA가 자동으로 처리하도록 하는 기능이 Auditing이다.
서비스, 컨트롤러에서 매번 LocalDateTime.now()를 넣을 필요가 없다.
업데이트할 때 updated_at을 깜빡한다든지, created_by를 잘못 넣는 실수를 줄일 수 있다.
프로젝트 전반에서 동일한 규칙으로 생성/수정 정보를 기록할 수 있다.
JPA Auditing은 크게 두 가지 요소로 구성된다.
Spring Boot에서 보통 다음 설정 하나면 끝난다.
@EnableJpaAuditing
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
여기서 @EnableJpaAuditing 이 중요하다.
이걸 통해 JPA가 엔티티의 생성·수정 시점을 감지하고 값을 넣어줄 수 있다.
보통 추상 클래스를 하나 만들어 상속한다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
여기서 중요한 어노테이션:
@CreatedDate: 엔티티가 처음 저장될 때 자동으로 값 삽입@LastModifiedDate: 엔티티가 변경될 때마다 자동으로 갱신@MappedSuperclass: 이 클래스를 상속하는 엔티티가 컬럼을 가져감@EntityListeners(AuditingEntityListener.class): JPA가 변경 이벤트를 감지하기 위함예를 들면 Product 엔티티에 적용하면 다음과 같다.
@Entity
public class Product extends BaseTimeEntity {
@Id @GeneratedValue
private Long id;
private String name;
}
이제 Product를 저장하면:
수정하면:
유저 정보까지 넣으려면 @CreatedBy, @LastModifiedBy를 사용한다.
@CreatedBy
private String createdBy;
@LastModifiedBy
private String updatedBy;
하지만 스프링이 알아서 사용자 정보를 알 수는 없으므로,
반드시 AuditorAware 구현체를 만들어서 “현재 로그인한 사용자”를 알려줘야 한다.
예시:
@Component
public class AuditorAwareImpl implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
// 예: Spring Security에서 username 가져오기
return Optional.of("hello22433");
}
}
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTime {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
@MappedSuperclass
public abstract class BaseEntity extends BaseTime {
@CreatedBy
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
@Entity
public class Order extends BaseEntity {
...
}
@EnableJpaAuditing 은 반드시 있어야 한다.설정하지 않으면 어노테이션이 있어도 자동 입력이 안 된다.
created_by, updated_by는 Security와 연동해야 진짜 유용해진다.@CreatedDate는 최초 1번만 입력된다. 수정해도 바뀌지 않는다.반면 @LastModifiedDate는 수정할 때마다 바뀐다.
datetime, timestamp와 매핑된다.@EnableJpaAuditing + Base Entity 클래스 + 어노테이션으로 동작아래는 실무에서 바로 써먹을 수 있는 JPA Auditing 심화 내용이다.
기본 개념을 이미 이해한 상태라 가정하고, 실제 프로젝트에서 중요한 포인트 중심으로 정리해줄게.
JPA Auditing은 JPA의 Entity Lifecycle Callback을 사용한다.
즉 다음 시점에 각각 호출된다.
| 어노테이션 | 동작 시점 |
|---|---|
@CreatedDate | @PrePersist 직전에 호출 |
@LastModifiedDate | @PreUpdate 직전에 호출 |
@CreatedBy | @PrePersist |
@LastModifiedBy | @PreUpdate |
따라서 중요한 특징이 생긴다.
엔티티를 수정했더라도 변경 감지가 일어나지 않으면 update 쿼리가 안 나가므로
@LastModifiedDate도 갱신되지 않는다.
예)
order.setStatus(order.getStatus());
값이 같으면 dirty checking이 발생하지 않아 updatedAt도 갱신되지 않는다.
Auditing 값은 영속성 컨텍스트에 세팅되지만, DB 반영은 flush 시점에 일어난다.
트랜잭션 경계를 명확히 알고 있어야 한다.
많은 실수 중 하나가 @EnableJpaAuditing을 @Configuration 클래스에 등록하는 경우인데,
그 자체는 문제 없지만 테스트 환경에서는 다음 문제가 발생한다.
@DataJpaTest는 Application 전체를 로드하지 않기 때문에
Auditing 설정을 따로 넣어야 한다.
해결:
@DataJpaTest
@Import(TestJpaConfig.class)
class RepositoryTest { }
@Component
public class AuditorAwareImpl implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
return Optional.empty();
}
return Optional.of(auth.getName());
}
}
하지만 실제 서비스에서는 다음 문제가 발생한다.
ThreadLocal 기반이라 @Async에서는 SecurityContextHolder가 비어있다.
SecurityContextPropagation을 해야 한다.
@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
return new DelegatingSecurityContextExecutor(Executors.newFixedThreadPool(8));
}
}
이 경우 Request Filter에서 직접 SecurityContext에 넣어줘야 한다.
보통 시스템 계정(system)으로 설정한다.
return Optional.of("SYSTEM");
대부분의 실제 서비스는 delete 쿼리를 사용하지 않고,
deleted_at, deleted_by, is_deleted 같은 컬럼을 사용한다.
예)
@Entity
public class Post extends BaseEntity {
private boolean deleted = false;
private LocalDateTime deletedAt;
private String deletedBy;
public void softDelete(String username) {
this.deleted = true;
this.deletedAt = LocalDateTime.now();
this.deletedBy = username;
}
}
JPA Auditing만으로는 delete 이벤트를 감지하는 기능이 없다.
@PreRemove는 엔티티가 삭제되기 직전에만 호출된다.
Soft Delete는 remove를 호출하지 않으므로 동작하지 않는다.
실무에서는 LocalDateTime은 권장하지 않는다.
타임존 정보가 없기 때문이다.
문제 예시)
서버는 UTC, DB는 Asia/Seoul, 애플리케이션은 로컬 LocalDateTime을 사용한다면
병합 시 혼란을 일으킬 수 있다.
Auditing 적용 예)
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
| 구분 | JPA Auditing | DB Trigger |
|---|---|---|
| 관리 위치 | 애플리케이션 | 데이터베이스 |
| 장점 | 코드로 관리, 테스트 가능 | 앱 로직과 상관없이 항상 보장 |
| 단점 | 앱 밖에서 변경된 데이터는 감지 못함 | DB마다 설정 방식이 다름 |
| 선호도 | MSA 환경에서는 선호 | 금융권·레거시는 트리거 선호 |
단일 서비스 → JPA Auditing
DB가 여러 곳에서 수정될 수 있는 환경 → 트리거
예를 들어 아래처럼 값 객체 스타일 엔티티는 setter가 없다.
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private final String title;
@CreatedDate
private LocalDateTime createdAt;
}
JPA Auditing은 필드에 직접 접근하므로 setter 없어도 괜찮다.
하지만 final 필드에는 값을 대입할 수 없기 때문에
Audit 필드는 final로 만들면 안 된다.
중요한 문제.
다음 코드는 updated_at이 전혀 갱신되지 않는다.
@Modifying
@Query("UPDATE Product p SET p.status = :status")
void bulkUpdateStatus(String status);
이유
Bulk Update는 JPA가 관리하는 영속성 컨텍스트를 건너뛴다.
따라서 @LastModifiedDate가 동작할 수 없다.
실무에서는 BaseEntity를 여러 개로 분리한다.
BaseTimeEntity
(createdAt, updatedAt)
BaseEntity
(createdAt, updatedAt, createdBy, updatedBy)
DeletableEntity
(+ deletedAt, deletedBy, isDeleted)
Auditing은 단순히 createdAt과 updatedAt을 넣어주는 기능을 넘어서서
실제 서비스에서는 다음을 고려해야 한다.
모두 제대로 알고 있어야 문제 없이 실무에서 활용할 수 있다.