Spring Framework를 도입하면 반드시 공부해야하는 개념 중 하나라고 생각한다.
@Transactional
public void findUser(UUID userId){
var member = memberRepository.findById(userId);
var member = memberRepository.findById(userId);
}
위 함수를 실행하면 select 문이 두번 실행될 것처럼 보이지만 실제로는 한번만 실행된다.
이는 처음 조회한 결과가 영속성 컨텍스트에서 관리되고 있기 때문이다.
그럼 영속성 컨텍스트가 무엇이길래 이런 게 가능한 걸까?
JPA에서 Entity Manager가 DB 객체를 관리하는 개념으로,
Java 공식 문서에서는 영속성 컨텍스트와 관련하여 다음과 같이 설명한다.
An EntityManager instance is associated with a persistence context. A persistence context is a set of entity instances in which for any persistent entity identity there is a unique entity instance. Within the persistence context, the entity instances and their lifecycle are managed. The EntityManager API is used to create and remove persistent entity instances, to find entities by their primary key, and to query over entities.
위 문장은 당장은 이해하기 어렵지만 끝까지 글을 읽고 나면 이해할 수 있을 것이다.
영속성 컨텍스트를 간단히 표현하면 DB에서 가져왔거나 DB에 save될 모든 entity를 담은 1차 캐시이다.
영속성 컨텍스트는 우리의 애플리케이션과 DB 사이에 존재한다. 그리고 영속화된 entity에 대해서 모든 변화를 추적한다.
(여기서 영속화되었다는 말은 영속성 컨텍스트에서 관리되고 있다는 뜻이다.)
영속성 컨텍스트는 Transaction 내에서 엔티티에 대해 어떤 변화가 생기면 이것을 '오염'되었다고 인식한다. 그리고 Transaction이 끝나면 이 변화들을 DB로 flush한다.
flush
여기서 flush한다는 것은 영속성 컨텍스트에서 관리하는 엔티티들에 대해 변경 사항을 찾고, 쿼리를 생성하여 적재된 쿼리들을 DB로 요청 보낸다는 것이다.
실제로 DB에 완전히 반영되려면 commit이 이루어져야 한다.
EntityManager는 이 영속성 컨텍스트와 우리가 소통하게 해주는 인터페이스이다. 우리가 EntityManager를 사용할 때, 실제로는 영속성 컨텍스트와 소통하고 있다고 보면 된다.
다시 말해, 위의 예시처럼 find()로 조회하는 경우,
우리는 사실 DB와 소통하는 게 아니라 영속성 컨텍스트에게 이 값을 달라고 요청하는 것이다.
그럼 영속성 컨텍스트가 1차 캐시 또는 (캐시에 없는 경우) DB에서 값을 새로 가져와서 우리에게 전달해주는 것이다.
우리가 동일한 값을 요청하거나 간단한 변화가 있을 때마다 DB에 요청을 보내게 되면 성능에 저하가 있을 수 있다. 그렇기 때문에 영속성 컨텍스트가 사이에서 캐시의 역할을 하는 것이다.
영속성 컨텍스트의 종류에는 두가지가 있다.
필자도 공부하면서 처음 알았다.
영속성 컨텍스트는 아래와 같이 두가지 종류가 있다.
이 경우 영속성 컨텍스트 생명주기는 트랜잭션 생명주기를 따라간다.
트랜잭션 안에서 EntityManager가 호출되면,
EntityManager는 영속성 컨텍스트가 있는지 확인 후 없으면 새로 영속성 컨텍스트를 생성하게 된다.
트랜잭션이 종료되면,
속성 컨텍스트에 존재하는 모든 엔티티를 DB로 flush하고 EntityManager가 close된다.
default 영속성 컨텍스트 타입이다. (PersistenceContextType.TRANSACTION)

위 사진을 보면 여러 Entity Manager가 하나의 Persistence Context를 공유하는 데 이 부분은 어떻게 된 걸까?
container-managed 엔티티 매니저는 기본적으로 persistence context가 모든 컴포넌트에 대해 propagate된다 (참고 문서)
그렇기 때문에 여러 entity manager 인스턴스에서 작업을 각기 수행해도 동일한 transaction 내에서라면 하나의 persisten context로 관리된다.
이 타입의 영속성 컨텍스트는 여러 트랜잭션에서 사용될 수 있으며 확장된 생명주기를 가진다.
따라서 트랜잭션이 종료되어도 엔티티를 계속 관리할 수 있지만 트랜잭션 없이 flush하지는 못한다.
아래와 같이 엔티티 매니저가 extended-scoped 영속성 컨텍스트를 사용하도록 할 수 있다.
@PersistenceContext(type = PersistenceContextType.EXTENDED)
private EntityManager entityManager;
그럼 extended-scoped persistence context의 생명주기는 무엇을 따를까?
바로 stateful session bean의 생명주기다
Extended-scoped 영속성 컨텍스트는 stateful session bean 안에서만 사용가능하며, 생명주기도 stateful session bean을 따라 생성되었다가 삭제된다.
영속성 컨텍스트에서 엔티티의 상태는 new, managed, detached, removed 네가지가 될 수 있다.

