본문 바로가기

Programming/Java & JSP & Spring

JPA 영속성 컨텍스트의 1차캐시 & 쓰기지연은 정말 동작하는가?

JPA는 엔티티를 영속성 컨텍스트에서 관리한다.

영속성 컨텍스트에 대한 장점을 다시 한번 살펴보면 다음과 같다.

 

1. 1차 캐시

2. 동일성 보장

3. 트랜잭션을 지원하는 쓰기 지연

4. 변경 감지

 

이러한 영속성 컨텍스트의 장점은 많이들 접해보고 들어봤을 것이다.

이 글에서는 1차캐시와 쓰기지연에 대해서 좀 더 자세히 알아보도록 한다.

 

1차 캐시

1차 캐시가 되는 것은 오직 식별자로 쿼리할 때만이 가능하다.

식별자가 무엇인가? 바로 @Id 어노테이션을 붙여둔 Key이다.

 

다음 예제를 살펴보자.

Person(id = 1, name = "A")

위와 같은 Entity가 DB에 저장되어 있다고 가정해보자.

여기서 다음과 같이 쿼리를 하면 어떤 일이 발생할까?

val p1 = repository.findByName("A") 
val p2 = repository.findByName("A")

p1을 쿼리할 때, 이미 ID 1번을 영속성 컨텍스트에 담았으므로 p2를 쿼리할 때는 영속성 컨텍스트에 있는 엔티티를 가져오지 않을까?

결과는 p1, p2 모두 sql에 쿼리를 날려서 해당 엔티티를 가져온다.

쿼리를 할때, 식별자가 아니면 엔티티 매니저는 쿼리를 해보기 전까지 같은 식별자를 가지고 있는지 모르기 때문이다.

(아마 @Id 전략을 Identity로 해서 그런 것 같다는 추측이다.)

 

여기서 주의할 점이 있다.

식별자로 쿼리하면 1차 캐시가 적용되지만 다음과 같이 적용하면 안된다.

val p1 = repository.findById(1)
val p2 = repository.findById(1)

Id 필드가 식별자라도 위와 같이 Spring Data JPA의 쿼리메소드를 사용해서 쿼리하면 위와 마찬가지로 1차 캐시가 적용되지 않는다.

물론 이것은 스프링부트 2.0 이하 버전에만 해당된다. 

스프링부트 2.0부터는 findById가 기본으로 제공되므로 사용해도 좋지만, 그 이하 버전에서는 findById대신 아래와 같이 findOne을 사용해야 1차 캐시가 적용된다.

val p1 = repository.findOne(1) 
val p2 = repository.findOne(1)

 

쓰기지연

영속성 컨텍스트는 쓰기지연을 지원한다.

이말이 무엇인가? 한 트랜잭션안에서 이뤄지는 UPDATE나 SAVE의 쿼리를 쓰기지연 저장소에 가지고 있다가 트랜잭션이 커밋되는 순간 한번에 DB에 날리는 것을 말한다.

이로써 얻을 수 있는 장점은 DB커넥션 시간을 줄일 수 있고, 한 트랜잭션이 테이블에 접근하는 시간을 줄일 수 있다는 장점이 있다.

 

개념은 위와 같다. 하지만 정말 모든 경우에 위와 같이 동작을 할까?

 

1. 단순 SAVE만을 하는 경우

@Transactional 
fun test() { 
	productItemRepository.save(productItem)  
	println("INSERT 쿼리는 이미 날아갔는가?") 
}

위 개념에 따르면 insert의 쿼리가 내가 삽입한 로그보다 늦게 출력되어야 한다.

하지만 예상과는 다르게 Insert 쿼리는 즉시 날라가고 그 이후 로그가 출력되었다.

 

이유가 뭘까?

엔티티가 영속상태가 되려면 식별자가 꼭 필요하다. 

그런데 식별자 생성전략을 IDENTITY로 사용하면 데이터베이스에 실제로 저장을 해야 식별자를 구할 수 있으므로 Insert 쿼리가 즉시 데이터베이스에 전달된다.

