영속성 컨텍스트(Persistence Context) 이해하기

Gyutae Ha·2025년 3월 14일

JPA 공부

목록 보기
2/5

JPA의 영속성 컨텍스트는 엔티티 관리의 핵심 개념입니다. 엔티티를 어떻게 저장하고 조회하며, 변경 사항을 언제 데이터베이스에 반영할지 결정하는 “1차 캐시”로서 동작합니다. 이번 글에서는 영속성 컨텍스트의 개념과 내부 메커니즘을 상세히 설명합니다. 또한 엔티티 매니저와의 상호작용, 엔티티 생명주기(비영속→영속→준영속→삭제), 지연 로딩과 즉시 로딩, 변경 감지와 자동 플러시, 트랜잭션과의 관계 등을 다룹니다. 실무에서 자주 접하는 N+1 문제와 Fetch Join, flush(), clear(), detach(), merge() 등의 활용법, 그리고 성능 최적화 팁도 포함합니다.


1. 영속성 컨텍스트의 정의와 필요성

영속성 컨텍스트(Persistence Context)란, “엔티티 인스턴스들을 관리하는 환경”을 뜻합니다. 보다 공식적인 정의에 따르면 “영속성 컨텍스트는 영구 식별자(persistent identity)에 대해 유일한 엔티티 인스턴스를 보장하는 엔티티 집합이며, 그 안에서 엔티티 인스턴스들의 생명주기가 관리된다”. 한 마디로, 특정 EntityManager를 통해 관리되는 엔티티 객체들을 모아 놓은 1차 캐시(First-Level Cache)입니다.
영속성 컨텍스트가 필요한 이유는 데이터베이스와 애플리케이션 객체 사이에 일종의 가교 역할을 하기 때문입니다. 데이터베이스에서 조회한 엔티티는 영속성 컨텍스트에 저장되고(캐싱), 애플리케이션에서는 항상 이 컨텍스트를 통해 엔티티에 접근합니다. 덕분에 동일한 트랜잭션 안에서는 같은 엔티티를 두 번 조회해도 쿼리가 한 번만 실행되고 나머지는 1차 캐시에서 재사용됩니다 (동일성 보장). 또한 트랜잭션 동안 엔티티의 변경 사항을 추적해 두었다가 트랜잭션 종료 시점에 한꺼번에 데이터베이스에 반영함으로써, 불필요한 SQL 중복 실행을 막고 애플리케이션 성능을 향상시킵니다. 요약하면 영속성 컨텍스트는 엔티티의 상태 관리와 성능 최적화(캐싱 및 쓰기 지연)를 위해 반드시 필요한 개념입니다.

참고: 영속성 컨텍스트는 JPA 구현체(Hibernate 등) 내부에서 엔티티 인스턴스를 식별자(PK) 기준으로 저장하는 Map 형태로 구현됩니다. 예를 들어 Hibernate에서는 PersistenceContextMap<EntityKey, Entity> 형태의 캐시로 동작하며, 영속성 컨텍스트를 통해 엔티티를 관리합니다. 엔티티를 persist()하거나 find()로 조회하면 이 Map에 엔티티가 저장되고, 반대로 remove()하면 Map에서 제거됩니다. 이러한 내부 구조 덕분에 식별자가 같은 엔티티 객체는 컨텍스트 내에서 단 하나만 존재하게 되어 애플리케이션 레벨에서 Repeatable Read 수준의 일관성을 제공합니다.


2. EntityManager와 영속성 컨텍스트의 관계

EntityManager는 영속성 컨텍스트를 관리하는 JPA 인터페이스입니다. EntityManager 객체는 곧 영속성 컨텍스트에 대한 연결을 나타내며, 엔티티를 생성, 조회, 삭제, 수정하는 작업을 제공합니다. 쉽게 말해, EntityManager를 통해서만 영속성 컨텍스트에 접근할 수 있습니다. EntityManager마다 별도의 영속성 컨텍스트가 할당되며, 보통 하나의 트랜잭션 당 하나의 EntityManager/영속성 컨텍스트가 사용됩니다.

Spring 환경에서는 @PersistenceContext를 이용해 컨테이너가 EntityManager를 주입하며, 이는 기본적으로 트랜잭션-스코프 영속성 컨텍스트를 사용합니다. 트랜잭션이 시작되면 EntityManager가 활성화되고 새로운 영속성 컨텍스트가 만들어져 엔티티를 관리하기 시작합니다. 반대로 트랜잭션이 끝나면 commit 시점에 컨텍스트를 flush(플러시)하여 DB와 동기화하고, 그 컨텍스트에 속한 엔티티들은 더 이상 관리되지 않는 상태(준영속 상태)로 변경됩니다. 이처럼 EntityManager는 생성될 때 영속성 컨텍스트를 함께 만들고, 트랜잭션 생명주기에 따라 이를 관리하는 역할을 합니다.

한편, JPA에는 영속성 컨텍스트의 생존 범위에 따라 트랜잭션-스코프(transaction-scoped)확장-스코프(extended-scoped) 두 가지 형태가 있습니다. 트랜잭션-스코프는 위에서 설명한 대로 트랜잭션 동안만 컨텍스트가 유지되고 종료 시 닫히는 방식입니다. 확장 영속성 컨텍스트는 트랜잭션이 없어도 살아남아 여러 트랜잭션에 걸쳐 동일한 컨텍스트를 사용할 수 있는데, 주로 복잡한 상태를 유지해야 하는 오래 지속되는 작업(예: 긴 대화 상태의 상태풀 세션빈)에서 사용됩니다. 하지만 Spring의 일반적인 서비스/레포지토리 계층에서는 기본적으로 트랜잭션-스코프를 사용하며, 확장 컨텍스트는 잘 활용되지 않습니다. 따라서 본문에서는 특별한 언급이 없는 한 트랜잭션 단위로 관리되는 영속성 컨텍스트를 전제로 설명합니다.


