CH03 영속성 관리
Last updated
Last updated
2장에서 살펴본 바와 같이 EntityManagerFactoryBuilder와 EntityManagerFactory는 bean 으로 관리된다. 나는 EntityManagerFactory 가 bean 으로 관리된다는 것만 인지하면 된다.
2장에서 이미 살펴보았기도 했고, 아래 그림에도 나와 있듯이 EntityManagerFactory 는 스레드마다 별도로 EntityManager 를 할당해준다. 이것의 의미는 곧 정리하겠지만 EntityManager 가 관리할 영속성 컨텍스트가 각 요청별로 따로 관리된다는 것이다.
책에서는 '엔티티를 영구 저장하는 환경' 이라고 정의하고 있다. 영속성 컨텍스트는 엔티티 매니저를 생성할 때 만들어 지고 엔티티 매니저를 통해서 영속성 컨텍스트를 관리할 수 있다. 개인적으로 ‘영속성 관리를 목적으로 사용하는 공간’이라고 이해하는 것이 좋을 것 같다.
나의 경험 내에서는 결국 실무에서는 스프링 데이터 JPA 를 쓰기 때문에 EntityManager 를 직접 다룰 일은 없다. 하지만 영속성 컨텍스트가 무엇이며, 내부적으로 어떻게 동작하는지 원리를 알아두어야 스프링 데이터 JPA 가 결국에는 어떤 일을 하는지, 내부적으로 지금 어떤 일이 일어나고 있는지 정확하게 이해할 수 있다.
책에는 지금 순서에 영속성 컨텍스트에 대해서 개념만 짚고 넘어가고 있는데, 영속성 컨텍스트는 아래와 같은 형태이다. 이를 기억하자.
비영속(new/transient): 영속성 컨텍스트와 전혀 관계가 없는 상태
영속(managed): 영속성 컨텍스트에 저장된 상태
준영속(detached): 영속성 컨텍스트에 저장되었다가 분리된 상태
삭제(removed): 삭제된 상태
각 상태 별로 자세히 알아보자.
엔티티를 만들고 어떤한 행위도 하지 않은 상태이다. 영속성 컨텍스트 및 데이터 베이스와 아무런 상관이 없는 객체 그대로의 상태다.
위에서 알아본 바로 entityManager 가 객체를 관리하고 있는 중의 상태가 영속이고, 아예 영속된 적이 없는 순수한 엔티티 객체 상태가 비영속이었다. 준영속은 영속이었던 객체가 영속 상태에서 벗어난 것을 의미한다.
entityManager를 살려둔 채로 초기화를 시키거나, entityManager를 아예 닫아버리면 영속 상태이던 엔티티들은 모두 준영속 상태가 된다.
또는 entityManager에서 해당 엔티티만 준영속 상태로 처리할 수도 있다.
정확히는 삭제가 될 예정인 상태다. 준영속은 해당 엔티티에 대해서 entityManager 를 통해서 영속성 컨텍스트 내에서 더 이상 관리를 하지 않겠다는 의미이고, 삭제는 데이터베이스에서 지우겠다는 의미이다.
CRUD 연산과 함께 영속성 컨텍스트에서 어떤 일들이 발생하는지 세세하게 알아보는 내용인데, 각 내용 속에 영속성 컨텍스트의 특징들이 녹아있다.
하지만 난 이미 책을 한번 다 봤기 때문에 먼저 영속성 컨텍스트의 특징에 대해서 자세히 정리를 하고 각 CRUD 연산을 보는게 더 좋을 것 같다.
아래 내용은 백기선님 강의에서 자세히 다뤘던 부분이기도 해서 해당 강의의 필기노트도 같이 정리한다.
그리고 영속성 컨텍스트의 특징을 알아보기 위해서 다시 영속성 컨텍스트의 구조에 대해서 인지하고 넘어간다.
위 구조도에도 나와 있듯이 영속성 컨텍스트 내에는 1차 캐시로 사용할 수 있는 일종의 Map 이 있다. 여기서 Map 이라고 비유한 이유는 1차 캐시를 사용할 때 엔티티의 PK 값을 key 값으로 사용하기 때문이다. 그래서 특정 엔티티에 대한 조회 로직이 발생하면 데이터 베이스에 조회하지 않고 먼저 영속성 컨텍스트의 1차 캐시를 PK를 기준으로 조회한다.
1차 캐시를 굳이 1차 캐시라고 다룬 이유는 2차 캐시가 존재하기 때문이다. 나중에 다시 다루겠지만 1차 캐시와 2차 캐시의 차이는 캐시의 커버 범위이다. 1차 캐시는 해당 영속성 컨텍스트 내에서 공유되는 캐시이고 2차 캐시는 어플리케이션 전체에서 공유된다.
조회 발생시 1차 캐시에 해당 엔티티가 존재하지 않으면 비로소 데이터베이스에 접근하여 해당 엔티티를 조회하고 가져온 엔티티를 1차 캐시에 저장해둔다. 그리고 트랜잭션이 끝나기 전에 다시 해당 엔티티를 조회하게 되면 이제는 1차 캐시에 해당 엔티티가 있는 상태이므로 데이터 베이스에 접근하지 않고 1차 캐시의 엔티티를 반환한다.
위에서 1차 캐시를 저장하는 과정에서 해당 엔티티의 스냅샷 역시 저장한다. 그리고 해당 트랜잭션 내에서 해당 엔티티에 대한 변경이 발생하면 1차 캐시 내에서 변경이 발생하고 트랜잭션이 종료되는 시점에 스냅샷과 엔티티의 차이를 비교하여 변경된 것을 우선해서 이에 대한 update 를 알아서 수행해준다.
정리를 하자면 entity의 조회 시점에 스냅샷을 만들어 두고 이를 트랜잭션이 끝나는 시점에 비교하여 변경된 것(dirty)이 있으면 이를 영속화 해준다.
상태가 변경된 것(dirty)을 트랜잭션이 끝나는 시점에 알아서 확인(check) 해서 반영해주는 것이 곧 dirty check 인 것이다.
위 코드에서 update 로직을 따로 처리한 것이 없는데 영속화 이후 이름을 바꿔준 것만으로 아래 로그처럼 update 가 발생했다. 스냅샷의 name 의 값은 최초 영속화 시점의 fistkim인데, fistkim1 로 엔티티를 바꿔줬고 트랜잭션 종료시점에 이를 비교(dirty check) 해서 변경 건에 대해서 update 를 실행해준 것이다.
그렇다면 변경을 많이 발생시키고 최종적으로는 결국 초기 상태 그대로로 만들면 어떻게 될까?
fistkim -> fistkim1 -> fistkim2 -> fistkim3 -> fistkim 으로 결국 트랜젝션이 종료되는 시점의 상태가 최초에 persist 된 상태 그대로 이므로 dirty check 에서 걸리는 것이 없어서 update 문이 발생하지 않은 것을 알 수 있다.
...
The persistence context, also known as the first level cache, acts as a buffer between the current entity state transitions and the database. In caching theory, the write-behind synchronization requires that all changes happen against the cache, whose responsibility is to eventually synchronize with the backing store.
위 글에도 나와있다시피 단순히 ‘당장 쿼리 수행을 요청하지 않고 commit이 될때 한번에 한다’의 개념이 아니라, 쿼리 수행 요청을 지연함으로써 최적으로 꼭 필요한 쿼리만을 최종 판단하여 수행 요청한다는 개념으로 이해하는 것이 좋을 것 같다.
transactional write behind 의 핵심 장점은 테이블 row 에 lock 이 걸리는 시간을 최소화 하는 것에 있다.
SQL을 직접 다룬 위와 같은 로직에서 update 가 발생된 순간부터 commit 될 때까지 해당 row 에 대해 lock 이 걸리게 된다. 그래서 만약 다른 트랜잭션이 해당 row 에 접근해야할 일이 생기면 격리 수준에 따라 다르겠지만 Read Committed 이상의 격리 수준에서는 해당 row 의 lock 이 풀릴 때까지 기다리게 된다.
JPA 에서는 트랜잭션이 끝나는 그 순간에 플러시를 통해 모든 쿼리를 데이터베이스에 보내고 트랜잭션을 커밋한다. 트랜잭션 수행의 시간을 최소화 하는 것이다.
이미 1장에서 다룬 부분인데 동일한 영속성 컨택스트에서 조회된 엔티티에 대해서 동일한 주소값을 보장한다. 즉, 데이터베이스에서 같은 row(같은 엔티티)에 대해서 다른 변수에 할당해줘도 결국 같은 주소값을 가진다. 영속성 컨텍스트가 이를 보장해주는 것이다.(패러다임 불일치를 해결해주는 측면이다.)
위와 같이 코드가 실행되었다고 하면 아래와 같은 그림이 된다.
위에서 알아본 바와 같이 이를 조회하면 PK 값을 기준으로 1차 캐시에서 먼저 조회를 해서 제공한다.
만약 여기서 기존에 이미 데이터베이스에 저장되어 있었던 다른 엔티티이자 아직 1차 캐시에는 없는 엔티티를 조회하면 위에서 알아본 바와 같이 1차 캐시 탐색 후 데이터 베이스에 직접 질의하여 엔티티를 가져와 1차 캐시에 저장 후 반환해준다.
아래 그림들은 위 코드를 실행시 영속성 컨텍스트 내부에서 어떤 일들이 일어나는지에 대한 설명이다. 핵심은 커밋이 되기 전까지 쓰기 지연 SQL 저장소에 쿼리가 쌓이기만 할 뿐 실제로 데이터베이스에 전달이 되지 않는 다는 것이다.
flush 는 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화 하는 작업인데, entityManager의 커밋(entityManager의 커밋과 실제 데이터베이스의 커밋의 개념은 다르다)이 발생하면 flush 가 발생하고 비로소 데이터베이스의 커밋이 발생한다.
하지만 위와 같은 방식으로 동작하는 것은 위 그림처럼 엔티티의 PK 값을 이미 정해준 경우에만 해당한다. 책에서는 혼동을 주지 않기 위해서 아직 설명하지 않고 있는데 아래의 경우를 보자.
PK를 직접 설정해주니 위에서 살펴본바와 똑같이 커밋이 되고서야 insert 가 실행 되는 것을 알 수 있다. 왜냐하면 persist 보다 뒤에 있는 System.out.println("before commit !!!") 가 먼저 실행 된 것이 로그상에서 확인이 되기 때문이다.
하지만 PK 생성 전략을 다르게 하면 1차 캐시에 저장할 PK 값을 채번하기 위해서 어쩔 수 없이 데이터베이스에 먼저 insert 를 실행시킨다. insert 후 1차 캐시에 저장을 하는 것이다. 실제로 그러한지 아래 코드를 보자.
persist() 발생하자마자 바로 insert 가 실행 된 것을 알 수 있다.
GeneratedValue 전략이 IDENTITY 인 경우에는 영속성 컨텍스트 저장을 위한 ID 값 채번이 필요하므로 persist 발생과 동시에 insert 가 실행된다. persist 처리로 인해 1차 캐시에 저장해야하고 1차 캐시에 저장하려면 PK 가 필요한데 PK 채번 주체가 데이터베이스이기 때문이다.
정리를 하자면 기본적으로 transactional write behind 가 동작을 하는 것이 맞지만 PK 생성 전략에 따라서 데이터베이스에 insert 를 해야 PK 를 알 수 있는 경우 영속화와 동시에 insert 가 실행된다고 알고 있어야 한다.
이미 위에서 dirty check 에 대해서 살펴보았듯이 JPA 에서는 엔티티의 변경건에 대해서 스냅샷과의 비교를 통해서 알아서 update 를 실행시킨다. (나의 경험 안에서 봤을때) 그렇다고 해서 update 할 일이 있을때 dirty check 을 하기 보다는 명시적으로 변경된 entity 를 save() 처리를 해준다.
아래 그림은 변경된 entity 를 명시적으로 save() 하지 않고 dirty check 에 의해서 update 가 되는 과정이다.
실행되는 update 문의 경우 변경된 특정 필드에 대해서만 update 가 실행되는 것이 아니고 변경된 entity 전체 필드를 가지고 update 문이 실행된다. 즉 실행되는 update 문은 바인딩 되는 데이터를 빼고는 항상 동일하다는 것이다. 덕분에 쿼리문을 어플리케이션 로딩 시점에 미리 생성해두고 데이터만 바꿔주는 형태로 재사용 할 수 있다.
@DynamicUpdate 를 통해서 동적으로 수정된 데이터만을 가지고 update 문을 생성해서 실행시킬 수는 있으나 책에 따르면 컬럼이 30개 이상 정도는 되어야 성능상 이점이 있다고 한다.
삭제 역시 즉시 데이터베이스에 delete 를 실행시키는 것이 아니고 remove() 와 동시에 엔티티는 준영속 상태가 되고 commit() 이 되면서 flush 가 실행됨과 동시에 delete 문이 데이터베이스에 실행된다.
로그에서도 확인할 수 있듯이 코드상 remove()를 먼저 실행해줬음에도 "before commit !!!" 이 먼저 실행 되고 delete 문이 실행 되었다. remove() 에서도 쓰기 지연이 사용되고 있다는 이야기이다.
entityManager 의 flush() 는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하게 한다. flush() 를 실행하면 아래와 같은 일들이 발생한다.
변경 감지가 동작해서 1차 캐시 내에 있는 entity 와 스냅샷을 비교해서 update 문을 쓰기 지연 SQL 저장소에 등록한다.
쓰기 지연 SQL 저장소에 쌓인 쿼리를 데이터 베이스에 전송한다.
주의해야 할 점이 entityManager의 flush 만으로는 데이터베이스에 commit() 이 발생하지는 않는다는 것이다. 그리고 flush() 를 실행한다고 해서 1차 캐시가 비워지는 것도 아니다. 단지 영속성 컨텍스트의 내용을 데이터베이스에 동기화 시키는 행위이다.
영속성 컨텍스트를 플러시하는 방법은 아래 세 가지이다.
직접 호출 entityManager.flush(); 로 직접 호출을 한다. 실무에서는 스프링 데이터 JPA 를 사용하여 내부적으로 자동으로 해주므로 이렇게 직접 호출 할 일은 없다.
트랜잭션 커밋시 플러시 자동 호출 JPA 에서 트랜잭션이 완료되면 커밋 직전에 플러시를 자동으로 호출해준다. 스프링 데이터 JPA 를 사용하면 이 방식으로 플러시를 계속 호출하는 것이다.
JPQL 쿼리 실행시 플러시 자동 호출 JPQL 쿼리를 실행할 경우 자동으로 플러시가 실행된다. 아래 코드 마지막 줄에서 플러시로 인해서 insert 문들이 실행된다.
책에서는 '준영속 상태의 엔티티를 받아서 그 정보로 새로운 영속 상태의 엔티티를 반환한다' 고 나와있는데, 그렇다고 해서 파라미터로 반드시 준영속 상태의 엔티티만 받는 것은 아니다. 영속 상태의 엔티티도 파라미터로 받을 수 있다.
위 설명에도 나와 있듯이 파라미터로 받은 엔티티를 영속성 컨텍스트에 병합시킬 뿐이다.
여기서 기억해야할 것은 결국 병합의 결과로 영속성 컨텍스트 내에서 관리되고 있는 객체를 반환한다는 것이다. 따라서 후속 처리가 있다면 파라미터로 넘긴 엔티티가 아니라 반환된 것을 사용해야한다.
이걸 굳이 짚고 넘어가는 의미는 실무에서 다룰 스프링 데이터 JPA 의 save() 의 원형이 아래와 같기 때문이다.
사실 실무에서는 결국 스프링 데이터 JPA 를 사용하기 때문에 EntityManager 를 직접 다룰 일은 적어도 나의 경험 안에서는 없어서 이번 장이 어떻게 보면 큰 의미가 없어 보일 수도 있다. 하지만 경험상 결국 스프링 데이터 JPA 를 잘 다루려면 하이버네이트가 내부적으로 어떻게 동작하고 있는지에 대해서 깊게 이해하고 있어야 한다.
그런 의미에서 이번 장에서 학습한 영속성 컨텍스트의 구조와 원리, CRUD 연산마다 영속성 컨텍스트가 내부적으로 어떻게 동작하는지 등에 관한 내용들이 JPA 학습을 성공적으로 잘 하기 위해 매우 필수적인 것들이라고 생각한다.
위에서 1차 캐시와 dirty check 에서 확인한 바와 같이, JPA 는 트랜젝션이 끝나는 순간(= commit 이 수행되는 순간)에 쿼리 요청을 수행한다. 이를 라고 하는데 위에서 다룬 1차 캐시와 밀접한 연관이 있다.
Hibernate tries to defer the Persistence Context flushing up until the last possible moment. This strategy has been traditionally known as .