영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)
Published:
목차
- 들어가며..
- 엔티티 매니저
- 영속성 컨텍스트
- 영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔
- 결론
N+1기
계좌가 많아질 경우, 어떻게 설계하면 좋을까?🤔
N+1은 왜 발생할까?🤔(Fetch Join 적용 전후 성능 비교)
N+1은 왜 JPA에서 발생할까?🤔(JPQL)
N+1은 메모리에 어떤 영향을 미칠까?🤔(JVM)
영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔(Hibernate)
Fetch Join은 영속성 컨텍스트에서 어떻게 동작할까?🤔
Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔
들어가며..
스프링은 직접 데이터베이스와 통신하지 않고, 다음 단계로 통신한다.
개발자 코드 → JPA(ORM을 위한 표준 인터페이스) → Hibernate(JPA 구현체) → JDBC(DB 연결 드라이버) → Database(H2 / MySQL 등)
Hibernate(ORM)가 자동으로 H2 데이터베이스에 테이블을 생성하고 쿼리를 실행해주는 관계이다.
엔티티 매니저
엔티티 매니저는 엔티티를 저장하고, 수정하고, 삭제하고, 조회하는 등 엔티티와 관련된 모든 일을 처리한다. 이름 그대로 엔티티를 관리하는 관리자다. 데이터베이스를 하나만 사용하는 애플리케이션은 일반적으로 EntityManagerFactory를 하나만 생성한다. 그리고 필요할 때마다 엔티티매니저 팩토리에서 엔티티 매니저를 생성하면 된다.
엔티티매니저팩토리는 이름 그대로 엔티티 매니저를 만드는 공장인데, 공장을 만드는 비용은 상당히 크다. 따라서 한 개만 만들어서 애플리케이션 전체에서 공유하도록 설계되어 있다.
반면 공장에서 엔티티매니저를 생성하는 비용은 거의 들지 않는다. 그리고 엔티티매니저팩토리는 여러 스레드가 동시에 접근해도 안전하므로 서로 다른 스레드 간에 공유해도 되지만, 엔티티 매니저는 여러 스레드가 동시에 접근하면 동시성 문제가 발생하므로 스레드 간에 절대 공유하면 안된다.

영속성 컨텍스트

ORM은 객체와 데이터베이스 테이블의 매핑을 통해 엔티티 클래스 객체 안에 포함된 정보를 테이블에 저장하는 기술이다. JPA에서는 테이블과 매핑되는 엔티티 객체 정보를 영속성 컨텍스트를 통해 애플리케이션 내에서 오래 지속되도록 보관한다.

JPA를 이해하는 데 가장 중요한 용어인 영속성 컨텍스트(persistence context)는 해석하자면 엔티티를 영구 저장하는 환경이란 뜻이다. 서버와 데이터베이스 사이에 엔티티를 저장하는 환경으로, 엔티티를 저장하거나 조회하면 엔티티 매니저는 영속성 컨텍스트에 엔티티를 보관하고 관리한다. 이러한 영속성 컨텍스트는 엔티티 매니저를 생성할 때(Session) 하나씩 만들어진다. 그리고 엔티티 매니저(Session)을 통해서 영속성 컨텍스트에 접근할 수 있고 영속성 컨텍스트를 관리할 수 있다. 즉, 스레드(사용자 요청)마다 하나의 엔티티 매니저가 있고 영속성 컨텍스트가 있고, 요청마다 공유하면 안 된다.
JPA로 엔티티 조회 시 DB에 접근하기 전 영속성 컨텍스트에 엔티티가 있는지 먼저 확인하는 과정을 거친다. 공식적으로는 엔티티를 관리하는 1차 캐시이지만 실제로는 단순 캐시가 아닌 [엔티티 + 상태 정보 + 스냅샷 + 변경 추적 메타데이터]를 저장하는 관리 공간이다.
Hibernate에서의 영속성 컨텍스트
영속성 컨텍스트는 엔티티를 관리하는 1차 캐시이자, 엔티티의 생명주기를 관리하는 공간이다. Hibernate에서는 이 역할을 Session이 수행한다.
- JPA
- EntityManager = 영속성 컨텍스트를 관리하는 인터페이스
- Hibernate
- Session = JPA의 EntityManager 구현체
- 내부적으로 PersistenceContext 구현체를 가지고 있음
EntityManager (JPA)
↓
Hibernate Session
↓
PersistenceContext (1차 캐시)
Hibernate 내부 구조
자세한 내용은 Hibernate 내부에서 영속성 컨텍스트는 어떻게 동작할까?🤔에서 다룬다.
- Hibernate Session 내부
- PersistenceContext
- entitiesByKey
- entityEntries
- ActionQueue
- DirtyChecking 메커니즘
- 핵심 클래스
- org.hibernate.engine.spi.PersistenceContext
- org.hibernate.internal.SessionImpl
트랜잭션과 영속성 컨텍스트
Spring + Hibernate 환경에서는 @Transactional 단위로 영속성 컨텍스트가 생성되고 종료된다. 이것이 트랜잭션 단위 영속성 컨텍스트 (Transaction-scoped)이다.
엔티티의 생명주기