3. 엔티티의 생명주기 및 상태 변화

JPA에서는 엔티티 객체가 다음 4가지 상태를 가집니다:

  • 비영속 (New, Transient) – 아직 영속성 컨텍스트에 전혀 포함되지 않은 새로운 상태입니다. 단순히 new로 객체를 생성했지만, 아직 EntityManager로 관리되지 않으며 DB와도 전혀 관계없는 상태를 말합니다 . 예를 들어 Member member = new Member(); 처럼 엔티티를 생성만 하고 persist하지 않은 경우입니다.
  • 영속 (Managed) – 엔티티가 영속성 컨텍스트에 저장되어 관리되는 상태입니다. EntityManager의 persist() 메서드를 호출하여 영속성 컨텍스트에 등록하면 비영속 → 영속 상태로 전이됩니다 . 혹은 find()나 JPQL 조회를 통해 DB에서 가져온 엔티티 역시 자동으로 영속성 컨텍스트에 저장되므로 영속 상태가 됩니다. 영속 상태의 엔티티는 1차 캐시에 존재하며 EntityManager가 지속적으로 추적하기 때문에, 이후 값이 변경되면 dirty checking을 통해 트랜잭션 종료 시점에 변경 내용이 DB에 반영됩니다.
  • 준영속 (Detached) – 한때 영속 상태였으나 영속성 컨텍스트에서 분리된 상태입니다. EntityManager의 detach(entity)를 호출하거나, clear()로 컨텍스트를 통째로 비우거나, 또는 해당 EntityManager/트랜잭션이 종료되어 더 이상 관리되지 않을 때 엔티티는 준영속 상태가 됩니다. 준영속 상태의 엔티티는 더 이상 1차 캐시에 존재하지 않으므로, 변경을 해도 컨텍스트는 알 수 없습니다. (필요하면 merge()를 통해 다시 영속 상태로 만들 수 있습니다 – 뒤에서 설명)
  • 삭제 (Removed)삭제 대상으로 지정된 상태입니다. EntityManager의 remove(entity)를 호출하면 영속성 컨텍스트에서 해당 엔티티를 제거하며, 삭제 상태로 표시됩니다. 이때 즉시 DB에서 삭제되는 것은 아니고, 트랜잭션 종료 시 flush 과정에서 실제 DELETE SQL이 반영됩니다. 삭제 상태의 엔티티는 이후 영속성 컨텍스트에서 관리되지 않게 됩니다.

엔티티 상태 전이는 아래 그림과 같습니다. 각 상태 간 전이를 일으키는 EntityManager 메서드(persist, merge, remove, detach 등)와 이벤트를 시각화하면 이해하기 쉽습니다:
엔티티의 생명주기: 비영속, 영속, 준영속, 삭제의 4가지 상태와 전이 메서드

그림에서 보듯이, 새로 생성된 객체는 persist() 호출로 영속성 컨텍스트에 등록되면 영속 상태로 전환됩니다. 영속 상태에서는 remove()를 호출하여 삭제 상태로 만들 수 있고, detach() 또는 clear()를 통해 준영속(Detached) 상태로 전환 가능합니다. 한편 준영속 상태의 객체에 대해 merge()를 호출하면 다시 영속성 컨텍스트가 관리하는 영속 상태 엔티티로 재붙이기(merge) 할 수 있습니다. 각 메서드 호출 시의 동작을 정리하면 다음과 같습니다.

  • em.persist(entity): 비영속 -> 영속. 엔티티를 영속성 컨텍스트에 넣어 관리 시작합니다 (INSERT SQL은 flush 시점에 실행).
  • em.find(Entity.class, id): DB에서 엔티티 조회 -> 영속. 조회 결과 엔티티를 컨텍스트에 저장하고 반환합니다 (이미 컨텍스트에 존재하면 DB를 건드리지 않고 캐시된 객체 반환).
  • em.remove(entity): 영속 -> 삭제. 엔티티를 컨텍스트에서 제거하고 삭제 표시합니다 (DELETE SQL은 flush 시 실행).
  • em.detach(entity): 영속 -> 준영속. 특정 엔티티만 컨텍스트에서 분리하여 더 이상 관리하지 않도록 합니다.
  • em.clear(): 영속성 컨텍스트를 완전히 초기화합니다. 컨텍스트에 들어있던 모든 엔티티가 준영속 상태가 됩니다.
  • em.merge(entity): 준영속 -> 영속. 준영속 상태의 엔티티를 받아 그 상태를 가진 새로운 영속 엔티티를 반환합니다. 기존 식별자에 해당하는 영속성 컨텍스트 내 엔티티가 없으면 새로운 영속 엔티티를 만들고, 이미 있으면 그 영속 엔티티에 detached 객체의 변경 내용을 병합합니다.

이러한 상태 전이 메서드들을 통해 JPA는 엔티티 객체의 생명주기를 관리합니다. 개발자는 엔티티의 상태를 올바르게 이해하고 메서드를 사용해야 하며, 잘못 사용할 경우 예외나 데이터 불일치가 발생할 수 있습니다. (예: 이미 영속 상태인 객체를 persist() 하면 EntityExistsException, 영속 상태가 아닌 객체를 remove() 하면 IllegalArgumentException 등이 발생)


4. 영속성 컨텍스트의 핵심 기능

4.1 1차 캐시와 동일성 보장

영속성 컨텍스트는 엔티티 1차 캐시 (First-Level Cache) 역할을 합니다. 이는 동일한 트랜잭션 범위 내에서 동일한 엔티티 조회 시 항상 같은 인스턴스를 반환함을 의미합니다. 예를 들어 아래 코드를 보겠습니다:

