JPA LazyInitializationException(no Session) 해결 - OSIV 밖 SSE에서 터지는 이유

JPA LazyInitializationException(no Session) 해결 - OSIV 밖 SSE에서 터지는 이유

1. 문제 상황

SSE(Server-Sent Events)를 사용해 실시간 알림을 발송하던 중 다음 예외가 발생했다.
com.fasterxml.jackson.databind.JsonMappingException: could not initialize proxy [com.inconcert.domain.category.entity.Category#1] - no Session (through reference chain: com.inconcert.domain.post.dto.PostDTO["postCategory"]-> com.inconcert.domain.category.entity.PostCategory["category"]-> com.inconcert.domain.category.entity.Category$HibernateProxy$TYT8anvG["title"])
▶ 기존 코드
Notification 엔티티를 DTO로 변환하는 convertToDTO에서 postCategory.getCategory().getTitle()을 호출했고, 이 시점에 세션이 닫혀 있어 프록시 객체에 접근하면서 예외가 던져졌다.
private NotificationDTO convertToDTO(Notification notification) { return new NotificationDTO( notification.getId(), notification.getKeyword(), notification.getMessage(), notification.isRead(), notification.getType(), notification.getCreatedAt(), notification.getPost().getPostCategory().getCategory().getTitle(), // 세션 종료 후 프록시 접근 시 LazyInitializationException 발생 notification.getPost().getPostCategory().getTitle(), notification.getPost().getId() ); }

2. 원인을 이해하기 위한 개념 정리

이 예외가 SSE에서만 유독 잘 터지는 이유를 알려면, JPA의 동작 원리를 단계적으로 이해할 필요가 있다.
(1) 영속성 컨텍스트와 세션
JPA가 관리하는 엔티티들이 들어 있는 메모리 공간이 영속성 컨텍스트(Persistence Context)이고, 이를 다루기 위해 DB와 연결을 유지하는 단위가 세션(Session, 정확히는 EntityManager) 이다. 세션은 보통 트랜잭션의 생명주기와 함께 시작되고 닫힌다.
(2) Lazy Loading 과 프록시 객체
연관 엔티티를 매번 모두 가져오면 비효율적이므로, JPA는 기본적으로 LAZY 전략을 권장한다. Lazy로 설정된 연관 필드 자리에는 진짜 엔티티 대신 프록시(Proxy) 객체가 들어간다.
post.postCategory // 실제 타입: PostCategory$HibernateProxy$xyz
이 프록시는 빈 껍데기이며 안에는 ID와 "자기를 만든 세션에 대한 참조"만 들어 있다. 실제 데이터는 누군가 proxy.getTitle()처럼 데이터를 요구하는 메서드를 호출하는 순간, 프록시가 자기 세션에 SQL을 위임해 채워 넣는다. 이를 프록시 초기화 라고 한다.
(3) LazyInitializationException이 던져지는 조건
프록시는 자기를 만든 세션이 살아 있을 때만 데이터를 채울 수 있다. 세션이 닫힌 뒤에 프록시 초기화를 시도하면 다음 예외가 발생한다.
LazyInitializationException: could not initialize proxy - no Session
(4) @Transactional과 OSIV
Spring의 @Transactional은 메서드 시작 시 트랜잭션을 열고 함께 영속성 컨텍스트(세션)를 연다. 메서드가 끝나면 트랜잭션이 커밋, 롤백되고 세션도 함께 닫힌다.
그렇다면 컨트롤러는 트랜잭션이 없을 텐데, 왜 일반 HTTP 응답에서는 Lazy 필드를 잘 직렬화할 수 있을까?
이유는 Spring Boot가 OSIV(Open Session In View)를 기본 활성화하기 때문이다. OSIV는 트랜잭션 종료 시점이 아니라, HTTP 응답이 완전히 나갈 때까지 세션을 살려두는 기능이다.
요청 도착 → OSIV 세션 열림 ↓ 컨트롤러 → 서비스(@Transactional 시작) → 트랜잭션 commit/close ↓ (세션은 여전히 살아있음) 컨트롤러로 복귀 ↓ Jackson이 응답 직렬화 (Lazy 프록시 접근 OK) ↓ 응답 전송 완료 → OSIV 세션 close
이 덕분에 일반 HTTP GET에서는 직렬화 시점에 Lazy 필드를 접근해도 문제없이 동작한다.

3. SSE에서 유독 이 예외가 터지는 이유

OSIV가 켜져 있는데도 SSE에서 LazyInit이 터진다는 것은, SSE의 직렬화 시점이 OSIV의 보호 범위 바깥이라는 뜻이다.
SSE의 알림 발송 흐름은 다음과 같다.
[게시글 등록 요청] 요청 도착 → OSIV 세션 열림 ↓ 컨트롤러 → 서비스(@Transactional) ↓ Notification 엔티티 저장 ↓ sseEmitters.sendToUser(userId, dto) 호출 @Transactional 종료 → 컨트롤러 종료 → 응답 완료 → OSIV 세션 close ↓ ─── 시간이 흐른 뒤 ─── [다른 사용자가 /notifications/stream으로 SSE 연결을 유지 중] SseEmitter가 보관한 dto를 Jackson으로 직렬화하여 클라이언트에 전송 ↓ 이 시점엔 원래 요청의 OSIV 세션이 이미 닫힌 지 오래 ↓ dto 안에 Lazy 프록시가 들어 있으면 → LazyInitializationException
SSE 발송은 원래 요청의 트랜잭션·OSIV 바깥에서 직렬화가 일어난다.
그래서 @Transactional을 붙이거나 OSIV에 의존하는 방식으로는 막을 수 없다. DTO 안에 프록시(=세션이 필요한 객체)를 담아 보내는 것 자체가 문제다.

4. 해결 방법: DTO Projection 으로 프록시를 아예 만들지 않기

DTO 안에 프록시가 들어가지 않도록, 처음부터 단순 값(String, Long, ...)만 담긴 DTO를 만들어 둔다. 이러면 세션 유무와 무관하게 어디서든 안전하게 직렬화할 수 있다.
이를 위해 JPQL의 constructor expression(생성자 표현식)을 활용한 DTO Projection을 사용했다.
@Query("SELECT new com.inconcert.domain.notification.dto.NotificationDTO(n.id, n.keyword, n.message, n.isRead," + "n.type, n.createdAt, c.title, pc.title, p.id) " + "FROM Notification n " + "JOIN n.post p " + "JOIN n.post.postCategory pc " + "JOIN n.post.postCategory.category c " + "WHERE n.user.id = :userId " + "ORDER BY n.isRead Asc, n.createdAt Desc") List<NotificationDTO> findByUserOrderByIsReadAscCreatedAtDesc(@Param("userId") Long userId);
Notification / Post / Category 엔티티가 영속성 컨텍스트에 올라오지 않아서 프록시 자체가 만들어지지 않는다.

5. 추가로 고려할 점

DTO Projection은 다음과 같은 트레이드오프가 있다.
  • 장점: 필요한 컬럼만 한 번의 쿼리로 조회하므로 효율적이고, 결과에 프록시가 없어 직렬화 시점이 자유롭다.
  • 단점: 연관 엔티티가 깊거나 많아질수록 JPQL이 길어지고, 화면/엔드포인트마다 별도 쿼리를 작성해야 해 유지보수 비용이 늘어날 수 있다.

6. 결론

LazyInitializationException 의 본질은 세션이 닫힌 뒤에 프록시 초기화를 시도하는 데 있다.
일반 HTTP 응답에서는 OSIV 덕분에 잘 드러나지 않지만, SSE 발송은 OSIV의 보호 범위 바깥이라 같은 코드여도 여기서는 에러가 터진다. 이를 해결하기 위해 DTO Projection 방식을 적용했다.
결과적으로 LazyInitializationException 없이 SSE 기반 실시간 알림 전송을 안정적으로 수행할 수 있게 됐다.