영속성 컨텍스트 ( Persistence Context )

persistence context는 엔티티를 영구 저장하는 환경으로, 논리적인 개념입니다.

엔티티 매니저( Entity Manager )로 엔티티를 저장( persist() ) , 조회( find() 또는 JPQL , QueryDSL )하면 엔티티 매니저는 그 엔티티를 영속성 컨테스트에 보관하고 관리합니다.


특징

1) persistence context는 엔티티의 @Id 필드를 이용하여 엔티티를 식별합니다.

따라서 엔티티를 정의할 때 식별자 값이 꼭 있어야 합니다.


2) persistence context에는 쓰기 지연 기능이 있습니다.

즉 값을 변경하자마자 바로 DB에 반영하는 것이 아니라, persistence context에 쓰기 지연 SQL 저장소가 있어서 SQL 쿼리들을 저장해 뒀다가, 엔티티 매니저가 commit() 메서드를 호출할 때 DB에 반영됩니다.

이를 flush라 합니다.


3) 그 밖에 1차 캐시 , 동일성 보장, 변경 감지, 지연 로딩 등의 특징이 있습니다.





엔티티 생명주기
1. 비영속 ( new )
persistence context와 관련이 없는 상태, 즉 DB와 관련이 없는 순수한 객체 상태를 의미합니다.
User라는 엔티티가 정의 되어 있을 때 new 키워드를 통해 객체를 생성한 상태입니다.
ex)
Member member = new Member();


2. 영속 ( managed )
엔티티 매니저가 관리하는 persistence context에 엔티티가 저장된 상태를 의미합니다.
persist() 메서드를 호출하거나, find() 또는 JPQL과 QueryDSL로 엔티티를 조회 했을 때 그 엔티티를 영속상태라 합니다.
ex)
em.persist(member)   또는   em.find(member.class, 1)


3. 준영속 (detached )
persistence context에 저장되었다가 분리된 상태, 즉 엔티티 매니저가 관리하지 않는 상태를 의미합니다.
비영속 상태와 유사하지만 몇 가지 차이점이 있는데, 글의 끝 부분에서 다루도록 하겠습니다.
영속 상태에서 준영속 상태로 바꾸는 방법은 아래와 같이 3가지가 있습니다.
ex)
em.detach(userA) : 특정 엔티티만 분리
em.clear() : persistence context를 초기화
em.close() : persistence context를 종료


4. 삭제 ( removed )
persistence context에서 엔티티를 제거하며, DB에서도 해당 객체를 삭제합니다.
remove() 메서드를 호출하여 엔티티를 삭제할 수 있습니다.
ex)
em.remove(member)





1.  1차 캐시

persistence context에는 1차 캐시( first level cache )라는 것이 존재합니다.

엔티티 매니저가 persist() 또는 find() 메서드를 호출하면 그 엔티티는 managed 상태가 되면서 persistence context의 1차 캐시에 저장됩니다.


1차 캐시는 일종의 Map이라고 생각하면 되는데,

key는 @Id로 지정한 식별자이고, value는 엔티티 인스턴스입니다.



엔티티 추가

예를들어, Member라는 엔티티가 있을 때, 이 엔티티를 추가하는 코드를 가정해보겠습니다.

public static void insert(EntityManager em) {
	Member member = new Member();
	member.setId("member1");
	member.setName("회원1");
	em.persist(member);
}

이전 글들에서 알아본 것처럼 INSERT 쿼리를 수행하기 위해서는 em.persist() 메서드를 호출해야 합니다.

이 메서드가 실행되면 1차 캐시에는 아래와 같이 해당 엔티티 정보가 저장됩니다.


persist() 메서드를 호출하는 것만으로 해당 엔티티는 managed 상태가 됩니다.



1차 캐시에서 엔티티 조회

이번에는 엔티티를 조회하는 메서드가 실행된다고 가정하겠습니다.

