JPA 영속성 컨텍스트란 무엇인가?

maketheworldwise·2023년 1월 16일


이 글의 목적?

최근에 구직활동을 한다고 강의를 뜸하게 봤던거 같다. 저번 강의에 이어서 영속성 관리에 대해서 알아보자. 😂

리마인드!

본격적으로 정리하기전에 이전 글의 내용을 리마인드해보자. 대략적인 흐름은 EntityManagerFactory에서 요청이 들어올 때마다 EntityManager를 할당해주고, EntityManager에서는 DB 커넥션 풀을 이용하여 접근하게 되는 구조로 되어있었다.

그럼 영속성 컨텍스트는 무엇일까?

영속성 컨텍스트?

영속성 컨텍스트는 엔티티를 영구 저장하는 환경이라는 뜻이다. 의미가 제대로 와닿지 않아서 검색해보니 - 다른 글에서는 영속성 컨텍스트를 애플리케이션과 데이터베이스 사이에서 객체를 보관하는 가상의 데이터베이스 같은 역할이라고 설명했는데 이 표현이 딱 적절한 것 같다.

엔티티 생명주기

영속성 컨텍스트와 관련하여 엔티티에는 생명주기가 존재한다. 각 상태에 대한 개념을 코드에 매칭해서 확인해보면 쉽게 이해할 수 있다. (왜 이렇게 어려운 용어를 사용하는건지...🥲)

비영속 (new/transient)

비영속은 영속성 컨텍스트와 전혀 관계없는 새로운 상태를 의미한다. 즉, 객체만을 생성한 상태라고 보면 된다.

Member member = new Member();
member.setId(1L);
member.setName("Hello");

영속 (managed)

영속은 EntityManager 내부의 영속성 컨텍스트에 생성한 객체가 들어가있는 상태를 의미한다. 즉, 영속성 컨텍스트에 관리된다는 것이다.

Member member = new Member();
member.setId(1L);
member.setName("Hello");

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

em.persist(member);

준영속 (detached)

객체를 영속성 컨텍스트에서 분리된 상태를 의미한다.

em.detach(member);

삭제 (removed)

객체를 삭제한 상태를 의미한다. 쉽게 생가하면 데이터베이스에서 데이터를 삭제하는 상태라고 생각하면 된다.

em.remove(member);

DB에 바로 반영되지 않는다?

아마 이번 강의의 핵심 포인트는 DB에 바로 반영되지 않는다는 것이다.

위에서 정의한 것처럼 영속성 컨텍스트는 데이터베이스 앞단의 가상의 데이터베이스라고 했다. 즉, 데이터를 데이터베이스에 저장하기 전에 가상의 데이터베이스에 저장한다는 것이다. 따라서 이전에 persist() 메서드를 이용한 코드는 DB에 데이터를 바로 반영하는 것이 아닌 영속성 컨텍스트라는 가상의 데이터베이스에 먼저 저장하는 것을 의미한다.

강의에서는 실제로 언제 쿼리가 날아가는지를 확인했는데, 이 과정이 뭔가 인상이 깊었다. (재밌어서 그런가...? 🤭)

