MySQL 공부 5 - JPA 영속성 컨텍스트와 Proxy, Lazy Loading

Chu Sang Yoon·2026년 3월 18일

MySQL

목록 보기
5/9

MySQL 공부 5 - JPA 영속성 컨텍스트와 Proxy, Lazy Loading

4편에서 격리 수준, Lock, B+Tree를 다뤘다. 이번 편은 레이어를 한 단계 올려서 JPA가 어떻게 DB 접근을 최적화하는지, 그리고 Proxy와 Lazy Loading이 왜 존재하는지를 다룬다.


영속성 컨텍스트란

엔티티를 영구 저장하는 환경이다. 애플리케이션과 데이터베이스 사이의 중간 계층으로, 엔티티의 생명주기를 관리하는 논리적 공간이다.

┌─────────────────────────────────────────┐
│         Application (Java Code)         │
└────────────────┬────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────┐
│      Persistence Context (1차 캐시)      │
│  ┌───────────────────────────────────┐  │
│  │ Entity 객체들 (메모리)             │  │
│  │ - User(id=1)                      │  │
│  │ - Order(id=100)                   │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 쓰기 지연 SQL 저장소               │  │
│  │ - INSERT INTO users...            │  │
│  │ - UPDATE orders...                │  │
│  └───────────────────────────────────┘  │
│  ┌───────────────────────────────────┐  │
│  │ 스냅샷 저장소 (변경 감지용)        │  │
│  │ - User(id=1) 최초 상태            │  │
│  └───────────────────────────────────┘  │
└────────────────┬────────────────────────┘
                 │ Flush 시점에만
                 ▼
┌─────────────────────────────────────────┐
│              Database (MySQL)           │
└─────────────────────────────────────────┘

핵심 요소 3가지

1차 캐시 (First Level Cache)

같은 트랜잭션 내에서 동일한 엔티티를 두 번 조회하면 두 번째는 SQL 없이 메모리에서 반환한다.

@Test
public void testFirstLevelCache() {
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();

    System.out.println("=== 첫 번째 조회 ===");
    User user1 = em.find(User.class, 1L);
    // SQL: SELECT * FROM users WHERE id = 1

    System.out.println("=== 두 번째 조회 ===");
    User user2 = em.find(User.class, 1L);
    // SQL 실행 안 됨! (1차 캐시에서 조회)

    System.out.println("user1 == user2: " + (user1 == user2));
    // 결과: true (같은 객체!)

    em.getTransaction().commit();
    em.close();
}
=== 첫 번째 조회 ===
Hibernate: select ... from users where id=?
User: Alice

=== 두 번째 조회 ===
User: Alice  ← SQL 없음!

user1 == user2: true
  • 같은 트랜잭션 내에서만 유효
  • EntityManager 종료 시 사라짐
  • 같은 ID면 같은 객체(동일성 보장)
  • DB 접근 최소화

내부 구조는 Map<EntityKey, Object> 다. EntityKey는 엔티티 타입 + ID의 조합이다.

// 내부 구조 (간소화)
class StatefulPersistenceContext {
    Map<EntityKey, Object> entitiesByKey = new HashMap<>();
    // EntityKey("User", 1L) → User 객체
}

쓰기 지연 (Write-Behind)

persist()를 호출해도 즉시 SQL이 실행되지 않는다. 커밋 시점에 한 번에 실행한다.