따라서 이 경우에 쓰기 지연을 활용한 성능 최적화를 할 수 없다.

 

2. 단순 UPDATE만을 하는 경우

@Transactional 
fun test() {  
	val productItem = productItemRepository.findById(1).orElse(null)  
	productItem.amount = 10  
    
	productItemRepository.save(productItem)  
	println("UPDATE 쿼리는 이미 날아갔는가?") 
}

SELECT 쿼리는 그 즉시 SQL로 날라가서 해당 데이터를 쿼리해온다.

UPDATE 쿼리는 언제 날아갈까?

이것은 예상대로 쓰기지연이 작동되어 트랜잭션이 종료되고 커밋되는 순간 데이터베이스에 전달되었다.

즉, 내가 삽입한 로그가 먼저 출력되고 그 이후 UDPATE쿼리가 작동하였다.

 

3. UPDATE 후에 식별자가 아닌 필드로 쿼리

@Transactional 
fun test() {  
	val productItem = productItemRepository.findById(1L).orElse(null)  
	productItem.amount = 1000  
    
	productItemRepository.save(productItem)  
	println("update query not working")  
    
	val productItem1 = productItemRepository.findByName("bananana")  
}

어느 타이밍에 UPDATE쿼리가 날아갈 것 같은가?

예상은 당연히 트랜잭션이 종료될 때 UPDATE 쿼리가 날아가는 줄 알았다.

 

하지만 ProductItem1에서 식별자가 아닌 name필드로 쿼리를 하고있다.

식별자가 아닌 필드로 조회를 하면 조회 쿼리를 하기 전에 쓰기지연 저장소에 있던 UPDATE 쿼리를 날리고 그 이후에 조회를 한다.

이것은 트랜잭션 Isolation Level을 낮춰도 동일하게 작동된다.

 

4. UPDATE 후에 식별자로 검색

@Transactional 
fun test() { 
	val productItem = productItemRepository.findById(1L).orElse(null)  
	productItem.amount = 10000  
    
	productItemRepository.save(productItem)  
	println("update query not working")  
    
	val productItem1 = productItemRepository.findById(2L).orElse(null)  
}

3번 예제와는 다르게 productItem1이 식별자로 검색하고 있다,.

이런 경우에는 UPDATE쿼리가 트랜잭션이 커밋될 때 DB에 쿼리를 전달한다.

즉, 쓰기 지연이 작동한다.

 

이유가 무엇일까?

정확히 이렇게 동작하는 이유는 알지 못한다.

하지만 한가지 추측을 해보자면, JPA 영속성 컨텍스트는 식별자로 엔티티를 관리한다.

4번 예제와 같이 식별자로 검색을 하게되면 JPA는 명확히 쓰기지연 SQL 저장소에 있는 쿼리와 무관하다는 것을 판단할 수 있다.

하지만 식별자가 아닌 키로 검색을 하게되면 JPA는 SQL 저장소에 있는 UPDATE쿼리와 연관이 있다는 것을 판단할 수 없다.

DB에 직접 쿼리를 하고 쿼리를 새로해야 알 수 있다는 것이다.

 

5. UPDATE 후에 다른 테이블 식별자가 아닌 필드로 검색

@Transactional 
fun test() { 
	val productItem = productItemRepository.findById(1L).orElse(null)  
	productItem.amount = 10000  
    
	productItemRepository.save(productItem)  
	println("update query not working")  
    
	val fruit = fruitRepository.findAllByName("apple")
}

위와 같은 경우에는 UPDATE 쿼리가 언제 DB에 전달될 것인가?

위와 같이 식별자가 아닌 필드로 검색하므로 Fruit 테이블을 SELECT하기 전 발생하는 AUTO FLUSH 에서 UPDATE쿼리가 전달되어야 하는거 아닐까?

결과는 그렇지 않다.

JPA가 쓰기지연 저장소에 있는 SQL과 현재 조회하는 테이블이 명확히 다르다는 것을 인지할 수 있으므로, AUTO FLUSH 타이밍에 UPDATE쿼리를 전달하지 않는다.