public static void find(EntityManager em) {
	Member member = new Member();
	member.setId("member2");
	member.setName("회원2");
	// 1. 1차 캐시에 저장
	em.persist(member);
	
	// 2. 1차 캐시에서 조회
	Member findMember = em.find(Member.class, "member2");
	System.out.println(findMember.getName());
}


member2 엔티티를 캐시에 저장한 후, 바로 조회하는 메서드입니다.

이 경우 member2 엔티티가 1차 캐시에 존재하므로 DB에 접근하기 위한 SELECT 쿼리를 수행하지 않습니다.

결과를 보시면 " 회원2 "와 INSERT 쿼리만 콘솔에 출력되는 것을 확인할 수 있습니다.


이를 그림으로 그려보면 다음과 같습니다.



DB에서 엔티티 조회

이번에는 DB에 저장되어 있는 member3를 가져오도록 해보겠습니다.

public static void find(EntityManager em) {
	// DB에서 조회
	Member findMember = em.find(Member.class, "member3");
	System.out.println(findMember.getName());
}


@Id로 지정한 필드의 값이 member3인 엔티티를 조회하는 메서드입니다.

member3가 1차 캐시에 존재하지 않으므로, DB에서 엔티티를 가져오기 위해 SELECT 쿼리를 수행합니다.




1차 캐시 정리

persist() 메서드를 호출하면 그 엔티티는 1차 캐시에 저장되어 managed 상태가 되어 엔티티 매니저가 관리하게 됩니다.


find() 메서드를 호출했을 때는 찾고자 하는 엔티티가 1차 캐시에 없는 상태라면, DB에 SELECT 쿼리를 수행하여 엔티티를 가져온 후, persistence context의 캐시에 저장하여 엔티티를 managed 상태로 만듭니다.

1차 캐시에 존재하는 상태라면 SELECT 쿼리를 수행하지 않고 바로 반환합니다.

어떤 경우에서든지 find() 메서드의 반환 값은 캐시에 저장된 엔티티가 되는 것을 알 수 있습니다.


이와 같이 캐시를 사용하면 DB에 접근하지 않아도 되므로 성능상의 이점을 누릴 수 있습니다.






2.  동일성 보장

JDBC( Mybatis )에서는 쿼리를 수행할 때 마다 DB에서 매 번 가져오지만, JPA의 경우 1차 캐시에 있는 엔티티를 조회하므로 동일한 객체를 반환합니다.

public static void identity( EntityManager em) {
	Member a = em.find(Member.class, "member1");
	Member b = em.find(Member.class, "member1");
		
	System.out.println(a == b);
}


처음 find() 메서드가 호출 되었을 때 1차 캐시에는 아무 것도 없는 상태이므로 DB에서 엔티티를 가져오기 위한 SELECT 쿼리를 수행합니다.

이어서 find() 메서드가 호출 되면 1차 캐시에 엔티티가 존재하므로 1차 캐시에 있는 엔티티를 반환합니다.

이 때 1차 캐시에서 참조하고 있는 엔티티는 같은 객체이므로 동일성 비교 결과 true가 반환되는 것을 알 수 있습니다.





3. 쓰기 지연 ( transaction write behind )

persist() 메서드를 호출하면 INSERT 쿼리가 수행되어 엔티티를 추가할 수 있습니다.

그러나 바로 DB에 추가하는 것이 아니라, persistence context 내부에 있는 쿼리 저장소에 INSERT 쿼리를 저장하고 있다가 엔티티 매니저가 commit() 메서드를 호출하면 저장해두었던 쿼리를 DB에 보냅니다.

이를 트랜잭션을 지원하는 쓰기지연이라 합니다.

public static void lazyWriting(EntityManager em) {
	Member memberA = new Member();
	memberA.setId("memberA");
	memberA.setName("회원A");
	em.persist(memberA);
	
	Member memberB = new Member();
	memberB.setId("memberB");
	memberB.setName("회원B");
	em.persist(memberB);
	
	System.out.println("persist()를 호출한다고 바로 INSERT가 실행되지 않는다.");
}


