이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Lazy loading은 왜 트랜잭션 범위와 연결되는가?
  • Open Session In View는 어떤 편의와 위험을 가지는가?
  • API 응답 설계에서 엔티티를 그대로 노출하면 왜 위험한가?

개요

Lazy loading은 연관 객체를 실제 접근할 때 가져오는 전략이다. 편리하지만 영속성 컨텍스트와 트랜잭션 범위 안에서 동작한다. 트랜잭션이 끝난 뒤 lazy 연관을 접근하면 예외가 발생하거나, OSIV가 켜진 환경에서는 view 렌더링 중 SQL이 나갈 수 있다.

백엔드 API에서는 필요한 데이터를 service 경계 안에서 명시적으로 준비하는 것이 좋다.

원리

Lazy loading은 필드 접근처럼 보이지만 실제로는 영속성 컨텍스트가 프록시를 초기화하며 SQL을 실행하는 동작이다. 따라서 어느 계층에서 엔티티 그래프를 탐색해도 되는지, DB connection을 언제까지 잡아도 되는지, 응답 직렬화 중 SQL이 나가도 되는지를 정해야 한다.

백엔드 API에서는 트랜잭션 안에서 필요한 데이터를 의도적으로 가져오고 DTO로 닫는 것이 기본 전략이다. OSIV는 예외를 줄여주는 편의 장치가 될 수 있지만, SQL 실행 위치를 웹 계층까지 퍼뜨린다.

Lazy Loading과 트랜잭션

다음 코드는 트랜잭션 안에서는 동작할 수 있다.

@Transactional(readOnly = true)
public OrderDetail getOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    return new OrderDetail(order.id(), order.getMember().getEmail());
}

order.getMember() 접근 시 lazy loading SQL이 실행될 수 있다. 이 SQL은 트랜잭션과 영속성 컨텍스트가 열려 있어야 자연스럽게 동작한다.

LazyInitializationException

트랜잭션 밖에서 lazy 연관을 접근하면 문제가 생긴다.

public Order getOrderEntity(Long orderId) {
    return orderRepository.findById(orderId).orElseThrow();
}
 
// Controller에서
Order order = orderService.getOrderEntity(orderId);
String email = order.getMember().getEmail(); // 예외 가능

엔티티를 service 밖으로 그대로 반환하면 어디서 SQL이 나가는지 추적하기 어렵다.

Open Session In View

OSIV가 켜져 있으면 웹 요청이 끝날 때까지 영속성 컨텍스트가 열려 있어 Controller나 JSON 직렬화 중 lazy loading이 가능할 수 있다. 편리하지만 위험하다.

  • view 렌더링 중 예측 못한 SQL이 나간다.
  • Controller에서 DB 접근이 발생한다.
  • connection 점유 시간이 길어질 수 있다.
  • N+1이 늦게 드러난다.

API 서버에서는 OSIV를 끄고 service 안에서 DTO를 완성하는 방식을 선호하는 팀이 많다.

spring.jpa.open-in-view=false

설정 변경은 기존 코드에 영향을 줄 수 있으므로 테스트가 필요하다.

OSIV를 끄는 작업은 단순 설정 변경이 아니라 경계 정리 작업이다.

  • Controller에서 엔티티 연관 접근 제거
  • JSON 직렬화 대상에서 엔티티 제거
  • service 안에서 fetch join, EntityGraph, DTO query로 필요한 데이터 준비
  • LazyInitializationException이 나는 API를 테스트로 고정
  • query count가 늘거나 줄어드는지 확인

OSIV를 켠 채 운영한다면 더더욱 query count와 connection 점유 시간을 관측해야 한다. “예외가 안 난다”는 것과 “좋은 구조다”는 다른 말이다.

DTO 응답

API는 엔티티가 아니라 DTO를 반환하는 편이 안전하다.

@Transactional(readOnly = true)
public OrderDetailResponse getOrder(Long orderId) {
    Order order = orderRepository.findByIdWithMember(orderId)
        .orElseThrow();
    return OrderDetailResponse.from(order);
}

필요한 연관은 fetch join, EntityGraph, DTO query로 명시적으로 가져온다. 응답 조립이 끝난 뒤 Controller는 DB를 다시 건드리지 않는다.

DTO 조립 중에도 컬렉션을 반복 접근하면 N+1은 발생할 수 있다.

return orders.stream()
    .map(order -> new OrderRow(order.getId(), order.getItems().size()))
    .toList();

이 코드는 service 안에 있어도 안전하다고 말할 수 없다. 위치가 service인지 Controller인지보다 중요한 것은 필요한 SQL을 의도했는지, 반복 lazy loading을 피했는지다.

트랜잭션 범위 설계

트랜잭션은 짧고 명확해야 한다. DB 조회 후 외부 API 호출, 파일 업로드, 긴 계산을 같은 트랜잭션 안에서 처리하면 connection과 lock 보유 시간이 늘어난다.

@Transactional
public void approve(Long orderId) {
    Order order = orderRepository.findByIdForUpdate(orderId).orElseThrow();
    paymentClient.capture(order.getPaymentKey()); // 위험: 외부 I/O 동안 DB 자원 보유
    order.approve();
}

이런 흐름은 상태 전이, 외부 호출, 보상 처리, outbox 같은 설계로 분리할 수 있는지 검토한다. Lazy loading 문제는 결국 “DB 접근이 가능한 코드 범위를 어디까지 허용할 것인가”의 문제다.

실전 팁

  • lazy loading은 SQL 실행 타이밍을 늦추는 것이지 비용을 없애는 것이 아니다.
  • API 응답에 필요한 데이터는 service transaction 안에서 준비한다.
  • OSIV 설정을 팀 표준으로 정하고 이유를 문서화한다.
  • 엔티티를 JSON으로 직접 직렬화하지 않는다.
  • Controller에서 엔티티 연관 접근이 보이면 위험 신호로 본다.
  • OSIV를 끄기 전후로 API별 query count와 LazyInitializationException 발생 지점을 테스트한다.
  • 트랜잭션 안에서 외부 API, 긴 파일 처리, 사용자 입력 대기를 하지 않는다.

위험 신호!

  • Controller나 serializer에서 lazy loading이 발생한다.
  • OSIV 덕분에 동작하는 코드가 많다.
  • 엔티티를 API 응답으로 그대로 반환한다.
  • 트랜잭션 밖에서 엔티티 그래프를 탐색한다.
  • N+1이 view 렌더링 단계에서 발생한다.
  • OSIV가 켜져 있다는 이유로 service 경계 밖에 엔티티를 계속 흘려보낸다.
  • 외부 API 호출 동안 DB transaction과 connection을 유지한다.

확인 질문

  • Lazy loading이 트랜잭션 범위와 연결되는 이유는 무엇인가?
    • 연관 객체를 가져오려면 영속성 컨텍스트와 DB 접근이 필요하므로 트랜잭션 또는 세션 범위가 중요하기 때문이다.
  • OSIV의 위험은 무엇인가?
    • 웹 요청 후반부에서 예측하지 못한 SQL이 나가고 connection 점유와 N+1 문제가 숨겨질 수 있다.
  • API에서 DTO를 반환하는 이유는 무엇인가?
    • 필요한 데이터만 명시적으로 조립하고 엔티티 내부 구조와 lazy loading을 API 경계 밖으로 노출하지 않기 위해서다.
  • OSIV를 끌 때 가장 먼저 확인할 코드는 무엇인가?
    • Controller, serializer, view, mapper에서 엔티티 lazy 연관을 접근하는 코드와 엔티티를 그대로 반환하는 API를 확인해야 한다.

참고 문서