🖼 영속성 컨텍스트

JPA를 활용하는 애플리케이션 아키텍처에서 데이터베이스에 대한 모든 접근은 전적으로 엔티티 매니저 팩토리(Entity Manager Factory)엔티티 매니저(Entity Manager)의 엄격한 역할 분담과 협력을 통해 이루어진다. 이 두 구성 요소의 특성을 이해하는 것은 동시성 제어와 메모리 누수 방지의 첫걸음이다.

엔티티 매니저 팩토리는 데이터베이스 연결 풀(Connection Pool), 2차 캐시(Second-level Cache), 엔티티 매핑 메타데이터 등 애플리케이션의 전역적인 설정 정보를 바탕으로 생성되는 매우 무거운 객체다. 생성 과정에서 막대한 시스템 자원과 I/O 비용이 소모되므로, 애플리케이션 구동 시점에 단 한 번만 생성되어 전체 스레드 간에 안전하게 공유(Thread-safe)되도록 설계되어야 한다.

엔티티 매니저는 엔티티 매니저 팩토리로부터 비즈니스 프로세스나 단일 HTTP 요청마다 생성되는 가벼운 객체다. 엔티티 매니저는 엔티티를 데이터베이스에 저장(Persist), 병합(Merge), 삭제(Remove), 조회(Find)하는 등 데이터베이스와 관련된 모든 트랜잭션 작업을 처리하는 실질적인 작업 단위의 관리자다. 엔티티 매니저는 생성되는 시점에는 데이터베이스 커넥션을 획득하지 않으며, 실제 쿼리가 실행되어야 하는 시점에 커넥션 풀로부터 JDBC 커넥션을 동적으로 할당받아 사용한다.

아키텍처 설계 시 가장 주의해야 할 핵심은 엔티티 매니저가 스레드에 안전하지 않다(Non-thread-safe)는 사실이다. 여러 스레드가 단일 엔티티 매니저 인스턴스에 동시에 접근할 경우, 내부 영속성 컨텍스트의 상태가 오염되어 심각한 데이터 정합성 문제나 경합 조건(Race Condition)이 발생한다. 따라서 엔티티 매니저는 절대 스레드 간에 공유되어서는 안 되며, 사용이 끝난 직후 즉시 폐기되어야 한다.

엔티티 매니저는 약간 센과 치히로에 나오는 숯검댕이들 같은 느낌(?)이다... 돌(엔티티)을 들고(영속화) 나르는 일하는게 비슷한데...?

위 그림처럼 엔티티 매니저는 내부적으로 DB 커넥션을 사용해서 DB에 접근할 수 있게 된다. 대충 느낌은 온다. 그럼 영속성 컨텍스트(Persistence Context)는 뭐냐? 영속성 컨텍스트는 엔티티 매니저 내부에 존재하는 논리적 환경으로, 식별자(Primary Key)를 키(Key)로, 엔티티 인스턴스 자체를 값(Value)으로 저장하는 1차 캐시(First-Level Cache) 저장소를 내포하고 있다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
EntityManager em = emf.createEntityManager();  // 엔티티를 관리할 담당자(엔티티 매니저) 생성

Member member = new Member();  // 새로운 Member 엔티티 생성
member.setId(1L);
member.setName("john doe");

em.persist(member);  // member를 영속성 컨텍스트에 등록해서 관리 시작

코드만 보면 뭔가 member를 DB에 저장하는 느낌이다. 근데 사실 persist(member)는 DB에 저장하는게 아니라, member 엔티티를 영속성 컨텍스트에 등록해서 JPA의 관리 대상이 되도록 만드는 것이다. 영속성 컨텍스트는 논리적인 개념이기 때문에 눈에 직접 보이지는 않으며, 개발자는 엔티티 매니저를 통해 영속성 컨텍스트에 접근하고 엔티티를 관리한다.

즉, 위 코드에서 엔티티 매니저는 member를 영속성 컨텍스트에 등록하고, 이후 JPA가 이 엔티티를 추적할 수 있도록 만든다.

 

💞 엔티티의 생명주기

1. 비영속(new/transient)

