[Spring/JPA] 영속성 컨텍스트(Persistence Context)에 대해 알아보겠습니다.
영속성 컨텍스트(Persistence Context)이란?
영속성 컨텍스트는 애플리케이션과 데이터베이스 사이에 위치해 엔티티를 영구 저장(영속)하는 가상의 데이터베이스입니다. 이는 논리적인 개념으로, JPA에서 엔티티의 생명주기를 관리하고, 데이터베이스와의 상호작용을 최적화하는 역할을 합니다.
이번 글에서는 영속성 컨텍스트에 어떻게 접근하는지, 영속성 컨텍스트의 구성요소는 무엇인지, 그리고 영속성 컨텍스트가 어떤 일을 하는지 알아보겠습니다.
영속성 컨텍스트 접근하에 접근하는 도구 EntityManager 와 그를 생성하는 EntityManagerFactory
EntityManager 는 영속성 컨텍스트에 접근할 수 있는 주요 인터페이스입니다. 예를 들어, entityManager 는 persist 메서드를 사용하여 엔티티를 영속성 컨텍스트에 저장할 수 있습니다.
EntityManager.persist(entity)
EntityManager 는 DB 테이블과 매핑된 엔티티에 대해 CRUD 작업을 수행할 수 있는 메서드를 제공하며, 엔티티의 생명주기를 관리합니다. Entity Manager 는 thread safe 하지 않기 때문에 각 요청마다 새로운 EntityManager 인스턴스를 생성하여 사용합니다. 이를 관리하는 것이 바로 EntityManagerFactory 인터페이스입니다. EntityManagerFactory 는 thread safe 로 구현되어 여러 스레드에서 동시에 접근해도 문제가 없습니다.
<EntityManagerFactory, EntityManger 기능 동작 사진 첨부 예정>
다음 그림과 같이 요청마다 EntityManagerFactory 는 EntityManger 를 생성하여 할당해주고, EntityManager 는 db connection 을 가져와서 사용합니다.
영속성 컨텍스트는 1차 캐시 영역과 쓰기 지연 저장소로 구성되어 있습니다.
1차 캐시 영역은 Map 형태로 구성되어 있으며, key-value 구조로 엔티티를 저장합니다. 여기서 key 는 엔티티의 식별자(PK)이며, value 는 엔티티 객체입니다.
1차 캐시에 저장할 수 있는 값은 식별자를 통한 쿼리만 해당합니다. 즉, 식별자는 데이터베이스의 PK(@Id 어노테이션으로 지정된 속성)를 사용하여 저장되므로, PK 가 아닌 다른 값을 통해 쿼리문을 수행할 경우, 영속성 컨텍스트에 저장되지 않습니다.
쓰기 지연 저장소에서는 SQL문을 바로 실행시키지 않고 담아두었다가, 영속성 컨텍스트의 명령에 따라 SQL 문을 모아두었다가 한번에 데이터베이스에 전송됩니다. 이를 통해 트랜잭션 관리와 성능 최적화가 가능합니다.
JPA 는 영속성 컨텍스트를 통해 이점을 제공합니다.
1. 1차 캐시 조회
2. 동일성 보장
3. 트랜잭션을 지원하는 쓰기 지연
4. 변경 감지
1. 1차 캐시 영역 조회
EntityManager 가 엔티티를 조회하면, 먼저 1차 캐시 영역에서 조회를 시도합니다.
Member findMember = entityManager.find(Memeber.class, 1L);
1차 캐시에 해당 엔티티가 있는 경우, 1차 캐시에서 엔티티를 찾아 반환합니다.
1차 캐시에 해당 엔티티가 없는 경우, SELECT 문을 실행하여 DB 에서 조회하고 데이터가 있다면 1차 캐시에 저장한 후 엔티티 객체를 반환합니다.
그러나 트랜잭션 종료 시 영속성 컨텍스트가 비워지기 때문에, 1차 캐시가 성능에 미치는 영향은 크지 않을 수 있습니다.
2. 동일성 보장
영속성 컨텍스트는 동일한 PK 값을 가진 엔티티를 조회할 때, 항상 같은 객체를 반환하여 동일성을 보장합니다.
예를 들어, PK 값이 같은 데이터를 두 번 조회하더라도, 서로 다른 객체(member1, member2)가 생성되는 것이 아니라, 동일한 객체가 반환됩니다.
Member member1 = em.find(Member.class, 1L);
Member member2 = em.find(Member.class, 1L);
System.out.println(member1 == member2); // true
위 코드에서 member1 과 member2 는 find 메서드를 통해 조회된 객체이지만 동일하다고 판단합니다. 그 이유는 1차 캐시에서 이미 조회된 동일한 엔티티가 존재하면, 새로운 객체를 생성하는 대신 1차 캐시에 저장된 엔티티를 그대로 반환하기 때문입니다. 이로써 영속성 컨텍스트는 동일성을 보장하며, 같은 트랜잭션 내에서 PK 가 동일한 엔티티는 항상 같은 인스턴스를 사용하게 됩니다.
3. 트랜잭션을 지원하는 쓰기지연
트랜잭션 내에서 발생하는 모든 데이터 변경 작업은 즉시 데이터베이스에 반영되지 않고, 쓰기 지연 저장소에 보관됩니다. 이러한 변경 사항은 EntityManager.flush 메서드가 호출될 때 한꺼번에 데이터베이스에 반영됩니다.
flush는 영속성 컨텍스트를 DB와 동기화하는 작업입니다. flush는 주로 다음과 같은 경우에 발생합니다:
1. 트랜잭션이 커밋될 때 : commit() 메서드가 호출되기 직전에 flush() 가 자동으로 호출되어 flush 가 발생합니다.
2.명시적으로 flush()를 호출할 때: 개발자가 EntityManager.flush()를 명시적으로 호출하면, flush 가 발생합니다.
3. JPQL 쿼리 실행 시: JPQL이나 SQL 쿼리가 실행되기 전에, 영속성 컨텍스트와 데이터베이스의 상태를 일치시키기 위해 flush가 발생합니다.
flush 가 완료된 후, commit() 메서드가 호출되면 트랜잭션이 종료되며, 데이터베이스는 영구적으로 변경사항을 반영합니다. 만약 트랜잭션 중에 예외가 발생하여 롤백된다면, 이전에 flush 된 변경사항들도 취소됩니다.
flush와 commit의 차이점에 대해 혼동하기 쉬운데, flush는 쓰기 지연 저장소에 있는 SQL 문을 데이터베이스에 반영하지만, 트랜잭션을 종료하지는 않습니다. 즉, flush가 발생해도 트랜잭션이 커밋되기 전까지는 데이터베이스에 변경 사항이 반영되더라도 확정되지 않은 상태입니다.
반면, commit은 트랜잭션을 종료하며, 그동안 flush된 변경 사항을 데이터베이스에 영구적으로 반영합니다.
따라서 flush는 데이터베이스와 영속성 컨텍스트를 동기화하는 작업이며, commit은 트랜잭션을 종료하고 변경 사항을 확정하는 작업입니다.
4. 변경 감지(Dirty Checking)
변경 감지는 flush 가 발생할 때 이루어지며, 다음과 같은 절차를 따릅니다 :
1. 변경 감지 : 영속성 컨텍스트는 엔티티의 변경사항을 감지합니다.
2. 쿼리 저장 : 수정된 엔티티에 대한 업데이트 쿼리가 생성되어 쓰기 지연 저장소에 저장됩니다.
3. DB 동기화 : 쓰기 지연 저장소에 저장된 쿼리문들이 데이터베이스에 반영됩니다.
어떻게 변경 감지가 이루어질까요?
1차 캐시에 저장된 엔티티의 초기 상태를 스냅샷으로 저장합니다. 이후 개발자가 엔티티의 필드를 변경하면, 영속성 컨텍스트는 현재 엔티티의 상태를 스냅샷과 비교하여 변경된 부분을 감지합니다. 이렇게 변경 사항을 찾아내는 과정을 Dirty Checking 또는 변경 감지라고 합니다.
@Transactional 을 사용하면 save 없이도 변경사항을 저장할 수 있습니다.
스프링에서 제공하는 @Transactional 어노테이션이 적용된 메소드에서는, 트랜잭션이 종료되기 직전에 flush가 자동으로 호출되어 변경 감지가 이루어집니다. 이로 인해 개발자가 명시적으로 save를 호출하지 않아도, 트랜잭션이 커밋될 때 변경 사항이 자동으로 DB에 반영됩니다.
그러나, 만약 @Transactional 어노테이션이 메소드에 적용되지 않았다면, 해당 메소드 내에서 발생한 변경 사항은 트랜잭션이 적용되지 않으며, 메소드가 종료되어도 트랜잭션 커밋이 이루어지지 않습니다.