결과를 보시면 INSERT 쿼리보다 sysout()이 먼저 출력되었습니다.

이로써 persist() 메서드가 호출되었을 때 바로 DB에 저장되는 것이 아니라는 것을 알 수 있습니다.

이 과정을 그림으로 그려보면 다음과 같습니다.



JPA가 쓰기 지연을 지원하는 이유는 쿼리를 모아 두었다가 한 번에 DB로 보내면 성능을 높일 수 있기 때문입니다.


엔티티 매니저가 commit() 메서드를 호출하면 persistence context의 변경 내용을 DB에 동기화 하는 flush 작업이 이루어집니다.

즉, 쓰기 지연 SQL 저장소에 모인 쿼리를 DB에 보내는 작업이 이루어지고 동기화가 되면 commit이 되어 DB에 물리적이고 영구적으로 반영됩니다.


웹에서는 이 과정을 @Transactional 어노테이션을 추가하여 해결합니다.

즉 메서드가 종료되었을 때 엔티티 매니저가 commit() 메서드를 실행한다고 생각하면 됩니다.






4. 변경 감지 ( dirty checking )
1차 캐시에 존재하는 엔티티에 대해서, setter를 이용해 필드 값을 수정하면 UPDATE가 이루어집니다.
엔티티 매니저는 find( 조회 ), persist( 추가 ), remove( 삭제 )와 달리, 따로 update() 메서드는 존재하지 않는데 그 이유는 변경 감지( dirty checking )를 하기 때문입니다.

변경 감지가 가능한 이유는 엔티티가 1차 캐시에 최초로 저장될 때, 그 상태를 복사해서 같이 저장해둡니다.
이를 스냅샷이라고 하는데, persistence context와 DB 사이의 동기화가 이루어지는 flush 시점에서 스냅샷과 현재 엔티티의 상태를 비교하여 엔티티가 변경되었다면 UPDATE 쿼리를 실행합니다.

변경 감지는 managed 상태에 있는 엔티티에 대해서만 적용되므로, managed 상태가 아닌 엔티티는 칼럼 값을 변경해도 DB에 반영되지 않습니다.
public static void dirtyChecking(EntityManager em) {
	Member member = new Member();
	member.setId("member1");
	member.setName("회원1");
	em.persist(member);
	
	member.setName("회원2");
}


commit 하는 순간에 변경 감지를 실행하며, 변경이 감지되면 UPDATE SQL을 SQL 저장소에 저장한 후에, flush를 합니다.



JPA가 수행하는 UPDATE 쿼리를 살펴보면 엔티티의 모든 필드에 대해서 업데이트가 이루어지는 것을 볼 수 있습니다.

즉 수정한 것은 name 필드이지만, age라는 필드가 있을 경우 age 필드까지 UPDATE를 수행합니다.


JPA에서 UPDATE 쿼리는 이와 같이 어떤 값이 변경되든 상관없이 쿼리가 항상 고정되어 있습니다.

이렇게 쿼리를 고정 시킴으로써 얻을 수 있는 장점은 UPDATE 쿼리를 미리 생성해둘 수 있으므로 재사용이 가능하며, DB 입장에서 동일한 쿼리를 보낼 경우 한 번 파싱된 쿼리를 재사용 할 수 있다는 점입니다.


그런데 모든 필드를 UPDATE 하면 데이터 전송량이 증가한다는 단점이 존재하지만, 얻을 수 있는 이점이 더 크므로 모든 필드를 업데이트 하는 전략을 기본 값으로 합니다.

모든 필드가 변경되는 것이 싫다면 엔티티를 정의할 때 @DynamicUpdate 어노테이션을 추가하면 실제로 UPDATE 되는 필드에 대해서만 UPDATE를 수행합니다.





5. 삭제 ( remove )

엔티티 매니저가 remove() 메서드를 호출하면 쓰기 지연 SQL 저장소에 쿼리를 저장하고 있다가 flush가 되었을 때,

persistence context에서 엔티티를 삭제하고 DB에서도 해당 데이터를 삭제합니다.

