
🚨 어떤 문제가 있었나?
Spring Boot 프로젝트에서 알림함 조회 API를 구현 중, 알림 종류 중 일부는 관련 유저의 userId를 응답에 포함시켜야 했다.
평소처럼 DTO에 반환형식 추가해서 userId 반환해야지~ 생각했는데…? 갑자기 스웨거에서 500 에러가 뙇.
인텔리제이 커맨드창을 보니 다음과 같은 오류가 발생했다.
org.hibernate.LazyInitializationException:
Could not initialize proxy [com.umc.hwaroak.domain.Member#4] - no session
LazyInitializationException…? 프록시..? 노 세션..?
처음 보는 문제여서 정리해본다.
✅어떻게 해결했을까?
알림 엔티티의 sender 필드는 LAZY 로딩으로 설정되어 있었다. 따라서 알림을 조회할 때는 sender가 실제 객체가 아니라 프록시 객체 상태로 남아 있었다.
그런데 해당 필드에 접근하는 시점에는 이미 세션이 종료된 상태였고, 프록시가 실제 데이터를 로딩하려다 LazyInitializationException이 발생한 것이다.
그렇다면 이를 해결하기 위해서 어떤 방법을 선택할 수 있을까?
1. fetch join으로 미리 로딩시켜놓자
@Query로 직접 쿼리문을 작성하여, LEFT JOIN FETCH a.sender를 통해 sender 필드를 미리 로딩하여 DTO 변환 시점에서 Lazy 로딩 오류가 발생하지 않게끔 한다.
@Query("""
SELECT a FROM Alarm a
LEFT JOIN FETCH a.sender
WHERE a.receiver = :receiver
OR (a.receiver IS NULL AND a.alarmType IN ('NOTIFICATION', 'DAILY'))
ORDER BY a.createdAt DESC
""")
List<Alarm> findAllIncludingGlobalAlarms(@Param("receiver") Member receiver);
2. 서비스 레이어에 조회 트랜잭션 열기 (@Transactional(readOnly = true))
@Transactional(readOnly = true)
public List<AlarmResponseDto.InfoDto> getAllAlarmsForMember() {
...
}
서비스 메서드에서 조회 트랜잭션을 열어두면 트랜잭션 시작과 함께 영속성 컨텍스트가 생성되고, DTO 변환 과정에서 LAZY 로딩이 발생해도 세션이 살아있어서 프록시 초기화(DB 조회)가 가능해진다.
나는 고민하다가 이 방법을 선택했다.
문제를 해결하다가 질문이 하나 생긴다.
왜 이 방법을 선택했고, 굳이 @Transactional(readOnly = true)를 써야 할까?
JPA는 내부적으로 영속성 컨텍스트(Persistence Context) 를 통해 DB와 통신한다.
이 영속성 컨텍스트는:
- 트랜잭션 시작 시 생성
- 트랜잭션 종료 시 소멸
즉, 트랜잭션이 없다 = 영속성 컨텍스트도 없다. LAZY 로딩은 이 영속성 컨텍스트가 살아 있어야 동작한다.
트랜잭션이 열리면 내부적으로 이런 흐름이 된다:
[트랜잭션 시작]
↓
영속성 컨텍스트 생성
↓
Alarm 조회
↓
DTO 변환 중 sender 접근
↓
Lazy 로딩 수행
↓
[트랜잭션 종료]
즉, DTO 변환이 끝날때까지 세션을 안전하게 유지해주는 역할을 한다. 이 덕분에 LazyInitializationException이 발생하지 않는다.
많은 사람들이 readOnly = true를 단순히 “쓰기 막는 옵션”이라고 생각하지만, Hibernate 관점에서는 조금 더 의미가 있다.
- Flush 모드 조정
- Dirty Checking 비활성화
- 변경 감지 스킵
- 일부 DB에서 조회 최적화 힌트 전달
즉 “이 트랜잭션은 조회 전용이니까 변경 감지 안 할게” 라는 뜻이다. 그래서 일반 트랜잭션보다 약간 더 가볍게 동작한다.
트랜잭션이 열리면 커넥션 점유, 영속성 컨텍스트 생성, 트랜잭션 관리 비용 발생 등의 cost가 생기므로 트랜잭션의 경계를 잘 선택해야 할 듯 하다.
🤔 무엇을 배웠나?
이번 문제의 본질은: LAZY 로딩을 “세션이 닫힌 뒤”에 호출한 것이다.
이를 해결하기 위해
조회 서비스에 @Transactional(readOnly = true)를 선언해
영속성 컨텍스트를 안전하게 유지하는 방법을 선택했다.
이 방식은 단순한 임시방편이 아니라, JPA의 동작 원리에 맞는 올바른 트랜잭션 경계 설정이다.