JPA를 사용하면서 겪었던 해결 과정을 공유하려고 합니다.
저는 프로젝트의 모든 엔티티에 생성 및 수정 시간을 자동으로 기록하기 위해, 아래와 같이 BaseTimeEntity
를 만들어 상속해서 사용하고 있었습니다.
@Getter
@MappedSuperclass // 자식 클래스에 매핑 정보만 상속
@Getter
@MappedSuperclass
public class BaseTimeEntity{
@CreatedDate
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@Column(nullable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@Column(nullable = false)
private LocalDateTime updatedAt;
@PrePersist
public void onCreate(){
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
}
@PreUpdate
public void onUpdate(){
this.updatedAt = LocalDateTime.now();
}
}
@PrePersist
와 @PreUpdate
는 JPA의 생명주기 콜백으로, 엔티티가 저장되거나 업데이트되기 직전에 지정된 메서드를 실행해주는 편리한 기능입니다.
Kafka를 도입하며 이벤트 발생 시각과 DB에 저장되는 시간차를 구해서 내부 API의 지연시간을 구하는것이 목표였습니다.
대부분의 엔티티에서는 이 기능이 잘 동작했습니다. 하지만 유독 ArticleLike
라는 엔티티만 createdAt
과 updatedAt
필드에 null
(또는 0000-00-00...
)이 들어가는 문제가 발생했습니다.
문제의 원인은 ArticleLike 엔티티를 저장하는 방식에 있었습니다.
ArticleLike는 중복된 '좋아요' 이벤트가 발생했을 때 DB 에러를 피하기 위해, 아래와 같은 INSERT IGNORE
구문을 사용하는 커스텀 메서드로 저장되고 있었습니다.
public interface ArticleLikeRepository extends JpaRepository<ArticleLike, Long> {
@Modifying
@Query(
value = "INSERT IGNORE INTO article_likes (article_id, user_id, created_at, updated_at) "
+ "VALUES (:articleId, :userId, NOW(), NOW())",
nativeQuery = true)
void insertIgnore(@Param("articleId") Long articleId, @Param("userId") Long userId);
}
바로 nativeQuery = true
옵션이 문제의 핵심이었습니다.
repository.save(entity)
: 이 메서드는 Spring Data JPA를 통해 EntityManager에게 엔티티의 관리를 위임합니다. EntityManager
는 엔티티의 상태 변화를 감지하고, 그 과정에서 @PrePersist
와 같은 생명주기 콜백을 순서대로 실행합니다.
네이티브 쿼리 (nativeQuery = true)
: 이 방식은 JPA의 생명주기 관리를 완전히 건너뛰고, 작성된 SQL을 데이터베이스로 직접 전송합니다. EntityManager
가 관여하지 않으므로, BaseTimeEntity
에 정의된 @PrePersist
메서드는 호출될 기회조차 없습니다.
결국 Article 엔티티는 save()
를 통해 저장되어 시간이 잘 기록됐지만, ArticleLike 엔티티는 네이티브 쿼리를 통해 저장되어 생명주기 콜백이 무시되었던 것입니다.
이 문제를 해결하는 가장 올바른 방법은 네이티브 쿼리 대신, JPA의 표준 save()
메서드를 사용하는 것입니다. 중복 저장 문제는 DB의 유니크 제약조건(uniqueConstraint
)을 활용하고, DataIntegrityViolationException
을 try-catch
로 처리하여 해결할 수 있습니다.
@Transactional
public void listen(LikeChange likeChange) {
Long articleId = likeChange.articleId();
Long userId = likeChange.userId();
if (likeChange.added()) {
articleLikeRepository.insertIgnore(articleId, userId);
} else {
articleLikeRepository.deleteByArticleIdAndUserId(articleId, userId);
}
}
@Transactional
public void listen(LikeChange likeChange) {
if (likeChange.added()) {
ArticleLike newLike = ArticleLike.create(likeChange.articleId(), likeChange.userId());
try {
// 네이티브 쿼리 대신 save() 사용
articleLikeRepository.save(newLike);
} catch (DataIntegrityViolationException e) {
// 중복 데이터로 인한 예외 발생 시, 무시하고 로그만 남김
log.warn("Like already exists, ignoring. Article: {}, User: {}",
likeChange.articleId(), likeChange.userId());
}
} else {
articleLikeRepository.deleteByArticleIdAndUserId(likeChange.articleId(), likeChange.userId());
}
// ...
}
이렇게 수정하자 save()
메서드가 @PrePersist
콜백을 정상적으로 트리거하여, createdAt
과 updatedAt
에 시간이 올바르게 기록되었습니다.
JPA의 @PrePersist
, @PreUpdate
나 Spring Data JPA의 Auditing(@CreatedDate
등)과 같은 편리한 기능들은 JPA의 영속성 컨텍스트와 생명주기 관리 하에서만 동작합니다.
성능 최적화 등의 이유로 네이티브 쿼리를 사용할 때는, 이러한 자동화 기능들이 동작하지 않을 수 있다는 점을 항상 염두에 두어야 합니다.