새로 생성된 entity 객체로 영속성 컨텍스트에 한번도 등록되지 않은 상태이다.
var member = new Member();
객체가 영속성 컨텍스트에 등록되어 관리되고 있는 상태
객체가 영속 상태가 되기 위한 방법은 다음과 같다.
SimpleJpaRepository.java 파일을 뜯어보면 save() 호출 시 아래와 같이 엔티티가 영속화되는 것을 볼 수 있다.
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (entityInformation.isNew(entity)) {
entityManager.persist(entity);
return entity;
} else {
return entityManager.merge(entity);
}
}
영속화되었던 객체가 영속성 컨텍스트에서 분리된 상태
em.detach(), em.close(), em.clear() 등으로 detached 상태가 될 수 있다
// 명시적으로 특정 엔티티 분리
em.detach(member);
// 영속성 컨텍스트 초기화
em.clear();
em.close();
close()와 clear()는 내부적으로 둘다 persistenceContext.clear()를 호출하며 영속성 컨텍스트를 초기화한다.
엔티티를 삭제한 경우
em.remove(member);

이미지 출처: https://velog.io/@seungho1216/JPA영속성-컨텍스트1차-캐시
영속성 컨텍스트에는 1차 캐시가 존재한다.
1차 캐시는 위 사진처럼 id를 key로 사용하여 entity를 저장한다.
엔티티 매니저가 특정 엔티티를 조회할 때 1차 캐시를 먼저 찾고, 존재하지 않으면 DB에 접근해 가져온다.
@Transactional
public void findUser(UUID userId, String username){
var member = memberRepository.findById(userId); --1
var member = memberRepository.findByName(username); --2
var member = memberRepository.findByName(username); --3
}
위의 함수를 실행하면 select 쿼리가 몇 번 호출될까?
정답은 3번이다.
그 이유는 1차 캐시를 사용하기 위해서는 식별자인 id로 접근해야하기 때문이다.
2,3번 줄에서는 1차 캐시를 사용하지 않고 JPQL로 쿼리를 요청하여 결과를 조회하기 때문에 쿼리가 총 세 번 나간다.
@Transactional
public void changeUserName(UUID userId, String username){
var member = memberRepository.findById(userId); --1
member.setName("test");
// memberRepository.save(member);
}
위 함수를 실행하면 member의 이름이 성공적으로 변경된 것을 볼 수 있다.
save()를 따로 호출하지 않아도 DB에 변경이 반영되어 있는 것은 영속성 컨텍스트의 변경 감지 때문이다.

엔티티 매니저는 엔티티를 1차캐시에 저장할때 스냅샷도 같이 저장한다.
그리고 이 스냅샷을 이용해 다음과 같은 순서로 변경 감지가 일어난다.
memberRepository.flush();
// 내부적으로 entityManager.flush()를 호출commit이 호출되면 entityManager.close()가 호출되며, 내부적으로 persistenceContenxt.clear()이 호출되어 영속성 컨스트가 초기화된다.
영속성 컨텍스트에서 꺼내온 객체는 동일성이 보장된다.
@Transactional
public void isSameUserWithTransaction(UUID userId, String username){
var member1 = memberRepository.findById(userId);
var member2 = memberRepository.findById(userId);
System.out.println(member1 == member2); // true
}
public void isSameUserWithoutTransaction(UUID userId, String username){
var member1 = memberRepository.findById(userId);
var member2 = memberRepository.findById(userId);
System.out.println(member1 == member2); // false
}
첫 번째 케이스에서는 동일한 객체를 리턴하지만
두 번째 케이스에서는 동일한 객체를 리턴하지 않는다.
영속성 컨텍스트를 통해 지연 로딩을 사용할 수 있다.
JPA는 엔티티가 실제로 사용되기 전까지 데이터베이스 조회를 지연할 수 있도록 제공하는데 이를 지연 로딩이라 한다. 실제 사용하는 시점에 데이터베이스에서 필요한 데이터를 가져오는 것이다.
필요시에 가져오기 때문에 불필요한 쿼리를 실행하지 않을 수 있다.
연관관계에 있는 객체는 실제 사용 전까지 프록시 객체로 초기화되지 않은 상태로 존재한다.
Team과 Member는 1:N 관계이며, Member 엔티티에서 Team을 지연 로딩을 통해 가져오도록 설정한 상태이다.
public class Member {
//...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id", insertable = false, updatable = false)
private Team team;
//...
}
이 때 아래와 같이 member를 통해 연관 관계인 team의 클래스를 출력해보자.
public void findUser(UUID userId){
var member = memberRepository.findById(userId);
System.out.println(member.getTeam().getClass());
//class hello.jpa.Team$HibernateProxy$e97rdqZR
}
그럼 프록시 객체가 출력되는 것을 알 수 있다.
public void findUserTeam(UUID userId){
var member = memberRepository.findById(userId);
System.out.println(member.getTeam().getClass());
System.out.prinln("team name : " + member.getTeam().getName());
}
위와 같이 team의 이름을 출력해보자.
그럼 proxy 객체가 출력된 후에 team을 조회하는 select 쿼리가 나가는 것을 볼 수 있다.
트랜잭션 내에서 insert, update, delete가 일어나면 쿼리가 즉시 날아가지 않고 쓰기지연 SQL 저장소에 저장된다.
그리고 flush가 일어날 때 한꺼번에 요청된다.
@Transactional
public void deleteUser(UUID userId){
var member = memberRepository.findById(userId);
System.out.println("start update");
memberRepository.delete(member);
System.out.println("end update");
}
위의 쿼리를 실행하면 start update, end update가 먼저 콘솔에 찍힌 후에 delete query가 날아간다.