[JPA] 영속성 컨텍스트의 특징

영속성 컨텍스트의 특징은 다음과 같은 것들이 있다. 

  1. 영속성 컨텍스트는 엔티티를 식별자 값(@Id로 테이블의 기본 키와 매핑한 값)으로 구분하기 때문에, 영속 상태는 식별자 값이 반드시 있어야 한다. 없으면 예외가 발생한다. 
  2. JPA는 보통 트랜잭션을 커밋하는 순간 영속성 컨텍스트에 저장된 엔티티를 데이터베이스에 반영한다. 이를 플러시(Flush)라고 하는데, 다음 포스팅에서 알아보도록하자. 

영속성 컨텍스트가 왜 필요하고 어떤 이점이 있는지 엔티티를 CRUD하며 하나씩 알아보자.


1️⃣ 엔티티 조회

영속성 컨텍스트는 내부에 캐시를 가지고 있는데, 이를 1차 캐시라고 한다. 영속 상태의 엔티티는 모두 이곳에 저장된다. 쉽게 얘기하면 영속성 컨텍스트 안에 Map이 하나 있는데, Key@Id로 매핑한 식별자이고, Value엔티티 인스턴스이다. 

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

// 엔티티를 영속
em.persist(member);

위의 코드를 실행하면 아래 그림처럼 1차 캐시에 회원 엔티티를 저장한다. 이 회원 엔티티는 아직 데이터베이스에 저장되지 않았다

1차 캐시의 키는 식별자 값이다. 이 식별자 값은 데이터베이스 기본 키와 매핑되어 있다. 따라서

영속성 컨텍스트에 데이터를 저장하고 조회하는 모든 기준은 데이터베이스 기본 키 값이다. 

 

이번에는 엔티티를 조회해보자.

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

find() 메소드를 보면 첫 번째 파라미터엔티티 클래스의 타입이고, 두 번째 파라미터조회할 엔티티의 식별자 값이다. 

em.find()를 호출하면 먼저 1차 캐시에서 엔티티를 찾고, 이곳에 없다면 데이터베이스에서 조회한다. 

1차 캐시에서 조회

아래 그림을 보자.

1차 캐시에서 조회

em.find()를 호출하면 우선 1차 캐시에서 식별자 값으로 엔티티를 찾는다. 찾는 엔티티가 있다면 메모리에 있는 1차 캐시에서 엔티티를 조회한다. 

 

- 1차 캐시에 있는 엔티티를 조회

Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

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

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

 

데이터베이스에서 조희

만약 em.find()를 호출했는데 엔티티가 1차 캐시에 없으면, 데이터베이스를 조회해서 엔티티를 생성한다. 그리고 1차 캐시에 저장한 후영속 상태의 엔티티를 반환한다. 

1차 캐시에 없어 데이터베이스에서 조회

위 그림을 분석해보자.

  1. em.find(Member.class, "member2")를 실행한다.
  2. member2가 1차 캐시에 없으므로 데이터베이스에서 조회한다.
  3. 조회한 데이터로 member2 엔티티를 생성해서 1차 캐시에 저장한다. → 영속 상태
  4. 조회한 엔티티를 반환한다. 

이제 member, member2는 1차 캐시에 있어 조회하면 바로 메모리에서 불러올 수 있다. 이를 통해 성능상 이점을 누릴 수 있다. 

 

2️⃣ 엔티티 등록

- 엔티티 등록 코드

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();

// 엔티티 매니저는 데이터 변경 시 트랜잭션을 시작해야 한다.
transaction.begin(); // [트랜잭션] - 시작

em.persist(memberA);
em.persist(memberB);
// 여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.

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

엔티티 매니저는 트랜잭션을 커밋하기 직전까지 데이터베이스에 엔티티를 저장하지 않고 내부 쿼리 저장소에 INSERT SQL을 모아둔다. 그리고 트랜잭션을 커밋할 때 모아둔 쿼리들을 데이터베이스에 보내는데 이것을 "트랜잭션을 지원하는 쓰기 지연(Transactional write-behind)"라고 한다.  그림으로 분석해보자. 

쓰기 지연, memberA 영속

먼저 memberA를 영속화했다. 영속성 컨텍스트는 1차 캐시에 엔티티를 저장함과 동시에 회원 엔티티 정보로 등록 쿼리를 만들고 이를 쓰기 지연 SQL 저장소에 보관한다. 

쓰기 지연, memberB 영속

다음으로 memberB를 영속화했다. 마찬가지로 회원 엔티티 정보로 등록 쿼리를 생성해서 쓰기 지연 SQL 저장소에 보관한다. 

 

쓰기 지연, 커밋