@Entity
class Member { ... }

// 같은 트랜잭션 내 연속 조회 예시
Member m1 = em.find(Member.class, 101L);
Member m2 = em.find(Member.class, 101L);
System.out.println(m1 == m2); // true (동일한 인스턴스)

Member.class의 PK가 101인 엔티티를 두 번 find()로 조회하지만, 쿼리는 최초 한 번만 날아갑니다. 두 번째 조회 때는 영속성 컨텍스트에 기존에 로딩되어 있는 엔티티를 그대로 반환하므로 객체 주소까지 동일(== 연산 결과 true)하게 됩니다. 이러한 동일성 보장은 애플리케이션이 엔티티의 객체 동등성(equality)뿐 아니라 실제 동일성(identity)을 유지할 수 있게 해주어 엔티티 관리에 일관성을 제공합니다.

또한 영속성 컨텍스트는 반복 가능한 읽기(Repeatable Read) 범위의 논리적 일관성을 제공합니다. 즉, 한 트랜잭션 안에서는 이미 조회한 데이터를 반복 조회해도 변경되지 않고 같게 보장됩니다. JPA 표준에서는 이 수준을 명시하진 않지만, 사실상 1차 캐시로 인해 애플리케이션 레벨에서 Repeatable Read 격리 수준의 효과를 얻는 셈입니다. (다만 이것이 데이터베이스의 격리 수준을 대체하는 것은 아니며, 트랜잭션 격리와는 별개로 동작하는 애플리케이션 캐시임을 유의해야 합니다.)

1차 캐시 동작 원리: EntityManager가 처음 엔티티를 조회할 때, 내부에서는 다음 순서로 진행됩니다:
1. Persistence Context 캐시 확인: 원하는 식별자(PK)를 키로 1차 캐시(Map)에서 엔티티를 찾습니다. 이미 있다면 DB를 건너뛰고 즉시 반환합니다.
2. 2차 캐시 확인: (옵션) JPA 공급자가 2차 캐시(보통 글로벌 캐시)를 지원하고 활성화된 경우, 1차 캐시에 없을 때 2차 캐시에서 조회할 수 있습니다. (Hibernate의 경우 설정에 따라 2차 캐시에서 엔티티의 Loaded State를 가져와 재구성)
3. 데이터베이스 조회: 캐시에 없을 경우에만 DB에 SQL을 실행하여 엔티티를 조회합니다. 조회한 결과로 엔티티 객체를 생성하고 영속성 컨텍스트에 저장한 뒤 반환합니다.

위 과정에서 1차 캐시 덕분에 동일한 엔티티에 대해 중복 쿼리가 발생하지 않으며, DB 부하를 줄이고 애플리케이션 성능을 높일 수 있습니다. 참고로, 1차 캐시는 트랜잭션 범위이므로 트랜잭션이 끝나면 사라지고, 스레드 간에 공유되지 않습니다. 따라서 여러 요청(또는 스레드)에서 같은 EntityManager를 동시에 사용하거나 1차 캐시를 공유하려 해서는 안 됩니다.

4.2 변경 감지(Dirty Checking)와 자동 플러시(Flush)

변경 감지(Dirty Checking)는 영속성 컨텍스트의 핵심 기능으로, 영속 상태의 엔티티 객체가 변경되었는지 추적하여 변경분을 자동으로 DB에 반영하는 메커니즘입니다. 개발자가 별도로 UPDATE 쿼리를 날리지 않아도, 트랜잭션 커밋 시점에 JPA가 변경된 엔티티를 모아 적절한 UPDATE SQL을 생성하여 실행합니다.

Hibernate 구현을 예로 들면, 영속성 컨텍스트는 엔티티를 처음 로딩할 때 그 Loaded State(엔티티 조회 시점의 스냅샷)도 함께 저장해 둡니다. 엔티티 클래스의 필드 값들을 배열이나 맵 등의 형태로 복사해 보관해 두는 것이죠. 그리고 flush 시점마다 현재 엔티티의 상태와 저장된 스냅샷을 비교(dirty check)합니다. 변경된 필드가 있다면 Dirty 상태로 표시하고, 그에 맞는 UPDATE 문을 준비합니다. 트랜잭션이 끝나기 전에 flush가 일어나면 이 UPDATE가 실행되고, 동시에 해당 엔티티의 스냅샷을 최신 상태로 갱신하여 다음 변경 감지에 대비합니다.

자동 플러시(Flush)는 영속성 컨텍스트의 변경 내용을 DB에 동기화하는 동작을 말합니다. 일반적으로 flush는 트랜잭션 커밋 시 자동으로 발생하며, 그 외에도 JPA 구현체에 따라 쿼리를 실행하기 전에도 자동 flush가 일어날 수 있습니다. (Hibernate의 flush 모드 기본값 AUTO의 동작) Flush가 발생하면 아래와 같은 일들이 순차적으로 일어납니다:
1. 엔티티 삽입 – persist()된 새로운 엔티티들을 찾아 INSERT SQL 생성 (영속성 컨텍스트에 추가된 순서대로)
2. 엔티티 수정 – Dirty Checking으로 감지된 변경사항에 대해 UPDATE SQL 생성
3. 컬렉션 변경 – 연관관계 필드(컬렉션)의 추가/삭제에 따른 INSERT/UPDATE/DELETE SQL 생성
4. 엔티티 삭제 – remove()로 삭제 표시된 엔티티들에 대해 DELETE SQL 생성 (엔티티 삭제는 최후에 실행).