최초의 Member 엔티티를 생성만 한 상태다. 아직 영속성 컨텍스트나 데이터베이스와 아무런 논리적, 물리적 연관을 맺지 않은 순수한 자바 객체 상태다.

Member member = new Member();  // 그냥 엔티티 생성만 한 상태
member.setId(1L);
member.setName("john doe");

이 상태에서는 JPA의 관리 대상이 아니므로, 객체의 필드 값을 아무리 변경하더라도 데이터베이스에 어떠한 쿼리도 전송되지 않는다.

 

2. 영속(managed)

Member 객체를 생성한 다음, 엔티티 매니저를 얻어와서 영속성 컨텍스트에 em.persist(member) 영속화한다. 참고로, em.find()나 JPQL을 사용해서 조회한 엔티티의 경우에도 영속성 컨텍스트가 관리하는 영속 상태다.

여기서 주의해야 할 점은 아직 DB에 실제 SQL 쿼리가 날라가지 않았다는 것이다. 트랜잭션을 커밋하는 시점에 영속성 컨텍스트에 있는 애가 DB에 쿼리로 날라가는 것이다. 다만 항상 그런 것은 아닌데, 예를 들어 IDENTITY 전략처럼 DB에 INSERT를 해야 식별자를 알 수 있는 경우에는 persist() 시점에 SQL이 즉시 실행될 수 있다.

 

3. 준영속(detached)

영속성 컨텍스트에 저장되었다가 분리된 상태로, 그냥 쉽게 말해 em.detach(member)로 영속성 컨텍스트에서 다시 지우는 것이다. em.detach(member)를 호출하면 1차 캐시부터 쓰기 지연 SQL 저장소까지 member 엔티티를 관리하기 위한 모든 정보가 제거된다. 이 외에도 em.close()를 호출해서 영속성 컨텍스트를 닫아버려도 당연히 엔티티가 관리되지 않을 것이고, em.clear()를 호출해서 영속성 컨텍스트를 초기화해도 영속성 컨텍스트가 관리하던 영속 상태의 엔티티는 준영속 상태가 된다.

준영속 상태의 엔티티를 다시 영속 상태로 편입시키기 위해서는 EntityManager.merge() 메서드를 사용해야 한다.

 

4. 삭제(removed)

엔티티가 영속성 컨텍스트에서 삭제 대상으로 관리되는 상태를 말한다. 실제 DB DELETE SQL은 보통 flush/commit 시점에 실행된다.

 

생명주기를 그림으로 표현하면 아래와 같다.

 

🛠 영속성 컨텍스트의 이점

그래서 영속성 컨텍스트를 왜 쓰냐? 그 이유에는 여러 가지가 있다.

💾 1차 캐시

일단 영속성 컨텍스트는 내부에 키-값 구조로 되어 있는 1차 캐시(First Level Cache)를 들고 있다. 예를 들어, Member 객체를 생성하고 값을 세팅하고 나서 영속성 컨텍스트에 집어 넣었다고 해보자.

그러면 키에 @Id로 매핑한 식별자가 들어가고, 값에는 엔티티 인스턴스 자체가 들어간다. 이때 개발자가 특정 엔티티 조회를 요청하면, JPA는 데이터베이스로 쿼리를 전송하기 전에 반드시 1차 캐시를 먼저 탐색한다. 만약 메모리 상에 해당 엔티티가 존재한다면 즉시 이를 반환하며, 캐시 미스(Cache Miss)가 발생한 경우에 한해 데이터베이스에 SELECT 쿼리를 전송하여 그 결과를 1차 캐시에 등록한 뒤 반환한다.

위 과정을 코드로 한번 확인해보자.

package hellojpa;

import jakarta.persistence.*;

public class JpaMain {

    public static void main(String[] args) {

        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction tx = em.getTransaction();
        tx.begin();

        try {
        	// 엔티티 생성 후, 아이디와 이름 세팅
            Member member = new Member();
            member.setId(101L);
            member.setName("테스트 멤버");

            System.out.println("=== 영속화 수행 전 ===");
            em.persist(member);  // 영속성 컨텍스트에 member 엔티티 집어 넣음
            System.out.println("=== 영속화 수행 후 ===");

			// 테스트 멤버 조회
            Member findMember = em.find(Member.class, 101L);

            System.out.println("findMember의 id: " + findMember.getId());
            System.out.println("findMember의 이름: " + findMember.getName());

            tx.commit();  // commit(실제 SQL 쿼리 날라감)
        } catch (Exception e) {
            tx.rollback();
        } finally {
            em.close();
        }

        emf.close();
    }
}

