OSIV — Open Session In View가 만드는 커넥션 풀 지옥
서비스에서 조회한 엔티티의 연관 객체를 컨트롤러에서 접근했는데
LazyInitializationException이 터졌습니다. 어제까지는 잘 됐는데 왜 갑자기?
Spring Boot에서 JPA를 쓰면 기본적으로 OSIV(Open Session In View) 가 켜져 있습니다. 이 설정 하나 때문에 "컨트롤러에서 지연 로딩이 된다/안 된다"가 갈리고, 트래픽이 몰리면 커넥션 풀이 고갈 되는 장애가 발생할 수 있습니다.
개념 정의
OSIV 는 HTTP 요청이 시작될 때 영속성 컨텍스트를 열고, 응답이 끝날 때까지 닫지 않는 패턴입니다. Spring Boot에서는 spring.jpa.open-in-view=true가 기본값입니다.
OSIV가 켜져 있으면 어떻게 동작하나
- 요청이 들어오면
OpenEntityManagerInViewInterceptor가 영속성 컨텍스트를 엽니다. - 서비스 계층에서
@Transactional안의 쿼리가 실행됩니다. - 트랜잭션이 끝나도 영속성 컨텍스트는 ** 살아 있습니다 **.
- 컨트롤러나 뷰에서 지연 로딩된 연관 객체에 접근하면 ** 추가 쿼리가 나갑니다 **.
- HTTP 응답이 완료되어야 비로소 영속성 컨텍스트가 닫히고 커넥션이 반환됩니다.
왜 문제인가 — 커넥션 풀 고갈
핵심 문제는 DB 커넥션을 요청 시작부터 끝까지 점유 한다는 것입니다.
| 항목 | OSIV ON | OSIV OFF |
|---|---|---|
| 커넥션 점유 시간 | 요청 시작 ~ 응답 완료 | 트랜잭션 시작 ~ 커밋 |
| 지연 로딩 범위 | 컨트롤러 + 뷰까지 | @Transactional 안에서만 |
| 커넥션 풀 압박 | 높음 | 낮음 |
HikariCP 기본 커넥션 풀 크기는 10개입니다. OSIV가 켜져 있으면:
- API 응답 시간이 평균 200ms라고 가정
- 동시 요청 10개가 오면 커넥션 10개가 전부 점유됨
- 11번째 요청은 커넥션을 기다리다가 ** 타임아웃**
- 외부 API 호출이나 파일 처리가 포함된 요청은 커넥션을 ** 수 초간** 점유
트래픽이 적을 때는 문제가 없다가, 트래픽이 몰리는 순간 갑자기 "커넥션을 획득할 수 없습니다" 에러가 터집니다. OSIV가 원인인 줄 모르고 커넥션 풀 크기만 늘리면 임시방편이지 근본 해결이 아닙니다.
OSIV를 끄면 뭐가 달라지나
# application.yml
spring:
jpa:
open-in-view: false
끄면 영속성 컨텍스트가 @Transactional이 끝나는 시점에 닫힙니다. 그 이후에 지연 로딩을 시도하면 LazyInitializationException이 발생합니다.
@Service
@Transactional(readOnly = true)
public class TeamService {
public Team findTeam(Long id) {
return teamRepository.findById(id).orElseThrow();
// members는 LAZY — 아직 로딩 안 됨
}
}
@RestController
public class TeamController {
@GetMapping("/teams/{id}")
public TeamResponse getTeam(@PathVariable Long id) {
Team team = teamService.findTeam(id);
team.getMembers().size(); // ← OSIV OFF면 여기서 LazyInitializationException!
return new TeamResponse(team);
}
}
대응 전략
OSIV를 끈 상태에서 지연 로딩 문제를 해결하는 3가지 방법:
1. 서비스 계층에서 필요한 데이터를 미리 로딩
@Service
@Transactional(readOnly = true)
public class TeamService {
public Team findTeamWithMembers(Long id) {
return teamRepository.findByIdWithMembers(id); // fetch join
}
}
// Repository
@Query("SELECT t FROM Team t JOIN FETCH t.members WHERE t.id = :id")
Team findByIdWithMembers(@Param("id") Long id);
2. DTO로 변환해서 반환
@Service
@Transactional(readOnly = true)
public class TeamService {
public TeamResponse findTeam(Long id) {
Team team = teamRepository.findByIdWithMembers(id);
return TeamResponse.from(team); // 트랜잭션 안에서 DTO 변환
}
}
3. @EntityGraph 사용
@EntityGraph(attributePaths = {"members"})
Optional<Team> findById(Long id);
함정 — 이걸 모르면 터진다
Spring Boot 기동 시 WARN 로그를 무시하면 안 된다
OSIV가 켜져 있으면 Spring Boot가 기동 시 이 경고를 출력합니다:
WARN: spring.jpa.open-in-view is enabled by default.
This may cause unexpected behavior...
이 로그를 무시하고 프로덕션에 올리면, 트래픽이 몰릴 때 커넥션 풀 고갈 장애가 발생합니다.
OSIV를 끌 때 모든 API를 테스트해야 한다
기존에 OSIV ON 상태로 개발했다면, 컨트롤러에서 지연 로딩에 의존하는 코드가 곳곳에 있을 수 있습니다. OSIV를 끄는 순간 LazyInitializationException이 여러 곳에서 동시에 터질 수 있으므로, ** 전체 API를 테스트 **해야 합니다.
읽기 전용 트랜잭션에서도 커넥션은 점유된다
@Transactional(readOnly = true)라도 OSIV가 켜져 있으면 커넥션 점유 시간은 동일합니다. readOnly는 Hibernate의 변경 감지를 끄는 것이지, 커넥션 반환 시점과는 무관합니다.
정리
| 항목 | OSIV ON (기본값) | OSIV OFF (권장) |
|---|---|---|
| 영속성 컨텍스트 범위 | HTTP 요청 전체 | @Transactional 범위 |
| 지연 로딩 | 컨트롤러/뷰에서 가능 | 트랜잭션 안에서만 |
| 커넥션 반환 | 응답 완료 시 | 트랜잭션 커밋 시 |
| 커넥션 풀 압박 | 높음 (장애 위험) | 낮음 |
| 추가 작업 | 없음 | fetch join / DTO 변환 필요 |
프로덕션에서는
spring.jpa.open-in-view=false로 설정하고, 필요한 데이터를 서비스 계층에서 미리 로딩하는 것이 안전합니다.