생성된 SQL들은 JDBC 배치로 모여 순서대로 DB에 보내집니다. 이 flush 과정이 끝나면 아직 트랜잭션이 커밋되지 않았더라도 영속성 컨텍스트 내 엔티티와 DB 상태가 동기화됩니다. (단, 트랜잭션이 커밋되어야 비로소 DB에 영구 반영되고, 롤백되면 flush로 인한 변경사항도 취소됩니다.)

flush vs. commit: em.flush()영속성 컨텍스트의 변경 내용을 DB에 반영하지만 트랜잭션을 커밋하지는 않습니다. 따라서 flush 이후에도 트랜잭션이 열려있으면 변경내용을 되돌릴 수 있으며, commit 전까지 다른 트랜잭션에서 이 변경을 못 봅니다. flush는 중간 저장 개념이고, commit은 실제 확정입니다. Spring Data JPA의 saveAndFlush()는 엔티티 저장 후 즉시 flush 하는 예이지만, 일반적으로 flush는 개발자가 직접 호출하지 않아도 commit 시 자동처리됩니다.

Flush 자동 발생 시점: 앞서 언급했듯이, 표준 JPA에서는 flush를 언제 할지 명시하지 않고 구현체에 위임하고 있습니다. Hibernate에서는 기본적으로 FlushMode.AUTO로 동작하여 쿼리 실행 전과 트랜잭션 커밋 직전에 flush합니다. 예를 들어, 영속 상태 엔티티를 수정한 뒤 곧바로 JPQL로 DB 데이터를 조회하면, JPQL 실행 전에 flush가 일어나 수정 내용이 반영된 후 조회가 이뤄집니다. 이를 통해 트랜잭션 내 일관성을 보장해줍니다. 반면 FlushMode를 COMMIT로 설정하면 commit 시에만 flush하여, 중간에 JPQL 실행해도 변경사항을 DB에 반영하지 않습니다. (이 경우 조회 결과가 stale(오래된) 데이터를 반환할 수 있으므로 주의 필요)

4.3 트랜잭션과 영속성 컨텍스트의 관계

트랜잭션(트랜잭션 매니저)은 영속성 컨텍스트와 불가분의 관계를 가집니다. 일반적으로 영속성 컨텍스트의 생명주기는 트랜잭션과 일치하며, 트랜잭션이 commit될 때 flush를 통해 DB와 동기화하고 영속성 컨텍스트를 종료(detach)합니다. 이것은 컨텍스트 입장에서 보면, 트랜잭션이라는 작업 단위마다 새로운 컨텍스트가 열리고 닫히는 사이클이라고 할 수 있습니다.

  • 트랜잭션 시작: 새로운 영속성 컨텍스트가 생성되고, EntityManager가 이를 이용해 엔티티를 관리하기 시작합니다. 이때부터 commit 전까지 쿼리 실행, 엔티티 변경 등이 이루어지며 모든 변경은 일단 영속성 컨텍스트에 반영됩니다.
  • 트랜잭션 커밋: 커밋 직전에 영속성 컨텍스트를 flush하여 변경사항을 DB에 동기화합니다. flush 도중 예외가 발생하면(commit 전에) 커밋이 롤백되고 트랜잭션이 종료됩니다. flush가 성공하면 DB 커밋이 진행되고, 영속성 컨텍스트의 엔티티들은 detached(준영속) 상태로 전환됩니다. 즉, 한 트랜잭션에서 작업이 끝나면 더 이상 해당 EntityManager가 엔티티를 관리하지 않게되어 1차 캐시를 비우고 새 트랜잭션에 대비합니다.
  • 트랜잭션 롤백: 롤백 시에는 flush가 의미 없으므로 (기존 변경사항 폐기) 그냥 컨텍스트를 닫고 엔티티를 detached로 만듭니다.

이러한 동작은 개발자가 크게 의식하지 않아도 트랜잭션 관리에 따라 자동으로 수행됩니다. 다만, 트랜잭션 범위를 벗어난 영속성 컨텍스트는 존재하지 않는다는 점을 명심해야 합니다. 영속성 컨텍스트가 살아있지 않은 상태(예: EntityManager가 이미 closed된 후 등)에서 영속성 컨텍스트가 필요한 작업(지연 로딩 등)을 수행하려 하면 예외가 발생합니다.

트랜잭션 vs 확장 컨텍스트: 특별한 경우로, 영속성 컨텍스트를 트랜잭션 없이도 유지하는 확장(Extended) 컨텍스트가 있습니다. 이 경우 commit 후에도 컨텍스트가 사라지지 않고 엔티티를 계속 영속 상태로 유지합니다. 다음 트랜잭션에서 해당 컨텍스트를 이어 받아 작업할 수 있죠. 그러나 이는 주로 EJB 상태풀 빈 등에서 DB 작업 사이에 객체 상태를 유지해야 하는 경우에 쓰입니다. Spring의 표준 프로그래밍 모델에서는 확장 컨텍스트를 직접 쓰기 어렵고, 굳이 비슷한 효과를 내려면 Open-Session-In-View 패턴으로 Hibernate 세션을 요청 전후 열어두는 정도입니다. 대부분의 경우 트랜잭션-스코프 컨텍스트로 충분하므로, 트랜잭션 범위 내에서 영속성 컨텍스트를 관리하는 것이 기본 전략입니다.


5. 지연 로딩(Lazy)과 즉시 로딩(Eager), N+1 문제 및 Fetch Join