- 비영속(new/traslent) : 영속성 컨텍스트와 전혀 관계가 없는 상태
- 영속(managed) : 영속성 컨텍스트에 저장된 상태
- 준영속(detached) : 영속성 컨텍스트에 저장되었다가 분리된 상태
- 삭제(removed) : 삭제된 상태
비영속
엔티티 객체를 생성했으나 순수한 객체 상태이며 아직 저장하지 않았다. 따라서 영속성 컨텍스트나 데이터베이스와는 전혀 관련이 없다.
// 객체를 생성한 상태(비영속)
Member member = new Member();
member.setId(“member1”);
member.setUsername(“회원1”);
영속
엔티티 매니저를 통해서 엔티티를 영속성 컨텍스트에 저장했다. 이렇게 영속성 컨텍스트가 관리하는 엔티티를 영속 상태라 한다.
// 객체를 저장한 상태(영속)
em.persist(member);
준영속
영속성 컨텍스트가 관리하던 영속 상태의 엔티티를 영속성 컨텍스트가 관리하지 않으면 준영속 상태가 된다. 특정 엔티티를 준영속 상태로 만들려면 em.detach()를 호출하면 된다. em.close()를 호출해서 영속성 컨텍스트를 닫거나 em.clear()를 호출해서 영속성 컨텍스트를 초기화해도 영속성 커넥스트가 관리하던 엔티티는 준영속 상태가 된다.
준영속 상태 시 1차 캐시, 쓰기 지연, 변경 감지, 지연 로딩을 포함한 영속성 컨텍스트가 제공하는 어떠한 기능도 동작하지 않는다. 식별자 값을 가지고 있다.
// 회원 엔티티를 영속성 컨텍스트에서 분리, 준영속 상태
em.detach(member);
삭제
엔티티를 영속성 컨텍스트와 데이터베이스에서 삭제한다.
// 객체를 삭제한 상태(삭제)
em.remove(member);
엔티티 상태 변화
Hibernate/JPA에서 엔티티는 4가지 상태가 있다.
| 상태 | 설명 |
|---|---|
| Transient | 새 객체 (영속성 컨텍스트에 없음) |
| Persistent | 영속성 컨텍스트에 의해 관리됨 |
| Detached | 영속성 컨텍스트에서 분리된 상태 |
| Removed | 삭제가 예정된 상태 |
상태 흐름
new → persist() → Persistent
Persistent → detach() → Detached
Persistent → remove() → Removed
특징
영속성 컨텍스트와 식별자 값
영속성 컨텍스트는 엔티티를 식별자 값(@Id로 테이블의 기본 키와 매핑한 값)으로 구분한다. 따라서 영속 상태는 식별자 값이 반드시 있어야 한다.
영속성 컨텍스트와 데이터베이스 저장
JPA는 보통 트랜잭션을 커밋하는 순간 영속성 컨텍스트에 새로 저장된 엔티티를 데이터베이스에 반영하는데 이를 플러시(flush)라 한다.
장점
1차 캐시
영속성 내부에는 1차 캐시가 존재한다. 영속 상태의 엔티티를 이곳에 저장하기 때문에 만약 엔티티를 조회했을 때 1차 캐시에 엔티티가 존재한다면 DB를 찾아보지 않아도 된다. 말 그대로 캐시로써의 기능과 장점을 가지고 있다.
영속 엔티티의 동일성 보장
1차 캐시로 반복 가능한 읽기(Repeatable Read) 등급의 트랜잭션 격리 수준을 데이터베이스가 아닌 애플리케이션 차원에서 제공해 줄 수 있다.
Member a = em.find(Member.class, "member1");
Member b = em.find(Member.class, "member1");
System.out.println(a==b) // true
트랜잭션을 지원하는 쓰기 지연
em.persist()로 객체를 영속성 컨텍스트에 저장해도 DB에 바로 Insert 쿼리를 날리지 않는다. SQL 쿼리들을 모아놓았다가 flush 될 때(영속성 컨텍스트의 변경 내용을 DB에 반영할 때) 모아둔 쿼리를 모두 날린다. 이를 쓰기 지연이라고 한다.
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
// 엔티티 매니저는 데이터 변경 시 트랜잭션을 시작해야 한다.
transaction.begin(); // [트랜잭션] 시작
em.persist(memberA);
em.persist(memberB);
// 여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.
// 커밋하는 순간 데이터베이스에 INSERT SQL을 보낸다.
transaction.commit(); // [트랜잭션] 커밋
변경 감지(Dirty Checking)
영속성 컨텍스트에서 엔티티를 조회해서 해당 엔티티를 수정한다고 하자. 이때 조회한 엔티티를 다시 업데이트하는 코드가 있어야 할 것 같지만, 그러한 코드가 없어도 영속성 컨텍스트 내의 스냅샷과 엔티티를 비교해 변경된 엔티티가 있으면 Update 쿼리를 자동으로 생성한다. 물론 이 Update 쿼리도 쓰기 지연이 될 수 있다.
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(); // [트랜잭션] 커밋
지연 로딩(Lazy Loading)
지연 로딩은 연관 관계 매핑되어 있는 엔티티를 조회 시 우선 프록시 객체를 반환하고, 실제로 필요할 때 쿼리를 날려 가져오는 기능이다. 즉, 필요할 때 데이터를 가져오는 기능이다.
플러시
트랜잭션 커밋을 실행하면 변경 내용을 데이터베이스에 반영하게 된다. 트랜잭션 커밋이 일어날 때 플러시도 함께 발생한다. 플러시(flush())는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영한다. 영속성 컨텍스트의 엔티티를 지우는 게 아니라 변경 내용을 데이터베이스에 동기화하는 것이다.
플러시의 흐름
- 변경 감지가 동작해서 스냅샷과 비교해서 수정된 엔티티를 찾는다.
- 수정된 엔티티에 대해서 수정 쿼리를 만들고 SQL 저장소에 등록한다.
- 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송한다.
플러시 하는 방법
- em.flush()
- 트랜잭션 커밋 시 자동 호출, tx.commit()
- JPQL 쿼리 실행 시 자동 호출
영속성 컨텍스트는 N+1과 어떤 관계가 있을까?🤔
N+1 문제는 흔히 “지연 로딩(Lazy Loading) 때문에 발생한다.”로 알고 있다. 그러나 조금 더 깊이 들어가 보면, N+1은 단순히 Lazy 옵션 때문이 아니라 영속성 컨텍스트의 동작 방식과 밀접하게 연결된 문제다.
영속성 컨텍스트
영속성 컨텍스트는 엔티티를 관리하는 트랜잭션 단위의 관리 공간이다. 조금 더 구체적으로 보면, 다음과 같은 역할을 한다.
- 1차 캐시
- 동일성 보장
- 변경 감지(Dirty Checking)
- 쓰기 지연(Write-Behind)
Hibernate에서는 이 영속성 컨텍스트가 Session 내부에 구현되어 있다. 즉, 우리가 엔티티를 조회하면 단순히 DB에서 가져오는 것이 아니라 영속성 컨텍스트가 관리하는 객체로 로딩된다.
지연 로딩은 어디에서 동작할까?
지연 로딩된 연관 엔티티는 실제 객체가 아니라 프록시 객체로 주입된다. 그리고 우리가 해당 필드에 접근하는 순간 다음처럼 동작한다.
- 프록시가 현재 영속성 컨텍스트에 접근하고 1차 캐시에 해당 엔티티가 있는지 확인한다.
- 없다면 SELECT 쿼리를 실행한다.
이 과정은 영속성 컨텍스트 안에서 일어난다.
N+1이 발생하는 이유
예를 들어, 부모 엔티티를 한 번에 조회했다고 가정해보자.
List<Order> orders = orderRepository.findAll();
이 시점에서는 아직 연관된 Member는 로딩되지 않았다.(지연 로딩이기 때문에 프록시 상태) 그리고 반복문에서 접근한다.
for (Order order : orders) {
System.out.println(order.getMember().getName());
}
이 때, 각 order.getMember() 호출 시 영속성 컨텍스트가 매번 초기화를 시도하고 1차 캐시에 없다면 SELECT 쿼리가 N번 실행된다. 즉, 지연 로딩 + 영속성 컨텍스트 내부 초기화 과정이 엔티티 수만큼 반복하며 N+1가 발생한다.
영속성 컨텍스트 “범위”가 중요한 이유
영속성 컨텍스트는 보통 트랜잭션 단위로 생성된다. 트랜잭션이 살아있는 동안 프록시는 정상적으로 초기화되고 필요할 때마다 DB에 접근한다. 만약 반복문 안에서 지연 로딩 필드를 계속 호출한다면, 같은 영속성 컨텍스트 내에서 계속 쿼리가 발생한다. 반대로 트랜잭션이 종료된 뒤 접근하면 LazyInitializationException이 발생한다. 이 역시 영속성 컨텍스트와 직접적으로 연결된 동작이다.
N+1은 무엇의 문제일까?
N+1은 단순히 “Lazy를 썼기 때문”이 아니다. 정확히는 지연 로딩된 프록시가 영속성 컨텍스트 안에서 반복 초기화되면서 발생하는 구조적 문제이다. 즉, Lazy는 트리거이고 영속성 컨텍스트는 그 동작이 수행되는 공간이다. 영속성 컨텍스트의 생명주기와 내부 동작을 이해하지 못하면 N+1은 언제든지 다시 나타난다.
결론
N+1은 쿼리 최적화 문제가 아니라, 영속성 컨텍스트의 동작 원리를 이해했는지 묻는 질문에 가깝다.
참고
Gemini
ChatGPT
Claude.ai
스프링 H2(+ Hibernate)
JPA 영속성 컨텍스트란?
[JPA] 영속성 컨텍스트(persistence context)란?
[JPA] 영속성 컨텍스트(Persistence Context)란? - 개넘 정리 및 사용법
[기술 면접] 영속성 컨텍스트란?
영속성 컨텍스트(Persistence Context)
JPA 영속성 컨텍스트는 어떻게 사용되는가
JPA 영속성 컨텍스트 내부 동작의 흐름 깊게 파헤치기
Jakarta Persistence 3.2
Hibernate ORM User Guide
6. Persistence Context
7. Flushing
hibernate-orm
