🏁 서론
Spring Boot에서 JPA를 사용하며 @CreationTimestamp을 활용해 CREATED_AT 필드를 자동으로 업데이트 하도록 하였다.
하지만 DB를 확인하던 중, 먼저 저장된 레코드의 생성 일자가 더 이후의 일자로 기록되고 있음을 알게 되었다.
이번 글은 이에 대한 트러블슈팅과 알게 된 점에 대해 정리해 보고자 한다.
🚨 문제 상황
@Column(name = "CREATED_AT", nullable = false, columnDefinition = "TIMESTAMP(2)")
@CreationTimestamp
private LocalDateTime createdAt;- 위와 같이, 엔터티 중 createdAt 필드에는 @CreationTimestamp를 적용해 두었다.
@Column(name = "MODIFIED_AT", nullable = false, columnDefinition = "TIMESTAMP(2)")
@UpdateTimestamp
private LocalDateTime modifiedAt;- 위와 같이, 엔터티 중 modifiedAt 필드에는 @UpdateTimestamp를 적용해 두었다.

먼저 저장된 레코드의 생성일자가 더 뒤인 모습이다.
로컬(127.0.0.1)에서 실행한 경우에는, UTC+9인 Asia/Seoul 타임존으로 들어갔지만, 클라우드 서버에서 구동중인 컨테이너 앱에서는 UTC 기준으로 insert가 되었다.
그 이유가 무엇일까?
DB 타임존 확인
DB의 타임존이 UTC로 되어 있어서 그런 것은 아닐까? 한 번 확인해 보았다.

# cat /etc/timezone
Etc/UTC
# ls -l /etc/localtime
lrwxrwxrwx 1 root root 27 Sep 19 2023 /etc/localtime -> /usr/share/zoneinfo/Etc/UTC
# date
Wed Nov 5 03:18:22 UTC 2025확인 결과, UTC+9로 잘 설정되어 있었다.
도커 컨테이너 타임존 확인
도커 컨테이너를 확인해 보니, UTC 타임존으로 돌아가고 있었다.