엔티티를 조회할 때 연관된 엔티티나 컬렉션을 언제 로딩할지에 대한 전략이 지연 로딩(Lazy Loading)즉시 로딩(Eager Loading)입니다. JPA에서는 연관 필드에 @ManyToOne이나 @OneToMany 등에 기본으로 Lazy 로딩이 설정되어 있습니다 (특히 컬렉션은 기본 Lazy).

  • 즉시 로딩 (EAGER): 연관된 엔티티를 즉시 함께 조회합니다. 한 번의 find나 JPQL로 주 엔티티를 가져올 때 연관된 것까지 조인(Join) 쿼리 또는 별도 쿼리로 모두 불러옵니다. 따라서 처음부터 모든 데이터가 필요한 경우 유용하지만, 불필요한 데이터까지 미리 로딩하여 성능을 떨어뜨릴 수 있고 상황에 따라 N+1 문제를 일으킬 수도 있습니다 (즉시 로딩이라고 해서 N+1이 아예 없는 것은 아님).
  • 지연 로딩 (LAZY): 연관된 엔티티를 실제로 사용할 때까지 가져오지 않고 미룸니다. 주 엔티티를 조회할 때는 연관 객체를 프록시 객체로 남겨두고, 해당 연관 객체를 처음 접근할 때 쿼리를 실행하여 가져오는 방식입니다. 이를 통해 처음 조회 시에는 필요한 주 엔티티만 가져와 초기 로딩 비용을 낮추고 메모리 사용을 절약합니다. 그러나 이후 연관 객체를 개별적으로 접근할 때 추가 쿼리가 발생합니다.

N+1 문제는 지연 로딩 시 연관된 엔티티를 반복 조회하면서 다수의 쿼리가 발생하는 비효율을 가리킵니다. 예를 들어 회원 목록 N명을 조회한 후(each) 각 회원의 주문 목록을 출력한다고 가정하면:

List<Member> members = em.createQuery("SELECT m FROM Member m", Member.class)
                         .getResultList();  // (1) 회원 N명 조회 - 1번 쿼리
for (Member m : members) {
    System.out.println(m.getOrders().size()); // (2) 각 회원의 주문 수 출력 - 회원마다 쿼리
}

여기서 (1)에서 회원을 모두 가져오지만, getOrders() 호출 시점까지는 각 회원의 주문 리스트가 조회되지 않은 상태입니다 (Lazy). 루프에서 m.getOrders()를 호출할 때마다 DB에서 SELECT * FROM orders WHERE member_id=? 와 같은 쿼리가 회원 수 N번 실행됩니다. 결국 총 1 (회원 목록) + N (각 회원별 주문) = N+1개의 쿼리가 발생하는 것이죠. 데이터량이 클 경우 이로 인한 성능 저하가 심각해질 수 있습니다.

즉시 로딩의 N+1: 즉시 로딩(EAGER)의 경우도 N+1 문제가 발생할 수 있습니다. 예를 들어 다대일(N:1) 관계를 EAGER로 설정하면, 주 엔티티 조회 시 조인 없이 별도 쿼리로 연관 엔티티를 가져오는 JPA 구현체도 있습니다. 이 경우 주 엔티티 N건 조회 + N건의 연관 엔티티 조회로 N+1 문제가 생길 수 있습니다. (Hibernate는 다대일 EAGER는 조인 패치로 처리하지만, 하이버네이트 외 구현체나 일부 상황에서는 발생 가능)

N+1 문제 해결: 대표적인 해법은 Fetch Join을 사용하는 것입니다. Fetch Join은 JPQL에서 연관된 엔티티를 함께 SELECT 해서 한 번에 가져오는 기능입니다. SQL의 조인과 비슷하지만, JPA 문법으로 엔티티 그래프를 한 번에 가져오도록 하는 것입니다. 예를 들어 위 회원-주문 예에서 N+1을 피하려면 JPQL에 페치 조인을 사용합니다:

// 회원과 주문을 한 번에 조회 (Fetch Join)
List<Member> members = em.createQuery(
    "SELECT m FROM Member m JOIN FETCH m.orders",
    Member.class
).getResultList();

이렇게 하면 회원을 조회할 때 JOIN FETCH m.orders 부분 때문에 회원과 연관된 주문을 한꺼번에 조인하여 가져옵니다. 결과적으로 실행되는 SQL은 SELECT * FROM Member M LEFT JOIN Order O ON O.member_id = M.id 식으로 단 한 번 실행되며, 이후 m.getOrders()를 호출해도 추가 쿼리가 발생하지 않습니다. 즉, N+1 쿼리가 1+0 쿼리로 줄어드는 것입니다.

실제로 Hibernate를 포함한 ORM에서 N+1 문제는 Lazy 로딩으로 인한 추가 쿼리 누적 현상으로 자주 나타나며, Fetch Join이나 Batch Fetch(배치 로딩), 혹은 Subselect 패치 전략 등으로 이를 완화합니다. JPA 표준에서는 Fetch Join만 명시되어 있고, Batch Size 등은 구현체 구현에 따라 제공됩니다. 가장 쉬운 접근법은 JPQL에 필요한 연관을 fetch join으로 미리 당겨오는 것입니다.

주의: Fetch Join을 남용하면 한번에 가져오는 데이터 양이 많아져 오히려 성능이 나빠질 수 있습니다. 필요한 관계에 대해서만 fetch join을 사용하고, 그렇지 않은 경우 Lazy 전략을 유지하는 것이 일반적입니다. 또한 둘 이상의 컬렉션을 한 JPQL에서 fetch join 하는 것은 제약(SQL Cartesian 곱 문제 등)이 있으므로 상황에 맞게 쿼리를 분리하거나 Batch Fetch 옵션을 고려해야 합니다.

마지막으로, 지연 로딩 활용 팁으로 영속성 컨텍스트의 생존 범위를 고려해야 합니다. Lazy 상태의 엔티티를 트랜잭션 종료 후 사용하려 하면 LazyInitializationException이 발생합니다. 예를 들어 컨트롤러나 뷰 계층까지 지연 로딩된 컬렉션을 전달하면 이미 EntityManager가 닫혀있어서 문제가 됩니다. 이를 해결하려고 Open-Session-In-View 같은 방식을 쓰기도 하지만, 권장되지는 않습니다. 가장 좋은 방법은 필요한 데이터는 트랜잭션 내에서 미리 모두 로드하고, 뷰에는 DTO 등으로 전달하는 패턴입니다.


