영속성 컨텍스트란?
오라클 공식 문서에는 영속성 컨텍스트가 다음과 같이 정의되어 있습니다.
- 영속성 컨텍스트란, 모든 영속성 엔티티 ID에 대해 고유한 엔티티 인스턴스가 있는 인스턴스 집합이다.
- 영속성 컨텍스트 내에서 엔티티 인스턴스 및 엔티티 인스턴스의 라이프사이클이 관리된다.
엔티티 인스턴스(Entity Instance)란, 하나의 데이터베이스의 테이블과 매칭되는 클래스의 인스턴스를 의미합니다.
내용이 조금 이해하기 어려우나, 보다 쉽게 다시 한번 정리하면 다음과 같습니다.
- 영속성 컨텍스트는 엔티티 인스턴스에 대한 1차 캐시로서 작동한다.
- 영속성 컨텍스트는 엔티티 인스턴스와 그들의 라이프사이클을 관리한다.
이런 영속성 컨텍스트는 어플리케이션과 영속성 저장소 사이에 위치합니다. 즉, 어플리케이션은 데이터베이스에 직접 접근하는 것이 아니고 항상 영속성 컨텍스트를 통해 접근하게 됩니다.
영속성 컨텍스트의 종류
영속성 컨텍스트는 두 가지 종류로 나뉩니다.
- 트랜잭션 범위 영속성 컨텍스트(Transaction-scoped persistence context)
- 확장된 범위의 영속성 컨텍스트(Extended-scoped persistence context)
트랜잭션 범위 영속성 컨텍스트
트랜잭션 범위 영속성 컨텍스트는 트랜잭션에 바인딩되어 있습니다. 즉, 트랜잭션이 끝날 때 영속성 컨텍스트에 존재하는 엔티티들이 실제 영속성 저장소로 flush 됩니다.
flush란 '물을 흘려보내다'라는 의미로, 영속성 컨텍스트의 내용과 실제 데이터베이스의 내용을 동기화시키는 작업을 의미합니다.
그리고 영속성 컨텍스트를 사용할 때, 따로 속성 명시를 해주지 않으면 기본적으로 트랜잭션 범위 영속성 컨텍스트가 사용됩니다.
확장된 범위의 영속성 컨텍스트
확장된 범위의 영속성 컨텍스트는 영속성 컨텍스트의 생명주기를 트랜잭션과 독립적으로 관리하는 방식입니다. 그리고 이 영속성 컨테이너는 트랜잭션 없이 컨테이너에 영속화는 가능하지만 트랜잭션을 통해서만 flush가 가능합니다.
웹 어플리케이션에서 여러 요청 간에 동일한 엔티티를 사용해야 하는 경우 이런 확장된 범위의 영속성 컨텍스트를 사용할 수 있습니다.
엔티티 생명주기
앞서 영속성 컨텍스트를 설명할 때, 영속성 컨텍스트는 엔티티 인스턴스와 그들의 라이프사이클을 관리한다고 했습니다. 즉, 이런 영속성 컨텍스트 개념이 어플리케이션과 데이터베이스 사이에 존재하게 됨으로서 우리가 영속화하고자 하는 엔티티는 보다 특별한 생명주기를 가지게 됩니다.
엔티티는 생명주기에서 총 4가지 상태를 가질 수 있습니다.
1. 비영속(new / transient): 영속성 컨텍스트와는 전혀 관계가 없는 새로운 상태
Member member = new Member(); // 비영속
2. 영속(managed): 영속성 컨텍스트에서 관리되는 상태
Member member = new Member(); // 비영속
entityManager.persist(member); // 영속
3. 준영속(detached): 영속성 컨텍스트에서 관리되다가 분리된 상태
Member member = new Member(); // 비영속
entityManager.persist(member); // 영속
entityManager.detach(member); // 준영속 (1번방법)
entityManager.clear(); // 준영속 (2번방법)
entityManager.close(); // 준영속 (3번방법)
4. 삭제(removed): 영속성 컨텍스트와 데이터베이스에서 삭제된 상태
Member member = new Member(); // 비영속
entityManager.persist(member); // 영속
entityManager.remove(member); // 삭제
영속성 컨텍스트의 특징
영속성 컨텍스트를 사용하는 이유에 대해서는 아직까지 설명하지 않았는데요, 과연 어떤 특징이 존재할까요? 하나씩 살펴보겠습니다.
1차 캐시
영속성 컨텍스트는 데이터베이스 앞에 존재하기 때문에 어플리케이션 입장에서는 이를 마치 캐시처럼 사용할 수 있습니다.
영속성 컨텍스트가 '1차 캐시'라고 불리는 이유는, 어플리케이션 범위에서 동작하는 '2차 캐시'도 존재하기 때문입니다.
따라서 영속성 컨텍스트에 해당 식별자를 가진 엔티티가 이미 존재하는 경우 이런 영속성 컨텍스트를 1차 캐시로 활용함으로서 데이터베이스에 접근할 필요가 없습니다. 코드를 통해 설명하면,
entityManager.clear();
entityManager.find(Member.class, 1L);
위 코드의 경우에는 clear()를 통해 영속성 컨텍스트를 비우고 난 뒤 조회(find)를 하므로 데이터베이스에 실제 SELECT 쿼리가 날아갑니다.
entityManager.clear();
entityManager.find(Member.class, 1L);
entityManager.find(Member.class, 1L);
하지만 위와 같이 또 한번 조회를 하는 경우에는 쿼리가 날아가지 않습니다. 즉, 위 코드에서 데이터베이스에 날아가는 쿼리는 총 한 번이라는 의미인데 이는 1차 캐시를 응용했기 때문입니다.
첫번째 find 메소드가 수행되는 순간, 데이터베이스에 실제 쿼리가 날아가 ID 1을 가진 Member를 찾습니다. 그리고 이는 영속성 컨텍스트에서 관리됩니다.
그렇기에 두번째 find 메소드가 수행될 때에는 이미 영속성 컨텍스트에 ID가 1인 Member가 존재하게 되고, 데이터베이스에 쿼리를 날릴 필요 없이 1차 캐시(영속성 컨텍스트)에서 값을 가져오게 됩니다.
영속 엔티티의 동일성 보장
영속성 컨텍스트를 사용하면 엔티티에 대한 동일성 보장이 가능해집니다. 즉, 아래와 같은 시나리오가 가능해지는 것입니다. 이는 영속성 컨텍스트가 동일 엔티티에 대해서는 고유한 식별자로 관리하고 있기 때문에 가능합니다.
Member member1 = entityManager.find(Member.class, 1L);
Member member2 = entityManager.find(Member.class, 1L);
System.out.println(member1 == member2); // true
하지만 아래와 같은 시나리오는 동일성 보장이 불가능합니다.
Member member1 = entityManager.find(Member.class, 1L);
entityManager.clear();
Member member2 = entityManager.find(Member.class, 1L);
System.out.println(member1 == member2); // false
이는 영속성 컨텍스트가 한 번 비워졌기 때문입니다. 영속성 컨텍스트를 사용한다고 해서 무조건 엔티티에 대한 동일성 보장이 되는 것이 아니라, 영속성 컨텍스트가 관리하는 엔티티들이 준영속 상태가 되지 않는 한 동일성 보장이 가능함에 유의해야 합니다.
트랜잭션을 지원하는 쓰기 지연(transaction write-behind)
사실 entityManager의 persist 메소드를 호출한다고 해서 INSERT 쿼리가 바로 날아가지는 않습니다. 트랜잭션이 커밋되는 시점에, 혹은 flush가 호출되는 시점에 INSERT 쿼리가 한번에 날아갑니다.
entityManager.persist(member1);
entityManager.persist(member2);
// 이 시점까지도 데이터베이스로 쿼리가 날아가지는 않음
entityTransaction.commit(); // 이 시점에 INSERT 쿼리가 두 개 날아감!
이렇게 쿼리를 모아서 한번에 보냄으로써 쿼리 최적화도 가능하고 batch를 사용하는 등 성능적인 이점을 얻을 수 있습니다.
변경 감지(Dirty-checking)
JPA를 사용하는 경우, 엔티티가 변경되더라도 따로 어떤 조치를 취할 필요가 없습니다. 영속성 컨텍스트에서 변경된 엔티티가 있다면 자동으로 UPDATE 쿼리를 날리기 때문입니다.
트랜잭션이 커밋되거나 flush가 호출되면 엔티티와 스냅샷을 비교해 변경된 엔티티를 찾고, UPDATE 쿼리를 생성해 쓰기 지연 SQL 저장소에 저장합니다. 그리고 이를 실제 데이터베이스에 반영합니다.
Member member = new Member();
member.setUsername("테오");
em.persist(member);
member.setUsername("오테");
Member found = em.find(Member.class, member.getId());
System.out.println(found.getUsername()); // 오테
지연 로딩(Lazy Loading)
영속성 컨텍스트는 지연 로딩도 가능하게 합니다. 지연 로딩이란, 어떤 엔티티가 참조하고 있는 엔티티를 필요할 때 로딩하는 것을 의미합니다. 이런 지연 로딩이 가능한 이유는 JPA가 프록시 디자인 패턴을 사용하기 때문입니다. 만약 Member가 Team을 참조하고 있고, 지연 로딩을 적용했다면 아래와 같은 그림이 됩니다.
그리고 이 과정에서 영속성 컨텍스트가 활용됩니다. 프록시 객체는 로딩된 실제 객체를 그대로 참조하는 구조이기 때문에 영속성 컨텍스트에서 엔티티를 보관하고 있어야 합니다.
참고 자료
김영한님 - <자바 ORM 표준 JPA 프로그래밍 - 기본편>
https://www.baeldung.com/jpa-hibernate-persistence-context
https://docs.oracle.com/javaee/7/api/javax/persistence/EntityManager.html