일단 테스트 멤버를 영속성 컨텍스트에 집어 넣은 다음, 다시 조회를 수행해보면 DB에 SELECT 쿼리가 나가지 않는다. 1차 캐시에 저장되었기 때문이다.

그럼 현재 DB에 테스트 멤버가 남아 있는 상태에서 다시 실행해보자. 엔티티 매니저가 생성될 때, 테스트 멤버를 2번 조회하면 어떻게 되어야 할까?

첫 번째 조회할 때는 DB로 쿼리가 나갔고, 두 번째 조회할 때는 쿼리가 나가지 않고 1차 캐시에서 조회가 되어야 한다.

보다시피 쿼리가 한 번만 나간 것을 확인할 수 있다. 하지만, 1차 캐시는 DB의 한 트랜잭션 안에서만 효과가 있기 때문에 성능의 큰 이점을 뽑아낼 수 있다고 하기엔 곤란하다.

 

👥 영속 엔티티의 동일성 보장

위의 예제의 2개의 똑같은 엔티티를 조회한 코드에서 == 동일성 비교를 하면 결과가 어떻게 될까?

Member memberA = em.find(Member.class, 101L);
Member memberB = em.find(Member.class, 101L);

System.out.println("result = " + (memberA == memberB));

// result = true

1차 캐시는 동일한 트랜잭션 내에서 영속 엔티티의 객체 동일성을 철저히 보장한다. 즉, 같은 식별자로 두 번 조회한 엔티티 인스턴스는 자바의 == 연산자 비교 시 참(True)을 반환한다. 이는 데이터베이스의 트랜잭션 격리 수준(Isolation Level)과 무관하게, 애플리케이션 계층에서 마치 반복 가능한 읽기(Repeatable Read) 수준의 일관성을 제공하는 것이다.

 

✍🏻 트랜잭션을 지원하는 쓰기 지연

Member member = new Member();
member.setId(101L);
member.setName("테스트 멤버");

System.out.println("=== 영속화 수행 전 ===");
em.persist(member);  // 영속성 컨텍스트에 올려 놓음
System.out.println("=== 영속화 수행 후 ===");

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

System.out.println("findMember의 id: " + findMember.getId());
System.out.println("findMember의 이름: " + findMember.getName());

tx.commit();

기본적으로 em.persist(member)를 할 때 INSERT 쿼리를 DB에 보내는 것이 아니라, 트랜잭션을 커밋하는 순간에 INSERT 쿼리문을 DB에 보내는 것이다. memberAmemberB 엔티티를 순차적으로 영속성 컨텍스트에 넣으면 JPA 안에서 무슨 일이 일어나는지 그림으로 자세하게 확인해보자.

영속성 컨텍스트 안에는 1차 캐시도 있지만, 쓰기 지연 SQL 저장소(Transactional Write-Behind SQL Store)라는 것도 있다. 그래서 em.persist(memberA)를 수행했을 때, 일단 memberA가 1차 캐시에 들어가는 동시에 JPA가 엔티티를 분석해서 INSERT 쿼리를 만들어서 쓰기 지연 SQL 저장소에 장전해둔다. memberB를 영속화하는 경우에도 마찬가지다.

그럼 쿼리가 언제 DB에 날아가냐? 트랜잭션을 커밋하는 시점에 쓰기 지연 SQL 저장소에 있던 애들이 플러시(Flush) 된다. 쉽게 말해, 엔티티 매니저가 쓰기 지연 SQL 저장소에 장전된 쿼리를 실제 DB로 쏜다는 말이다.

이제 코드로 한번 확인해보자.

Member memberA = new Member(150L, "A");
Member memberB = new Member(160L, "B");

em.persist(memberA);
em.persist(memberB);
            
System.out.println("===============================");

tx.commit();  // flush 된다... SQL 쓰기 지연 저장소에 장전된 SQL 쿼리 발사

