스프링 부트와 JPA를 사용하다 보면 개발이 편해진다. SQL을 직접 짤 필요도 없고, 그저 코드로 작성하면 데이터가 저장된다.
MyBatis나 직접 SQL을 작성하던 시절에는 모든 것이 명확했을 것이다. INSERT 쿼리를 실행하면, 정확히 그 시점에 DB에 데이터가 들어갔다. 하지만 JPA는 다르다.
분명 데이터를 수정했는데 update() 메서드를 호출하지 않아도 DB가 변경되고, save()를 호출했는데도 즉시 INSERT 쿼리가 나가지 않는다.
이것을 이해하려면, 그리고 JPA를 이해하려면 영속성 컨텍스트를 알아야한다.
이번 글에서는 JPA가 왜 영속성 컨텍스트라는 중간 단계를 두엇는지, 이로 인해 얻는 이점은 무엇인지 알아보겠다.
영속성 컨텍스트란?
해석하면 엔티티를 영구 저장하는 환경이다. JPA를 사용해 엔티티(객체)를 데이터베이스에 저장하기 전에 항상 이 영속성 컨텍스트라는 곳에 먼저 저장해야 한다.
다만, 영속성 컨텍스트에 엔티티를 저장한다고 해서 데이터베이스에 저장되는 것은 아니다.
영속성 컨텍스트를 이해하기 전에 먼저 엔티티의 생명주기를 알아보자.
Entity의 생명주기

Entity는 EntityManager에 의해 4가지 상태로 관리된다.
1. 비영속 (New/Transient)
- 객체를 new로 생성만 한 상태. 영속성 컨텍스트와 관계없다. JPA는 이 객체의 존재를 모른다. DB와도 무관하다.
// 비영속 상태
Member member = new Member();
member.setId(1L);
member.setName("회원1");
2. 영속 (Managed)
- 영속성 컨텍스트에 저장되어 관리되는 상태.
- 이때부터 JPA가 객체를 지켜보기 시작한다. 1차 캐시에 저장되고, 변경 감지(Dirty Checking)의 대상이 된다.
// 객체를 생성한 후 (비영속)
em.persist(member); // 영속성 컨텍스트에 저장 (영속)
3. 준영속 (Detached)
- 영속성 컨텍스트에 저장되었다가 분리된 상태.
- em.detach(), em.clear(), em.close() 등을 호출하거나 트랜잭션이 종료되었을 때 발생한다.
- 영속 상태였지만 더 이상 JPA가 관리하지 않는다. 따라서 값을 수정해도 변경 감지가 동작하지 않아 DB에 반영되지 않는다.
4. 삭제 (Removed)
- 삭제하기로 마킹된 상태로 em.remove() 호출 시점
- 실제 DB 삭제는 트랜잭션이 커밋(flush)되는 시점에 일어난다.
왜 이렇게 복잡하게 나누었을까?
엔티티들을 데이터베이스에 바로 반영하지 않고 굳이 생명주기를 나누고 영속성 컨텍스트라는 공간에서 관리하는 이유가 있다.
영속성 컨텍스트의 이점
1. 1차캐시
영속성 컨텍스트는 내부에 엔티티를 저장할 수 있는 캐시를 가지고 있는데 이를 1차 캐시라고 한다. 엔티티를 처음 영속성 컨텍스트에 저장하면(영속 상태로 만들면) 1차 캐시에 엔티티가 저장된다.
// 비영속 상태
Member member = new Member();
member.setId(1L);
member.setName("회원1");
em.persist(member) // 영속성 컨텍스트에 저장

내부적으로 Map<Key, Entity> 형태를 가진다.
만약 1차 캐시에 조회하려는 엔티티가 있다면, DB를 거치지 않고 메모리 상에서 바로 조회할 수 있다. (성능 증가)
만약 1차 캐시에 없는 엔티티를 조회면 어떻게 될까?
Member member2 = em.find(Member.class, "member2");

