개발자는 기록이 답이다
org.hibernate.LazyInitializationException 에러 해결 방법 본문
개발을 하다보면 org.hibernate.LazyInitializationException 에러와 마주치게 될 때가 있습니다. 이 문제는 JPA에서 1:N 참조 관계를 사용할 때 흔히 발생하는 문제 중 하나입니다. JPA를 오랜만에 사용해서 이 문제를 해결하는데 2시간 정도 소요된것 같습니다.
org.hibernate.lazyinitializationexception: could not initialize proxy - no session
문제 원인
+-----------------------+ +----------------------+
| Compensation | | Customer |
+-----------------------+ +----------------------+
| compensationId (PK) | N:1 | customerId (PK) |
| amount |------------>| customerName |
| Customer customer |<------------| List<Compensation> |
+-----------------------+ +----------------------+
LazyInitializationException은 주로 "지연 로딩(Lazy Loading)"을 사용할 때 발생하는 예외입니다. 이 예외는 Entity(Customer)를 로딩하려고 할 때, 해당 엔티티가 이미 영속성 컨텍스트(Persistence Context/Session)가 이미 닫혀 있거나 접근할 수 없는 상태에서 에서 발생합니다. 지연 로딩은 필요한 데이터를 실제로 사용할 때 로딩하는 방식으로, 이로 인해 엔티티가 분리될 수 있습니다.
1. FetchType.EAGER 사용
- FetchType.EAGER는 연관된 엔티티를 즉시 로딩하도록 지시하는 옵션입니다.
- 필요한 데이터를 한 번에 가져올 수 있으므로 성능 면에서 유리할 수 있습니다.
- 특정 상황에서 연관된 엔티티의 크기가 크거나 깊게 중첩된 경우, 불필요한 데이터까지 함께 가져오므로 성능 저하나 메모리 부담이 발생할 수 있습니다.
2. Open Session in View 패턴(Optimistic Locking)
- Spring 프레임워크에서 제공하는 Open Session in View(OSIV) 패턴을 활용하여 세션을 유지하도록 설정합니다
- OSIV 패턴은 클라이언트 요청 처리 동안 영속성 컨텍스트와 세션을 유지하여 LazyInitializationException이 발생하지 않게 합니다.
- 하지만 OSIV 패턴은 장기 실행 트랜잭션이나 대량의 데이터 조회 등으로 인해 성능 이슈가 발생할 수 있다는 점에 주의해야 합니다.
3. DTO로 래핑
- Entity를 DTO(Data Transfer Object)로 래핑하는 것입니다.
- DTO는 엔티티의 필요한 부분만 포함하는 단순한 데이터 전송용 객체로, 엔티티와의 연관성 문제를 해결할 수 있습니다.
- 하지만 DTO 객체와 엔티티 사이의 변환 작업이 추가적으로 필요하며, 복잡한 구조의 객체일 경우 많은 변환 작업이 필요해질 수도 있습니다.
해결 방법
해결 방법 3가지를 보고, 저는 EAGER로 다 바꿔보기도하고, open-in view를 true로 설정도 해보고 false로도 설정해봤는데 계속 에러가 발생했습니다. 게다가 이미 저는 전부다 DTO로 변경했는데 왜 안되는 것인지 고민했습니다.
지연 로딩 : 프록시 객체로 실제 엔티티 객체 참조
즉시 로딩 : 연관관계에 있는 객체까지 바로 조회
게다가 즉시 로딩을 쓰면, 모든 테이블 다 연관시키고, 생각 못한 쿼리가 나간다고해서 즉시로딩(EAGER)으로 바꾸고 싶지 않았습니다.
프록시(Proxy), 지연로딩(LAZY)와 즉시로딩(EAGER)
FetchType을 EAGER로 설정하지 않고 LAZY로 사용하려면, 세션(Session)이 엔티티를 로딩할 수 있는 상태여야 합니다.
그래서 스택 트레이스를 자세히 살펴봤는데, CompensationResponse DTO에서 customer.getName();와 같이 엔티티의 필드에 접근하려고 할 때 발생했습니다. 이때, customer 필드는 LAZY 로딩으로 설정되어 있으므로, 실제 데이터베이스에서 해당 필드를 로딩하는 시점에 세션이 열려있어야 합니다.
CompensationResponse DTO 객체 내부에서 엔티티(Customer)를 참조이 있었다는 것을 깨달았고, 해당부분도 DTO 객체로 변경했습니다. 또한 spring.jpa.open-in-view=true로 설정을 변경하면서 문제가 해결되었습니다.
Request
+
V
+--------------------+ +--------------------------------------------+
| CompensationService| | CompensationController |
+--------------------+ +--------------------------------------------+
| getAllCompensations|--->(Return)---> ResponseEntity<List<CompensationResponse>>|
+--------------------+ +--------------------------------------------+
V
Response
List<CompensationResponse>
+
V
+-------------------------------+
| CompensationResponse |
+-------------------------------+
| compensationId |
| amount |
| customerId |- - - - - -> CustomerResponse
| customerName |- - - - - -> (DTO)
| contactPerson |- - - - - -
| contactNumber |- - - - - -
+-------------------------------+
CustomerResponse
+
V
+------------------------------+
| CustomerResponse |
+------------------------------+
| id |
...
spring.jpa.open-in-view=true // false에서 true로 변경
결론
org.hibernate.LazyInitializationException 에러는 JPA와 영속성 컨텍스트 관련된 문제로, JPA에 대한 더 깊은 이해가 필요하다고 생각했습니다.
스프링과 JPA를 사용하면 트랙잭션 범위내에서만 영속성 컨텍스트를 생성하고 관리한다
수정 전 vs 수정 후 코드 비교
///// 수정 전 코드 /////
package com.api.teamfresh.controller.dto.response;
import com.api.teamfresh.domain.entity.Compensation;
import com.api.teamfresh.domain.entity.Customer;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class CompensationResponse {
private long compensationId;
private Float amount;
private Customer customer;
private CompensationResponse(Compensation compensation) {
this.compensationId = compensation.getId();
this.amount = compensation.getAmount();
this.customer = compensation.getCustomer();
}
public static CompensationResponse of (Compensation compensation) {
if (compensation == null) {
return null;
}
return new CompensationResponse(compensation);
}
}
///// 수정 후 코드 /////
package com.api.teamfresh.controller.dto.response;
import com.api.teamfresh.domain.entity.Compensation;
import com.api.teamfresh.domain.entity.Customer;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class CompensationResponse {
private long compensationId;
private Float amount;
// -> 여기부분을 Customer라는 엔티티 객체를 참조중이었음
private CustomerResponse customer; // 수정된 부분
private CompensationResponse(Compensation compensation) {
this.compensationId = compensation.getId();
this.amount = compensation.getAmount();
// -> CustomerResponse에 있는 생성자 사용해서 참조 변경
this.customer = CustomerResponse.of(compensation.getCustomer()); // 수정된 부분
}
public static CompensationResponse of(Compensation compensation) {
if (compensation == null) {
return null;
}
return new CompensationResponse(compensation);
}
}
// Customer 엔티티 객체 대신 CustomerResponse 클래스 생성
package com.api.teamfresh.controller.dto.response;
import com.api.teamfresh.domain.entity.Customer;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class CustomerResponse {
private long customerId;
private String customerName;
private String contactPerson;
private String contactNumber;
public CustomerResponse(Customer customer) {
this.customerId = customer.getId();
this.customerName = customer.getName();
this.contactPerson = customer.getContactPerson();
this.contactNumber = customer.getContactNumber();
}
public static CustomerResponse of(Customer customer) {
return new CustomerResponse(customer);
}
}
// 컨트롤러
@RequiredArgsConstructor
@RequestMapping("/api")
@RestController
public class CompensationController {
private final CompensationService compensationService;
// 배상 전체 목록 API
@GetMapping("/compensation")
public ResponseEntity<List<CompensationResponse>> getAllCompensations() {
return ResponseEntity.status(HttpStatus.OK).body(compensationService.getAllCompensations());
}
}
// 서비스 (수정 전 - for문 사용)
@RequiredArgsConstructor
@Service
public class CompensationService {
private final CompensationRepository compensationRepository;
// 배상 전체 목록 조회
public List<CompensationResponse> getAllCompensations() {
List<Compensation> compensations = compensationRepository.findAll();
List<CompensationResponse> response = new ArrayList<>();
for (Compensation x : compensations) {
CompensationResponse compensationResponse = CompensationResponse.of(x);
response.add(compensationResponse);
}
return response;
}
}
// 위의 for문을 stream으로 변경해서 가독성 향상
// 서비스 (수정 전 - stream 사용)
@RequiredArgsConstructor
@Service
public class CompensationService {
private final CompensationRepository compensationRepository;
public List<CompensationResponse> getAllCompensations() {
List<Compensation> compensations = compensationRepository.findAll();
return compensations.stream()
.map(CompensationResponse::of)
.collect(Collectors.toList());
}
}
'Spring > 트러블 슈팅' 카테고리의 다른 글
쿠폰 발급에 대한 동시성 처리 (2) - MySQL의 NamedLock, Redis의 분산락(Lettuce, Redisson) (0) | 2024.03.02 |
---|---|
쿠폰 발급에 대한 동시성 처리 (1) - synchronized, pessimistic Lock, optimistic Lock (0) | 2024.02.29 |
DATETIME vs TIMESTAMP 둘 중 어느것이 더 나을까? (0) | 2024.02.11 |
@RequestBody는 어떻게 바인딩 되는걸까? (with. 디버깅 과정) (0) | 2024.02.09 |
Request에 대한 Validation과 Exception 처리에 대한 고찰 (0) | 2024.02.09 |