보다시피 INSERT 쿼리가 2방 나가는데, 모두 선을 그은 다음에 실행되는 것을 확인할 수 있다. 근데 그냥 em.persist(member) 할 때, 쿼리가 나가면 안 되나? 일단 그렇게 되면 최적화할 수 있는 여지 자체가 없어진다. 예를 들어, 여기서 버퍼링 기능으로 쿼리를 모았다가 한번에 날릴 수 있다.

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

위 옵션을 주면 사이즈만큼 모아서 한 방에 네트워크로 쿼리를 보내고 DB를 커밋 시킬 수 있다. 옵션 하나 더 써서 성능을 먹고 들어가는 셈이다.

 

👀 변경 감지(Dirty Checking)

DB에 저장되어 있는 아이디가 150인 A를 변경해보자.

Member findMember = em.find(Member.class, 150L);
findMember.setName("jane doe");  // 이름을 "jane doe"로 변경
            
//            em.update(findMember);

System.out.println("===============================");

tx.commit();

JPA의 기능을 가장 마법처럼 보이게 하는 요소는 명시적인 UPDATE 쿼리 없이, 자바 객체의 프로퍼티(Setter)를 변경하는 것만으로 트랜잭션 커밋 시점에 자동으로 UPDATE 쿼리가 데이터베이스로 날아간다는 점이다.

이를 변경 감지(Dirty Checking) 기술이라 한다. 그러나 이 강력한 기능의 내부 원리를 인지하지 못한 채 남용하면 심각한 성능 저하의 늪에 빠질 수 있다. 영속성 컨텍스트를 뜯어보자...

기본적으로 엔티티가 데이터베이스로부터 조회되거나 영속성 컨텍스트에 최초로 등록될 때, Hibernate는 영속성 컨텍스트의 1차 캐시에 해당 엔티티를 보관함과 동시에 그 상태를 깊은 복사(Deep Copy)하여 스냅샷(Snapshot)이라는 초기 상태 백업본을 메모리 한편에 생성한다.

그리고 지금 memberA를 변경한다고 하면, 트랜잭션이 커밋 되는 시점에 내부적으로 flush()가 호출되면서 Hibernate는 영속성 컨텍스트의 관리 대상에 있는 모든 영속 엔티티의 현재 상태와 미리 저장해 둔 스냅샷을 순회하며 일일이 비교(Diff Calculation)한다. 이 비교 연산을 통해 속성에 차이가 발견된 더티(Dirty) 엔티티를 추출해내고, 이들에 한해서만 UPDATE 쿼리를 동적으로 생성하여 쓰기 지연 저장소에 장전한다.

 

과정을 정리하자면,

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

 

하지만 이렇게 일일이 비교하는 방식은 관리되는 엔티티의 규모가 커질수록 기하급수적인 연산 부하를 유발한다. 극단적인 예로 10,000개의 엔티티 중 단 한 개의 엔티티에서 속성 하나만 변경되었더라도, Hibernate는 10,000개 모든 엔티티의 모든 프로퍼티를 스냅샷과 대조하는 무의미한 CPU 낭비를 감내해야 한다. 더욱 치명적인 단점은 메모리 풋프린트(Memory Footprint)의 팽창이다. 영속성 컨텍스트는 관리 대상 엔티티 자체뿐만 아니라 동일한 크기의 스냅샷 객체까지 별도로 유지해야 하므로, 애플리케이션의 힙(Heap) 메모리를 정확히 2배 요구하게 된다.

스냅샷 비교 알고리즘이 정상적으로 작동하기 위해 Hibernate는 자바의 equals()hashCode() 메서드에 전적으로 의존하여 객체의 논리적 동치성을 판단한다. 따라서 엔티티 클래스 내에 이 두 메서드가 비정상적으로 오버라이드 되어 있거나 누락되었다면, 엔티티 상태가 실제로 변경되지 않았음에도 Hibernate가 상태가 변경된 것으로 오판하여 불필요한 UPDATE 쿼리를 무한히 날리거나 반대로 필수적인 업데이트를 무시하는 재앙에 가까운 버그가 발생하게 된다.

💡 해결책: 읽기 전용 트랜잭션과 @Immutable 최적화 기법

