Spring

[Spring JPA] 영속성 컨텍스트 (2)

아윤_ 2024. 5. 12. 02:45

 

JPA에서 가장 중요한 두 가지 사항이 있는데, 바로 객체와 관계형 데이터베이스를 매핑하는 것과 영속성 컨텍스트를 이해하는 것이다. 이번에는 영속성 컨텍스트에 대해 알아볼 것인데, 영속성 컨텍스트에 대해 명확히 이해한다면 JPA가 어떻게 동작하는지 정확히 이해할 수 있다.

 

 

엔티티 매니저 팩토리와 엔티티 매니저

 

그 전에 JPA를 사용하기 위해서는 먼저 엔티티 매니저 팩토리와 엔티티 매니저에 대해 이해해야 한다.

 

 

다음과 같은 웹 어플리케이션을 개발할 때, 엔티티 매니저 팩토리는 고객의 요청이 올 때마다 엔티티 매니저를 생성하게 된다. 엔티티 매니저는 내부적으로 데이터베이스 커넥션을 사용해서 DB를 사용하게 된다.

 

 

영속성 컨텍스트

 

영속성 컨텍스트란, JPA를 이해하는데 가장 중요한 용어로 "엔티티를 영구 저장하는 환경"이라는 뜻이다. 

 

EntityManager.persist(entitiy);를 호출하여 엔티티를 집어넣으면 이 엔티티 객체를 DB에 저장한다고 알고 있는데, 해당 코드는 실제 DB에 엔티티를 저장하는 것이 아닌 영속성 컨텍스트를 통해 엔티티를 영속화한다는 뜻이다. 즉, 엔티티를 DB에 저장하는 것이 아닌, 영속성 컨텍스트에 저장한다는 의미이다.

  • 영속성 컨텍스트는 논리적인 개념이다.
  • 눈에 보이지 않는다.
  • 엔티티 매니저를 통해 영속성 컨텍스트에 접근 가능하다.

 

따라서 엔티티 매니저를 생성하면, 그 안에 보이지 않는 영속성 컨텍스트 공간이 생성된다.

 

 

엔티티의 생명주기

 

엔티티의 생명주기는 총 4가지가 있다.

  • 비영속 (new/transient): 영속성 컨텍스트와 전혀 관계가 없는 새로운 상태
  • 영속 (managed): 영속성 컨텍스트에 관리되는 상태
  • 준영속 (detached): 영속성 컨텍스트에 저장되었다가 분리된 상태
  • 삭제(removed)삭제된 상태

 

엔티티 생명주기

 

비영속

 

비영속 상태

 

// 객체를 생성한 상태(비영속)
Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

 

비영속 상태란, member 객체를 생성만 하고 엔티티 매니저에 아무것도 넣지 않은 경우를 말한다. JPA와 전혀 관계가 없기 때문에 이러한 상태를 비영속 상태라고 한다.

 

영속

 

영속 상태

 

// 객체를 생성한 상태 (비영속)
Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

EntityManager em = emf.createEntityManager();
em.getTransaction().begein();

// 객체를 저장한 상태 (영속)
em.persist(member);

 

엔티티 매니저 안에는 영속성 컨텍스트가 존재한다. 멤버 객체를 생성하고 엔티티 매니저를 생성한 다음, 엔티티 매니저 안에 persist를 통해 멤버 객체를 저장하면 멤버 객체는 엔티티 매니저의 영속성 컨텍스트에 들어가게 되면서 영속 상태가 된다. 하지만, DB에 저장되지 않는다.

 

영속 상태에서는 쿼리가 날아가지 않고, 트랜잭션을 commit하는 시점에 DB에 쿼리가 날아가게 된다.

 

준영속, 삭제

 

멤버 객체를 준영속 상태로 만들기 위해서는 detach 를 사용하면 된다. 멤버 객체는 영속성 컨텍스트에서 분리되며 준영속 상태가 된다.

em.detach(member);

 

멤버 객체를 삭제할 경우에는 remove 를 사용하면 된다. 이 경우, 실제 DB에서 삭제된다.

em.remove(member);

 

 

영속성 컨텍스트의 이점

 

그렇다면, 영속성 컨텍스트를 사용하는 이유는 무엇일까?

 

영속성 컨텍스트는 애플리케이션과 데이터베이스 사이에 존재하는 중간 계층이다. 이를 통해 다음과 같은 큰 이점을 얻어낸다. 예를 들어, 중간에 존재하면 버퍼링을 할 수 있고, 캐싱이 가능하다.

  • 1차 캐시
  • 동일성(identity) 보장
  • 트랜잭션을 지원하는 쓰기 지연
  • 변경 감지
  • 지연 로딩

 

1차 캐시

 

 

// 객체를 생성한 상태 (비영속)
Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

// 객체를 저장한 상태 (영속)
em.persist(member);

 

