Spring Boot

Spring Boot JPA Auditing(@CreatedDate 등)로 생성/수정 시간 자동 기록하기

IT Lab 2026. 3. 17. 20:00

Spring Boot 3.x에서 JPA Auditing으로 createdAt/updatedAt을 자동 채우는 방법과 베이스 엔티티 설계, 운영에서 자주 겪는 함정을 정리합니다.

도입 (문제 상황)

엔티티를 저장할 때마다 createdAt, updatedAt을 매번 세팅하다 보면, 한두 군데는 꼭 빠지기 마련입니다. 특히 배치/관리자 기능/테스트 코드에서 누락되면 “왜 어떤 데이터는 시간이 비어 있지?” 같은 운영 이슈로 이어지기도 해요. 이럴 때 JPA Auditing을 쓰면 생성/수정 시간을 거의 자동화할 수 있습니다.

핵심 개념 — Spring Boot JPA Auditing이 중요한 이유

JPA Auditing은 엔티티의 “언제 생성/수정됐는지(그리고 누가 했는지)” 같은 메타 정보를 영속성 이벤트 시점에 자동으로 채워주는 기능입니다. 핵심은 두 가지예요.

  1. 애플리케이션 코드에서 시간 세팅 책임을 제거합니다.
    서비스/컨트롤러/배치 어디에서 저장하든, 엔티티가 persist/update 되는 순간에 동일한 규칙으로 값이 들어갑니다. 마치 “DB 트리거”처럼 동작하지만, 로직은 애플리케이션에 있어 테스트와 유지보수가 쉬워요.
  2. 베이스 엔티티로 표준화할 수 있습니다.
    여러 엔티티에 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 기반).
  • createdAtupdatable = 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 모두 강제 가능 디버깅/테스트 어려움, 운영 변경 부담 다수 시스템이 같은 테이블을 갱신하는 환경

JPA Auditing과 DB 트리거/기본값 비교 개념도

실무 팁

💡 실무에서는: 시간 타입/시간대(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 도입 전 꼭 알아야 할 것들]