CATEGORY

카테고리 (671)
AI (69)
Language & Specs (260)
FrameWork (36)
Library (20)
App (41)
Git (10)
Build & Dependency (2)
AWS (15)
DataBase (45)
OS (33)
Tool (17)
IT (120)
반응형
SEEMINGLY ONLINE

Seemingly
Online

이모저모 방방곡곡 두루두루 개발지식 저장소

RECENT POSTS

Language & Specs/JPA

LazyInitializationException 원인과 해결 — could not initialize proxy

반응형

LazyInitializationException은 영속성 컨텍스트(세션)가 닫힌 뒤에 LAZY로 설정된 연관 엔티티를 건드릴 때 납니다. JPA는 LAZY 연관을 진짜 객체가 아닌 프록시로 채워 두고, 실제로 값을 읽는 순간 쿼리를 날리는데 이때 세션이 이미 닫혀 있으면 초기화를 못 해 예외를 던집니다. 해결의 정석은 필요한 연관을 조회 시점에 join fetch로 함께 가져오는 것입니다. 아래 예외 메시지와 해결 코드는 Hibernate 6.5.3 + H2 + Java 21에서 직접 재현해 확인한 결과입니다.

어떤 상황에서 터지나

가장 흔한 형태는 서비스에서 조회해 트랜잭션이 끝난 엔티티를 컨트롤러나 뷰에서 getTeam().getName()처럼 파고들 때입니다. 재현 코드는 이렇습니다.

@Entity
public class Member {
    @Id @GeneratedValue private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)   // LAZY 연관
    private Team team;
    // getter 생략
}

// 조회 후 영속성 컨텍스트를 닫는다
EntityManager em = emf.createEntityManager();
Member found = em.find(Member.class, memberId);
em.close();                               // ← 세션 종료

found.getTeam().getName();                // 💥 여기서 예외

실행하면 이 두 가지 메시지 중 하나가 나옵니다. 검색으로 많이 유입되는 문구가 그대로 나옵니다.

# ① @ManyToOne 같은 단일 연관일 때
org.hibernate.LazyInitializationException:
could not initialize proxy [Team#1] - no Session

# ② @OneToMany 컬렉션일 때
org.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role: Team.members:
could not initialize proxy - no Session

왜 프록시가 문제인가

LAZY 연관 자리에 들어 있는 것은 실제 Team이 아니라 Hibernate가 만든 프록시 객체입니다. 실행해서 클래스 이름을 찍어 보면 바로 보입니다.

System.out.println(found.getTeam().getClass().getSimpleName());
// Team$HibernateProxy$K69YbEDb        ← 실제 Team이 아니다

재미있는 건 ID는 예외 없이 읽힌다는 점입니다. 외래 키 값은 이미 손에 있으니 쿼리가 필요 없기 때문입니다.

found.getTeam().getId();                      // 1   ← 예외 없음
Hibernate.isInitialized(found.getTeam());     // false ← 여전히 미초기화
💡 그래서 연관 엔티티의 ID만 필요할 땐 예외가 안 나고, 이름 같은 다른 필드를 읽는 순간 터집니다. "어제까진 됐는데 오늘 갑자기"의 정체가 대개 이것입니다.

해결 1. join fetch — 가장 권장

조회 쿼리에서 필요한 연관을 한 번에 같이 가져옵니다. 세션이 닫힌 뒤에도 이미 로딩돼 있으니 안전합니다.

Member m = em.createQuery(
        "select m from Member m join fetch m.team where m.id = :id", Member.class)
    .setParameter("id", memberId)
    .getSingleResult();
em.close();

m.getTeam().getName();   // "백엔드"  ← 정상

Spring Data JPA라면 리포지토리 메서드에 @Query로 같은 JPQL을 쓰면 됩니다. 연관을 여러 개 당겨올 때의 주의점은 JPA N+1 문제 해결 — Fetch Join vs EntityGraph에 정리해 뒀습니다.

해결 2. EntityGraph

JPQL을 새로 쓰기 싫을 때 씁니다. 가져올 속성을 그래프로 지정하면 find에서도 함께 로딩됩니다.

EntityGraph<Member> graph = em.createEntityGraph(Member.class);
graph.addAttributeNodes("team");

Member m = em.find(Member.class, memberId,
        Map.of("jakarta.persistence.fetchgraph", graph));
em.close();

m.getTeam().getName();   // "백엔드"  ← 정상

Spring Data JPA에서는 리포지토리 메서드에 @EntityGraph(attributePaths = "team")을 붙이면 같은 효과입니다.

해결 3. 세션 안에서 미리 초기화

이미 조회한 엔티티라면 세션이 살아 있는 동안 강제로 초기화해 둘 수 있습니다.

Member m = em.find(Member.class, memberId);
Hibernate.initialize(m.getTeam());    // 세션이 열려 있을 때 초기화
em.close();

m.getTeam().getName();   // "백엔드"  ← 정상

실무에서는 트랜잭션(@Transactional) 안에서 필요한 값을 다 꺼내 DTO로 변환해 내보내는 방식이 더 많이 쓰입니다. 엔티티를 트랜잭션 밖으로 흘려보내지 않는 것이 근본적인 예방책입니다.

이렇게는 풀지 말자 — EAGER와 open-in-view

FetchType.EAGER로 바꾸면 예외는 사라집니다. 대신 목록 조회에서 N+1 쿼리가 터집니다. 멤버 3명을 조회했을 때 실제로 찍힌 쿼리 로그입니다.

select m1_0.id, m1_0.name, m1_0.team_id from member m1_0     ← 1번
select t1_0.id, t1_0.name from team t1_0 where t1_0.id=?     ← +1
select t1_0.id, t1_0.name from team t1_0 where t1_0.id=?     ← +2
select t1_0.id, t1_0.name from team t1_0 where t1_0.id=?     ← +3

멤버가 100명이면 쿼리가 101번 나갑니다. 예외 하나를 없애려다 성능을 잃는 셈이라, EAGER는 해결책이 아니라 문제를 옮기는 것에 가깝습니다.

⚠️ spring.jpa.open-in-view를 켜 두면 뷰 렌더링까지 세션이 살아 있어 예외가 안 납니다. 하지만 그만큼 커넥션을 오래 붙들고 있게 되고, 쿼리가 어디서 나가는지도 흐려집니다. 이 설정의 경고 문구와 의미는 spring.jpa.open-in-view is enabled by default 해결 방법에 정리돼 있습니다.

자주 묻는 질문 (FAQ)

Q. 테스트에서는 잘 되는데 실제 요청에서만 터집니다.
테스트 메서드에 @Transactional이 붙어 트랜잭션이 테스트 끝까지 열려 있는 경우가 많습니다. 세션이 계속 살아 있으니 LAZY 접근이 성공합니다. 실제 흐름과 다르므로 테스트만 믿으면 안 됩니다.

Q. toString()이나 JSON 직렬화에서 터집니다.
직렬화가 모든 필드를 훑으면서 LAZY 연관까지 건드리기 때문입니다. 엔티티를 그대로 응답으로 내보내지 말고 필요한 필드만 담은 DTO로 변환해 반환하세요.

Q. 연관 ID만 필요한데도 굳이 fetch 해야 하나요?
아닙니다. member.getTeam().getId()는 프록시 상태에서도 쿼리 없이 읽히므로 그대로 쓰면 됩니다.

Q. 컬렉션에서 나는 "failed to lazily initialize a collection"도 같은 원인인가요?
네. @OneToMany 컬렉션도 프록시(지연 컬렉션)라 원인과 해결이 같습니다. join fetch나 EntityGraph로 함께 로딩하세요.

마무리

정리하면 LazyInitializationException은 "세션 닫힌 뒤 LAZY 연관 접근" 딱 한 문장으로 요약됩니다. 해결은 ① join fetch(기본) → ② EntityGraph → ③ 트랜잭션 안에서 DTO 변환 순으로 검토하고, EAGER 전환이나 open-in-view 의존은 피하는 것이 좋습니다. 위 예외 메시지와 쿼리 로그는 모두 Hibernate 6.5.3에서 재현해 확인한 것입니다.


📚 참고 출처 (2026년 7월 21일 확인)
· Hibernate ORM 6.5 User Guide — Fetching
· Hibernate ORM 6.5 Introduction — Proxies and lazy fetching
· 예외 메시지·쿼리 로그는 Hibernate 6.5.3.Final + H2 2.3.232 + Java 21에서 직접 실행해 확인

반응형

COMMENTS