사실 영속성 컨텍스트는 내부에 1차 캐시를 가지고 있다. 1차 캐시는 key@id를, valueEntity를 가진다.

key는 DB의 Primary Key를 통해 매핑된 id 값이고, value는 엔티티 객체 자체이다.

 

// 객체를 생성한 상태 (비영속)
Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

// 1차 캐시에 저장됨
em.persist(member);

// 1차 캐시에서 조회
Member findMember = em.find(Member.class, "member1");

 

 

다음은 1차 캐시에서 엔티티를 조회하는 상황이다. 멤버 객체를 1차 캐시에 저장해 놓고, em.find를 통해 멤버를 조회하면 JPA는 DB를 먼저 탐색하지 않고, 1차 캐시를 탐색한다. 1차 캐시에 찾고자 하는 멤버 엔티티가 있으므로 바로 캐시에 있는 값을 조회한다.

 

이처럼 영속성 컨텍스트를 사용하면 1차 캐시를 통해 조회할 수 있다는 이점이 있다.

 

Member findMember = em.find(Member.class, "member2");

 

 

이전의 경우 조회하고자 하는 엔티티가 1차 캐시에 있었지만, 현재는 1차 캐시에 조회하려는 엔티티가 존재하지 않는 경우이다. 이 경우 먼저 1차 캐시를 조회하고, 1차 캐시에 찾고자 하는 엔티티가 없으면 DB를 조회하여 조회한 값을 1차 캐시에 저장한 다음 1차 캐시에 저장된 값을 반환하게 된다. 나중에 동일한 멤버 엔티티를 조회할 경우, 1차 캐시에 있는 멤버 엔티티가 반환되게 된다.

 

하지만 1차 캐시는 데이터베이스 트랜잭션 내에서만 효과가 있기 때문에 성능 이점을 크게 얻을 수는 없다고 한다.

 

영속 엔티티의 동일성 보장

 

다음과 같이 영속성 컨텍스트에서 find를 통해 동일한 엔티티를 2번 조회하는 경우, 처음에 조회할 때는 영속성 컨텍스트의 1차 캐시에 멤버 엔티티가 존재하지 않지만, DB에서 조회한 값을 영속성 컨텍스트의 1차 캐시에 저장해 놓기 때문에 두 번째 조회 시 1차 캐시에서 바로 값을 가져오게 된다.

 

Member a = em.find(Member.class, "member1");
Member b = em.find(Member.class, "member1");

System.out.println(a == b); //동일성 비교 true

 

1차 캐시가 있기 때문에 똑같은 값 두 개를 가져와 == 비교를 하면, 결과로 true가 나오게 된다. 즉, 영속성 컨텍스트는 영속 엔티티의 동일성을 보장해준다.

 

좀 더 어렵게 얘기하면 1차 캐시로 반복 가능한 읽기(REPEATABLE READ) 등급의 트랜잭션 격리 수준을 데이터베이스가 아닌 애플리케이션 차원에서 제공한다. 같은 트랜잭션 내에서 조회한 동일한 두 엔티티를 == 비교를 했을 때, true가 나온다고 이해하면 된다.

 

엔티티 등록 시 트랜잭션을 지원하는 쓰기 지연

 

JPA는 persist를 통해 영속성 컨텍스트에 memberA와 memberB를 저장해도 실제 DB에 INSERT SQL을 보내지 않고, 쌓아뒀다가 트랜잭션을 커밋하는 순간에 한번에 SQL을 보낸다.

 

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction()
// 엔티티 매니저는 데이터 변경시 트랜잭션을 시작해야 한다.
transaction.begin();    // [트랜잭션] 시작

em.persist(memberA);
em.persist(memberB);

// 커밋하는 순간 데이터베이스에 INSERT SQL을 보낸다.
transaction.commit();   // [트랜잭션] 커밋

 

 

사실, 영속성 컨텍스트 내부에는 1차 캐시뿐만 아니라 쓰기 지연 SQL 저장소가 존재한다.

 

먼저 persist를 통해 memberA를 저장하면, 영속성 컨텍스트의 1차 캐시에 저장이 되고, JPA는 memberA를 분석하여 INSERT SQL을 생성한 다음, 쓰기 지연 SQL 저장소에 생성한 SQL을 저장하게 된다. 마찬가지로 memberB를 저장할 때에도 동일하게 1차 캐시에 memberB가 저장되고, memberB와 관련된 INSERT SQL이 쓰기 지연 SQL 저장소에 쌓이게 된다.

 

 

마지막으로 트랜잭션을 커밋할 경우, 커밋하는 시점에 쓰기 지연 SQL 저장소에 쌓여있던 SQL이 flush가 되면서 한번에 DB로 날아가게 되고, 실제 DB 트랜잭션이 커밋된다.

 

