Spring에서 데이터베이스에 대한 Transaction을 적용하다보면 LazyInitialization Exception에 대하여 자주 접하게 된다
오늘은 이 LazyInitializationException에 대하여 알아보자.

LazyInitializationException이란?
LazyInitializationException은 JPA와 같은 ORM 기반 프레임워크에서 지연로딩 된 엔티티를 세션이 닫힌 후에 DB에 접근하려고 할 때 발생하는 예외이다. (세션. 영속성 컨텍스트가 닫힌 뒤에 접근할 때 생기는 에러라고 생각하면 된다!)
지연 로딩(Lazy Loading)이란?
지연 로딩은 엔티티를 불러올 때, 연관된 객체를 실제로 사용하기 전까지는 로딩하지 않는 방식이다.
이는 애플리케이션 성능 최적화에 도움이 많이 된다.
“쓸 때까지 로딩하지 않기” 전략이라 성능 최적화에 유용하지만, 트랜잭션·세션 경계 밖에서 접근하면 문제가 된다.
예시를 통해 확인해보자. 나는 현재 가족 그룹을 기반으로 한 Family라는 Entity와 그 팀의 구성원인 Member라는 Entity를 설계해두었으며, 이 들의 관계는 1:N으로 구성되어있다.
코드는 다음과 같다.
Family.class
@Entity
@Table(name = "family")
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Setter
public class Family extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "family_id")
private Long id;
@OneToMany(mappedBy = "family")
private List<Member> memberList;
}
Member.class
@Setter
@Getter
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "member")
public class Member extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
@ManyToOne
@JoinColumn(name = "family_id")
private Family family;
}
}
위와 같이 설정을 해두었다고 하자. ( 기본으로 OneToMany에 fetch Lazy 붙음)
그러면 부모 엔티티는 굳이 자식 엔티티를 불러오지 않는다. 가령 위의 경우에는 Family.getMemberList()를 하지 않는 이상 member entity는 조회하지 않는 것과 동일하다.
그러면 이런 예외가 발생하는 이유는?
이유에 대하여 더 깊게 알아보자.
예외가 발생하는 이유는 무엇일까?
LazyInitializationException은 바로 프록시 객체 때문에 발생한다.
ORM은 실제 엔티티 대신 프록시 객체를 생성하여 지연 로딩을 구현한다.
이 프록시 객체는 데이터베이스 세션이 살아있는 동안에만 실제 데이터에 접근할 수 있는 것으로,
Spring 트랜잭션 AOP와 관련이 있다.
Spring은 @Transactional을 AOP로 처리하는데, 보통 메서드 시작 시 트랜잭션/세션을 열고 끝날 때 닫는다.
만약, 트랜잭션이 종료되어 세션이 닫힌 후, 닫힌 세션의 프록시 객체를 통해 데이터에 접근하려 하면, 더 이상 데이터베이스와 연결할 수 없으므로 LazyInitializationException이 발생하게 된다.
예시 코드로 확인해보자.
Service 코드
@Service
public class BoardService {
@Autowired
private BoardRepository boardRepository;
@Transactional
public Board getBoard(Long boardId) {
// 이 시점(트랜잭션 내부)에서는 지연 로딩된 replies에 접근 가능
return boardRepository.findById(boardId).get();
}
}
Controller 코드
@RestController
public class BoardController {
@Autowired
private BoardService boardService;
@GetMapping("/boards/{boardId}")
public Board getBoard(@PathVariable Long boardId) {
Board board = boardService.getBoard(boardId);
// 여기서는 트랜잭션이 이미 종료됨 → 세션 닫힘
// 아래 접근 시 LazyInitializationException 발생 가능
System.out.println(board.getReplies().size()); // <-- LazyInitializationException 발생
return board;
}
}
위의 코드의 경우에는,
서비스 메서드 내부에서는 세션이 열려 있어 프록시 초기화가 가능하지만,
컨트롤러로 반환된 뒤 접근하면 세션이 닫혀 예외가 발생한다.
그렇다면 이런 세션관리는 어떻게 하면 좋을까?
해결 방법
LazyInitializationException을 해결하는 몇 가지 방법은 다음과 같다.
1. Fetch Join 사용하기
초기 쿼리에서 필요한 연관 데이터를 함께 로딩한다. N+1도 예방한다.
- 장점: 필요한 데이터만 정확히 가져와 성능·안정성 모두 좋다!
- 단점: 조인 폭증/중복 로우에 주의(컬렉션 fetch join은 페이징과 상충)
// 예시: 특정 게시글과 연관된 댓글을 함께 가져오기
SELECT b FROM Board b JOIN FETCH b.replies WHERE b.id = :id
2. Transaction의 범위 확장하기
연 로딩된 객체를 사용하는 코드까지 트랜잭션 범위를 확장하여 세션이 유지되도록 한다.
Spring Framework에서는 @Transactional 어노테이션의 위치를 조정하면서 트랜잭션 세션을 유지할 수 있다 (Spring 트랜잭션 AOP가 인식 가능하도록 설정)
- 장점: 간단하다. 그냥 Transactional 어노테이션을 필요한 로직까지만 붙이면 된다.
- 단점: 트랜잭션이 불필요하게 길어짐(락/커넥션 점유, 지연 커밋), 웹 계층까지 확장하면 부작용 커짐
3. DTO/ 프로젝션으로 조회 분리하기
처음부터 화면/응답에 필요한 데이터를 전부 다 로딩한 후에 DTO나 따로 객체에 저장한다. 그러면 더이상 DB에 접근하려고 닫힌 세션에 접근하는 일이 없으니 해당 예외가 일어나지 않는다.
- 장점: 계층 간 의존 최소화, 안정적.
- 단점: DTO가 많아지면 코드가 늘어난다.
4. Hibernate.initialize()로 명시적 초기화하기
initialize를 통하여 세션이 열려있는 시점에 컬렉션/프록시를 강제로 초기화한다
Board board = boardRepository.findById(1L).orElseThrow();
Hibernate.initialize(board.getReplies()); // 세션 내에서 초기화
위와 같은 식으로 초기화하면 세션에 대하여 다시 시작이 가능하다.
- 장점: 빠르게 응급처치 가능.
- 단점: 남용 시 의도 파악이 어려워지고 숨은 쿼리 증가.
5. OSIV(Open Session In View)
요청 ~ 응답까지의 세션을 여는 것으로, Springboot는 기본적으로 활성화되어있다고 한다.
나는 이 방법에 대해서는 조사하면서 처음 알았는데,
최근에는 비권장 추세라고 한다. 세션이 장시간 점유로 커넥션 풀 고갈/성능의 위험때문이라고 한다.
@Transactional과 LazyInitializationException — 세션 관리의 중요성
Spring은 AOP(Aspect-Oriented Programming)를 사용하여 @Transactional 어노테이션을 처리한다.
@Transactional이 붙은 메서드가 호출되면, Spring은 다음과 같은 과정을 거친다.
- 프록시 생성: Spring AOP는 @Transactional이 선언된 서비스(Service) 클래스의 메서드에 대해 프록시 객체를 생성한다.실제 비즈니스 로직은 프록시가 아닌 원본 객체에서 실행된다!
- 트랜잭션 시작: 프록시는 메서드 실행 직전에 데이터베이스 세션을 열고 트랜잭션을 시작한다. 이 세션은 메서드 내에서 JPA/Hibernate가 엔티티를 조회하고 조작하는 데 사용된다.
- 지연 로딩: 메서드 내부에서는 지연 로딩된 엔티티에 접근해도 문제가 없다. 세션이 열려있기 때문에 필요 시점에 데이터베이스에서 데이터를 가져올 수 있다! (하나의 트랜잭션 안의 로직은 세션 열려있다)
- 트랜잭션 종료 및 세션 닫기: 메서드가 성공적으로 종료되면, 프록시는 트랜잭션을 커밋하고 데이터베이스 세션을 닫는다. 이 때 세션이 종료되는 것이다. 세션이 닫히는 순간, 해당 세션에 묶여있던 모든 영속성 컨텍스트(Persistent Context)와 관리되던 엔티티들은 준영속 (detached) 상태가 된다!
문제는 메서드 외부, 즉, @Transactional 메서드가 종료되고 나서 발생하는 것이다.
지연 로딩된 필드에 접근하려고 하면, 이미 세션이 닫혀 있으므로 JPA/Hibernate가 데이터를 가져올 수 없게 되므로,
이때 LazyInitializationException이 발생한다.
최근 코드 작업 중 @Transactional의 시점에 대한 중요성을 깨닫는 일을 겪었다.
사건의 발단
한때 @Transactional 메서드가 끝난 뒤 이벤트를 발행하고, 리스너에서 DB 접근을 했다.
이때는 이미 세션이 닫혀 있었고, 리스너에서 지연 로딩 접근이 발생해 예외가 났다.
해결
리스너 쪽에도 @Transactional을 적용해 세션을 확보했다. 이후 예외가 사라졌다.
핵심 교훈은 단순하다: DB 접근 시점(트랜잭션 경계) 을 명확히 설계하자.
이벤트/비동기 리스너에서 DB 접근 시 별도 트랜잭션을 여는지에 대한 체크가 항상 필수이다.
그런데 이 트랜잭션에 대하여 좀더 고도화한다면,
서비스 로직에서의 트랜잭션이 커밋된 이후에 새로 트랜잭션을 여는 쪽으로 설계하는 것이 중요하다.
트랜잭션 한 단계 더 고도화하기 - 커밋 이후 새로운 트랜잭션 설계
앞서 설명했듯 @Transactional 메서드가 종료되는 시점에 Hibernate 세션도 함께 닫히므로,
이후 지연 로딩(Lazy Loading)을 시도하면 LazyInitializationException이 발생한다.
하지만 단순히 트랜잭션 범위를 늘려 문제를 덮는 방식은 성능 저하·락 점유·불필요한 커넥션 유지라는 새로운 문제를 야기한다.
이때 고려할 수 있는 설계가 서비스 로직의 트랜잭션이 커밋된 이후, 별도의 트랜잭션을 새로 여는 방식이다.
왜 새로운 트랜잭션을 굳이 파야할까?
- 후처리(Post-Processing) 로직 분리
서비스 메서드가 끝난 뒤 이벤트 발행, 알림 전송, 통계 집계와 같이 메인 로직의 성공 여부와 독립적인 작업이 필요할 수 있다.
이런 로직까지 기존 트랜잭션에 묶으면,- 불필요하게 DB 락이 오래 유지되고
- 작업이 길어질수록 전체 커밋이 늦어지며
- 장애 시 메인 로직이 롤백될 수 있다.
- 지연 로딩 필요
후처리 로직에서 다시 DB 조회가 필요하다면 이미 세션이 닫혔으므로 새로운 트랜잭션이 없이는 정상 동작이 어렵다.
그래서 생긴 것이 Spring에서의 트랜잭션 전파(Propagation) 속성이다.
이에 대해서는 다음 포스트에서 작성하겠다!
마무리
LazyInitializationException은 단순한 “예외”가 아니라, 세션 관리와 트랜잭션 경계가 잘못 설계됐다는 신호다.
가장 안전한 기본 해법은 “처음부터 필요한 데이터만” 가져오는 것이다.
Fetch Join과 DTO/프로젝션을 적절히 조합하고, 트랜잭션 경계를 서비스 계층에서 명확히 유지하자.
필요한 경우에만 명시적 초기화나 범위 확장을 선택적으로 활용하면 된다.
핵심은 변하지 않는다. 세션이 열려 있을 때만 프록시가 진짜 데이터를 데려올 수 있다는 사실이다.
💡 Tip
@Transactional은 단순히 트랜잭션을 관리하는 것뿐 아니라, DB 세션의 라이프사이클과도 밀접하게 연결되어 있습니다. 트랜잭션 경계 밖에서 Lazy Loading을 시도하면 예외가 발생하는 이유가 바로 여기에 있습니다.
'백엔드 > SpringBoot' 카테고리의 다른 글
| [SpringBoot] UnitTest (1) | 2025.08.27 |
|---|---|
| [SpringBoot] Spring에서 비동기 작업과 스케줄링 - ThreadPoolTaskScheduler 편 (0) | 2025.08.22 |
| [Spring Boot] 동시성 제어하기 (5) | 2025.08.10 |
| [JPA] Page와 Slice 클래스 구분하기 (0) | 2025.08.08 |
| [JWT] JWT에서의 로그아웃 (6) | 2025.06.23 |