그렇기에 로컬 환경에서 동일한 환경을 맞추어(-Duser.timezone=UTC 옵션) 테스트해보니, VM의 타임존을 그대로 따르며 UTC 기준으로 레코드가 insert되는 모습을 확인하였다.
즉, 해당 부분에서 문제를 해결해야 한다.
DB는 Asia/Seoul로 잘 설정되어 있고, 여기서 처음 사용해 보는 것은 @CreationTimestamp 뿐이기 때문에 해당 기능을 파헤쳐봐야 겠다는 생각이 들었다.
🔍 @CreationTimestamp 파고들기
/*
* Hibernate, Relational Persistence for Idiomatic Java
*
* License: GNU Lesser General Public License (LGPL), version 2.1 or later.
* See the lgpl.txt file in the root directory or <http://www.gnu.org/licenses/lgpl-2.1.html>.
*/
package org.hibernate.annotations;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import org.hibernate.generator.internal.CurrentTimestampGeneration;
/**
* Specifies that the annotated field of property is a generated <em>creation timestamp</em>.
* The timestamp is generated just once, when an entity instance is inserted in the database.
* <p>
* By default, the timestamp is generated by {@linkplain java.time.Clock#instant() in memory},
* but this may be changed by explicitly specifying the {@link #source}.
* Otherwise, this annotation is a synonym for
* {@link CurrentTimestamp @CurrentTimestamp(timing=INSERT,source=VM)}.
*
* @author Gunnar Morling
*
* @see CurrentTimestamp
*/
@ValueGenerationType(generatedBy = CurrentTimestampGeneration.class)
@Retention(RUNTIME)
@Target({ FIELD, METHOD })
public @interface CreationTimestamp {
/**
* Specifies how the timestamp is generated. By default, it is generated
* in memory, which saves a round trip to the database.
*/
SourceType source() default SourceType.VM;
}
- 주석을 읽어보면 아래와 같다.
Specifies that the annotated field of property is a generated creation timestamp. The timestamp is generated just once, when an entity instance is inserted in the database. By default, the timestamp is generated by {@linkplain java.time.Clock#instant() in memory}, but this may be changed by explicitly specifying the {@link source}. Otherwise, this annotation is a synonym for {@link CurrentTimestamp @CurrentTimestamp(timing=INSERT,source=VM)}.
어노테이션이 지정된 필드 또는 프로퍼티가 생성된 생성 타임스탬프임을 명시합니다. 이 타임스탬프는 엔티티 인스턴스가 데이터베이스에 삽입될 때 단 한 번만 생성됩니다.
기본적으로 타임스탬프는 {@linkplain java.time.Clock#instant() 메모리에서} 생성되지만, {@link source}를 명시적으로 지정하여 변경할 수 있습니다.
다른 설정이 없으면 이 어노테이션은 {@link CurrentTimestamp @CurrentTimestamp(timing=INSERT,source=VM)}와 동의어입니다.
Specifies how the timestamp is generated. By default, it is generated in memory, which saves a round trip to the database.
타임스탬프가 어떻게 생성되는지 명시합니다. 기본적으로는 메모리에서 생성되며, 이는 데이터베이스로의 왕복(round trip)을 줄여줍니다.
💡 “이 타임스탬프는 엔티티 인스턴스가 데이터베이스에 삽입될 때 단 한 번만 생성됩니다.”
- 이 때문인지 드물지만 save를 여러 번 호출하는 경우에, 해당 필드가 null로 저장될 수가 있다고 한다. [Spring boot] @CreationTimeStamp null after update
💡 “다른 설정이 없으면 이 어노테이션은 {@link CurrentTimestamp @CurrentTimestamp(timing=INSERT,source=VM)}와 동의어입니다.” / “타임스탬프가 어떻게 생성되는지 명시합니다. 기본적으로는 메모리에서 생성되며, 이는 데이터베이스로의 왕복(round trip)을 줄여줍니다.”
- 소스 코드에서 보이는 것처럼, 디폴트 값은 VM이다.
SourceType source() default SourceType.VM;- 그렇기 때문에, 아무런 옵션을 지정하지 않는다면 VM의 타임존을 따르게 된다.
✅ 해결 방법
방법 1. @CreationTimestamp(source = SourceType.DB)
@Column(name = "CREATED_AT", nullable = false, columnDefinition = "TIMESTAMP(2)")
@CreationTimestamp(source = SourceType.DB)
private LocalDateTime createdAt;
@Column(name = "MODIFIED_AT", nullable = false, columnDefinition = "TIMESTAMP(2)")
@UpdateTimestamp(source = SourceType.DB)
private LocalDateTime modifiedAt;- 소스 타입을 DB로 설정하여, DB의 타임존과 동기화 시키는 방식이다.
- 일관성 측면에서
SourceType.DB가 압도적으로 유리하다. SourceType.DB로 설정하면, Hibernate는INSERT나UPDATE쿼리를 생성할 때 시간 값을 직접 넣는 대신, 데이터베이스의 내장 함수를 호출하도록 SQL을 생성한다.INSERT INTO ... (created_at) VALUES (... a, now())UPDATE ... SET updated_at = now() WHERE ...
사이드 이펙트: 시점의 미묘한 차이
SourceType.VM: Java에서 엔티티 객체가 생성/수정된 시점의 시간SourceType.DB: 이 엔티티가 실제 DB 트랜잭션의 일부로 커밋되거나 쿼리가 실행된 시점의 시간
→ 만약 트랜잭션이 매우 길거나 큐(Queue)에 적재되었다가 한참 뒤에 실행된다면, 두 시간의 차이가 발생할 수 있다.
방법 2. SourceType.VM & 기본 타임존 변경
이 방식은 SourceType.VM을 유지하되, 모든 JVM과 Docker 컨테이너의 기본 타임존을 Asia/Seoul 등으로 강제하는 방식이다.
- JVM 옵션:
java -Duser.timezone="Asia/Seoul" -jar app.jar - Docker 환경 변수:
ENV TZ="Asia/Seoul" - 시간을
메모리에서 가져오기 때문에, 성능적으로 미세하게 우수하다.- (주석 중) “기본적으로는 메모리에서 생성되며, 이는 데이터베이스로의 왕복(round trip)을 줄여줍니다.”
사이드 이펙트 1: 설정 누락의 위험
- 개발자가 로컬에서 실행할 때, CI/CD에서 빌드할 때, Docker 이미지를 빌드할 때, 운영 서버에 배포할 때 등 모든 환경에서 타임존 설정을 단 한 곳이라도 빠뜨리면 해당 환경에서만 UTC(혹은 다른 기본값)로 시간이 기록되어 꼬일 수가 있다.
사이드 이펙트 2: JVM 전역 설정 변경
Duser.timezone은 해당 JVM의 기본 타임존을 바꿔버린다. 만약 코드 어딘가에서LocalDateTime.now()처럼 타임존 명시 없이 현재 시간을 가져오는 다른 로직이 있다면, 그 로직의 결과까지 모두 바뀌어버리는 전역적인 사이드 이펙트가 발생할 수 있다.
🧑🏻💻 결과적으로, 일관성을 지키는 것이 가장 중요하다고 생각하여 방법 1을 택하여 해결하였다.