public static void remove(EntityManager em) {
	Member memberA = em.find(Member.class, "memberA");
	em.remove(memberA);
}





6. 플러쉬 ( Flush )

flush란 persistence context의 변경 내용을 DB에 동기화 하는 작업을 말합니다.

INSERT , UPDATE , DELETE 할 때 flush를 수행합니다.


flush는 다음의 동작으로 이루어집니다.

1) 변경 감지를 통해 수정된 엔티티를 찾습니다.

2) 수정된 엔티티가 있다면 UPDATE 쿼리를 persistence context에 있는 SQL 저장소에 등록합니다.

3) 쓰기 지연 SQL 저장소의 쿼리( 추가, 삭제, 수정 )를 모두 DB에 보냄으로써 동기화를 합니다.


그렇다면 언제 flush가 될까요?

1) 엔티티 매니저가 직접 호출 ( em.flush() )

2) 트랜잭션이 commit() 호출 할 때 자동으로 호출

3) JPLQ 쿼리 실행 전에 자동으로 호출


1번 방식은 강제로 flush 하도록 호출하는 것이기 때문에 당연하며,

2번 방식은 지금까지 알아보았던 방식입니다.

그래서 3번 방식에 대해서만 알아보도록 하겠습니다.


JPLQ 쿼리 실행 전에 flush가 호출
public static void flushBeforeJQPL(EntityManager em) {
	Member user1 = new Member();
	user1.setId("user1");
	user1.setName("유저1");
	em.persist(user1);

	Member user2 = new Member();
	user2.setId("user2");
	user2.setName("유저2");
	em.persist(user2);

	String jpql = "SELECT m FROM Member m";
	TypedQuery<member> query = em.createQuery(jpql, Member.class);

	// SELECT 쿼리 수행
	// 쿼리 실행 전에 flush가 수행되어 위의 두 엔티티를 persist하는 INSERT가 실행된다.
	List<member> memberList = query.getResultList();

	for( Member member : memberList) {
		System.out.println(member.getName());
	}
}


원래 대로라면 persist() 메서드는 엔티티가 바로 DB에 추가되는 것이 아니라 commit() 메서드가 호출되었을 때 DB에 반영이 됩니다.

그래서 콘솔 창에 유저1과 유저2가 먼저 출력이 된 후에, INSERT 쿼리가 실행되어야 하지만 중간에 JPQL로 엔티티를 조회하는 코드가 있기 때문에 조회를 하기 전에 flush가 한 번 일어나는 것을 확인할 수 있습니다.





7. 준영속 ( Detached )

엔티티 매니저는 detach() 메서드를 호출하여 엔티티를 영속 상태(managed)에서 준영속 상태(detached)로 만듭니다.

준영속 상태가 되면 persistence context에 있는 1차 캐시, 쓰기 지연 SQL에 저장된 쿼리들을 제거하기 때문에 엔티티 매니저는 더 이상 해당 엔티티를 관리하지 않습니다.


또한 detach() 메서드 뿐만 아니라 clear() 메서드( em.clear() )를 호출하여 persistence context에 있는 모든 엔티티들을 준영속 상태로 만들 수 있고, close() 메서드를 호출하면 persistence context를 아예 사용하지 못하게 종료 시켜버립니다.



특징

1) 거의 비영속 상태에 가까움

2) 비영속 상태는 식별자 값이 없을 수도 있지만, 준영속 상태는 한 번 영속 상태가 된 것이기 때문에 식별자 값이 반드시 존재

3) 지연 로딩을 할 수 없음 ( 지연 로딩이란 엔티티가 실제로 사용될 때, 즉 필요할 때 그제서야 로딩이 되는 방식을 의미합니다. )


public static void detached(EntityManager em) {
	Member userA = new Member();	// 1. Entity 생성 - 비영속 상태( new )
	userA.setId("userA");
	userA.setName("유저A");
	em.persist(userA);		// 2. 영속 상태( managed )
	
	em.detach(userA); 		// 3. persistence context에서 분리 - 준영속 상태( detached )
}