@Test
public void testWriteBehind() {
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();

    System.out.println("=== persist 호출 (1) ===");
    em.persist(new User("Alice", "alice@test.com"));
    // SQL 실행 안 됨!

    System.out.println("=== persist 호출 (2) ===");
    em.persist(new User("Bob", "bob@test.com"));
    // SQL 실행 안 됨!

    System.out.println("=== persist 호출 (3) ===");
    em.persist(new User("Charlie", "charlie@test.com"));
    // SQL 실행 안 됨!

    System.out.println("=== 커밋 ===");
    em.getTransaction().commit(); // 여기서 한 번에 실행!
    em.close();
}
=== persist 호출 (1) ===
=== persist 호출 (2) ===
=== persist 호출 (3) ===
=== 커밋 ===
Hibernate: insert into User (email, name, id) values (?, ?, ?)
Hibernate: insert into User (email, name, id) values (?, ?, ?)
Hibernate: insert into User (email, name, id) values (?, ?, ?)
  • 네트워크 왕복 최소화
  • JDBC 배치 처리 가능
  • 트랜잭션 일관성 (모두 성공 or 모두 실패)

JDBC 배치 설정:

spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

배치 효과: 100건 삽입 기준 → 배치 없이 100번 왕복 ~1000ms, 배치(50) 2번 왕복 ~20ms.


변경 감지 (Dirty Checking)

엔티티를 수정할 때 em.update() 같은 메서드를 호출하지 않아도 자동으로 UPDATE SQL이 생성된다.

@Test
public void testDirtyChecking() {
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();

    User user = em.find(User.class, 1L);
    // 1차 캐시에 저장 + 스냅샷 저장

    user.setName("Alice Updated");
    // em.update(user) 같은 거 호출 안 함!

    em.getTransaction().commit();
    // Flush 시점에 변경 감지 → UPDATE SQL 자동 생성

    em.close();
}
Hibernate: select ... from users where id=?
조회한 이름: Alice

=== 엔티티 수정 ===

Hibernate: update users set email=?, name=? where id=?

동작 과정:

  1. em.find() → 1차 캐시에 객체 저장 + 스냅샷 복사본 저장
  2. setName() → 1차 캐시의 객체만 변경. 스냅샷은 그대로.
  3. commit()flush() 호출
// flush() 내부 (간소화)
for (entity in managedEntities) {
    if (entity != snapshot) {
        scheduleUpdate(entity);  // UPDATE SQL 생성
    }
}
actionQueue.executeActions();  // 실행

Flush — 동기화 시점

영속성 컨텍스트의 변경 내용을 DB에 반영하는 것. 1차 캐시를 지우는 게 아니라 쓰기 지연 SQL을 실행하는 것이다.

발생 시점:
1. 트랜잭션 커밋 시 → em.getTransaction().commit()
2. JPQL 쿼리 실행 직전 → 일관성 보장
3. 강제 호출 → em.flush()


내부 구조 — Hibernate Session

EntityManager (JPA 표준 인터페이스)
        │ 구현
        ▼
SessionImpl (Hibernate 구현체)
  ├─ StatefulPersistenceContext  ← 1차 캐시, 스냅샷
  ├─ ActionQueue                  ← 쓰기 지연 SQL
  └─ TransactionCoordinator       ← 트랜잭션 조율

EntityManager는 JPA가 정한 표준 인터페이스다. 구현체에 대한 의존성을 없애기 위해 인터페이스만 존재한다. Hibernate의 SessionImplEntityManagerSession을 모두 구현한 실제 클래스다.

ActionQueue — SQL 실행 순서

쓰기 지연 SQL은 순서를 지켜야 한다. ActionQueue가 이를 관리한다.

Flush 시점의 실행 순서:
1. OrphanRemoval (고아 객체 삭제)
2. INSERT
3. UPDATE
4. 컬렉션 UPDATE
5. 컬렉션 REMOVE
6. DELETE

외래 키 제약 조건을 지키기 위해 INSERT가 DELETE보다 먼저, 부모가 자식보다 먼저 처리된다.

전체 트랜잭션 생명주기

1. 트랜잭션 시작
   em.getTransaction().begin()
   → JDBC: connection.setAutoCommit(false)

2. 엔티티 조회
   StatefulPersistenceContext.addEntity()
   → 1차 캐시 저장 + 스냅샷 저장

3. 엔티티 수정
   → 메모리에서만 변경 (currentState 변경)

