사용자가 상품을 구매할 때 상품의 색상과 사이즈를 선택할 수 있도록 옵션을 표시하기 위해 해당 상품에 대한 옵션을 조회하는 API를 구현하는 과정에서 LazyInitializationException 오류가 발생했다. 이 오류는 JPA에서 객체의 지연 로딩(Lazy Loading) 방식으로 인해 자주 발생하는 예외 중 하나이며, 해당 포스팅에서는 지연 로딩이 어떻게 작동하는지, 그리고 이로 인해 발생할 수 있는 문제를 해결하는 방법을 정리하고자 한다.
문제가 발생한 메소드는 productId를 받아 해당 제품의 옵션을 조회하는 기능을 수행하는 메소드이다. 조회한 상품 옵션 정보를 JSON 형식으로 응답 본문에 담아 클라이언트에 반환하고자 했다. 하지만 기대와는 달리 클라이언트에는 어떠한 값도 전달되지 않았다. 아래는 문제가 발생한 코드이다:
/**
* 주문 상품 옵션 조회
*/
@GetMapping("/orders/product-options/{productId}")
public ResponseEntity<Object> getProductOptions(@PathVariable("productId") Long productId) {
List<ProductOption> productOptions = productOptionService.getProductOptionsByProductId(productId);
return ResponseEntity.ok().body(productOptions);
}
디버깅 결과 LazyInitializationException이 발생한 것을 확인할 수 있었다.
이 오류는 아래와 같이 JPA의 지연 로딩(Lazy Loading) 방식과 관련이 있다.
ProductOption
과 Product
는 N:1
관계를 가지며, ProductOption
엔티티의 Product
속성은 FetchType.LAZY
로 설정되어 있다.
JPA에서 FetchType.LAZY
는 엔티티를 즉시 로딩하지 않고 필요할 때까지 데이터를 로딩하지 않도록 설정하는 방식이다. 이 설정은 성능 최적화에 유리하나, 사용 시 주의해야 하는 점이 있다. LAZY로 설정된 필드는 실제 데이터가 필요한 시점에 프록시 객체로 초기화되며, 영속성 컨텍스트(Persistence Context)가 열려 있는 상태에서만 접근 가능하다.
서비스 계층(Service Layer)에서 @Transactional
애노테이션이 부여된 메소드 내에서 ProductOption
데이터를 조회할 때 영속성 컨텍스트가 생성되고, ProductOption
엔티티의 Product
필드는 지연 로딩되어 프록시 객체로 유지된다.
그러나 트랜잭션이 종료되면 영속성 컨텍스트가 닫히게 되며, 이 시점 이후로는 프록시 객체에 접근이 불가능하다. 즉, 컨트롤러 계층에서 ProductOption
의 Product
필드에 접근할 때 영속성 컨텍스트가 이미 닫혀 있어, JPA는 데이터를 로딩할 수 없게 되고 LazyInitializationException
예외가 발생하는 것이다.
FetchType을 Eager로 설정한다. 그러나 해당 전략은 연관된 엔티티가 항상 즉시 로딩되기 때문에 성능에 부담이 될 수 있다. 상황에 따라 적절한 FetchType를 선택해야 한다.
Controller 레벨에서도 영속성 컨텍스트의 범위를 유지하기 위해 @Transactional 어노테이션을 추가한다.
Service 단에서 조회한 정보를 DTO로 변환하여 Controller로 반환한다. (트랜잭션이 설정된 서비스 계층에서 연관관계의 엔티티를 미리 조회하고 트랜잭션 종료 시점에 DTO로 변환하는 방법)
위 방법들 중에 3번 방법으로 오류를 해결했다.
3번을 선택한 이유는 DTO를 사용하면 영속성 컨텍스트의 범위를 벗어나도록 하여 LazyInitializationException과 같은 문제를 방지할 수 있고, 엔티티의 연관된 객체를 직접적으로 로딩하지 않고, 필요한 데이터만을 추출하여 전달할 수 있기 때문이다.
/**
* 주문 상품 옵션 조회
*/
@GetMapping("/orders/product-options/{productId}")
public ResponseEntity<Object> getProductOptions(@PathVariable("productId") Long productId) {
return ResponseEntity.ok().body(adminService.getProductOptionsByProductId(productId));
}
/**
* 상품 옵션 조회
*/
public List<ProductOptionDTO> getProductOptionsByProductId(Long productId) {
// 상품 옵션 리스트 조회
List<ProductOption> productOptions = productOptionService.getProductOptionsByProductId(productId);
// Entity -> DTO 변환
List<ProductOptionDTO> productOptionListDTO = new ArrayList<>();
for (ProductOption option : productOptions) {
ProductOptionDTO productOptionDTO = ProductOptionDTO.builder().color(option.getColor()).size(option.getSize()).build();
productOptionListDTO.add(productOptionDTO);
}
return productOptionListDTO;
}