개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

기초개념 › JPA

영속성 컨텍스트

영속성 컨텍스트는 무엇이고, 무엇을 해 주는가

영속성 컨텍스트는 EntityManager(코드의 em)가 엔티티 객체를 보관하면서 지켜보는 곳입니다. 엔티티는 DB의 행 하나에 대응하는 자바 객체이고, 여기서는 Member입니다. em.find로 DB에서 읽어 온 엔티티가 영속성 컨텍스트에 들어갑니다(새로 저장할 때 쓰는 em.persist로 넘긴 엔티티도 들어가지만 이 실습에는 나오지 않습니다). 들어 있는 엔티티는 JPA가 지켜보고 있어서, 값을 바꾸기만 해도 트랜잭션을 커밋할 때 JPA가 UPDATE 문을 만들어 보냅니다. 같은 id를 다시 find하면 DB에 가지 않고 보관하던 그 객체를 줍니다. 반대로 new로 직접 만든 객체나 em.detach로 떼어 낸 객체는 영속성 컨텍스트에 들어 있지 않은 평범한 자바 객체여서, 값을 바꿔도 DB에 반영되지 않습니다. 이 실습은 Member를 얻는 방법을 바꿔 가며 같은 코드(name을 이영희로 바꾸고 커밋)를 실행해, 커밋 뒤 DB의 name과 나간 UPDATE, SELECT 문의 수를 봅니다.

실행으로 대조 · Hibernate 7.4.5와 H2 2.4.240에서 여섯 가지 조합을 실행해 대조, 규칙은 Jakarta Persistence 3.2 명세와 대조

이 실습에 나오는 개념

영속성 컨텍스트
엔티티를 보관하고 관리하는 JPA의 논리적 공간이다. 조회하거나 저장한 엔티티는 이곳에 보관되며, JPA는 이곳의 엔티티에 일어난 변화를 추적해 트랜잭션이 끝날 때 DB에 반영한다. 보통 트랜잭션 하나와 생명주기를 같이한다(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다). 1차 캐시, 변경 감지, 쓰기 지연 같은 기능이 이 위에서 동작한다.
변경 감지(Dirty Checking)
영속성 컨텍스트가 관리하는 엔티티의 값이 바뀌면, 플러시할 때(보통 커밋 직전) JPA가 처음 읽었던 상태와 비교해 값이 달라진 엔티티에 대한 UPDATE 문을 자동으로 실행하는 기능이다. Hibernate는 기본적으로 바뀐 컬럼만이 아니라 전체 컬럼을 UPDATE 문에 넣으며, 바뀐 컬럼만 넣으려면 @DynamicUpdate를 쓴다. 따라서 조회한 엔티티의 값을 바꾸기만 하면 별도의 저장 메서드를 호출하지 않아도 DB에 반영된다.
1차 캐시
영속성 컨텍스트 안에 엔티티를 식별자 기준으로 보관하는 저장소다. 같은 트랜잭션 안에서 같은 식별자로 다시 조회하면 DB에 쿼리를 보내지 않고 보관된 엔티티를 돌려준다. 그 결과 같은 트랜잭션 안에서 같은 식별자의 엔티티는 항상 같은 객체다. 기본적으로 트랜잭션이 끝나면 사라지므로(OSIV가 켜진 웹 요청에서는 요청이 끝날 때까지 유지된다) 애플리케이션 전체의 캐시와는 다르다.
준영속 상태
영속성 컨텍스트가 관리하다가 더는 관리하지 않게 된 엔티티의 상태다. em.detach로 떼어 내거나, em.clear로 영속성 컨텍스트를 비우거나, em.close로 닫으면 그 안에 있던 엔티티가 준영속이 된다. 식별자(id) 값은 가지고 있지만 평범한 자바 객체와 같아서, 값을 바꿔도 UPDATE가 나가지 않고 예외도 나지 않는다. 화면에서 받은 값으로 new 해서 만든 객체도, DB에 이미 있는 행의 id를 가지고 있으면 준영속으로 다룬다. 트랜잭션이 끝난 뒤에 엔티티를 고치는 코드에서 자주 만난다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. DB의 준비: 코드가 실행되기 전에 DB의 member 테이블에는 id가 1인 행 (1, 김철수)가 하나 들어 있습니다. 이 행은 화면의 코드 바깥에서 미리 넣어 둔 것입니다. 코드에 나오는 em은 EntityManager 객체이고, em이 엔티티(DB의 행 하나에 대응하는 자바 객체, 여기서는 Member)를 보관하면서 지켜보는 곳이 영속성 컨텍스트입니다. 지금은 비어 있습니다.
  2. em.getTransaction().begin(): 트랜잭션을 시작합니다. 여기서부터 commit까지가 한 묶음의 작업입니다.
  3. Member m = em.find(Member.class, 1L): em이 id가 1인 Member를 찾습니다. 영속성 컨텍스트에는 아직 없으므로 JPA가 SELECT 문을 만들어 DB에 보내고, 읽어 온 값으로 Member 객체를 만들어 영속성 컨텍스트에 넣습니다. JPA는 이때의 값(김철수)도 기억해 둡니다. m은 이 객체를 가리킵니다. SELECT를 쓴 줄은 우리가 쓴 코드에 없고, JPA가 만들어 보냈습니다.
  4. m.setName("이영희"): 자바 객체의 name만 이영희로 바뀝니다. 이 줄에서는 SQL이 나가지 않고, DB의 name은 그대로 김철수입니다. 영속성 컨텍스트 안의 객체는 지금 값(이영희)과 JPA가 기억하는 처음 값(김철수)이 달라졌습니다.
  5. em.getTransaction().commit(): 커밋할 때 JPA가 영속성 컨텍스트에 보관 중인 엔티티의 현재 값을 기억해 둔 처음 값과 비교합니다. name이 김철수에서 이영희로 달라졌으므로, JPA가 UPDATE 문을 만들어 DB에 보냅니다. 우리가 쓴 코드에는 UPDATE도, 저장하라는 호출도 없습니다. UPDATE가 나간 것은 m.setName 줄이 아니라 이 commit 줄입니다.
  6. 커밋이 끝난 뒤 다른 연결로 DB의 member 테이블을 조회하면, id가 1인 행의 name은 이영희입니다. (이 조회는 확인용이어서 나간 SELECT 문의 수에 세지 않습니다.)

