글 작성자: beaniejoy

JPA를 사용하면서 마주했던 이슈에 대해서 기록하고자 블로그에 정리하게 되었습니다.

A Entity가 있고 B Entity가 있는데 A Entity에 대한 내용을 수정하고 A Entity의 필드 값들을 B Entity에 담아서 DB에 insert 요청을 하기 위한 save 작업을 하는 내용이었습니다. 코드 상으로 보면 다음과 같습니다.

@Transactional
fun saveHistoryAfterCafeInfoUpdated(cafeId: Long, request: UpdateDto) {
    // 대상 cafe 조회
    val cafe = cafeRepository.findByIdOrNull(cafeid) 
    	?: throw RuntimeException("cafe not found")
        
    // cafe 내용 변경
    cafe.updateInfo(request)
    // db에 update 쿼리 작성
    cafeRepository.save(cafe)
    
    logger.info { "cafe name ${cafe.name}, address ${cafe.address}" }
    logger.info { "cafe updatedAt: ${cafe.updatedAt}" }
    
    // cafe_histories에 변경된 cafe 내용을 db에 저장
    cafeHistoryService.saveHistory(cafe)
}

Cafe 내용을 조회하고 cafe의 name, address 등의 필드 내용을 변경하고 Repository를 이용해 save를 함으로써 update 쿼리를 작성하게 됩니다. 그 이후에 CafeHistory에 변경된 Cafe 정보들을 가지고 반영하여 신규 save(insert)를 하게 되는 프로세스입니다.

 

📌 쓰기 지연으로 인한 AuditingEntityListener 적용된 updatedAt의 시점에 따른 값의 차이

해당 메소드에 @Transactional 어노테이션이 붙어있는 상황에서 로직을 수행하면 어떤 일이 발생하는지 알아보겠습니다.

findByIdOrNull에 의해 우선 select 쿼리가 날라가서 cafe 데이터를 조회해오는 것을 볼 수 있습니다. 그런데 그 이후를 보면 cafeRepository.save(cafe)를 통해 update가 된 줄 알았던 Cafe Entity 내용을 그대로 CafeHistory에 반영하려고 보았더니 updatedAtnull입니다.

변경된 cafe를 Repository에 save하면서 updatedAt도 현재시간으로 업데이트 될 줄 알았는데 null이 나온 것입니다. 위의 로그를 보면 명확하게 알 수 있는데요. 로그를 찍고나서 그 다음에 실제 update 쿼리가 나간 것을 알 수 있습니다. 왜 그럴까요?

이게 바로 영속성 컨텍스트의 쓰기 지연 작업 때문이라 할 수 있습니다. 쓰기 지연은 말 그대로 insert, update 같이 데이터를 생성하거나 수정할 때 지연되어 요청보낸다는 내용입니다.

쓰기 지연에서 가장 중요한 설정은 Transactional 입니다. 해당 로직에 @Transactional로 묶여 있어야 쓰기지연이 제대로 동작합니다.
JPA에서 Repository에 의해 save 같은 메소드로 호출하거나 필드 변경을 통한 변경감지를 이용해 insert, update를 요청할 수 있는데요. 메소드 호출시점에 바로 이러한 쿼리를 요청해서 DB에 반영하는 것이 아니라 쿼리만 작성해두고 모아두었다가 Transactional로 메소드가 끝나는 시점에 한꺼번에 호출하게 됩니다.
(정확히는 transaction commit이 발생하는 시점에 쿼리들이 실제 날라가게 됩니다.)

@Transactional
fun saveHistoryAfterCafeInfoUpdated(cafeId: Long, request: UpdateDto) {
    // 대상 cafe 조회
    val cafe = cafeRepository.findByIdOrNull(cafeid) 
    	?: throw RuntimeException("cafe not found")
        
    // cafe 내용 변경
    cafe.updateInfo(request)
    // db에 update 쿼리 작성
    cafeRepository.save(cafe)
    
    logger.info { "cafe name ${cafe.name}, address ${cafe.address}" }
    logger.info { "cafe updatedAt: ${cafe.updatedAt}" }
    
    // 메소드가 끝나는 시점(Transactional commit 되는 순간)
    // 이 때 update cafe 쿼리가 날라가게 된다.
    // 그렇기 때문에 위의 updatedAt은 기존 cafe 데이터의 값인 null로 나오게 된다.
}

