이번에는 BaseEntity
에 있는 시간 필드들을 자동으로 업데이트해주는 기능을 만들겠습니다.
Spring Data JPA 에서는 기본적으로 @CreatedDate
, @LastModifiedDate
를 통해 생성/수정 시각을 자동으로 주입해주는 기능을 제공합니다.
하지만 시간 타입으로 ZonedDateTime
을 사용하고 있다면 호환되지 않습니다.
왜 호환이 안 되는 걸까 궁금해서 어노테이션을 따라 내부까지 들어가 보았습니다.
@Retention(RetentionPolicy.RUNTIME)
@Target(value = { FIELD, METHOD, ANNOTATION_TYPE })
public @interface CreatedDate {
}
Spring Data는 내부적으로 리플렉션 + ConversionService 시스템을 사용합니다.
구체적으로는 다음과 같은 방식으로 동작합니다:
@CreatedDate
가 붙은 필드를 찾음 (리플렉션)AuditingHandler
가 현재 시간을 DateTimeProvider.getNow() 로 구함지원되는 타입은 Spring Data Commons 내부의 org.springframework.data.convert
패키지의 Jsr310Converters
클래스에서 확인할 수 있습니다.
해당 클래스는 Java 8 의 날짜/시간 API(JSR‑310) 타입을 다루기 위한 각종 Converter 들을 등록해놓은 클래스입니다.
대표적으로 지원되는 타입은 다음과 같습니다:
즉, 위 타입들은 @CreatedDate
와 @LastModifiedDate
로 값을 주입받을 수 있지만
ZonedDateTime
은 이 목록에 포함되어 있지 않기 때문에 주입이 실패하게 됩니다.
그래서 이걸 이제 비틀어서 사용하는 방법으로는
ConversionService
에 직접 커스텀 컨버터를 등록하거나DateTimeProvider
를 오버라이드해서 ZonedDateTime
을 강제로 사용하게 만들 수도 있습니다.하지만 이런 방법은 너무 번거롭고 프로젝트에서 일관되게 적용하기도 어렵습니다.
그래서 저는 더 간단한 방식으로 우회했습니다.
@PreUpdate
어노테이션으로 updatedAt
만 바꿔주는 방식을 사용했습니다
JPA 는 @PrePersist
, @PreUpdate
등의 Entity Lifecycle Callback 어노테이션을 제공합니다.
이 어노테이션을 사용하면 엔티티가 DB에 저장되거나 수정되기 직전에 직접 지정한 메서드를 실행할 수 있습니다.
@PreUpdate
는 다음 시점에 동작합니다:
1. EntityManager.merge(entity) 또는 repository.save(entity) 호출
2. 트랜잭션 commit 또는 flush 발생
3. 변경 감지 → dirty checking 수행
4. 변경사항 존재 → @PreUpdate
메서드 실행
5. UPDATE 쿼리 생성 및 실행
테스트를 작성하다가 알았는데 ZonedDateTime
을 DB 에 넣으면 Asia/Seoul 같은 지역 정보가 사라집니다.
Expected :2025-06-18T22:41:50.318229+09:00[Asia/Seoul]
Actual :2025-06-18T22:41:50.318229+09:00
알고보니 RDB 에서는 ZonedDateTime
을 저장할 수 없습니다.
대신 DATETIME
, TIMESTAMP
같은 타입으로 저장되며
ZoneOffset(+09:00)은 저장되지만 ZoneId(Asia/Seoul)은 유실됩니다.
조회할 때는 그냥 OffsetDateTime
이나 LocalDateTime
비슷하게 복원됩니다.
그래서 String 으로 변환하는 컨버터를 같이 짜줬습니다
@Converter(autoApply = false)
class ZonedDateTimeConverter : AttributeConverter<ZonedDateTime, String> {
override fun convertToDatabaseColumn(attribute: ZonedDateTime?): String? {
return attribute?.toString()
}
override fun convertToEntityAttribute(dbData: String?): ZonedDateTime? {
return dbData?.let { ZonedDateTime.parse(it) }
}
}
@MappedSuperclass
abstract class BaseEntity {
@Id
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private val _id: Long? = null
val id: Long
get() = _id!!
@Column(name = "created_at", nullable = false, updatable = false)
@Convert(converter = ZonedDateTimeConverter::class)
var createdAt: ZonedDateTime = ZonedDateTime.now()
@Column(name = "updated_at", nullable = false)
@Convert(converter = ZonedDateTimeConverter::class)
var updatedAt: ZonedDateTime = ZonedDateTime.now()
@PreUpdate
fun onPreUpdate() {
updatedAt = ZonedDateTime.now()
}
}
context("User PreUpdate 확인") {
test("PreUpdate 는 값 변경 후 flush 후에 호출됨") {
val user = User(
username = "dustle",
password = "111",
email = "111",
nickname = "111"
).also {
userRepository.save(it)
}
val saved = userRepository.findById(user.id).get()
val oldUpdatedAt = saved.updatedAt
saved.nickname = "updated"
saved.updatedAt shouldBe oldUpdatedAt
userRepository.saveAndFlush(saved)
val savedAfterUpdate = userRepository.findById(user.id).get()
savedAfterUpdate.updatedAt.isAfter(oldUpdatedAt).shouldBeTrue()
}
}
ZoneId 가 사라져서 테스트가 깨지지도 않고 시간 갱신이 잘 되는 것을 확인할 수 있습니다.
Spring Data JPA 의 @CreatedDate
, @LastModifiedDate
는 ZonedDateTime 호환되지 않습니다.
ZonedDateTime
을 사용하려면 컨버터를 작성하거나 @PreUpdate
등을 이용해 수동으로 관리하는 방식이 필요합니다.
특히 ZonedDateTime
은 RDB 저장 시 ZoneId
가 유실되기 때문에 문자열 저장 방식이나 Instant
+ ZoneId
분리 저장도 고려할 수 있습니다.