이처럼 한번에 쿼리를 쌓았다가 보내는 것을 JDBC batch라 부른다. JPA 설정 파일인 persistence.xml  파일의 옵션 부분을 보면 “hibernate.jdbc.batch_size” 옵션이 있는데, value에 지정한 값만큼 쿼리를 쌓아뒀다가 쿼리가 지정한 만큼 쌓이면, 커밋하는 시점에 한 번에 보낸다. 버퍼링 같은 기능이라고 보면 된다.

 

엔티티 수정, 변경 감지

 

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction()
// 엔티티 매니저는 데이터 변경시 트랜잭션을 시작해야 한다.
transaction.begin();    // [트랜잭션] 시작

// 영속 엔티티 조회
Member memberA = em.find(Member.class, "memberA");

// 영속 엔티티 데이터 수정
memberA.setUsername("hi");
memberA.setAge(10);

// em.update(member)

transaction.commit();   //[트랜잭션] 커밋

 

영속 상태인 엔티티 데이터를 변경한 다음, JPA에게 em.update(member)와 같이 변경한 데이터를 업데이트 해달라는 코드가 있어야 할 것 같지만, JPA는 변경감지 기능을 제공하기 때문에 마치 자바 컬렉션에서 하는 것처럼 업데이트 코드를 작성하지 않아도 DB의 값이 알아서 변경된다.

 

이 비밀은 바로 영속성 컨텍스트 안에 존재한다. 동작 과정을 설명하면 다음과 같다.

 

  1. JPA는 데이터베이스 트랜잭션을 커밋하는 시점에 내부적으로 flush()가 호출된다.
  2. 1차 캐시에는 아이디와 엔티티뿐만 아니라, 스냅샷이 존재하는데, 스냅샷은 값을 읽어온 최초 시점의 상태를 말한다. flush()를 하고난 뒤 JPA는 엔티티와 스냅샷을 내부적으로 일일이 비교하여 변경된 내용을 확인한다.
  3. 엔티티가 변경된 것을 감지하면 업데이트 쿼리를 쓰기 지연 저장소에 생성한다.
  4. DB에 이를 반영한다.
  5. 커밋한다.

이러한 과정을 바로 변경 감지라고 한다.

 

엔티티 삭제

 

// 삭제 대상 엔티티 조회
Member memberA = em.find(Member.class, "memberA");

em.remove(memberA);	// 엔티티 삭제

 

엔티티 삭제 시에는 find를 통해 삭제할 엔티티를 조회하고, remove를 하면 엔티티가 삭제된다. 메커니즘은 위와 동일하다.

 

 

flush

 

flush는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하는 것을 말한다. 보통 데이터베이스 트랜잭션이 커밋되는 시점에 flush가 발생하게 된다.

 

flush 발생

  • 변경 감지
  • 수정된 엔티티를 쓰기 지연 SQL 저장소에 등록
  • 쓰기 지연 SQL 저장소의 쿼리를 DB에 전송(등록, 수정, 삭제 쿼리)

 

flush가 발생한다 해서 데이터베이스 트랜잭션이 커밋되는 건 아니고, 보낸 다음 트랜잭션을 커밋한다.

 

영속성 컨텍스트를 flush 하는 방법

  • em.flush() - 직접 호출
  • 트랜잭션 커밋 - 자동 호출
  • JPQL 쿼리 실행 - 자동 호출

 

persist를 하는 시점에 flush를 직접 호출하면 영속성 컨텍스트의 SQL 저장소에 저장되어 있던 쿼리가 바로 DB에 반영된다.

 

flush를 하더라도 1차 캐시는 지워지지 않는다. 쓰기 지연 SQL 저장소에 쌓여 있는 SQL문이 데이터베이스에 반영되는 과정이라고 보면 된다.

 

flush 모드 옵션

  • FlushModeType.AUTO: 커밋이나 쿼리를 실행할 때 플러시 (기본값)
  • FlushModeType.COMMIT: 커밋할 때만 플러시

 

가급적 손을 대지 않고 AUTO로 사용하는 것을 권장한다.

 

flush 정리

  • flush는 영속성 컨텍스트를 비우지 않는다.
  • 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화한다.
  • 트랜잭션이라는 작업 단위가 중요하다 -> 커밋 직전에만 동기화하면 된다.

 

JPA는 기본적으로 어떤 데이터를 맞추거나 동시성에 대한 일을 데이터베이스 트랜잭션에 위임해서 사용한다.

 

 

준영속 상태

  • 영속 상태의 엔티티가 영속성 컨텍스트에서 분리(detached) 되는 것
  • 준영속 상태가 되면 영속성 컨텍스트가 제공하는 기능을 사용하지 못한다.

 

준영속 상태로 만드는 방법

  • em.detach(entity): 특정 엔티티만 준영속 상태로 전환
  • em.clear(): 영속성 컨텍스트를 완전히 초기화
  • em.close(): 영속성 컨텍스트를 종료