자바 백엔드 입문 51편 — 영속성 컨텍스트와 LazyLoading

2026-05-16자바 백엔드 입문

자바 백엔드 입문 51편. JPA의 가장 어려운 개념 영속성 컨텍스트와 LazyLoading. 1차 캐시·변경 감지·N+1 함정과 회피 패턴을 도서관 사서 비유로 풀어쓴 Phase 6.5 마무리 글.

📚 자바 백엔드 입문 · 51편 — 영속성 컨텍스트와 LazyLoading

이 글은 자바 백엔드 입문 시리즈 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.ymlspring.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만 반환하면 양방향 함정 자체가 안 일어나요.

🎯 JPA 잘하는 사람의 필수 5가지

영속성 컨텍스트 이해 (트랜잭션 안만 살아있음) ② 변경 감지 활용 (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 어려운 영역이지만 — 면접 단골이라 반드시 잡아둘 것

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!