JPA Auditing

김민호·2026년 5월 24일

JPA

목록 보기
1/2

엔티티가 생성/수정될 때 시간을 자동으로 기록해주는 JPA Auditing 기능을 이해하자.

매번 createdAt = LocalDateTime.now()를 직접 쓰지 않아도 자동으로 처리되는 원리를 익히자.


@CreatedDate / @LastModifiedDate

어노테이션동작 시점이후 변경 여부
@CreatedDate최초 INSERT 시변경 안 됨
@LastModifiedDateINSERT + UPDATE 시수정될 때마다 갱신

동작 원리

JPA 엔티티 생명주기 훅

JPA는 엔티티 생명주기마다 전/후로 실행할 수 있는 이벤트 훅을 제공한다.

훅실행 시점
@PrePersistpersist() 직전
@PostPersistpersist() 직후
@PreUpdateupdate 직전
@PostUpdateupdate 직후
@PreRemoveremove() 직전
@PostRemoveremove() 직후

앱 시작 시 JPA가 리스너를 스캔하면서 어떤 훅 메서드가 있는지 전부 기억해뒀다가, 해당 시점이 되면 자동으로 실행한다.

AuditingEntityListener

우리가 훅을 직접 구현하지 않아도 되는 이유는, Spring Data JPA가 AuditingEntityListener 안에 이미 구현해뒀기 때문이다.

// AuditingEntityListener 내부 (Spring Data JPA가 만들어둔 것)
public class AuditingEntityListener {

    @PrePersist
    public void touchForCreate(Object target) {
        // @CreatedDate 필드를 찾아서 현재 시간 주입
    }

    @PreUpdate
    public void touchForUpdate(Object target) {
        // @LastModifiedDate 필드를 찾아서 현재 시간 주입
    }
}

AuditingEntityListener는 6개의 훅 중 @PrePersist와 @PreUpdate만 사용한다.

우리는 @EntityListeners(AuditingEntityListener.class)로 "이 리스너 써줘" 라고 등록만 하면 된다.

전체 동작 흐름

앱 시작
  → JPA가 엔티티 클래스 스캔
  → @EntityListeners 확인 → AuditingEntityListener 등록
  → 리스너 안의 @PrePersist, @PreUpdate 붙은 메서드도 기억해둠
  → 이후 해당 시점마다 자동 실행 (매번 확인 X)

em.persist(member) 호출
  → JPA: "PrePersist 시점이다!"
  → 기억해둔 touchForCreate() 실행
  → @CreatedDate 필드에 현재 시간 자동 주입
  → INSERT 쿼리에 시간 포함되어 DB 저장

설정 방법

필수 조건 2가지

① 엔티티에 리스너 등록

@EntityListeners(AuditingEntityListener.class)

② 스프링에 Auditing 활성화

@EnableJpaAuditing
@SpringBootApplication
public class Application { ... }

둘 중 하나라도 없으면 시간이 null로 저장된다.

  • @EntityListeners 없으면 → 리스너 자체가 연결 안 됨
  • @EnableJpaAuditing 없으면 → 리스너가 Bean으로 등록 안 돼서 동작 안 함

실무 패턴: BaseEntity 상속

엔티티마다 @EntityListeners를 붙이는 건 번거롭기 때문에, BaseEntity를 하나 만들어서 상속하는 방식을 사용한다.

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass // DB 테이블은 안 만들고 필드만 자식 엔티티에 포함
public abstract class BaseEntity {

    @CreatedDate
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;
}
@Entity
public class Member extends BaseEntity { // 상속만 하면 자동 적용
    ...
}

@Entity
public class Order extends BaseEntity { // 얘도 자동 적용
    ...
}

@MappedSuperclass는 DB 테이블을 별도로 만들지 않고, 필드만 자식 엔티티 테이블에 포함시켜 주는 어노테이션이다.


Persistable 인터페이스

왜 필요한가?

@GeneratedValue를 사용하면 JPA가 ID를 자동 생성하기 때문에 새 엔티티인지 판단이 쉽다.

하지만 ID를 직접 할당하는 경우 문제가 생긴다.

// ID를 직접 세팅하는 경우
Member member = new Member();
member.setId("user-001"); // ID 직접 할당
em.persist(member);

JPA는 save() 호출 시 새 엔티티인지 판단하기 위해 ID가 null인지 확인한다.

ID == null → 새 엔티티 → INSERT
ID != null → 기존 엔티티 → SELECT 후 merge (불필요한 SELECT 발생!)

ID를 직접 할당하면 ID가 null이 아니므로, JPA가 기존 엔티티로 오판해서 불필요한 SELECT 쿼리를 날린다.

해결 방법: Persistable 구현

Persistable 인터페이스를 구현해서 새 엔티티 판단 기준을 직접 정의한다.

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class BaseEntity implements Persistable<String> {

    @CreatedDate
    private LocalDateTime createdAt;

    @Override
    public boolean isNew() {
        // createdAt이 null이면 새 엔티티 (아직 한 번도 저장 안 됨)
        return createdAt == null;
    }
}

동작 흐름

save(member) 호출
  → isNew() 실행
  → createdAt == null ? → true (아직 저장 안 된 새 엔티티)
  → SELECT 없이 바로 INSERT 실행
  → @PrePersist → createdAt에 현재 시간 주입
  → 이후 save() 호출 시 createdAt != null → 기존 엔티티로 판단

결론

ID 직접 할당 전략을 쓴다면 Persistable을 구현해서 불필요한 SELECT를 방지하자.

@CreatedDate와 함께 사용하면 isNew() 판단 기준으로 딱 맞아 떨어진다.


핵심 정리

  • JPA 훅(@PrePersist 등)은 앱 시작 시 한 번 스캔해서 기억해뒀다가 해당 시점에 자동 실행된다.
  • AuditingEntityListener는 6개 훅 중 @PrePersist, @PreUpdate만 사용하며, Spring이 이미 구현해뒀다. 우리는 등록만 하면 된다.
  • ID 자동 생성(@GeneratedValue) → 기본 동작으로 충분하다.
  • ID 직접 할당 → Persistable 구현해서 불필요한 SELECT를 막자.
profile
개발자를 꿈꾸고 있어요

0개의 댓글