실행 결과

코드

em.getTransaction().begin();
// find: DB에서 읽어 온 엔티티가 영속성 컨텍스트에 들어간다
Member m = em.find(Member.class, 1L);
m.setName("이영희");
em.getTransaction().commit();

이 모형의 전제

Hibernate 7.4.5와 H2 2.4.240(메모리 DB)에서 실행해 대조했습니다. 스프링 없이 SessionFactory를 직접 만들었고, JDK 21입니다. 여섯 가지 조합을 모두, 화면의 코드 줄을 글자 그대로(주석 줄 포함) 컴파일해 실행했습니다. 조합마다 DB와 SessionFactory를 새로 만들고, DB에는 실행 전에 JDBC로 (1, 김철수) 한 행을 미리 넣었습니다. 이 준비의 SQL은 세지 않았습니다. 화면에 보이지 않는 것은 Member 엔티티(@Entity, @Id가 붙은 id와 name, 생성자, setName과 getName)와 EntityManager를 얻고 닫는 코드이고, 트랜잭션은 em에서 직접 시작하고 커밋했습니다. 대조한 것은 커밋 뒤 다른 연결(순수 JDBC)로 조회한 name, Hibernate의 StatementInspector로 센 UPDATE와 SELECT 문의 수(SQL 원문과 나간 줄은 대조 출력에 남겼습니다), 코드가 화면에 찍은 m == m2입니다. 단계 문장이 기대는 사실도 코드 줄마다 바로 아래에 측정 줄을 끼운 두 번째 실행에서 쟀습니다. em.contains(m)(find로 가져온 뒤에는 true, new로 만든 객체와 detach로 뗀 뒤에는 false), UPDATE는 m.setName 줄이 아니라 commit 줄에서 나간다는 것, 같은 id를 다시 find할 때 find 조합에서는 SELECT가 늘지 않고 아직 커밋 전인데도 name이 이영희인 같은 객체를 받는다는 것(new와 detach 조합에서는 SELECT가 늘고 name이 김철수인 새 객체를 받는다는 것), Hibernate가 읽어 올 때의 값을 따로 기억한다는 것(내부 API로 보면 읽은 뒤 [김철수], detach 뒤에는 기억 없음)입니다. 이름을 이영희로 바꿨다가 김철수로 되돌린 뒤 커밋하면 UPDATE가 나가지 않는다는 것도 따로 실행해, "바꾸는 호출을 했는지"가 아니라 처음 값과 비교한다는 근거로 삼았습니다. 또 new로 만든 객체를 em.persist로 넘기면 em.contains가 true가 되고 커밋할 때 INSERT가 나가는 것도 실행해 확인했습니다. 문서가 정한 것은 Jakarta Persistence 3.2 명세의 3.1절(EntityManager마다 영속성 컨텍스트가 있고, 그 안에는 같은 식별자의 엔티티 인스턴스가 하나뿐이라는 것), 3.3절(엔티티 인스턴스의 생명주기: managed는 영속성 컨텍스트에 속한 상태, detached는 속하지 않거나 더는 속하지 않는 상태), 3.3.2절(persist로 managed가 되는 것), 3.3.4절(영속 엔티티의 상태가 트랜잭션 커밋 때 DB와 맞춰지는 것), 3.3.6절과 3.3.7절(영속성 컨텍스트에서 떼어 낸 인스턴스는 그 밖에서 살아간다는 것)입니다. 명세 페이지는 요약으로만 돌아와 이 절들의 원문 전체를 읽지는 못했습니다. 처음 값을 기억해 비교하는 방식과 나가는 SQL의 모양은 명세가 정한 것이 아니라 Hibernate 7.4.5에서 관찰한 동작이므로, 화면에는 SQL 문장 대신 횟수만 적었습니다. 이 실습은 em을 직접 만들어 쓰는 모양이라 스프링의 @Transactional과 함께 쓸 때(영속성 컨텍스트가 트랜잭션과 같이 만들어지고 끝납니다)나, 쓰기 지연, 쿼리를 실행하기 직전의 flush, em.persist로 새로 저장하는 일은 다루지 않으며, 세부는 공부 탭의 "영속성 컨텍스트" 실습에서 다룹니다. MySQL에서의 실행 대조는 하지 않았습니다.

앞 실습: 엔티티 · 다음 실습: 리포지토리

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요