목차
1. 문제 상황
정책 필러팅 조회 API(`/api/v1/users/profile/policy-filter`) 호출 시, 다음과 같은 예외가 발생했다.
2024-09-29T17:21:23.834Z ERROR 1 --- [nio-8080-exec-1] c.e.w.exception.GlobalExceptionHandler
: Handler in Exception Error Message = Could not write JSON: failed to lazily initialize a collection of role
: com.example.withpeace.domain.User.regions: could not initialize proxy - no Session
이 오류는 JSON 직렬화 과정에서 발생한 것으로, `User` 엔티티의 `regions` 필드에서 지연 로딩된 데이터를 직렬화하려다 실패한 상황이다.
전체 스택 트레이스 일부
2024-09-29T17:21:23.834Z ERROR 1 --- [nio-8080-exec-1] c.e.w.exception.GlobalExceptionHandler :
Handler in Exception Error Message = Could not write JSON:
failed to lazily initialize a collection of role:
com.example.withpeace.domain.User.regions: could not initialize proxy - no Session
...
org.springframework.http.converter.HttpMessageNotWritableException:
Could not write JSON: failed to lazily initialize a collection of role:
com.example.withpeace.domain.User.regions: could not initialize proxy - no Session
...
Caused by: com.fasterxml.jackson.databind.JsonMappingException:
failed to lazily initialize a collection of role:
com.example.withpeace.domain.User.regions: could not initialize proxy - no Session
(through reference chain: com.example.withpeace.dto.ResponseDto["data"]
->com.example.withpeace.dto.response.UserPolicyFilterResponseDto["region"])
...
Caused by: org.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role:
com.example.withpeace.domain.User.regions: could not initialize proxy - no Session
...
로그 분석
- 예외는 응답 객체를 JSON으로 변환하는 직렬화 과정에서 발생했다.
- `User` 엔티티의 `regions` 필드는 `@OneToMany(fetch = FetchType.LAZY)`로 설정되어 있어, 실제 데이터 대신 프록시 객체로 존재하고 있었다.
- `UserPolicyFilterResponseDto`의 `region` 필드를 직렬화하려는 시점에 Hibernate 세션이 이미 종료되어 있었고, 이로 인해 프록시 객체를 통해 데이터를 초기화할 수 없어 `LazyInitializationException`이 발생했다.
- `ResponseDto["data"] → UserPolicyFilterResponseDto["region"]`: Jackson의 직렬화 경로에서 문제가 발생한 위치이다.
즉, 컨트롤러가 반환하는 DTO가 JSON으로 변환될 때, 내부의 Lazy 컬렉션 필드에 접근하면서 예외가 발생한 것이다.
2. 문제 원인 분석
발생한 예외의 핵심은 Hibernate의 Lazy Loading 전략과 트랜잭션의 종료 시점이 맞물리면서 발생한 `LazyInitializationException`이다.
2.1. 지연 로딩(Lazy Loading)의 동작 방식
JPA에서 `FetchType.LAZY`로 설정된 필드는 엔티티 조회 시 데이터베이스에서 로딩되지 않고 Hibernate 프록시 객체로 대체된다. 해당 필드에 실제로 접근하는 시점에 DB 쿼리가 실행된다.
Hibernate 프록시 객체란?
실제 엔티티의 지연 로딩을 위해 사용되는 대리 객체 (실제 데이터를 로딩하지 않은 상태의 가짜 객체)
User 엔티티 코드 일부
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "user_regions", joinColumns = @JoinColumn(name = "user_id"))
@Enumerated(EnumType.STRING)
@Column(name = "region")
private List<EPolicyRegion> regions;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "user_classifications", joinColumns = @JoinColumn(name = "user_id"))
@Enumerated(EnumType.STRING)
@Column(name = "classification")
private List<EPolicyClassification> classifications;
`regions`, `classifications` 필드는 `user.getRegions()` 또는 `user.getClassifications()`이 최초 호출될 때 DB로부터 데이터를 로드하게 된다. 이때 Hibernate의 세션이 열려 있어야 로딩이 가능하다.
2.2. 세션 종료 후 프록시 객체 접근
기존 서비스 코드
public UserPolicyFilterResponseDto getRegionAndClassification(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_USER));
return UserPolicyFilterResponseDto.from(user); // Lazy 필드 접근
}
- `userRepository.findById(userId)` 호출 시, `regions`, `classifications`는 프록시 객체로만 존재한다.
- 트랜잭션이 없기 때문에 메서드가 종료되면 Hibernate 세션도 즉시 종료된다.
- 이후 `UserPolicyFilterResponseDto.from(user)`에서 지연 로딩 필드에 접근 → 세션이 닫혀 있어 프록시 초기화 실패
위 코드로 인해서 아래 예외가 발생하게 된다.
LazyInitializationException: could not initialize proxy - no Session
2.3. DTO 변환과 JSON 직렬화 타이밍 문제
기존 DTO 코드
@Builder
public record UserPolicyFilterResponseDto(
List<EPolicyRegion> region,
List<EPolicyClassification> classification
) {
public static UserPolicyFilterResponseDto from(User user) {
return UserPolicyFilterResponseDto.builder()
.region(user.getRegions())
.classification(user.getClassifications())
.build();
}
}
- 위 코드에서는 Hibernate 프록시 객체가 DTO에 그대로 전달된다.
- 이후 컨트롤러가 DTO를 응답으로 반환하는 과정에서 Jackson이 내부 필드에 접근하며 JSON 직렬화를 시도한다.
- 이 시점에 `getRegions()`, `getClassifications()`에 접근하게 되고, 이미 닫힌 세션을 통해 프록시 초기화를 시도하면서 예외가 발생한다.
=> Jackson은 직렬화 시 DTO의 getter에 접근하고, 이 과정에서 `getRegions()`와 같이 지연 로딩된 필드에 접근하게 된다. 해당 필드가 프록시 객체로 남아 있고 Hibernate 세션이 이미 종료된 상태라면, 프록시 초기화가 불가능해 ` LazyInitializationException`가 발생한다.
3. 해결방법
`LazyInitializationException`은 지연 로딩된 필드에 트랜잭션 범위 밖에서 접근하면서 발생한 문제이다. 두 가지 조치로 문제를 해결했다.
3.1. 서비스 계층에서 @Transactional 적용 (트랜잭션 범위 확장)
`User` 엔티티를 조회하고 DTO로 변환하는 메서드에 `@Transactional`을 추가해 Lazy 필드가 초기화되는 시점까지 Hibernate 세션이 열린 상태로 유지되도록 트랜잭션 범위를 확장했다.
기존 코드
public UserPolicyFilterResponseDto getRegionAndClassification(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_USER));
return UserPolicyFilterResponseDto.from(user);
}
수정 후
@Transactional // 추가
public UserPolicyFilterResponseDto getRegionAndClassification(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new CommonException(ErrorCode.NOT_FOUND_USER));
return UserPolicyFilterResponseDto.from(user);
}
- `@Transactional`이 적용되면 메서드 실행 동안 트랜잭션이 유지되고, Hibernate의 영속성 컨텍스트(Session)도 열려 있는 상태가 된다.
- `user.getRegions()` 또는 `user.getClassifications()`와 같은 Lazy 컬렉션을 안전하게 초기화할 수 있다.
(user 객체가 from 메서드로 전달된 후, getter 호출 시점에 Lazy 컬렉션이 초기화된다.) - DTO 변환 시점까지 세션이 유지되기 때문에, Lazy 컬렉션을 문제없이 사용할 수 있다.
초기화란?
Hibernate 입장에서 지연 로딩되어 있던 프록시 객체를 실제 엔티티(또는 컬렉션)로 채우는 작업
3.2. DTO 생성 시 컬렉션 즉시 초기화 (프록시 컬렉션 복사)
트랜잭션 내에서 Lazy 컬렉션이 초기화되었다고 하더라도, 해당 컬렉션은 Hibernate가 생성한 프록시 컬렉션 객체로 존재한다.
(Lazy 컬렉션은 Hibernate에서 프록시 형태로 관리되며, 내부 데이터를 로딩하더라도 원본 객체는 프록시이다.)
이런 프록시 컬렉션이 DTO에 그대로 전달되면, 컨트롤러의 응답 직렬화 과정에서 다시 `LazyInitializationException` 예외가 발생할 수 있다.
DTO는 표현 계층(View, Response 등)에서 사용되는 객체이기 때문에, 영속성 계층(Hibernate Session, 프록시 등)에 의존해서는 안된다.
따라서 DTO를 생성할 때, 프록시 컬렉션을 명시적으로 복사하여 Hibernate와 분리된 새로운 컬렉션 객체로 전달하도록 변경했다.
기존 코드
@Builder
public record UserPolicyFilterResponseDto(
List<EPolicyRegion> region,
List<EPolicyClassification> classification
) {
public static UserPolicyFilterResponseDto from(User user) {
return UserPolicyFilterResponseDto.builder()
.region(user.getRegions()) // 프록시 컬렉션 그대로 전달
.classification(user.getClassifications()) // 프록시 컬렉션 그대로 전달
.build();
}
}
수정 후
@Builder
public record UserPolicyFilterResponseDto(
List<EPolicyRegion> region,
List<EPolicyClassification> classification
) {
public static UserPolicyFilterResponseDto from(User user) {
return UserPolicyFilterResponseDto.builder()
.region(new ArrayList<>(user.getRegions())) // 프록시 컬렉션 복사
.classification(new ArrayList<>(user.getClassifications())) // 프록시 컬렉션 복사
.build();
}
}
- `new ArrayList<>(user.getRegions())`는 초기화된 프록시 컬렉션의 실제 데이터를 복사한 새로운 리스트를 생성한다.
- 이 리스트는 Hibernate와 연결되지 않으므로, 세션이 닫힌 이후에도 JSON 직렬화에 안전하게 사용될 수 있다.
- 이 방식은 표현 계층과 영속성 계층의 의존성을 분리한다는 점에서 관심사의 분리 원칙(Separation of Concerns) 에도 만족한다.
- 단일 책임 원칙(SRP)은 클래스 내부 책임에 대한 것이고,
관심사의 분리 원칙(SoC)은 계층 간 의존성 분리 관점이기 때문에 여기서는 SoC가 정확한 표현이다.
- 단일 책임 원칙(SRP)은 클래스 내부 책임에 대한 것이고,
정리
- 트랜잭션 범위 확장: `@Transactional`을 적용해 Lazy 컬렉션이 초기화되는 시점까지 Hibernate 세션을 유지
- 프록시 컬렉션 초기화: 초기화된 프록시 컬렉션을 DTO 생성 시 명시적으로 복사하여 직렬화 오류 방지
4. 추가로 고려할 수 있는 해결 방법
해결 방법 | 설명 | 장점 | 주의사항 |
`@Transactional` + 컬렉션 복사 (현재 사용) | 트랜잭션 내에서 컬렉션 초기화 후 DTO에 복사 | 단순하고 명확 | 복사 누락 시 예외 발생 가능 |
`@EntityGraph` | 엔티티 조회 시 연관된 필드를 명시적으로 함께 로딩 | 선언만으로 쿼리 조정 가능 | 필요한 필드만 지정해야 하며 과도한 로딩 주의 |
JPQL `fetch join` | 쿼리에서 직접 연관 컬렉션을 조인 | 쿼리 제어 가능, Lazy 문제 사전 차단 | 쿼리 복잡도 증가 가능 |
DTO Projection | 쿼리 결과를 바로 DTO로 반환 | 엔티티 분리 명확 | 유지보수 복잡도 증가 |
5. 회고 및 인사이트
- 단순히 데이터를 DTO에 담아 반환하는 과정에서도 Hibernate 세션의 생명 주기와 직렬화 시점이 충돌할 수 있다.
- Lazy Loading은 효율적인 데이터 로딩을 가능하게 하지만, 그만큼 Hibernate 세션의 유효 범위에 대한 이해가 필요하다.
- `@Transactional`의 범위를 인식하고, 지연 로딩된 프록시 컬렉션을 트랜잭션 내에서 안전하게 초기화할 수 있었다.
- DTO 생성 시 Hibernate에 의존하지 않도록 컬렉션을 명시적으로 복사하여 직렬화 오류를 사전에 차단했다.
- “영속성 계층과 표현 계층의 분리”라는 관심사의 분리 원칙(Separation of Concerns)이 실제로 어떻게 적용되는지 경험할 수 있었다.
'Projects > 청하-청년을 위한 커뮤니티 서비스' 카테고리의 다른 글
[청하] Swagger UI 개선 - API 문서 가독성 향상 (0) | 2025.04.03 |
---|---|
[청하] 청년 정책 검색 기능 - (2) 구현 (0) | 2025.02.04 |
[청하] 맞춤 & 핫한 정책 조회 기능 - MySQL의 ONLY_FULL_GROUP_BY 오류 해결 (0) | 2025.01.01 |
[청하] AI 사서 프롬프트 엔지니어링 (0) | 2024.12.31 |
[청하] 맞춤 정책 리스트 조회 기능 구현 (0) | 2024.12.31 |
댓글