updatedAtAuditingEntityListener에 의해서 실제 update 쿼리가 실행될 때 업데이트된 시간을 넣게 되는데요.
즉, logger에 의해 logging 되는 시점에서 updatedAt은 메소드가 끝나기 전이기 때문에 쓰기 지연에 의한 update 쿼리가 실행되기 전이고 update 되기 전인 본래의 cafe 데이터의 updatedAt 값이 로그에 찍히는 것을 확인할 수 있습니다.
(만약 update 된적이 한 번도 없고 기존에 updatedAt에 null로 저장되어있었다면 null이 로그에 나올 것입니다.)

위 내용들을 정리하면 다음과 같습니다.

  • 하나의 Transaction으로 묶인(@Transactional) 메소드 안에서 Entity 조회후 해당 Entity 내용을 수정
  • 수정한 Entity를 그대로 Repository(JpaRepository) save 메소드를 통해 update 수행
  • JPA 쓰기 지연 특징으로 실제 쿼리가 실행되는 것은 메소드가 끝나는 지점(메소드 끝나는 지점이 tx commit 되는 시점)
  • 메소드 끝나기전에는 실제 update 쿼리가 실행된 것이 아니기 때문에 updatedAt은 update되기 전의 값으로 나오게 됨

 

📌 @Transactional이 없는 상황에서의 updatedAt

@Transactional 어노테이션을 제거하고 같은 요청을 한 번 진행해보았습니다.

이번에는 updatedAt에 날짜 시간이 잘 찍힌 것을 볼 수 있습니다.
Transactional 어노테이션이 없이 실행되면 해당 메소드는 트랜잭션으로 묶이지 않게 됩니다.

fun saveHistoryAfterCafeInfoUpdated(cafeId: Long, request: UpdateDto) {
    // 대상 cafe 조회
    val cafe = cafeRepository.findByIdOrNull(cafeid) 
    	?: throw RuntimeException("cafe not found")
        
    // cafe 내용 변경
    cafe.updateInfo(request)
    // db에 update 쿼리 작성
    // 여기서는 JpaRepository 구현체에 의해 Transactional 적용됨
    cafeRepository.save(cafe)
    
    logger.info { "cafe name ${cafe.name}, address ${cafe.address}" }
    logger.info { "cafe updatedAt: ${cafe.updatedAt}" }
    
    // cafe_histories에 변경된 cafe 내용을 db에 저장
    cafeHistoryService.saveHistory(cafe)
}

JpaRepository를 상속받은 cafeRepository 인터페이스를 통해 Spring Data JPA의 기본 제공 메소드인 save를 중간에 실행하게 됩니다. 여기에는 Transactional이 적용되어 있는데요. 이렇게 되면 cafeRepository.save(cafe)를 실행했을 때 transaction이 시작하고 save 메소드가 끝날 때 transaction이 끝나게 됩니다.

즉 cafeRepository의 save 작업이 끝나는 동시에 transaction commit이 발생하고 JPA에서는 flush가 발생하게 되어 쓰기지연 쿼리들이 실제 DB요청으로 날라가게 됩니다. DB에 쿼리를 실행하게 되는 것이므로 updatedAt에도 값이 담겨져서 데이터에 저장이 된 상태가 됩니다.

cafeRepository.save(cafe)의 인자로 들어간 cafe의 updatedAt에는 AuditingEntityListener에 의해 현재날짜 값이 담겨질 것이고 그 아래의 log에 찍히게 되는 것이라 할 수 있습니다.

 

📌 정리

결국 JPA에서는 쓰기 관련 쿼리(insert, update)들이 임시저장되어 있다가 flush 시점에서 한 번에 DB 요청을 보내게 됩니다. 이를 쓰기지연 쿼리 기능이라 하고 JPA에서 중요한 내용 중 하나입니다. 

flush는 여러 상황에서 발생할 수 있지만 그 중에서 transaction commit 될 때 flush가 발생합니다. 그래서 @Transactional이 있을 때와 없을 때의 상황을 통해 updatedAt이라는 데이터는 어떻게 담겨지는지를 보았던 것입니다. JPA에서 가장 기초적인 부분이기도 하고 이러한 부분들을 알고 개발하면 좋을 것 같네요.