[kotlog] 코프링으로 블로그 만들기 - 6 (@PreUpdate)

dustle·2025년 6월 18일
1

kotlog

목록 보기
6/6

자동으로 시간 필드를 업데이트하는 방법 (with. ZonedDateTime + @PreUpdate)

이번에는 BaseEntity에 있는 시간 필드들을 자동으로 업데이트해주는 기능을 만들겠습니다.

Spring Data JPA 에서는 기본적으로 @CreatedDate, @LastModifiedDate 를 통해 생성/수정 시각을 자동으로 주입해주는 기능을 제공합니다.
하지만 시간 타입으로 ZonedDateTime 을 사용하고 있다면 호환되지 않습니다.

왜 호환이 안 되는 걸까 궁금해서 어노테이션을 따라 내부까지 들어가 보았습니다.

@Retention(RetentionPolicy.RUNTIME)
@Target(value = { FIELD, METHOD, ANNOTATION_TYPE })
public @interface CreatedDate {
}

@CreatedDate 동작 방식

Spring Data는 내부적으로 리플렉션 + ConversionService 시스템을 사용합니다.

구체적으로는 다음과 같은 방식으로 동작합니다:

  1. Spring은 @CreatedDate 가 붙은 필드를 찾음 (리플렉션)
  2. AuditingHandler 가 현재 시간을 DateTimeProvider.getNow() 로 구함
  3. 이 값을 필드 타입에 맞춰 변환할 수 있는지 판단 (ConversionService.convert(...))
  4. 지원되는 타입이면 Field.set()으로 주입

지원되는 타입은 Spring Data Commons 내부의 org.springframework.data.convert 패키지의 Jsr310Converters 클래스에서 확인할 수 있습니다.

해당 클래스는 Java 8 의 날짜/시간 API(JSR‑310) 타입을 다루기 위한 각종 Converter 들을 등록해놓은 클래스입니다.

대표적으로 지원되는 타입은 다음과 같습니다:

  • Instant
  • LocalDateTime
  • LocalDate
  • LocalTime
  • java.util.Date
  • Calendar
  • Long / long

즉, 위 타입들은 @CreatedDate@LastModifiedDate 로 값을 주입받을 수 있지만
ZonedDateTime 은 이 목록에 포함되어 있지 않기 때문에 주입이 실패하게 됩니다.

그래서 이걸 이제 비틀어서 사용하는 방법으로는

  • Spring 에서 제공하는 ConversionService 에 직접 커스텀 컨버터를 등록하거나
  • DateTimeProvider 를 오버라이드해서 ZonedDateTime 을 강제로 사용하게 만들 수도 있습니다.

하지만 이런 방법은 너무 번거롭고 프로젝트에서 일관되게 적용하기도 어렵습니다.
그래서 저는 더 간단한 방식으로 우회했습니다.
@PreUpdate 어노테이션으로 updatedAt 만 바꿔주는 방식을 사용했습니다

@PreUpdate를 이용한 수동 갱신

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 쿼리 생성 및 실행

주의: @PreUpdate가 호출되지 않는 상황

  • 변경된 필드가 없을 경우 (dirty checking 이 동작하지 않으면 호출되지 않음)
  • EntityManager.persist() 로 insert 만 수행한 경우
  • save 만 하고 flush 나 commit 이 발생하지 않은 경우

ZoneId 가 DB 에서 사라짐

테스트를 작성하다가 알았는데 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

@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) }
    }
}

BaseEntity

@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 분리 저장도 고려할 수 있습니다.

0개의 댓글