Published on

Spring Boot 3 LazyInitializationException 근본 해결

Authors

서버에서 LazyInitializationException을 처음 마주치면 많은 팀이 즉시 OSIV(Open Session In View)를 켜거나, @Transactional을 컨트롤러까지 올리는 방식으로 “증상”을 덮습니다. 하지만 이 예외는 대개 트랜잭션 경계 설계와 조회 모델(쿼리/DTO) 설계가 어긋났다는 신호입니다. Spring Boot 3(=Spring Framework 6, Jakarta EE) 환경에서는 기본값과 관례가 조금 더 명확해졌고, OSIV에 기대는 방식은 성능·안정성 측면에서 더 위험해졌습니다.

이 글은 다음을 목표로 합니다.

  • 예외의 정확한 발생 원리를 이해한다.
  • OSIV에 기대지 않고도 근본적으로 제거하는 패턴을 선택한다.
  • 조회/응답 모델을 분리해 N+1, 커넥션 점유, 성능 저하를 동시에 막는다.

1) LazyInitializationException의 본질: “Lazy”가 아니라 “경계” 문제

JPA(Hibernate)에서 LAZY 연관은 실제 객체 대신 프록시(proxy) 로 채워집니다. 프록시는 영속성 컨텍스트(=Hibernate Session) 가 살아 있을 때만 DB를 추가 조회해 값을 채울 수 있습니다.

즉, 아래 상황이 핵심입니다.

  • 엔티티를 조회한 트랜잭션이 종료됨
  • 컨트롤러/뷰/직렬화 단계에서 지연 로딩 필드에 접근함
  • 이미 Session이 닫혀 있어 프록시 초기화가 불가 → LazyInitializationException

대표적인 트리거는 JSON 직렬화입니다. @RestController가 엔티티를 그대로 반환하면, Jackson이 getter를 호출하는 순간 지연 로딩이 터집니다.

2) Spring Boot 3에서 OSIV를 “근본 해결”로 보면 안 되는 이유

OSIV는 웹 요청이 끝날 때까지 영속성 컨텍스트를 열어둡니다. 그러면 컨트롤러/직렬화 단계에서도 프록시 초기화가 가능해져 예외가 사라집니다.

하지만 대가가 큽니다.

  • DB 커넥션을 더 오래 점유할 수 있음(특히 트랜잭션/세션 관리 방식에 따라)
  • 지연 로딩이 뷰/직렬화 중에 무분별하게 발생 → N+1 쿼리가 숨겨진 채로 운영 반영
  • 응답 시간이 늘고, 동시성에서 커넥션 풀이 고갈될 위험 증가

커넥션 풀이 고갈되면 장애 양상이 더 커집니다. 관련해서는 Spring Boot HikariCP 커넥션 고갈 3분 진단 글의 진단 루틴이 그대로 적용됩니다.

결론적으로 OSIV는 “예외를 숨기는 스위치”에 가깝고, 조회 모델을 명시적으로 설계하는 쪽이 근본 해결입니다.

3) 재현 코드로 보는 실패 패턴(엔티티 직접 반환)

엔티티 예시

// jakarta.persistence.* (Spring Boot 3)
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();

    // getters
}

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;

    private BigDecimal totalPrice;

    // getters
}

문제되는 컨트롤러

@RestController
@RequiredArgsConstructor
public class MemberController {
    private final MemberRepository memberRepository;

    @GetMapping("/members/{id}")
    public Member get(@PathVariable Long id) {
        return memberRepository.findById(id).orElseThrow();
    }
}

이때 Jackson이 orders에 접근하는 순간(혹은 Order.member를 다시 타고 들어가는 순간) 세션이 닫혀 있다면 예외가 발생합니다.

4) 근본 해결 전략 1: “엔티티 반환 금지” + DTO/조회 모델 분리

가장 확실한 해법은 API 응답 모델을 엔티티와 분리하는 것입니다.

  • 엔티티: 도메인 규칙과 변경에 집중
  • DTO(응답 모델): 화면/클라이언트 요구에 맞는 형태

DTO 기반 응답

public record OrderSummaryDto(Long id, BigDecimal totalPrice) {}
public record MemberDetailDto(Long id, String name, List<OrderSummaryDto> orders) {}