4. 커밋
   a. session.flush()       → Dirty Checking + SQL 실행
   b. connection.commit()   → 트랜잭션 커밋
   c. afterTransactionCompletion() → 통계 업데이트, 리소스 정리

5. EntityManager 종료
   em.close()
   → StatefulPersistenceContext 정리
   → 1차 캐시 소멸

Proxy와 Lazy Loading

문제 상황

@Entity
public class User {
    @OneToMany(mappedBy = "user")
    private List<Order> orders;
}

User를 조회할 때 orders까지 항상 같이 가져오면 불필요한 데이터까지 로딩하게 된다. 그래서 JPA는 기본적으로 @OneToMany를 LAZY로 처리한다.

EAGER vs LAZY:

EAGER: SELECT * FROM users WHERE id = 1;
       SELECT * FROM orders WHERE user_id = 1;  ← 무조건 같이 조회

LAZY:  SELECT * FROM users WHERE id = 1;  ← User만 조회
       orders는 Proxy로 대체
       → 실제 사용할 때만 SELECT!

Proxy — 가짜 객체

Hibernate가 LAZY 관계에 대해 반환하는 껍데기 객체다.

진짜 객체                     Proxy 객체
Order                         Order$HibernateProxy
  id: 100                       id: 100        ← ID만 있음
  amount: 50000                 amount: ???    ← 비어있음
  user: [User]                  target: null   ← 나중에 채워짐

Hibernate는 ByteBuddy로 원본 클래스를 상속한 서브클래스를 런타임에 동적으로 생성한다.

// 개념적으로 이런 클래스가 자동 생성됨
class Order$HibernateProxy extends Order {

    private Order target;
    private boolean initialized = false;

    @Override
    public Integer getAmount() {
        if (!initialized) {
            target = loadFromDatabase();  // 이 시점에 SQL 실행!
            initialized = true;
        }
        return target.getAmount();
    }
}

getId()는 이미 ID를 갖고 있으니 DB 접근 없이 바로 반환한다. 나머지 메서드는 처음 호출 시 DB에서 실제 데이터를 로딩한다.


LazyInitializationException — 왜 터지는가

User user;

{
    EntityManager em = emf.createEntityManager();
    em.getTransaction().begin();
    user = em.find(User.class, 1L);
    // user.orders = PersistentBag (Proxy)
    em.getTransaction().commit();
    em.close();  // ← Session 종료!
}

// Session이 없는 상태에서
int size = user.getOrders().size();
// 💥 LazyInitializationException!

흐름을 따라가면:
1. getOrders().size() 호출
2. PersistentBag (Proxy)가 초기화 시도
3. DB 접근을 위해 Session 필요
4. session.isOpen() = false (이미 em.close()로 닫힘)
5. LazyInitializationException 발생


해결 방법 4가지

방법 1 — 트랜잭션 내에서 초기화

EntityManager em = emf.createEntityManager();
em.getTransaction().begin();

User user = em.find(User.class, 1L);

// 트랜잭션 내에서 강제 초기화
Hibernate.initialize(user.getOrders());  // 또는 user.getOrders().size()

em.getTransaction().commit();
em.close();

// 이제 안전
System.out.println(user.getOrders().size());
  • 장점: 로딩 시점을 개발자가 완벽히 제어
  • 단점: 코드 곳곳에 initialize() 호출로 난잡해짐
  • 사용 시기: fetch join 시 row 폭발이 우려될 때, 특정 조건에서만 로딩해야 할 때

방법 2 — Fetch Join

User user = em.createQuery(
    "SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id",
    User.class
).setParameter("id", 1L).getSingleResult();

한 번의 쿼리로 User + Orders를 모두 조회한다. Orders가 이미 로딩된 상태이므로 Proxy가 아니다.

  • 장점: N+1 문제 해결, SQL 1번으로 필요한 데이터 모두
  • 단점: @OneToMany fetch join + 페이징 불가, 중복 row 증가, 여러 컬렉션 동시 fetch join 불가
  • 사용 시기: 특정 화면에서 연관 데이터가 반드시 필요할 때, N+1 해결이 필요할 때