마지막으로 트랜잭션을 커밋했다. 트랜잭션을 커밋하면 엔티티 매니저는 우선 영속성 컨텍스트를 플러시한다. 플러시는 영속성 컨텍스트의 변경 내용을 데이터베이스와 동기화하는 작업인데 이때 CRUD한 엔티티를 데이터베이스에 반영한다(쓰기 지연 SQL 저장소에 있는 쿼리들을 데이터베이스에 보내는 작업). 이렇게 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화한 후에 실제 데이터베이스 트랜잭션을 커밋한다. 

 

3️⃣ 엔티티 수정

SQL을 사용하면 수정 쿼리를 직접 작성해야 한다. 이 방식의 문제점은 경우에 따라서 수정 쿼리가 많아지는 것은 물론이고 비즈니스 로직을 분석하기 위해 SQL을 계속 확인해야 한다. 결국 직·간접적으로 비즈니스 로직이 SQL에 의존하게 된다. 

 

그럼 JPA는 엔티티를 어떻게 수정할까? 다음 코드를 보자.

- 엔티티 수정

transaction.begin(); // [트랜잭션] - 시작

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

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

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

JPA로 엔티티를 수정할 때는 단순히 엔티티를 조회해서 데이터만 변경하면 된다. 엔티티의 변경사항을 데이터베이스에 자동으로 반영하는 기능변경 감지(dirty checking)라 한다. 

 

변경 감지 기능을 자세히 알아보자.

JPA는 엔티티를 영속성 컨텍스트에 보관할 때, 최초 상태를 복사해서 저장해두는데 이를 스냅샷이라 한다. 그리고 플러시 시점에 스냅샷과 엔티티를 비교해서 변경된 엔티티를 찾는다. 

  1. 트랜잭션을 커밋하면 엔티티 매니저 내부에서 먼저 플러시(flush())가 호출된다. 
  2. 엔티티와 스냅샷을 비교해서 변경된 엔티티를 찾는다.
  3. 변경된 엔티티가 있으면 수정 쿼리를 생성해서 쓰기 지연 SQL 저장소에 보낸다. 
  4. 쓰기 지연 저장소의 SQL을 데이터베이스에 보낸다. 
  5. 데이터베이스 트랜잭션을 커밋한다. 

변경 감지는 영속성 컨텍스트가 관리하는 영속 상태의 엔티티에만 적용된다. 비영속, 준영속처럼 영속성 컨텍스트의 관리를 받지 못하는 엔티티는 값을 변경해도 데이터베이스에 반영되지 않는다. 

 

JPA는 UPDATE SQL을 변경된 부분에 대해서만 수행하는 것이 아니라, 다음과 같이 엔티티의 모든 필드를 업데이트한다.

- 엔티티의 모든 필드를 수정에 반영

UPDATE MEMBER
SET
    NAME = ?,
    AGE = ?,
    GRADE = ?
    ...
WHERE
    id = ?

 이렇게 모든 필드를 사용하면 데이터 전송량이 증가하는 단점이 있지만, 다음과 같은 장점들이 있어 모든 필드를 업데이트한다. 

  • 모든 필드를 사용하면 수정 쿼리가 항상 같다. 따라서 애플리케이션 로딩 시점에 수정 쿼리를 미리 생성해두고 재사용할 수 있다. 
  • 데이터베이스에 동일한 쿼리를 보내면 데이터베이스는 이전에 한 번 파싱된 쿼리를 재사용할 수 있다. 

만약, 필드가 너무 많거나 저장되는 내용이 너무 크면 수정된 데이터만 사용해서 동적으로 UPDATE SQL을 생성하는 전략을 사용하면 된다. 이때는 하이버네이트 확장 기능을 사용해야 한다. 

@Entity
@org.hibernate.annotations.DynamicUpdate
@Table(name = "Member")
public class Member { ... }

이를 사용하면 수정된 데이터만 사용해서 동적으로 UPDATE SQL을 생성한다. 

 

4️⃣ 엔티티 삭제

엔티티를 삭제하기 위해서는 먼저 삭제 대상 엔티티를 조회해야 한다. 

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

em.remove()에 삭제 대상을 넘겨주면 엔티티를 삭제한다. 물론 바로 삭제하는 것이 아니라 등록과 비슷하게 삭제 쿼리를 쓰기 지연 SQL 저장소에 등록하고, 트랜잭션을 커밋하여 플러시를 호출하면 실제 데이터베이스에 삭제 쿼리를 전달한다. 

em.remove(memberA)를 호출하는 순간 memberA는 영속성 컨텍스트에서 제거된다.


참고

자바 ORM 표준 JPA 프로그래밍(김영한 저)