스냅샷 메커니즘이 야기하는 메모리 및 CPU 오버헤드를 원천 차단하기 위한 첫 번째 전략은 해당 비즈니스 로직이 읽기 전용임을 명시하는 것이다. Spring 환경에서 트랜잭션 경계에 @Transactional(readOnly = true)를 선언하면, 내부적으로 Hibernate의 Session 플러시 모드가 MANUAL로 고정됨과 동시에 해당 트랜잭션 내에서는 영속성 컨텍스트가 엔티티의 스냅샷을 아예 생성하지 않도록 최적화가 이뤄진다. 이를 통해 대규모 조회 작업 시 메모리 사용량을 정확히 50% 절감할 수 있다.

또한, 애플리케이션 구동 시간 내내 데이터가 절대 변경되지 않는 공통 코드나 참조 데이터 딕셔너리 성격의 엔티티 클래스에는 @Immutable 애노테이션을 부착할 수 있다. 이 경우 Hibernate는 해당 엔티티를 불변 객체로 취급하여, 영속성 컨텍스트에 스냅샷을 찍어두지도 않고 변경 감지 대상에서도 완전히 배제시켜 성능을 끌어올린다.

 

⚡ 플러시(Flush)

영속성 컨텍스트 내에서 발생하는 엔티티의 상태 변화(등록, 수정, 삭제)는 데이터베이스에 실시간 동기화되지 않는 대신, JPA는 변경 내역을 바탕으로 SQL 쿼리를 동적으로 생성한 후 쓰기 지연 SQL 저장소에 적재(Queueing)한다고 했다. 이렇게 대기 중인 쿼리들을 트랜잭션 커밋 시점 등에 데이터베이스로 일괄 전송하는 동기화 메커니즘을 플러시(Flush)라고 하는 것이다.

이는 애플리케이션 레벨에서 JDBC 배치를 투명하게 적용하여 데이터베이스 네트워크 왕복 횟수(Round-trip)를 획기적으로 줄여주는 성능 최적화의 핵심 기제다

 

영속성 컨텍스트를 플러시하는 방법에는 3가지가 있다.

  1. em.flush()를 직접 호출하는 경우
    • 영속성 컨텍스트를 강제로 플러시하는 방법이다. 거의 사용되지는 않는다.
  1. 트랜잭션 커밋 시 플러시가 자동 호출되는 경우
    • 아까 말했다시피 커밋 전에 SQL을 전달하지 않으면 트랜잭션을 커밋해도 DB에 반영되지 않는다고 했다. 따라서 트랜잭션을 커밋하기 전에 꼭 flush()를 호출해서 영속성 컨텍스트의 변경 내용을 DB에 반영해야 한다. 이런 이유로 JPA는 플러시를 자동으로 호출한다.
  1. JPQL 쿼리 실행 시 플러시가 자동 호출되는 경우
    • 만약 member1, member2, member3persist() 해서 영속 상태로 만들었다고 해보자. 이 엔티티들은 영속성 컨텍스트에는 존재하지만, 아직 DB에는 반영되지 않은 상황이다. 이때 JPQL을 실행하면, JPQL은 SQL로 변환되어 DB에서 엔티티를 조회한다. 근데 엔티티들이 DB에 반영되지도 않았으니 조회되지 않을 것이다. 따라서 쿼리를 실행하기 직전에 영속성 컨텍스트를 플러시해서 변경 내용을 DB에 반영하는 것이다.

 

🤔 flush()를 하면 1차 캐시가 다 지워지나?

1차 캐시는 그대로 유지된다. 플러시는 영속성 컨텍스트를 비우지 않고, 영속성 컨텍스트의 변경 내용을 DB에 동기화하는 작업이다. 그리고 DB와 동기화를 최대한 늦추는 것이 가능한 이유는 트랜잭션이라는 작업 단위가 있기 때문이다. 트랜잭션 커밋 직전에만 변경 내용을 DB에 보내 동기화하면 된다.


<참고 자료>
Chapter 5. Transactions and Concurrency
Flushing - Hibernate
How Hibernate Dirty Checking Mechanism Works
The anatomy of Hibernate dirty checking mechanism
Unraveling Hibernate’s Dirty Checking

0개의 댓글