try {
  // 생략...
  
  System.out.println("=== BEFORE ===");
  entityManager.persist(member);
  System.out.println("=== AFTER ===");
  
  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

아무튼 결과를 보면 persist() 메서드가 실행될 때 반영되는 것이 아닌 트랜잭션이 커밋이 되는 시점에 쿼리가 날아가는 것을 확인할 수 있었다. 그럼 내부적으로 어떻게 동작하기에 이런 결과가 나오는 걸까?

1차 캐시

영속성 컨텍스트에는 내부에는 Map으로된 1차 캐시가 존재한다. 1차 캐시는 쉽게 생각하면 PK를 키로하여 엔티티 자체를 값으로 넣어주는 방식이다.

Member member = new Member();
member.setId(1L);
member.setName("Hello");

em.persist(member);

그럼 1차 캐시가 가지는 이점이 뭘까? 조회할 때를 생각해보면 된다.

조회할 때는 1차 캐시에 데이터가 있느냐 없느냐에 따라서 동작이 살짝 다르다. 데이터가 있을 경우에는 1차 캐시의 엔티티를 그대로 반환해주면 되지만, 없을 경우에는 데이터베이스에서 조회하고 1차 캐시에 저장한 후 엔티티를 반환하는 과정으로 이루어진다.

Member findMember = em.find(Member.class, 1L);

그렇다고해서 1차 캐시가 성능적으로 엄청난 이점을 가지고 있다고 할 수는 없다. EntityManager는 DB 트랜잭션 단위로 만들고 종료시키기 때문이다. 즉, 트랜잭션이 끝나는 시점에 1차 캐시도 없어지기 때문에 굉장히 찰나의 순간에서만 도움이 된다는 것이다. 물론 비즈니스 로직이 복잡하거나 내부적으로 처리하는 내용이 많을 경우에는 장점이 될 수 있겠지만... (참고로 애플리케이션 전체에서 공유하는 캐시는 2차 캐시라고 부른다!)

그럼 실제로 코드로 확인해보자.

try {
  Member member = new Member();
  member.setId(1L);
  member.setName("Hello");

  System.out.println("=== BEFORE ===");
  entityManager.persist(member);
  System.out.println("=== AFTER ===");

  Member findMember = entityManager.find(Member.class, 1L);
  System.out.println("findMember.id : " + findMember.getId());
  System.out.println("findMember.name : " + findMember.getName());

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

처음 코드의 결과에서는 DB에 반영하지 않았는데도 데이터를 가지고 오는 것을 볼 수 있고, 마지막에 INSERT 쿼리가 반영되는 것을 확인할 수 있다. 즉, 1차 캐시에 저장한 뒤에 그 데이터를 바로 가져오기 때문에 그 어떤 SELECT 쿼리도 동작하지 않았고, 트랜잭션이 커밋되는 시점에 데이터를 DB에 저장하기 위해 INSERT 쿼리만 발생한 것이다.

다른 코드도 살펴보자.

try {
  Member findMember1 = entityManager.find(Member.class, 1L);
  Member findMember2 = entityManager.find(Member.class, 1L);
  System.out.println(findMember1 == findMember2);

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

처음 조회할 때는 찾고자하는 데이터가 1차 캐시에 없기 때문에 DB에서 데이터를 가져와 1차 캐시에 저장한 후 데이터를 가져오고, 두 번째 조회할 때는 1차 캐시에 저장된 데이터를 가져오기 때문에 단 하나의 SELECT 쿼리만 동작한다.

심지어 영속 상태의 엔티티의 동일성도 보장해준다. (강의 자료에서는 1차 캐시로 반복 가능한 읽기(REPEATABLE READ) 등급의 트랜잭션 격리 수준을 데이터베이스가 아닌 애플리케이션 차원에서 제공한다고 표현했다.)

쓰기 지연

쓰기 지연에 대한 내용도 이전에 언급했듯이, 쿼리문을 모아서 한번에 DB에 전달하는 개념이다. (해당 내용에 대한 코드는 특별한 내용이 없기에 생략하겠다!)

이 기술은 JDBC에서 제공하는 Batch 기능을 이용한다. Hibernate의 경우에는 JPA 설정 파일에서 얼마만큼의 쿼리문을 모아 네트워크에 실어 데이터베이스에 쿼리를 한번에 보낼지 구성하는 옵션이 있다.

<property name="hibernate.jdbc.batch_size" value="10"/>

변경 감지

변경 감지(Dirty Checking)은 데이터를 수정할 때 발생한다. 재미있는 점은 엔티티와 스냅샷을 비교하여 JPA가 UPDATE 쿼리를 만들어 쓰기 지연 SQL 저장소에 쌓은 후 처리한다는 것이다. (스냅샷은 1차 캐시에 최초로 들어온 엔티티의 상태를 함께 저장해두기 때문에 비교가 가능하다!)

플러시

플러시는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하는 것을 의미한다. 여기서 중요한 점은 - 플러시는 쓰기 지연 SQL 저장소에 있는 쿼리문들을 DB에 반영하는 것이기 때문에 1차 캐시의 데이터는 삭제되는 것이 아니라는 점이다.

영속성 컨텍스트를 플러시하기 위해 사용하는 방법은 3가지가 있다.

  • em.flush() 메서드를 이용한 강제 호출 방식 (직접 사용할 일은 거의 없지만 테스트할 때 용이)
  • 트랜잭션 커밋을 이용한 플러시 자동 호출 방식
  • JPQL 쿼리 실행을 통한 플러시 자동 호출 방식

직접 코드를 작성해서 확인해보자!

try {
  Member member = new Member(4L, "!");

  System.out.println("=== BEFORE ===");
  entityManager.persist(member);
  entityManager.flush();
  System.out.println("=== AFTER ===");

entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

본래라면 트랜잭션이 커밋되는 순간에 쿼리가 출력되는 것이 맞지만, 커밋이 이루어지기 전에 쿼리가 날라가는 것을 확인할 수 있다.

추가적으로 강의에서는 JPQL 쿼리 실행시 플러시가 자동으로 호출되는 이유에 대해서 설명했다.

em.persist(member1);
em.persist(member2);
em.persist(member3);

query = em.createQuery("select m from Member m", Member.class);
List<Member> members = query.getResultList();

코드에서 볼 수 있듯이 member1, member2, member3이 DB에 반영되지 않은 상태에서 DB 테이블의 데이터를 모두 가져오려고 시도하고 있다. 즉, 데이터가 반영되지도 않았는데 모든 데이터를 가져오려는 행위를 시도하는 것이다. 따라서 JPA에서는 JPQL 쿼리를 실행할 때 플러시를 자동으로 호출하고 직접 작성한 JPQL 쿼리를 DB에 날리는 과정을 수행한다.

플러시 모드 옵션

직접 건드릴 상황은 거의 없으나, 플러시에도 옵션을 설정할 수 있다.

// 기본값, 커밋이나 쿼리를 실행할 때 플러시
em.setFlushMode(FlushModeType.AUTO);

// 커밋할 때만 플러시
em.setFlushMode(FlushModeType.COMMIT);

개인적으로 나는 거의 건드릴 필요가 없는 내용은 무심히 넘어가는 편인데 강의에서는 왜 플러시 모드를 변경할까? 라는 질문에서 접근하여 인상이 깊었다. 그럼 FlushModeType.COMMIT은 언제 사용하는가?!

코드를 살펴보자.

em.persist(member1);
em.persist(member2);
em.persist(member3);

query = em.createQuery("select t from Team t", Team.class);
List<Team> teams = query.getResultList();

JPQL로 작성한 쿼리를 보면 member1, member2, member3와 관계없는 다른 테이블의 조회를 수행하고 있다. 그렇다면 굳이 member1, member2, member3를 JPQL 쿼리 실행시 자동으로 플러시를 할 이유가 없게 된다. 이럴 때 사용하면 된다!라고 김영한님이 말씀하셨지만, 그냥 마음편히 기본값인 AUTO로 두고 사용하라고 하셨다. ㅎㅎ 😄

준영속 상태

영속 상태는 결국 1차 캐시에 데이터가 있는 상태라고 보면 된다. 그렇다면 준영속 상태는 언제 필요할까?

준영속 상태를 만들기 위해서는 3가지 방법이 있다.

  • em.detach() 메서드를 이용한 특정 엔티티 준영속화
  • em.clear() 메서드를 이용한 영속성 컨텍스트 완전 초기화
  • em.close() 메서드를 이용한 영속성 컨텍스트 종료

코드로 살펴보자.

try {
  Member findMember = entityManager.find(Member.class, 4L);
  findMember.setName("data");

  entityManager.detach(findMember);
  // entityManager.clear();
  // entityManager.close();

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

여기서 재미있는 점은 분명 더티 체크가 되어서 변경사항에 대한 UPDATE 쿼리문이 전달되어야 했으나 SELECT 조회 쿼리만 전달되었다는 점이다. 그럼 준영속 상태로 만든 뒤에 다시 조회를 하면 어떻게 될까?

try {
  Member findMember = entityManager.find(Member.class, 4L);
  findMember.setName("data");

  entityManager.detach(findMember);
  // entityManager.clear();
  // entityManager.close();

  Member findMember2 = entityManager.find(Member.class, 4L);

  entityTransaction.commit();
} catch (Exception e) {
  entityTransaction.rollback();
} finally {
  entityManager.close();
}

entityManagerFactory.close();

결과는 SELECT 쿼리가 두 번 전달이 되는 것을 볼 수 있다!

이번 강의를 통해서 영속성 컨텍스트와 내부적으로 동작하는 과정을 살펴보면서 그동안 사용해왔던 JPA에 대해서 다른 시선으로 바라볼 수 있을 것 같다. 👏

이 글의 레퍼런스

profile
세상을 현명하게 이끌어갈 나의 성장 일기 📓

0개의 댓글