JPA 생명주기 콜백과 네이티브 쿼리의 관계

dongwoo you·2025년 7월 1일
0

에러 정리 및 해결

목록 보기
4/4

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라는 엔티티만 createdAtupdatedAt 필드에 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)을 활용하고, DataIntegrityViolationExceptiontry-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 콜백을 정상적으로 트리거하여, createdAtupdatedAt에 시간이 올바르게 기록되었습니다.


결론

JPA의 @PrePersist, @PreUpdate나 Spring Data JPA의 Auditing(@CreatedDate 등)과 같은 편리한 기능들은 JPA의 영속성 컨텍스트와 생명주기 관리 하에서만 동작합니다.

성능 최적화 등의 이유로 네이티브 쿼리를 사용할 때는, 이러한 자동화 기능들이 동작하지 않을 수 있다는 점을 항상 염두에 두어야 합니다.

profile
꾸준함 빼면 시체

0개의 댓글