6. 영속성 컨텍스트 제어 방법 (flush, clear, detach, merge 등)

영속성 컨텍스트를 다루다 보면 기본 동작 외에 수동으로 컨텍스트를 제어해야 할 때도 있습니다. JPA는 이를 위해 EntityManager 인터페이스에 몇 가지 메서드를 제공합니다. 앞서 생명주기 전이에서 일부 소개했지만, 용도와 효과를 정리해보겠습니다.

6.1 flush() – 수동 플러시

flush()는 영속성 컨텍스트의 변경 내용을 즉시 데이터베이스에 반영하는 메서드입니다. 일반적으로 commit 시 자동 호출되므로 직접 호출할 일은 많지 않지만, 다음과 같은 상황에서 사용됩니다:

  • 트랜잭션 중간에 SQL을 실행하여 DB 제약조건 위배 여부 등을 미리 확인하고자 할 때. (예: 유니크 제약 위반을 커밋 전에 감지)
  • JPA를 통해 변경 후, 그 결과를 반영한 상태로 즉시 JPQL 쿼리를 수행해야 할 때. (flush 없이 JPQL 수행시 자동 flush되므로 드물지만, FlushMode가 MANUAL인 경우 등)
  • Batch 처리시 메모리 누수를 방지하기 위해 일정 간격으로 flush() 호출이 필요할 때 (뒤 참고).

Flush를 호출해도 트랜잭션은 유지되므로, flush된 쿼리는 DB에 적용되었지만 커밋 전이면 다른 트랜잭션에서는 아직 보이지 않습니다. flush는 영속성 컨텍스트의 SQL 저장소를 비우고(DB와 동기화), 트랜잭션은 지속됩니다.

// flush() 사용 예시
EntityManager em = ...;
em.persist(entityA);
em.flush(); // 이 시점에 INSERT 실행 (트랜잭션은 아직 commit 전)
entityA.setName("ABC");
em.flush(); // UPDATE 실행
// (변경 후 flush하지 않아도, commit하면 자동 flush됨)

위 코드에서 persist 후 flush를 호출하면 INSERT가 즉시 수행되고, 그 이후 엔티티 수정 후 다시 flush를 하면 UPDATE가 수행됩니다. 만약 중간 flush를 생략했다면 commit 시 한꺼번에 INSERT+UPDATE가 날아갈 텐데, 필요에 따라 수동 flush를 호출해 단계별로 보낼 수도 있는 것입니다. 그러나 특별한 이유가 없다면 flush를 남발할 필요는 없습니다.

6.2 clear() – 영속성 컨텍스트 초기화

clear()현재 영속성 컨텍스트를 완전히 비워서 모든 엔티티를 준영속(detached) 상태로 만드는 메서드입니다. 이 메서드를 호출하면 1차 캐시가 초기화되고, 더불어 아직 flush되지 않은 변경사항이 있다면 모두 버려집니다 (flush 없이 clear하면 변경 내용은 영원히 반영되지 않습니다). 따라서 트랜잭션 중간에 clear()를 호출할 경우, 반드시 그 전에 flush()를 수행하여 변경사항을 반영해야 안전합니다.

clear()는 주로 다음 상황에서 사용됩니다:

  • 대용량 배치 처리: 수천 건 이상의 엔티티를 한 번에 처리할 때, 한 트랜잭션에 모두 영속 상태로 들고 있으면 메모리 과부하가 생길 수 있습니다. 이때 적당한 배치 단위(batch size)로 끊어서 flush하고, clear()로 컨텍스트를 비워주는 패턴을 씁니다. 예를 들어 1000건씩 persist -> flush -> clear를 반복하면 메모리 사용을 일정 수준으로 유지할 수 있습니다.
  • 영속성 컨텍스트와 DB간 데이터 불일치 해소: JPQL로 벌크 업데이트를 수행한 뒤, 영속성 컨텍스트 내 캐시와 DB 상태가 달라질 수 있습니다. 이때 clear()를 통해 캐시를 비우고 다음 조회부터는 DB를 참조하게 만들어 데이터 정합성을 맞춥니다. (아래 벌크 연산 참고)

Spring 환경에서는 @Modifying(clearAutomatically=true) 같은 옵션으로 스프링 데이터 JPA에서 JPQL 실행 후 자동 clear하도록 지원하기도 합니다.

주의할 것은 clear()를 남용하면 영속성 컨텍스트의 이점을 잃게 되므로, 필요한 경우에만 써야 합니다. 또한 clear 후 이어지는 작업에서 이전에 관리되던 엔티티를 다시 사용하면 안 됩니다 (이미 detached 되었으므로). 필요한 경우 merge()를 통해 다시 붙여야 합니다.

6.3 detach(entity) – 엔티티 개별 분리

detach(entity)clear()와 달리 특정 엔티티만 영속성 컨텍스트에서 분리(준영속화) 시킵니다. 일부 객체를 컨텍스트에서 제거하여 더 이상 관리하지 않게 함으로써, 해당 객체에 대한 변경 감지나 flush 시 반영이 이루어지지 않게 할 수 있습니다.

사용 예시는 다음과 같습니다:

Member m = em.find(Member.class, 1L);
em.detach(m);
// 여기서 m.setName("Z") 해도 더 이상 JPA가 관리하지 않음 – UPDATE 안 일어남