방법 3 — @EntityGraph

@EntityGraph(attributePaths = {"orders"})
Optional<User> findById(Long id);

JPQL 없이 fetch join 효과를 얻는다. 내부적으로 fetch join과 동일한 SQL을 생성한다.

  • 장점: JPQL 없이 선언적으로 로딩 전략 지정, 메서드 단위로 전략 변경 가능
  • 단점: 복잡한 조건(JOIN ON, 서브쿼리) 불가, 여러 컬렉션 동시 로딩 여전히 불가
  • 사용 시기: Repository 메서드에서 JPQL 쓰기 싫을 때, 유연한 로딩 전략이 필요할 때

방법 4 — Open Session In View

spring.jpa.open-in-view=true  # 기본값

HTTP 요청 시작부터 종료까지 Session을 유지해서 Controller에서도 Lazy Loading이 가능하게 한다.

  • 장점: LazyInitializationException이 사라짐
  • 단점: DB 커넥션을 요청당 1개씩 계속 점유 → 트래픽이 많은 시스템에서 Connection 부족 → 실무에서는 false 권장

LAZY vs EAGER 정리

LAZYEAGER
로딩 시점실제 사용 시조회 즉시
기본 적용@OneToMany, @ManyToMany@ManyToOne, @OneToOne
장점불필요한 로딩 없음, 메모리 절약LazyInitializationException 없음
단점LazyInitializationException 위험, N+1 가능불필요한 데이터 로딩, N+1 더 심각
사용 시기사용 여부가 불확실할 때, 데이터 많을 때항상 함께 사용하는 데이터, 적은 데이터

실무에서 LazyInitializationException을 본 적이 없는 이유

@Transactional 덕분이다.

@Transactional 범위:
  트랜잭션 시작
  → Hibernate Session 열기
  → 데이터 조회 및 Lazy 로딩 처리
  → 트랜잭션 종료 시 Flush & Close

@Transactional이 붙은 서비스 메서드가 실행되는 동안 Session이 항상 열려있다. Lazy 로딩이 필요한 시점에도 Session이 살아있으니 예외가 발생하지 않는다.

그리고 Controller → Service → Repository의 계층 분리 덕분에, Controller는 DTO만 받고 Service 내부에서 모든 Lazy 접근이 완료된다. Session이 닫힌 후 Lazy 필드에 접근할 일 자체가 없다.

💡 : open-in-view=true (기본값)도 한몫을 한다. HTTP 요청 전체에 Session이 열려있어서 View 레이어에서도 Lazy 접근이 가능하다. 하지만 이는 DB 커넥션을 요청 전체 동안 점유하기 때문에 실무에서는 false로 설정하고, 서비스 레이어에서 필요한 데이터를 모두 DTO로 변환해서 반환하는 게 안전하다.


마치며

영속성 컨텍스트의 세 가지 핵심을 다시 정리하면:

  • 1차 캐시: 같은 트랜잭션 내 반복 조회를 메모리에서 처리해서 DB 접근을 줄인다.
  • 쓰기 지연: persist()를 모아뒀다가 커밋 시점에 한 번에 실행해서 네트워크 왕복을 최소화한다.
  • 변경 감지: 스냅샷과 비교해서 변경된 필드만 UPDATE SQL을 자동 생성한다.

그리고 Proxy는 LAZY 로딩을 가능하게 하는 핵심 메커니즘이다. Session이 열려있는 동안은 안전하지만, Session이 닫힌 후 Lazy 필드에 접근하면 LazyInitializationException이 발생한다. 이를 해결하는 방법은 상황에 따라 트랜잭션 내 초기화, Fetch Join, @EntityGraph 중 적절한 것을 골라 쓰면 된다.

0개의 댓글