- @Id 값이 member2인 Member 엔티티가 1차 캐시에 있는지 조회한다.
- 1차 캐시에 없으므로 데이터베이스에서 조회한다.
- 조회한 데이터로 엔티티를 생성해 영속성 컨텍스트의 1차 캐시에 저장한다.
- 조회한 엔티티를 반환한다.
즉, 1차 캐시에 없는 엔티티는 데이터베이스에서 조회한 후, 새롭게 영속 상태로 만들어 관리한다.
이렇게 1차 캐시의 도움을 받으면, 데이터베이스에 접근해야 할 횟수를 줄일 수 있으므로 성능상 이점이 있다.
2. 쓰기 지연
em.persist()를 한다고 바로 DB에 INSERT를 날리지 않는다. 쓰기 지연 SQL 저장소에 쌓아뒀다가, 트랜잭션이 커밋(flush)되는 순간 한 번에 보낸다.

장점은 다음과 같다.
- 네트워크 Latency 감소: 쿼리를 100번 날리면 네트워크 왕복(RTT)이 100번 발생. 이를 모아서(Batch) 한 번에 보내면 RTT가 1번으로 줄어듦
- DB 락 시간 최소화: 트랜잭션 커밋 직전에 쿼리를 쏟아부으므로, DB 테이블에 락이 걸리는 시간을 최소화하여 DB의 동시성 처리 능력을 높임.
3. 변경 감지 (Dirty Checking)
스냅샷과 현재 상태를 비교하여 UPDATE SQL을 자동 생성한다.
JPA에서는 엔티티를 수정할 때 따로 update()같은 메서드가 필요 없다. 단지 영속성 컨텍스트 안의 엔티티를 수정하기만 하면, 엔티티의 변경사항을 데이터베이스에 자동으로 반영해 준다.
JPA는 엔티티를 영속성 컨텍스트에 보관할 때, 최초 상태를 복사해서 스냅샷으로 저장한다. 그러다 플러시flush()가 호출되는 시점에 스냅샷과 엔티티를 비교해서 변경된 엔티티를 찾는다.
flush()란?
플러시는 영속성 컨텍스트의 변경 내용을 데이터베이스에 동기화하는 작업이다.
플러시 발생 시 일어나는 일
- 변경 감지: 모든 엔티티와 스냅샷을 비교한다.
- 쿼리 등록: 수정된 엔티티가 있으면 UPDATE 쿼리를 만들어 쓰기 지연 SQL 저장소에 등록한다.
- 전송: 쓰기 지연 SQL 저장소의 모든 쿼리(INSERT, UPDATE, DELETE)를 DB로 전송한다.
플러시를 호출하는 3가지 방법
- 직접 호출: em.flush() (테스트 등에서 사용)
- 트랜잭션 커밋 시 자동 호출: (가장 일반적)
- JPQL 쿼리 실행 시 자동 호출: (JPQL 실행 전에 DB와 싱크를 맞춰야 올바른 결과를 조회할 수 있으므로)
4. 지연 로딩
엔티티를 조회할 때, 연관된 객체들을 즉시 SQL로 조인해서 가져오지 않고, 실제 사용할 때까지 로딩을 미루는 기술이다. (위의 장점들은 영속성 컨텍스트가 직접 수행하는 기능이라면 지연 로딩은 영속성 컨텍스트가 기반이 되어 가능한 기능이라고 보면 된다.)
핵심은 데이터베이스 조회 시점의 분리이다.
이를 위해 JPA는 프록시(Proxy) 기술을 사용한다.
프록시(Proxy)의 동작 원리
가장 흔한 예시인 Member(회원)와 Team(팀)이 N:1 관계라고 가정해보자. (Member가 Team을 참조)
1. 가짜 객체 주입 (Injection)
개발자가 em.find(Member.class, 1L)을 호출하여 회원을 조회할 때, JPA는 DB에서 회원 정보만 가져온다. 이때 Team 필드는 비워둘 수 없으므로, 실제 Team 객체 대신 프록시(Proxy)라고 불리는 가짜 객체를 채워 넣는다.
- 이 프록시 객체는 Team 클래스를 상속받아 만들어지므로 겉보기엔 진짜와 똑같다. (클래스명 예: Team$HibernateProxy...)
- 하지만 내부에는 데이터가 없고 비어있다.
2. 초기화 (Initialization) 개발자가 member.getTeam()을 호출할 때까지는 아무 일도 일어나지 않는다. 하지만 member.getTeam().getName() 처럼 실제 데이터를 사용하는 시점이 되면, 프록시 객체는 영속성 컨텍스트에 초기화를 요청한다.
- 요청: 영속성 컨텍스트에 위임
- 조회: 영속성 컨텍스트가 DB 조회(SELECT * FROM Team...)를 수행한다.
- 연결: 실제 엔티티 객체를 생성하고, 프록시 객체가 이 실제 객체를 가리키도록 연결한다.
여기서 영속성 컨텍스트의 존재 이유가 드러난다. 프록시는 단순한 껍데기일 뿐 DB 연결 정보가 없다. DB를 조회하고 엔티티를 생성하는 건 영속성 컨텍스트만 할 수 있는 일이다.
따라서 영속성 컨텍스트가 닫힌(준영속) 상태에서 지연 로딩을 시도하면, 도움을 받을 곳이 없어서 LazyInitializationException 예외가 발생하는 것이다.
지연 로딩의 장점
굳이 이렇게 복잡한 프록시를 써가며 지연 로딩을 하는 이유는 명확하다.
1. 초기 로딩 비용 감소 (불필요한 조인 방지): 단순히 회원의 이름만 필요한 로직에서, 굳이 팀 정보까지 묶어서(Join) 가져올 필요가 없다. 지연 로딩을 사용하면 최초 쿼리가 가벼워지고, 네트워크 트래픽과 DB 연산 비용을 아낄 수 있다.
2. 비즈니스 로직에 따른 유연한 데이터 로딩: 어떤 로직에서는 회원만 필요하고, 어떤 로직에서는 팀도 같이 필요할 수 있다.
- 지연 로딩 미사용 시: findMember(), findMemberWithTeam() 처럼 메서드를 상황별로 쪼개야 한다.
- 지연 로딩 사용 시: 그냥 findMember() 하나만 호출하고, 팀 정보가 필요한 경우에만 getTeam()을 호출하면 된다. 즉, 데이터 로딩 시점을 쿼리 단계가 아닌 비즈니스 로직 단계에서 결정할 수 있게 된다.
3. 메모리 효율성: 사용하지 않을 데이터를 메모리에 적재하지 않으므로, 애플리케이션의 메모리 리소스를 효율적으로 사용할 수 있다.
마치며
우리는 흔히 @Entity를 붙여 DB와 객체를 매핑하는 것에만 집중하곤 한다. 하지만 정작 애플리케이션을 움직이는 실체는 매핑 정보가 아니라, 엔티티의 상태를 실시간으로 추적하고 관리하는 영속성 컨텍스트다.
이 글을 통해 영속성 컨텍스트가 단순한 저장소가 아니라, 엔티티의 탄생(비영속)부터 관리(영속), 그리고 분리(준영속)까지의 생명주기를 책임지는 핵심 엔진임을 확인했다.
그런데 우리는 그저 클래스 위에 @Entity 한 줄을 붙였을 뿐인데, 도대체 누가 이 영속성 컨텍스트를 생성하고 관리하는 걸까?
다음 글에서는 어노테이션 처리 과정과 내부 동작 과정을 알아보겠다.
'Java' 카테고리의 다른 글
| @Entity를 붙이면 일어나는 일 - JPA 내부 동작 원리 (0) | 2026.02.01 |
|---|---|
| [우아한테크코스] 출석부 (1) | 2025.05.29 |
| [우아한테크코스] 로또 (2) | 2025.05.02 |
| [우아한테크코스] 자동차 경주 (0) | 2025.04.29 |