이렇게 분리한 객체는 완전히 일반 자바 객체가 되며(JPA와 무관), 필요하면 em.merge(m)으로 다시 붙일 수 있습니다. 특정 엔티티만 분리하는 경우는 흔치 않지만, 예를 들어 여러 엔티티 중 일부만 변경을 무시하고 싶을 때 detach를 사용할 수 있습니다. 또는 UI에 수정용으로 넘긴 엔티티를 사용자가 취소했을 때 해당 엔티티를 컨텍스트에서 빼버리는 식으로 활용 가능하겠습니다. 대부분은 clear()로 한꺼번에 관리하지만, 상황에 따라 구체적인 제어가 필요할 때 사용합니다.

6.4 merge() – 준영속 엔티티 병합

merge()는 준영속(detached) 상태의 엔티티 (또는 새로운 엔티티라도 상관없음)을 받아서, 동일한 식별자의 엔티티를 영속성 컨텍스트에 찾아 있으면 업데이트, 없으면 새로 만들어 영속화하는 메서드입니다. 보통 다음 두 경우에 활용됩니다:

  • 준영속 상태 엔티티를 다시 활성화할 때: 뷰에서 수정된 엔티티를 컨트롤러 계층에서 새 트랜잭션으로 받아 처리할 때, 이미 영속성 컨텍스트 밖에 있는 객체를 merge()로 넘겨주면 JPA가 알아서 기존 영속성 컨텍스트의 동일 ID 엔티티에 값을 복사해주거나, 없으면 새로 DB에서 조회하여 적용합니다.
  • DTO 등을 엔티티로 변환 저장할 때: ID가 세팅된 객체를 merge하면, 해당 ID의 엔티티를 찾은 후 값들을 채워 넣어줍니다. ID가 없다면 새로운 엔티티로 간주하여 persist와 유사하게 동작합니다.

주의할 점은, merge()는 인자로 넘어온 객체 자체를 영속상태로 만들지 않고 영속성 컨텍스트 안에 따로 엔티티를 만들어서 반환합니다. 따라서 merge 호출 후 리턴값을 받아 사용해야 합니다. 예:

Member detached = new Member(1L, "ABC"); // 준영속 혹은 새 엔티티
detached.setName("DEF");
Member managed = em.merge(detached);

위에서 detached는 여전히 detached 상태이고, managed가 영속 상태 엔티티입니다. detached를 더 참조하지 말고, managed를 사용해야 합니다. merge는 복잡한 동작을 수반하므로, 가능하면 애플리케이션 설계 시 merge를 남발하지 않도록 하는 것이 좋습니다. 특히 동일한 식별자를 지닌 준영속 엔티티를 두 번 이상 merge하면 충돌이나 예외가 발생할 수 있으므로, 동일 PK에 대해 merge는 한 번만 일어나도록 관리하는 것이 중요합니다.

6.5 벌크 연산(Bulk Operation) 시 주의점

JPA의 JPQL을 사용하면 한 번의 쿼리로 여러 엔티티를 수정/삭제하는 벌크 연산(예: UPDATE Product p SET p.stock = 0)이 가능합니다. 벌크 연산은 영속성 컨텍스트를 거치지 않고 바로 데이터베이스에 반영되기 때문에, 주의해야 할 점이 있습니다:

  • 벌크 연산 전 flush: 영속성 컨텍스트에 아직 반영되지 않은 변경사항이 있다면, 벌크 연산 전에 flush하여 DB와 동기화를 맞춰야 합니다. 그렇지 않으면 메모리 상 상태와 DB 상태가 달라진 채로 벌크 연산이 수행되어 논리적 충돌이 생길 수 있습니다. (예를 들어 아직 flush 안 된 INSERT 엔티티는 벌크 UPDATE의 대상에서 빠질 수 있음)
  • 벌크 연산 후 영속성 컨텍스트 정리: 벌크 연산이 DB에 직접 적용되므로, 영속성 컨텍스트 내 캐시된 엔티티 정보는 오래된 상태가 됩니다. 따라서 벌크 연산 후 영속성 컨텍스트를 그대로 사용하면 잘못된 데이터를 읽게 될 수 있습니다. 이를 해결하려면 em.clear()를 호출하여 1차 캐시를 비우거나, 아니면 해당 엔티티를 em.detach()로 개별 분리하여 다시 조회해야 합니다. Spring Data JPA에서는 @Modifying(clearAutomatically=true) 옵션을 통해 자동 clear 처리할 수 있습니다.

예를 들어, 재고가 0인 상품을 일괄 삭제하는 코드를 생각해보겠습니다:

em.createQuery("DELETE FROM Product p WHERE p.stock = 0")
  .executeUpdate();

이 JPQL이 실행되면 조건에 맞는 Product 레코드를 모두 삭제하지만, 정작 애플리케이션에서는 여전히 삭제된 Product 엔티티 객체가 영속성 컨텍스트에 남아있을 수 있습니다. 이후 이 객체를 참조하면 DB에는 없어도 객체는 남아있는 일관성 문제가 발생합니다. 그러므로 위 쿼리 직후에는 em.clear()로 컨텍스트를 초기화하거나, 최소한 방금 삭제된 엔티티들을 em.remove() 또는 detach해주는 추가 작업이 필요합니다.

정리하면, 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 적용되므로 트랜잭션 내 일반 작업 흐름과 따로 놀게 된다는 점을 항상 염두에 두어야 합니다. 보통 벌크 연산은 성능을 위해 사용하는데, 사용 후 영속성 컨텍스트 정리를 확실히 해서 문제를 예방해야 합니다.


7. 실무 활용 팁 및 성능 최적화 방안