서비스에서 필요한 연관을 “트랜잭션 안에서” 로딩하고 DTO로 변환합니다.

@Service
@RequiredArgsConstructor
public class MemberQueryService {
    private final MemberRepository memberRepository;

    @Transactional(readOnly = true)
    public MemberDetailDto getMemberDetail(Long id) {
        Member member = memberRepository.findById(id).orElseThrow();

        // 트랜잭션 안에서 필요한 만큼만 접근(초기화)
        List<OrderSummaryDto> orders = member.getOrders().stream()
                .map(o -> new OrderSummaryDto(o.getId(), o.getTotalPrice()))
                .toList();

        return new MemberDetailDto(member.getId(), member.getName(), orders);
    }
}

@RestController
@RequiredArgsConstructor
public class MemberController {
    private final MemberQueryService memberQueryService;

    @GetMapping("/members/{id}")
    public MemberDetailDto get(@PathVariable Long id) {
        return memberQueryService.getMemberDetail(id);
    }
}

이 방식은 예외를 제거할 뿐 아니라 “응답이 어떤 데이터를 필요로 하는지”를 코드로 고정합니다.

주의: DTO 변환만으로 N+1이 사라지진 않는다

위 코드는 member.getOrders() 접근 시점에 컬렉션이 초기화되며, 주문을 추가로 조회합니다. 단건이면 괜찮지만 목록 조회에서는 N+1이 됩니다. 다음 전략이 필요합니다.

5) 근본 해결 전략 2: Fetch Join / EntityGraph로 필요한 연관을 한 번에 로딩

5.1 Fetch Join (JPQL)

public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("""
        select m from Member m
        left join fetch m.orders
        where m.id = :id
    """)
    Optional<Member> findDetailById(@Param("id") Long id);
}

서비스는 findDetailById를 사용해 한 번에 필요한 그래프를 로딩하고 DTO로 변환합니다.

@Transactional(readOnly = true)
public MemberDetailDto getMemberDetail(Long id) {
    Member member = memberRepository.findDetailById(id).orElseThrow();
    List<OrderSummaryDto> orders = member.getOrders().stream()
            .map(o -> new OrderSummaryDto(o.getId(), o.getTotalPrice()))
            .toList();
    return new MemberDetailDto(member.getId(), member.getName(), orders);
}

Fetch Join의 함정

  • 컬렉션 fetch join은 결과 row가 뻥튀기됩니다(조인으로 인해 member가 중복 row로 나옴)
  • 페이징과 함께 쓰기 어렵습니다(특히 @OneToMany 컬렉션 fetch join)

목록 + 페이징이 필요하면 다음의 “조회 전용 DTO 쿼리”가 더 안전합니다.

5.2 EntityGraph

public interface MemberRepository extends JpaRepository<Member, Long> {

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

EntityGraph는 “이번 조회에서는 이 연관을 eager로 가져오라”는 선언적 방식입니다. 팀 표준으로 잡기 좋고, JPQL 문자열 관리 부담이 줄어듭니다.

6) 근본 해결 전략 3: 조회 전용 DTO Projection(가장 예측 가능)

API 응답이 엔티티 그래프와 1:1로 매칭되지 않는 경우가 많습니다. 이때는 애초에 DTO로 바로 조회하는 것이 가장 깔끔합니다.

6.1 단건 Member + Orders를 2쿼리로(성능/안정성 균형)

페이징/중복 row 문제를 피하려면 “부모 1쿼리 + 자식 1쿼리” 패턴이 자주 최적입니다.

public record MemberRow(Long id, String name) {}
public record OrderRow(Long id, Long memberId, BigDecimal totalPrice) {}

public interface MemberQueryRepository {
    Optional<MemberRow> findMemberRow(Long id);
    List<OrderRow> findOrderRowsByMemberId(Long memberId);
}

@Repository
@RequiredArgsConstructor
public class MemberQueryRepositoryImpl implements MemberQueryRepository {
    private final EntityManager em;

    @Override
    public Optional<MemberRow> findMemberRow(Long id) {
        List<MemberRow> rows = em.createQuery(
                "select new com.example.MemberRow(m.id, m.name) from Member m where m.id = :id",
                MemberRow.class)
            .setParameter("id", id)
            .getResultList();
        return rows.stream().findFirst();
    }