persist() 메서드를 호출 했음에도, 준영속 상태이므로 DB에 반영되지 않습니다.

( INSERT가 출력 되지를 않으니 수행되는 쿼리를 보여드리지 못하네요. )

public static void clearAndClose(EntityManager em) {
	// persistence context 초기화
	em.clear();
	
	// persistence context 종료
	em.close();
}

detach()는 특정 엔티티를 준영속 상태로 만들지만, clear()는 영속 상태에 있는 모든 엔티티를 준영속 상태로 만듭니다.

즉, 1차 캐시 및 SQL 저장소를 초기화 한다고 생각하면 됩니다.

close()는 persistence context 자체를 제거합니다.





8. merge 

detached 상태를 다시 영속상태로 변경하려면 병합( merge )을 해야 합니다.

detached 상태에서는 필드 값을 변경하여도 DB에 반영이 되지 않지만, 필드 값을 변경 한 후 merge를 하면 그 엔티티는 managed 상태가 되므로 변동 감지가 이루어지고, commit() 메서드를 호출할 때 변경 내용이 DB에 반영됩니다.


정확히는 merge() 메서드의 결과로 detached 상태인 엔티티가 managed 상태로 변경되는 것이 아니라, managed 상태인 새로운 엔티티를 반환합니다.

즉, merge() 메서드를 호출할 때 넘겨준 엔티티는 여전히 detached 상태에 있습니다.

그리고 merge() 메서드를 호출할 때 넘겨준 엔티티의 값을 새로운 엔티티의 값으로 채워 넣습니다.

public class App {
	private static EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpatest");

	public static void main(String[] args) {
		Member member = createMember("user0", "멤버1");
		
		// 준영속 상태에서 데이터 변경
		member.setName("멤버2");
		
		mergeMember(member);
	}
	
	public static Member createMember(String id, String name) {
		/***		영속성 컨텍스트 시작 		***/
		EntityManager em = emf.createEntityManager();
		EntityTransaction tx = em.getTransaction();
		tx.begin();
		
		// 엔티티 생성 - 비영속 상태 ( new )
		Member member = new Member();
		member.setId(id);
		member.setName(name);
		
		// 영속 상태 ( managed )
		em.persist(member);
		
		// flush 및 commit 수행
		tx.commit();
		
		// member 엔티티는 준영속 상태가 된다.
		em.close();
		/***		영속성 컨텍스트 종료 		***/
		
		// 준영속 엔티티를 반환
		return member;
	}
	
	public static void mergeMember(Member member) {
		/***		영속성 컨텍스트 시작 		***/
		EntityManager em = emf.createEntityManager();
		EntityTransaction tx = em.getTransaction();
		tx.begin();
		
		// 비영속 상태인 엔티티를 merge하여 새로운 영속상태 객체를 반환
		Member mergedMember = em.merge(member);

		// flush 및 commit 수행
		tx.commit();
		
	
		System.out.println("memberName : " + member.getName());
		System.out.println("mergedMemberName : " +mergedMember.getName());
		
		
		// Entity Manager가 member와 mergedMember 엔티티를 갖고 있는지 확인
		System.out.println("em contains member : " +em.contains(member));
		System.out.println("em contains mergedMember : " +em.contains(mergedMember));
		
		em.close();
		/***		영속성 컨텍스트 종료 		***/
	}
}


출력 결과를 보시면 memer 객체는 entity manager가 관리하고 있지 않지만,

mergedMember 객체는 entity manager가 관리하고 있다는 것을 알 수 있습니다.





이상으로 persistence context에 대한 내용을 마치도록 하겠습니다.

JPA에서 중요한 것 중 하나는 DB의 접근을 최소화 하여 성능을 높이는 것입니다.

그러기 위해서는 persistence context의 이해가 필수적이라 할 수 있겠습니다.


다음 글에서는 엔티티들이 관계를 맺도록 하는 연관 관계 매핑에 대해 알아보겠습니다.