🔍 요약: JPA에서 자주 발생하는 LazyInitializationException 문제의 원인과 다양한 해결 방법을 실제 사례와 코드 예제를 통해 알아봅니다.
LazyInitializationException이란?
JPA를 사용하다 보면 가장 흔하게 마주치는 예외 중 하나가 LazyInitializationException
입니다.
이 예외는 다음과 같은 메시지와 함께 발생합니다:
org.hibernate.LazyInitializationException: could not initialize proxy - no Session
이 예외는 영속성 컨텍스트(Persistence Context)가 종료된 후에 지연 로딩(Lazy Loading)으로 설정된 엔티티를 참조하려고 할 때 발생합니다.
즉, 이미 데이터베이스 연결이 종료된 상태에서 데이터베이스에서 정보를 가져와야 하는 지연 로딩 객체에 접근할 때 발생하는 문제입니다.
이 예외는 주로 서비스 계층에서 엔티티를 조회한 후, 해당 엔티티의 연관 관계를 컨트롤러나 뷰 계층에서 접근할 때 많이 발생합니다.
문제가 발생하는 전형적인 시나리오
다음과 같은 엔티티 관계가 있다고 가정해봅시다:
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
// 생략...
}
@Entity
public class Comment {
@Id @GeneratedValue
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
// 생략...
}
이제 서비스 계층에서 다음과 같이 게시글을 조회합니다:
@Service
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
public Post findById(Long id) {
return postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
}
}
그리고 컨트롤러에서 이를 사용합니다:
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
@GetMapping("/{id}")
public PostResponse getPost(@PathVariable Long id) {
Post post = postService.findById(id);
// LazyInitializationException 발생!
int commentCount = post.getComments().size();
return new PostResponse(post, commentCount);
}
}
위 코드에서 post.getComments().size()
를 호출할 때 LazyInitializationException
이 발생합니다.
왜냐하면:
@Transactional
어노테이션이PostService
의 메소드에 적용되어 있으므로 해당 메소드가 종료되면 트랜잭션도 종료됩니다.- 트랜잭션이 종료되면 영속성 컨텍스트도 함께 종료됩니다.
- 컨트롤러에서 지연 로딩으로 설정된
comments
컬렉션에 접근하려고 할 때, 이미 영속성 컨텍스트가 종료되어 세션을 사용할 수 없습니다. - 따라서
LazyInitializationException
이 발생합니다.
해결 방법 1: Fetch 전략 변경 (EAGER Loading)
가장 간단한 해결 방법은 지연 로딩(LAZY)을 즉시 로딩(EAGER)으로 변경하는 것입니다.
@Entity
public class Post {
// ...
@OneToMany(mappedBy = "post", fetch = FetchType.EAGER)
private List<Comment> comments = new ArrayList<>();
// ...
}
장점:
- 구현이 매우 간단합니다.
- 추가 쿼리 없이 엔티티와 연관 관계를 한 번에 가져옵니다.
단점:
- 항상 연관 관계 데이터를 함께 로딩하므로 성능에 악영향을 줄 수 있습니다.
- N+1 문제가 발생할 가능성이 높습니다.
- 필요하지 않은 상황에서도 데이터를 항상 로딩합니다.
권장 사용 사례:
- 연관 관계의 데이터가 적고, 항상 함께 사용되는 경우
- 성능이 크게 중요하지 않은 간단한 애플리케이션
해결 방법 2: 트랜잭션 범위 확장
컨트롤러까지 트랜잭션 범위를 확장하여 영속성 컨텍스트가 컨트롤러에서도 유지되도록 할 수 있습니다.
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
@GetMapping("/{id}")
@Transactional(readOnly = true)
public PostResponse getPost(@PathVariable Long id) {
Post post = postService.findById(id);
// 이제 LazyInitializationException이 발생하지 않습니다
int commentCount = post.getComments().size();
return new PostResponse(post, commentCount);
}
}
장점:
- 기존 엔티티 구조를 변경하지 않아도 됩니다.
- 필요한 상황에서만 트랜잭션을 적용할 수 있습니다.
단점:
- 프레젠테이션 계층(컨트롤러)에 트랜잭션 로직이 노출됩니다.
- 계층 간 책임이 명확하지 않게 됩니다.
- 트랜잭션이 너무 오래 유지될 수 있습니다.
권장 사용 사례:
- 임시 해결책으로는 괜찮지만, 장기적인 해결책으로는 권장되지 않습니다.
해결 방법 3: JPQL의 Fetch Join 활용
JPQL의 fetch join을 사용하여 필요한 연관 관계를 함께 로딩할 수 있습니다.
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT p FROM Post p JOIN FETCH p.comments WHERE p.id = :id")
Optional<Post> findByIdWithComments(@Id Long id);
}
@Service
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
public Post findByIdWithComments(Long id) {
return postRepository.findByIdWithComments(id)
.orElseThrow(() -> new PostNotFoundException(id));
}
}
이제 컨트롤러에서는 다음과 같이 사용합니다:
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
@GetMapping("/{id}/with-comments")
public PostResponse getPostWithComments(@PathVariable Long id) {
// 댓글 정보를 함께 로딩하는 메소드 사용
Post post = postService.findByIdWithComments(id);
// 이미 로딩되어 있으므로 LazyInitializationException이 발생하지 않습니다
int commentCount = post.getComments().size();
return new PostResponse(post, commentCount);
}
}
장점:
- 필요한 상황에서만 연관 관계를 함께 로딩하므로 효율적입니다.
- 단일 쿼리로 처리되어 N+1 문제를 방지합니다.
- 계층 간 책임이 명확합니다.
단점:
- 각 유스케이스에 맞는 별도 메소드를 구현해야 합니다.
- 복잡한 연관 관계에서는 쿼리가 복잡해질 수 있습니다.
권장 사용 사례:
- 특정 화면이나 API에서 연관 관계 데이터가 필요한 경우
- 성능 최적화가 중요한 경우
해결 방법 4: EntityGraph 활용
JPA 2.1에서 추가된 @EntityGraph
를 사용하면 더 간결하게 Fetch Join과 유사한 효과를 얻을 수 있습니다.
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
@EntityGraph(attributePaths = {"comments"})
Optional<Post> findById(Long id);
// 또는 Named EntityGraph 사용
@EntityGraph(value = "Post.withComments")
Optional<Post> getById(Long id);
}
@Entity
@NamedEntityGraph(
name = "Post.withComments",
attributeNodes = @NamedAttributeNode("comments")
)
public class Post {
// 이전과 동일
}
장점:
- 메소드 이름 규칙을 그대로 사용하면서 Fetch Join 효과를 얻을 수 있습니다.
- 코드가 간결해집니다.
- 재사용성이 높습니다.
단점:
- 복잡한 연관 관계에서는 여러 EntityGraph를 정의해야 할 수 있습니다.
- 모든 JPA 구현체에서 최적화가 동일하게 적용되지 않을 수 있습니다.
권장 사용 사례:
- 표준 JPA 기능을 활용하고자 하는 경우
- 다양한 조합의 연관 관계 로딩이 필요한 경우
해결 방법 5: DTO 프로젝션 활용
필요한 데이터만 DTO로 직접 조회하는 방법입니다.
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT new com.example.dto.PostDto(p.id, p.title, SIZE(p.comments)) " +
"FROM Post p WHERE p.id = :id")
Optional<PostDto> findPostDtoById(@Param("id") Long id);
}
// DTO 클래스
public class PostDto {
private Long id;
private String title;
private int commentCount;
public PostDto(Long id, String title, int commentCount) {
this.id = id;
this.title = title;
this.commentCount = commentCount;
}
// getters, setters...
}
이제 컨트롤러에서는 다음과 같이 사용합니다:
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
@GetMapping("/{id}/dto")
public PostDto getPostDto(@PathVariable Long id) {
return postService.findPostDtoById(id)
.orElseThrow(() -> new PostNotFoundException(id));
}
}
장점:
- 필요한 데이터만 정확히 조회하므로 매우 효율적입니다.
- LazyInitializationException이 발생할 여지가 없습니다.
- 데이터 전송과 표현에 최적화되어 있습니다.
단점:
- 각 유스케이스마다 별도의 DTO 클래스와 쿼리가 필요합니다.
- 도메인 모델과 DTO 간 변환 로직이 필요할 수 있습니다.
권장 사용 사례:
- 읽기 전용 작업이 많은 경우
- 성능이 매우 중요한 경우
- API 응답 형태가 엔티티 구조와 다른 경우
해결 방법 6: @BatchSize 활용 (N+1 문제 최적화)
@BatchSize
어노테이션을 사용하면 연관 엔티티를 일괄적으로 로딩하여 N+1 문제를 줄일 수 있습니다.
@Entity
public class Post {
// ...
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
@BatchSize(size = 100) // 최대 100개씩 일괄 조회
private List<Comment> comments = new ArrayList<>();
// ...
}
또는 전역 설정으로 적용:
# application.properties 또는 application.yml
spring.jpa.properties.hibernate.default_batch_fetch_size=100
장점:
- N+1 문제를 효과적으로 줄일 수 있습니다.
- 전역 설정으로 간편하게 적용 가능합니다.
- 기존 코드 변경이 최소화됩니다.
단점:
- 여전히 별도의 쿼리가 발생합니다(단, 횟수는 크게 감소).
- 최적의 batch size를 결정하는 것이 어려울 수 있습니다.
권장 사용 사례:
- 많은 연관 엔티티를 다루는 경우
- 다양한 컬렉션이 함께 사용되는 경우
해결 방법 7: Open Session In View (OSIV) 패턴
Spring Boot에서는 기본적으로 OSIV 패턴이 활성화되어 있습니다. 이 패턴은 영속성 컨텍스트를 HTTP 요청이 완료될 때까지 유지합니다.
# application.properties
spring.jpa.open-in-view=true # 기본값은 true
장점:
- 간단하게 LazyInitializationException을 방지할 수 있습니다.
- 코드 변경 없이 설정만으로 적용 가능합니다.
단점:
- 요청 처리 시간 동안 데이터베이스 연결을 유지하므로 리소스를 많이 사용합니다.
- 대규모 트래픽에서는 성능 문제를 일으킬 수 있습니다.
- 무분별한 쿼리 발생 가능성이 높습니다.
권장 사용 사례:
- 개발 환경이나 소규모 애플리케이션
- 프로토타입 단계의 애플리케이션
Spring에서는 OSIV 사용을 권장하지 않는 추세입니다. 특히 고성능이 요구되는 프로덕션 환경에서는 비활성화하는 것이 좋습니다.
# application.properties
spring.jpa.open-in-view=false # 프로덕션 환경 권장 설정
실제 프로젝트 적용 사례
실제 프로젝트에서는 상황에 맞게 여러 해결 방법을 조합하여 사용하는 것이 일반적입니다.
복잡한 화면용 API 구현 시
게시판 상세 화면과 같이 복잡한 데이터를 표시해야 하는 경우:
@Service
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
// 상세 화면용 메소드 - Fetch Join 사용
public PostDetailDto getPostDetail(Long id) {
Post post = postRepository.findByIdWithCommentsAndAuthor(id)
.orElseThrow(() -> new PostNotFoundException(id));
return new PostDetailDto(post);
}
}
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT p FROM Post p " +
"JOIN FETCH p.author " +
"LEFT JOIN FETCH p.comments c " +
"LEFT JOIN FETCH c.author " +
"WHERE p.id = :id")
Optional<Post> findByIdWithCommentsAndAuthor(@Param("id") Long id);
}
목록 조회 최적화
목록 화면에서는 필요한 데이터만 DTO로 직접 조회:
@Service
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
// 목록 화면용 메소드 - DTO 프로젝션 사용
public Page<PostSummaryDto> getPostList(Pageable pageable) {
return postRepository.findPostSummaries(pageable);
}
}
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT new com.example.dto.PostSummaryDto(" +
"p.id, p.title, p.author.name, SIZE(p.comments), p.createdAt) " +
"FROM Post p")
Page<PostSummaryDto> findPostSummaries(Pageable pageable);
}
성능 중요 API 최적화
대량의 데이터를 다루는 API에서는 BatchSize와 DTO 프로젝션 조합:
// application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=100
spring.jpa.open-in-view=false
@Entity
public class Post {
// ...
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
@BatchSize(size = 50)
private List<Comment> comments = new ArrayList<>();
// ...
}
@Service
@Transactional(readOnly = true)
public class StatisticsService {
// 통계용 메소드 - 커스텀 쿼리와 DTO 프로젝션 사용
public List<PostStatDto> getPostStatistics() {
return postRepository.findPostStatistics();
}
}
권장 접근 방식
LazyInitializationException을 효과적으로 처리하기 위한 최적의 접근 방식은 다음과 같습니다:
- OSIV는 비활성화하는 것이 원칙
spring.jpa.open-in-view=false
로 설정- 이로 인해 발생하는 LazyInitializationException은 다른 방법으로 해결
- 사용 패턴에 따른 해결책 선택
- 상세 조회: Fetch Join 또는 EntityGraph 사용
- 목록 조회: DTO 프로젝션 또는 필요한 속성만 선택적 로딩
- 대량 데이터: BatchSize 설정으로 N+1 문제 최적화
- 화면/API 요구사항에 맞는 DTO 설계
- 표현 계층에서 필요한 데이터만 정확히 전달
- 불필요한 연관 관계 탐색 방지
- 성능 테스트 필수
- 각 해결책 적용 후 실제 성능 측정
- 대용량 데이터 환경에서 테스트
마치며
JPA의 LazyInitializationException은 단순히 오류를 피하는 것이 아니라, 애플리케이션의 아키텍처와 성능을 개선하는 기회로 삼아야 합니다.
각 상황에 맞는 해결책을 선택하고, 지속적인 성능 모니터링을 통해 최적의 방법을 찾는 것이 중요합니다.
특히 대규모 프로젝트에서는 OSIV를 비활성화하고, 계층 간 명확한 책임 분리와 DTO 패턴을 적극 활용하는 것이 좋은 접근 방식입니다.
이 글에서 소개한 여러 해결 방법 중 프로젝트 상황에 맞는 최적의 방법을 선택하여 적용해 보시기 바랍니다.
추가적인 최적화 방법과 실제 성능 비교 결과는 후속 글에서 다루도록 하겠습니다.
'트러블슈팅' 카테고리의 다른 글
IntelliJ에서 Gradle 버전 충돌 해결하기: 완벽한 트러블슈팅 가이드 (0) | 2025.05.24 |
---|---|
Spring Boot에서 발생하는 OutOfMemoryError 완벽 해결 가이드 (0) | 2025.05.24 |
REST API 성능 최적화를 위한 3단계 캐싱 전략과 실무 적용 가이드 (1) | 2025.01.19 |
레거시 오라클 쿼리 리팩토링: 주문번호 부분입력으로 편의성 추가(Feat. 성능 최적화) (0) | 2024.04.19 |
JVM , 아파치, 아파치 톰캣 튜닝 (35) | 2023.09.22 |