자바 백엔드 입문 51편. JPA의 가장 어려운 개념 영속성 컨텍스트와 LazyLoading. 1차 캐시·변경 감지·N+1 함정과 회피 패턴을 도서관 사서 비유로 풀어쓴 Phase 6.5 마무리 글.
이 글은 자바 백엔드 입문 시리즈 59편 중 51편이에요. Phase 6.5 JPA의 마지막 글이고, JPA의 가장 어려운 개념인 영속성 컨텍스트와 LazyLoading을 풀어 가요. 잘하는 자바 백엔드 개발자와 못하는 사람을 가르는 핵심 단원.
영속성 컨텍스트가 어렵게 들리는 이유
처음 JPA를 배우면 — "save() 안 했는데 DB에 반영됐다", "findById 같은 쿼리를 두 번 호출했는데 SQL은 한 번만 나갔다", "트랜잭션 끝난 후 getUser() 호출하니 예외가 났다" 같은 "이상한 동작" 이 자꾸 발생해요. 이게 다 영속성 컨텍스트라는 보이지 않는 메커니즘 때문.
이 글에서는 도서관 사서 비유로 풀어요. 영속성 컨텍스트 = "한 손님이 빌린 책들을 보관·관리하는 사서의 작업 책상". 끝까지 따라오시면 JPA의 가장 큰 함정 3개 (N+1·LazyLoading 예외·Detached 객체)가 한 그림에 들어와요.
영속성 컨텍스트 — JPA의 1차 캐시
영속성 컨텍스트(Persistence Context) 는 "JPA가 관리하는 Entity들을 보관하는 메모리 공간" 이에요. 트랜잭션 안에서만 살아있어요.
핵심 동작 — 같은 트랜잭션 안에서 같은 PK로 조회하면, 두 번째 호출은 DB에 안 가고 메모리에서 반환.
@Transactional
public void example() {
Order o1 = orderRepo.findById(1L).get(); // 1번 SQL
Order o2 = orderRepo.findById(1L).get(); // SQL 안 나감 — 메모리에서
System.out.println(o1 == o2); // true (같은 객체)
}
비유 — 같은 손님이 한 책을 두 번째 빌리겠다고 하면 사서가 "아 그 책 이미 들고 계셨네요" 하고 다시 안 가져옴. 첫 번째 조회 결과를 "내 책상에 보관" 하고 있다가 두 번째 요청에 즉시 반환.
변경 감지 (Dirty Checking) — save() 없이 자동 UPDATE
영속성 컨텍스트의 두 번째 마법. 트랜잭션 안에서 Entity 필드를 변경하면 — save() 안 호출해도 자동으로 UPDATE.
@Transactional
public void updateAmount(Long id, int newAmount) {
Order order = orderRepo.findById(id).get();
order.setAmount(newAmount);
// save() 안 했는데 ... 트랜잭션 끝날 때 자동 UPDATE!
}
"왜?" — 영속성 컨텍스트가 처음 조회 시 "이 Entity의 초기 상태" 를 스냅샷으로 보관해두고, 트랜잭션 커밋 직전에 "현재 상태와 비교" 해 변경된 부분을 자동 UPDATE 발행. 이걸 변경 감지(Dirty Checking) 또는 Dirty Tracking.
비유 — 사서가 "손님이 책을 빌릴 때 책 상태를 메모해둠". 손님이 책에 줄을 그어 돌려주면 — "아, 이 책이 변경됐구나" 알아채고 시스템에 자동 반영.
이게 처음 만나면 "마법" 이지만 — 익숙해지면 "왜 save 호출 안 했는데 DB가 바뀌었지?" 가 디버깅 함정이 되기도 해요. 의도하지 않은 변경 감지가 작동하는 경우.
Entity 상태 4가지
영속성 컨텍스트 관점에서 Entity는 4가지 상태로 분류돼요.
| 상태 | 의미 |
|---|---|
| Transient | new Order() 직후 — JPA가 모르는 객체 |
| Managed (영속) | save() 호출 후 또는 findById() 결과 — 영속성 컨텍스트가 관리 중 |
| Detached (준영속) | 트랜잭션 종료 후 — JPA 관리 떠난 객체 |
| Removed | delete() 호출 후 — 삭제 예약된 객체 |
// Transient
Order o = new Order(...);
// Managed (영속) 상태로
orderRepo.save(o); // 또는 EntityManager.persist(o)
// 트랜잭션 종료
// → o는 Detached 상태
o.setAmount(99999); // 메모리에서만 바뀜, DB 반영 X
LazyLoading 예외(LazyInitializationException) 가 발생하는 정확한 원인 — Detached 상태의 Entity에서 "아직 로딩 안 한 연관 필드" 접근 시.
LazyLoading — 필요할 때만 조회
JPA Entity 사이에 연관관계(예: Order → User)가 있을 때, 두 가지 로딩 전략.
@Entity
public class Order {
@Id private Long id;
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 (기본)
private User user;
}
FetchType.LAZY(기본) —order.getUser()호출하는 시점에 SQL 발행FetchType.EAGER— Order 조회 시점에 User까지 즉시 SQL로 조회
기본이 Lazy인 이유 — 성능. 항상 user 정보가 필요한 건 아닌데 매번 JOIN하면 비용 큼. "필요할 때만" 이 효율적.
다만 함정도 여기서 시작.
LazyInitializationException — 가장 자주 만나는 예외
위 Order의 user 가 Lazy일 때.
// Service 메서드
@Transactional
public Order findById(Long id) {
return orderRepo.findById(id).get(); // user는 아직 로딩 X
}
// Controller
@GetMapping("/{id}")
public OrderResponse get(@PathVariable Long id) {
Order order = orderService.findById(id); // 트랜잭션 종료
return OrderResponse.from(order); // 여기서 order.getUser() 호출 시 ...
// ❌ LazyInitializationException 폭발!
}
이유 — findById() 메서드의 @Transactional 이 끝나면 영속성 컨텍스트가 닫혀요. 그 후 컨트롤러에서 order.getUser() 를 부르면 — JPA가 "이제 새 쿼리 발행해야 하는데 컨텍스트 없네" 에서 폭발.
해결법 3가지:
1. Fetch Join — SQL JOIN으로 미리 가져오기
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :id")
Optional<Order> findByIdWithUser(@Param("id") Long id);
JOIN FETCH 한 줄로 "Order 조회 시 user도 즉시 함께". 가장 권장 패턴.
2. DTO 변환을 트랜잭션 안에서
@Transactional
public OrderResponse findByIdAsDto(Long id) {
Order order = orderRepo.findById(id).get();
return new OrderResponse(order.getId(), order.getUser().getName(), order.getAmount());
// 트랜잭션 안이라 LazyLoading 동작 OK
}
3. open-in-view (Spring Boot 기본, 비권장)
application.yml 의 spring.jpa.open-in-view: true (기본값)는 — "트랜잭션 끝나도 HTTP 응답 완료까지 영속성 컨텍스트 유지". LazyInitializationException 자동 방지.
다만 — "컨트롤러에서 무심코 Lazy 필드 호출 → 무수한 N+1 쿼리 폭발" 함정이 더 커요. 운영 표준 = open-in-view: false 로 끄고 1·2번 방식 사용.
N+1 문제 — JPA 최대 함정
49편 에서 짧게 다룬 N+1 문제. 자세히 풀면.
List<Order> orders = orderRepo.findAll(); // 1번: SELECT * FROM orders (100건)
for (Order order : orders) {
String userName = order.getUser().getName(); // 매 반복마다 SELECT * FROM users WHERE id = ?
}
// 총 쿼리 = 1 + 100 = 101번 → 매우 느림
100개 주문 + 각 주문의 user 정보 = 101개 SQL. "한 쿼리" 인 줄 알았는데 "101 쿼리". 이게 N+1 문제.
해결법 1 — Fetch Join
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUser();
// 1번: SELECT o.*, u.* FROM orders JOIN users
해결법 2 — @EntityGraph
@EntityGraph(attributePaths = {"user"})
List<Order> findAll();
@EntityGraph 박은 메서드는 자동으로 user 즉시 로딩. JPQL 안 쓰고 메서드 이름 쿼리에 적용 가능.
해결법 3 — Batch Size
spring.jpa.properties.hibernate.default_batch_fetch_size: 100
"같은 Lazy 필드 여러 번 조회 시 100개씩 묶어서" 자동 IN 쿼리. 101번이 2번(1 + 1)으로 줄어요. 글로벌 설정이라 한 번 박으면 모든 쿼리에 적용.
한국 회사 백엔드 표준 = Fetch Join + default_batch_fetch_size 조합. 둘 다 박아둬요.
양방향 연관관계 — 무한 루프 함정
JPA에 "양방향 연관관계" 도 자주 나와요.
@Entity
public class Order {
@ManyToOne private User user;
}
@Entity
public class User {
@OneToMany(mappedBy = "user") private List<Order> orders; // 반대 방향
}
이런 양방향 관계에서 JSON 직렬화 시 — Order → User → orders → Order → User → ... 무한 순환 발생. StackOverflowError 또는 거대한 JSON.
해결법 — @JsonIgnore 또는 DTO 변환. 30편의 "Entity 직접 응답 X" 룰 재강조. DTO만 반환하면 양방향 함정 자체가 안 일어나요.
① 영속성 컨텍스트 이해 (트랜잭션 안만 살아있음) ② 변경 감지 활용 (save 안 해도 자동 UPDATE) ③ Fetch Join 으로 N+1 회피 ④ DTO 변환 트랜잭션 안에서 ⑤ open-in-view: false + 명시적 Fetch. 이 5가지 잡으면 JPA 잘하는 사람.
Phase 6.5 JPA 4편 마무리 — 전체 흐름
29편부터 32편까지 정리.
- 29편 — JPA·Hibernate·Spring Data JPA 세 이름의 관계
- 30편 —
@Entity+JpaRepository두 축 - 31편 — 쿼리 작성 3방식 (메서드 이름 /
@Query/ QueryDSL) - 32편 — 영속성 컨텍스트 + LazyLoading 함정 회피
이 4편이 박혀 있으면 한국 회사 자바 백엔드 채용 면접 JPA 영역의 90% 처리 가능.
한 줄 정리 — 영속성 컨텍스트 = 트랜잭션 안 1차 캐시. 변경 감지로 save 없이 UPDATE. LazyLoading은 기본이지만 트랜잭션 종료 후 호출 시 예외. 해결 = Fetch Join + DTO 변환 + open-in-view false.
시험 직전 한 번 더 — 영속성 컨텍스트 입문자가 매번 헷갈리는 것
- 영속성 컨텍스트 = JPA가 관리하는 Entity 보관 메모리
- 트랜잭션 안에서만 살아있음
- 1차 캐시 — 같은 PK 조회 두 번째는 DB 안 감
- 변경 감지(Dirty Checking) =
save()없이 트랜잭션 끝날 때 자동 UPDATE - Entity 상태 4가지 = Transient·Managed·Detached·Removed
new Order()= Transient (JPA 모름)save()·findById()결과 = Managed- 트랜잭션 종료 후 = Detached
LazyInitializationException= Detached 상태에서 Lazy 필드 접근 시FetchType.LAZY(기본) = 필요할 때만 SQL 발행FetchType.EAGER= Order 조회 시 즉시 모든 연관 로딩 (비권장)- 기본이 Lazy인 이유 = 성능
- Fetch Join =
JOIN FETCH— 한 쿼리로 미리 가져옴 @EntityGraph= 메서드 이름 쿼리에 즉시 로딩 적용- N+1 문제 = 1번 + N번 = N+1 쿼리 폭발
- 해결법 = Fetch Join +
default_batch_fetch_size open-in-view: false= 운영 표준 (기본 true 비권장)- 양방향 연관관계 + JSON 직렬화 = 무한 순환 → DTO 변환 표준
- DTO 변환은 트랜잭션 안에서 처리
- JPA 잘하는 사람 = 영속성·변경 감지·Fetch Join·DTO·open-in-view 5가지
- JPA 어려운 영역이지만 — 면접 단골이라 반드시 잡아둘 것
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 46편 — JPA 연관관계 @OneToMany @ManyToOne
- 47편 — JPA @Embedded @Embeddable 값 객체
- 48편 — JPA Auditing @CreatedDate @LastModifiedDate
- 49편 — JPA 메서드 이름 쿼리 @Query
- 50편 — QueryDSL 타입 안전 동적 쿼리
다음 글: