"서비스 잘 돌다가 갑자기 500? 뭐야, 또 DB 터졌나?"
에러 로그를 봤다.
org.hibernate.LazyInitializationException: could not initialize proxy – no Session
아, 또 얘야?
처음엔 이 에러가 Hibernate나 JPA의 버그인 줄 알았다. 근데 이제는 안다.
내가 트랜잭션을 조절 못해서 그런 거였다.
LazyInitializationException이 왜 터지는지 제대로 이해해보자
Spring Data JPA에서 @OneToMany(fetch = LAZY) 같은 관계는, 실제 DB 쿼리를 지연 실행한다.
즉, 엔티티를 들고 왔지만, 관계 필드는 프록시 상태로 남겨둔다. 필요할 때 쿼리를 날리려고.
@Entity
classMember {
@OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
private List<Order> orders;
}
문제는 이걸 트랜잭션 밖에서 꺼내려 할 때 발생한다.
// 이 메서드가 트랜잭션 범위 안에서 orders를 안 꺼냈다면...
member.getOrders().size(); // 💥 LazyInitializationException 발생
- Controller에서 DTO로 바꿀 때 Lazy 로딩 접근함
- Service에서 로그 찍다가 entity.getXXX() 하면서 에러 남
- 테스트 코드에서 트랜잭션 안 걸고 엔티티 필드 접근함
해결책?
1. 트랜잭션 범위를 넓히기
- @Transactional(readOnly = true)를 Service 레벨이 아니라 Controller까지 걸 수도 있음
- 근데 이건 좋은 방법은 아님. 트랜잭션 너무 넓히면 부작용 많음.
2. JOIN FETCH로 아예 한방 쿼리
@Query("SELECT m FROM Member m JOIN FETCH m.orders WHERE m.id = :id")
Optional<Member> findMemberWithOrders(@Param("id") Long id);
→ 가장 안정적인 방법.
3. EntityGraph 사용
@EntityGraph(attributePaths = {"orders"})
@Query("SELECT m FROM Member m WHERE m.id = :id")
Optional<Member> findMember(@Param("id") Long id);
→ 재사용성이 좋고, 네이밍도 깔끔해서 선호하는 방법.
✋ 근데 주의해야 할 점
- LAZY 전략은 성능 이점이 분명히 있음
- 무조건 EAGER로 바꾸는 건 최악의 해결법
- 진짜 중요한 건, 트랜잭션 범위와 쿼리 타이밍을 예측 가능하게 유지하는 것
정리
LazyInitializationException은 JPA 잘못이 아니다.
우리가 지연 로딩의 시점과 트랜잭션의 범위를 헷갈려서 나는 거다.
'TIL' 카테고리의 다른 글
| DB 마이그레이션 적용하는 법 (1) | 2025.05.20 |
|---|---|
| fetch join vs EntityGraph (0) | 2025.05.19 |
| Kotlin vs Java (0) | 2025.05.13 |
| 조인 vs 서브쿼리 (0) | 2025.05.13 |
| LoadBalancer에 대해서 (0) | 2025.05.12 |