Spring Boot 3.x에서 JPA Auditing으로 createdAt/updatedAt을 자동 채우는 방법과 베이스 엔티티 설계, 운영에서 자주 겪는 함정을 정리합니다.
도입 (문제 상황)
엔티티를 저장할 때마다 createdAt, updatedAt을 매번 세팅하다 보면, 한두 군데는 꼭 빠지기 마련입니다. 특히 배치/관리자 기능/테스트 코드에서 누락되면 “왜 어떤 데이터는 시간이 비어 있지?” 같은 운영 이슈로 이어지기도 해요. 이럴 때 JPA Auditing을 쓰면 생성/수정 시간을 거의 자동화할 수 있습니다.
핵심 개념 — Spring Boot JPA Auditing이 중요한 이유
JPA Auditing은 엔티티의 “언제 생성/수정됐는지(그리고 누가 했는지)” 같은 메타 정보를 영속성 이벤트 시점에 자동으로 채워주는 기능입니다. 핵심은 두 가지예요.
- 애플리케이션 코드에서 시간 세팅 책임을 제거합니다.
서비스/컨트롤러/배치 어디에서 저장하든, 엔티티가persist/update되는 순간에 동일한 규칙으로 값이 들어갑니다. 마치 “DB 트리거”처럼 동작하지만, 로직은 애플리케이션에 있어 테스트와 유지보수가 쉬워요. - 베이스 엔티티로 표준화할 수 있습니다.
여러 엔티티에createdAt,updatedAt필드를 반복해서 만들지 않고,@MappedSuperclass기반의 베이스 클래스로 공통화하면 누락도 줄고 일관성도 좋아집니다.
다만 “완전 자동”이라고 해서 아무 생각 없이 붙이면 운영에서 문제가 생길 수 있는 포인트도 있습니다. 대표적으로는:
- 시간대(UTC vs KST) 정책이 불명확해서 데이터가 뒤섞임
updatedAt이 기대대로 갱신되지 않는 케이스(변경 감지/더티체킹 조건)- DB 기본값/트리거와 Auditing이 충돌
아래 예제로 가장 흔한 형태(생성/수정 시간 자동화 + 베이스 엔티티)를 Spring Boot 3.x 기준으로 정리해 보겠습니다.
flowchart LR
A["Service"] --> B["Repository save()"]
B --> C["JPA EntityManager"]
C --> D["AuditingEntityListener"]
D --> E["@CreatedDate / @LastModifiedDate set"]
E --> F["DB Insert/Update"]
JPA 저장/수정 이벤트에서 Auditing 리스너가 값을 채운 뒤 DB에 반영되는 흐름입니다.
코드 예제 — @CreatedDate/@LastModifiedDate 베이스 엔티티로 바로 적용하기
아래 코드는 그대로 복붙해서 실행 가능한 최소 구성입니다. (Java 17, Spring Boot 3.x)
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.2'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
운영 DB를 쓰실 때도 Auditing 자체는 동일하지만, 시간대 정책(UTC 고정 등)은 별도로 정하시는 게 중요합니다.
Auditing 활성화 설정
package com.example.demo;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}
베이스 엔티티 (생성/수정 시간)
package com.example.demo.domain;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.Instant;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}
Instant를 쓰면 시간대 혼선을 줄이기 좋습니다(UTC 기반).createdAt은updatable = false로 막아두면 실수로 덮어쓰는 사고를 줄일 수 있어요.
예시 엔티티/리포지토리
package com.example.demo.domain;
import jakarta.persistence.*;
@Entity
@Table(name = "posts")
public class Post extends BaseTimeEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
protected Post() {}
public Post(String title) {
this.title = title;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public void changeTitle(String title) {
this.title = title;
}
}
package com.example.demo.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PostRepository extends JpaRepository<Post, Long> {
}
동작 확인용 컨트롤러
package com.example.demo.web;
import com.example.demo.domain.Post;
import com.example.demo.domain.PostRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.Map;
@RestController
public class PostController {
private final PostRepository postRepository;
public PostController(PostRepository postRepository) {
this.postRepository = postRepository;
}
@PostMapping("/posts")
public Map<String, Object> create(@RequestParam String title) {
Post saved = postRepository.save(new Post(title));
return Map.of(
"id", saved.getId(),
"createdAt", saved.getCreatedAt(),
"updatedAt", saved.getUpdatedAt()
);
}
@Transactional
@PatchMapping("/posts/{id}")
public Map<String, Object> update(@PathVariable Long id, @RequestParam String title) {
Post post = postRepository.findById(id).orElseThrow();
Instant before = post.getUpdatedAt();
post.changeTitle(title); // 더티체킹으로 update 발생 시 updatedAt 갱신
return Map.of(
"id", post.getId(),
"updatedAtBefore", before,
"updatedAtAfter", post.getUpdatedAt()
);
}
}
한눈에 정리: Auditing vs DB 기본값/트리거
운영에서 “어디에서 시간을 책임질지”를 먼저 정하시는 게 좋아요.
| 방식 | 장점 | 단점/주의 | 추천 상황 |
|---|---|---|---|
| JPA Auditing | 애플리케이션 표준화, 테스트 용이, 엔티티 단에서 일관적 | 시간대 정책 필요, 일부 업데이트 케이스에서 기대와 다를 수 있음 | 대부분의 Spring Boot/JPA 서비스 |
| DB DEFAULT(now) | DB가 강제, 타 언어/도구로 넣어도 동일 | 수정 시간 자동화는 별도 처리 필요, DB 종속 | 생성 시간만 간단히 보장하고 싶을 때 |
| DB Trigger | insert/update 모두 강제 가능 | 디버깅/테스트 어려움, 운영 변경 부담 | 다수 시스템이 같은 테이블을 갱신하는 환경 |

실무 팁
💡 실무에서는: 시간 타입/시간대(UTC)부터 먼저 합의해 보세요
LocalDateTime은 “시간대 정보가 없는 값”이라, 서버/배치/운영자 PC가 서로 다른 시간대를 쓰면 해석이 꼬일 수 있습니다. 가능하면 DB에는Instant(UTC)로 저장하고, 화면/API 응답에서만 KST로 변환하는 방식이 운영 사고가 적습니다.
또한createdAt/updatedAt컬럼에는 인덱스가 필요한지(예: 최신순 조회, 기간 검색)를 초기에 같이 검토해 두는 편이 좋습니다.
💡 실무에서는: updatedAt이 안 바뀌는 “정상 케이스”가 있습니다
@LastModifiedDate는 “업데이트 쿼리가 실제로 나갈 때” 갱신됩니다. 즉, 엔티티 값을 바꾸지 않았거나(더티 아님), 변경이 감지되지 않는 방식으로 수정했다면(예: 일부 네이티브 쿼리/벌크 업데이트)updatedAt이 기대대로 안 바뀔 수 있어요.
- 벌크 업데이트(JPQL update)는 영속성 컨텍스트를 우회하므로 Auditing도 우회합니다. 이런 경우
updated_at = now()를 쿼리에 명시하거나, 벌크 작업 후clear()전략을 포함해 설계해 보세요.
핵심 요약
- JPA Auditing은 생성/수정 시간 기록을 저장 시점에 자동화해 누락과 중복 코드를 줄여줍니다.
@MappedSuperclass + BaseTimeEntity로 공통 필드를 표준화하는 게 가장 실용적입니다.- 운영에서는 시간대(UTC) 정책과 벌크 업데이트 시 Auditing 우회 문제를 특히 주의하세요.
다음 글: [#20 QueryDSL 도입 전 꼭 알아야 할 것들]