마지막으로 영속성 컨텍스트를 효과적으로 활용하기 위한 실무 팁과 성능 최적화 방법을 정리합니다.

  • 트랜잭션 범위를 최소화: 영속성 컨텍스트는 트랜잭션과 함께 생성/소멸하므로, 가능하면 트랜잭션 범위를 짧게 유지하여 오래된 컨텍스트가 메모리를 잡아먹는 것을 피하세요. 트랜잭션을 오래 유지하면 1차 캐시가 커지고, 지연 로딩 시 예기치 않은 쿼리가 늦게 수행되는 등 관리가 어려워집니다.
  • 대량 데이터 처리 시 Batch Flush: 앞서 언급했듯, 수많은 엔티티를 반복 저장하거나 수정할 때는 주기적으로 flush()와 clear()를 호출해 메모리 사용량을 억제하세요. 예컨대 10,000건을 처리해야 한다면 한 번에 하지 말고 500건 단위로 persist한 후 flush+clear를 호출하는 식으로 나누면 메모리 피크를 낮출 수 있습니다.
  • 읽기 전용 트랜잭션 활용: 데이터 변경이 전혀 없는 조회 전용 작업의 경우 @Transactional(readOnly=true)를 부여하면, Hibernate는 Flush 모드를 MANUAL로 최적화하여 flush(dirty check)를 생략하고 약간의 성능 향상을 이끌어냅니다. 또한 JDBC 드라이버에게 힌트를 주어 최적화할 수도 있습니다.
  • 지연 로딩과 필요 데이터 선별: 연관을 Lazy로 해두고, 필요한 경우에만 fetch join이나 EntityGraph 등을 사용해 가져오는 필요한 데이터만 로딩 전략이 권장됩니다. Eager는 편할 수 있으나 예상치 못한 N+1이나 성능 문제를 야기하기 쉽습니다. Lazy로 두되 서비스 계층에서 필요한 것만 적절히 초기화하는 패턴이 유지보수에 유리합니다.
  • N+1 문제 모니터링: 개발 단계나 테스트 단계에서 Hibernate의 SQL 로그나 P6Spy 같은 툴을 이용해 쿼리 발생 패턴을 모니터링하세요. N+1 문제가 발견되면 즉시 fetch join이나 Batch Size 조정을 통해 개선합니다. N+1은 작은 데이터에서는 드러나지 않다가 프로덕션에서 성능 병목을 일으킬 수 있으므로, 쿼리 로그 점검이 중요합니다.
  • 객체 동등성 vs 동일성: JPA는 한 컨텍스트 내 동일성을 보장하지만, 컨텍스트가 다르면 동일한 엔티티라도 서로 다른 객체입니다. 따라서 뷰 계층 등에서 넘어온 엔티티와 새로운 영속성 컨텍스트에서 조회한 엔티티를 섞어 사용할 경우 equals/hashCode 구현에 따라 오동작할 수 있습니다. Entity의 equals()는 식별자를 기준으로 하고, 동일성은 보장되지 않는다는 점을 이해해야 합니다. 일반적으로 한 트랜잭션 내에서는 == 비교도 가능하지만, 여러 트랜잭션에 걸쳐서는 식별자 비교로 로직을 구현해야 합니다.
  • 엔티티 조회 성능 개선: 대량 조회 시에는 JPA가 한번에 엔티티를 모두 생성하므로 메모리와 변환비용이 큽니다. 필요한 경우 스트림 처리 (하이버네이트 Scroll이나 하이버네이트 전용 fetchSize 세팅)를 고려하거나, DTO 프로젝션으로 필요한 데이터만 SELECT 하는 것도 방법입니다. 영속성 컨텍스트는 엔티티를 관리하는데 비용이 들기 때문에, 정말 읽기 전용 다량 데이터는 차라리 JPA를 통하지 않는 네이티브 쿼리나 JdbcTemplate 등을 쓰는 것도 검토 대상입니다.
  • 2차 캐시 사용: JPA 2차 캐시(Shared cache)는 트랜잭션 간 공유 캐시로서 자주 참조되는 엔티티를 메모리에 보관해 DB 부하를 줄입니다. 그러나 사용 시 일관성 관리를 신경써야 하므로 캐시 일관성에 민감하지 않은 데이터에 한해 활용합니다. 영속성 컨텍스트(1차 캐시)와는 용도가 다르며, 2차 캐시를 쓴다고 1차 캐시가 사라지지 않습니다. 둘 다 성격에 맞게 사용하면 좋습니다.
  • 엔티티 생명주기 콜백 활용: 영속성 컨텍스트의 엔티티 상태 변화에 반응해야 할 때(JPA 이벤트), @PrePersist, @PostPersist, @PreUpdate, @PostLoad 등의 콜백 어노테이션을 사용할 수 있습니다. 이를 통해 엔티티가 저장되거나 로딩될 때 추가 로직(예: 기본 값 설정이나 로그 출력)을 자동 수행하게 할 수 있습니다. 다만 비즈니스 로직은 서비스 계층에 두고, 콜백에는 부가 기능만 넣는 것을 권장합니다.

이상으로 영속성 컨텍스트의 개념과 작동 원리를 깊이 있게 살펴보았습니다. 정리하면, 영속성 컨텍스트는 엔티티를 모아 관리하는 1차 캐시이며, 트랜잭션과 함께 생명주기를 가지면서 엔티티의 동일성 보장, 변경 감지, 지연 로딩 등을 지원합니다. EntityManager를 통해 영속성 컨텍스트와 상호작용하며, 올바른 메서드 사용으로 엔티티 생명주기를 제어해야 합니다. 이러한 JPA의 동작 원리를 제대로 이해하면, 효율적인 데이터 액세스 계층을 구현하고 성능 문제를 미연에 방지할 수 있습니다. JPA/Hibernate의 내부 동작을 알고 쓰는 것과 모르고 쓰는 것의 차이는 상당하므로, 본 글의 내용을 바탕으로 Spring JPA 학습에 도움이 되길 바랍니다.

profile
Search Engineer

0개의 댓글