    @Override
    public List<OrderRow> findOrderRowsByMemberId(Long memberId) {
        return em.createQuery(
                "select new com.example.OrderRow(o.id, o.member.id, o.totalPrice) from Order o where o.member.id = :id",
                OrderRow.class)
            .setParameter("id", memberId)
            .getResultList();
    }
}

서비스 조립:

@Service
@RequiredArgsConstructor
public class MemberReadService {
    private final MemberQueryRepository queryRepository;

    @Transactional(readOnly = true)
    public MemberDetailDto getMemberDetail(Long id) {
        MemberRow m = queryRepository.findMemberRow(id).orElseThrow();
        List<OrderSummaryDto> orders = queryRepository.findOrderRowsByMemberId(id).stream()
                .map(r -> new OrderSummaryDto(r.id(), r.totalPrice()))
                .toList();
        return new MemberDetailDto(m.id(), m.name(), orders);
    }
}

이 접근은 다음이 장점입니다.

  • Lazy 로딩 자체가 없음 → 예외 구조적으로 불가
  • 쿼리 수가 예측 가능(1+1)
  • 필요 컬럼만 조회 → 대역폭/메모리 절감

7) “@Transactional을 어디에 둘 것인가”가 진짜 핵심

근본 해결의 공통점은 트랜잭션 경계 내에서 필요한 데이터를 확정한다는 점입니다.

권장 경계:

  • 컨트롤러: 트랜잭션 X, 요청/응답 변환
  • 서비스(애플리케이션 계층): @Transactional(readOnly = true)로 조회 경계 설정
  • 리포지토리: 쿼리 제공

컨트롤러에 @Transactional을 붙여 예외를 피하는 방식은 OSIV와 유사하게 “웹 계층이 영속성에 의존”하게 만들고, 직렬화/로깅/필터 등 예상 못한 지점에서 추가 쿼리를 유발합니다.

8) 운영에서 자주 터지는 2차 문제: 커넥션/풀 고갈

LazyInitializationException을 OSIV로 덮어두면, 트래픽이 증가했을 때 다음 현상이 겹치기 쉽습니다.

  • 요청 처리 중 예상치 못한 지연 로딩 쿼리 다발
  • 커넥션 점유 시간 증가
  • HikariCP 풀 고갈 → 타임아웃/지연/연쇄 장애

DB 커넥션은 병목의 중심입니다. 커넥션 고갈 패턴과 진단은 Spring Boot HikariCP 커넥션 고갈 3분 진단에서, Spring Boot 3 환경에서 동시성 확장(가상 스레드)과 커넥션 관리 관점은 Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기도 함께 참고하면 좋습니다.

9) 체크리스트: “근본 해결”을 위한 실전 가이드

9.1 절대 규칙

  • API에서 엔티티를 그대로 반환하지 않는다.
  • 서비스 계층에서 트랜잭션 경계를 명확히 한다.
  • 응답에 필요한 연관/컬럼은 쿼리로 확정한다(명시적 로딩).

9.2 상황별 추천

  • 단건 상세 + 연관 몇 개: fetch join 또는 @EntityGraph + DTO 변환
  • 목록 + 페이징 + 연관 포함: DTO projection(부모/자식 분리 쿼리, IN 절 배치 로딩)
  • 복잡한 화면(집계/통계): 조회 전용 SQL/QueryDSL/Projection로 별도 모델

9.3 금지에 가까운 패턴

  • OSIV를 기본 해결책으로 사용
  • 컨트롤러까지 @Transactional 전파
  • EAGER로 바꿔서 “항상 다 가져오기”(대개 더 큰 성능 문제로 돌아옴)

10) 결론

Spring Boot 3에서 LazyInitializationException은 “LAZY가 나쁘다”가 아니라 영속성 컨텍스트 경계 밖에서 엔티티 그래프를 탐색하고 있다는 설계 문제를 알려줍니다. 근본 해결은 다음 한 줄로 요약됩니다.

  • 엔티티를 응답 모델로 쓰지 말고,
  • 서비스 트랜잭션 안에서 필요한 데이터만
  • 명시적으로 로딩/조회하여 DTO로 반환하라.

이렇게 바꾸면 예외가 사라지는 것은 물론, N+1과 커넥션 고갈 같은 운영 리스크까지 함께 줄일 수 있습니다.