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) │
└─────────────────────────────────────────┘
같은 트랜잭션 내에서 동일한 엔티티를 두 번 조회하면 두 번째는 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
내부 구조는 Map<EntityKey, Object> 다. EntityKey는 엔티티 타입 + ID의 조합이다.
// 내부 구조 (간소화)
class StatefulPersistenceContext {
Map<EntityKey, Object> entitiesByKey = new HashMap<>();
// EntityKey("User", 1L) → User 객체
}
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 배치 설정:
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.
엔티티를 수정할 때 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=?
동작 과정:
em.find() → 1차 캐시에 객체 저장 + 스냅샷 복사본 저장setName() → 1차 캐시의 객체만 변경. 스냅샷은 그대로.commit() → flush() 호출// flush() 내부 (간소화)
for (entity in managedEntities) {
if (entity != snapshot) {
scheduleUpdate(entity); // UPDATE SQL 생성
}
}
actionQueue.executeActions(); // 실행
영속성 컨텍스트의 변경 내용을 DB에 반영하는 것. 1차 캐시를 지우는 게 아니라 쓰기 지연 SQL을 실행하는 것이다.
발생 시점:
1. 트랜잭션 커밋 시 → em.getTransaction().commit()
2. JPQL 쿼리 실행 직전 → 일관성 보장
3. 강제 호출 → em.flush()
EntityManager (JPA 표준 인터페이스)
│ 구현
▼
SessionImpl (Hibernate 구현체)
├─ StatefulPersistenceContext ← 1차 캐시, 스냅샷
├─ ActionQueue ← 쓰기 지연 SQL
└─ TransactionCoordinator ← 트랜잭션 조율
EntityManager는 JPA가 정한 표준 인터페이스다. 구현체에 대한 의존성을 없애기 위해 인터페이스만 존재한다. Hibernate의 SessionImpl이 EntityManager와 Session을 모두 구현한 실제 클래스다.
쓰기 지연 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차 캐시 소멸
@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!
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에서 실제 데이터를 로딩한다.
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 발생
방법 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() 호출로 난잡해짐방법 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가 아니다.
@OneToMany fetch join + 페이징 불가, 중복 row 증가, 여러 컬렉션 동시 fetch join 불가방법 3 — @EntityGraph
@EntityGraph(attributePaths = {"orders"})
Optional<User> findById(Long id);
JPQL 없이 fetch join 효과를 얻는다. 내부적으로 fetch join과 동일한 SQL을 생성한다.
방법 4 — Open Session In View
spring.jpa.open-in-view=true # 기본값
HTTP 요청 시작부터 종료까지 Session을 유지해서 Controller에서도 Lazy Loading이 가능하게 한다.
LazyInitializationException이 사라짐false 권장| LAZY | EAGER | |
|---|---|---|
| 로딩 시점 | 실제 사용 시 | 조회 즉시 |
| 기본 적용 | @OneToMany, @ManyToMany | @ManyToOne, @OneToOne |
| 장점 | 불필요한 로딩 없음, 메모리 절약 | LazyInitializationException 없음 |
| 단점 | LazyInitializationException 위험, N+1 가능 | 불필요한 데이터 로딩, N+1 더 심각 |
| 사용 시기 | 사용 여부가 불확실할 때, 데이터 많을 때 | 항상 함께 사용하는 데이터, 적은 데이터 |
@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로 변환해서 반환하는 게 안전하다.
영속성 컨텍스트의 세 가지 핵심을 다시 정리하면:
persist()를 모아뒀다가 커밋 시점에 한 번에 실행해서 네트워크 왕복을 최소화한다.그리고 Proxy는 LAZY 로딩을 가능하게 하는 핵심 메커니즘이다. Session이 열려있는 동안은 안전하지만, Session이 닫힌 후 Lazy 필드에 접근하면 LazyInitializationException이 발생한다. 이를 해결하는 방법은 상황에 따라 트랜잭션 내 초기화, Fetch Join, @EntityGraph 중 적절한 것을 골라 쓰면 된다.