서비스에서 조회한 엔티티의 연관 객체를 컨트롤러에서 접근했는데 LazyInitializationException이 터졌습니다. 어제까지는 잘 됐는데 왜 갑자기?

Spring Boot에서 JPA를 쓰면 기본적으로 OSIV(Open Session In View) 가 켜져 있습니다. 이 설정 하나 때문에 "컨트롤러에서 지연 로딩이 된다/안 된다"가 갈리고, 트래픽이 몰리면 커넥션 풀이 고갈 되는 장애가 발생할 수 있습니다.

개념 정의

OSIV 는 HTTP 요청이 시작될 때 영속성 컨텍스트를 열고, 응답이 끝날 때까지 닫지 않는 패턴입니다. Spring Boot에서는 spring.jpa.open-in-view=true가 기본값입니다.

OSIV가 켜져 있으면 어떻게 동작하나

  1. 요청이 들어오면 OpenEntityManagerInViewInterceptor가 영속성 컨텍스트를 엽니다.
  2. 서비스 계층에서 @Transactional 안의 쿼리가 실행됩니다.
  3. 트랜잭션이 끝나도 영속성 컨텍스트는 ** 살아 있습니다 **.
  4. 컨트롤러나 뷰에서 지연 로딩된 연관 객체에 접근하면 ** 추가 쿼리가 나갑니다 **.
  5. HTTP 응답이 완료되어야 비로소 영속성 컨텍스트가 닫히고 커넥션이 반환됩니다.

왜 문제인가 — 커넥션 풀 고갈

핵심 문제는 DB 커넥션을 요청 시작부터 끝까지 점유 한다는 것입니다.

항목OSIV ONOSIV OFF
커넥션 점유 시간요청 시작 ~ 응답 완료트랜잭션 시작 ~ 커밋
지연 로딩 범위컨트롤러 + 뷰까지@Transactional 안에서만
커넥션 풀 압박높음낮음

HikariCP 기본 커넥션 풀 크기는 10개입니다. OSIV가 켜져 있으면:

  1. API 응답 시간이 평균 200ms라고 가정
  2. 동시 요청 10개가 오면 커넥션 10개가 전부 점유됨
  3. 11번째 요청은 커넥션을 기다리다가 ** 타임아웃**
  4. 외부 API 호출이나 파일 처리가 포함된 요청은 커넥션을 ** 수 초간** 점유

트래픽이 적을 때는 문제가 없다가, 트래픽이 몰리는 순간 갑자기 "커넥션을 획득할 수 없습니다" 에러가 터집니다. OSIV가 원인인 줄 모르고 커넥션 풀 크기만 늘리면 임시방편이지 근본 해결이 아닙니다.

OSIV를 끄면 뭐가 달라지나

YAML
# application.yml
spring:
  jpa:
    open-in-view: false

끄면 영속성 컨텍스트가 @Transactional이 끝나는 시점에 닫힙니다. 그 이후에 지연 로딩을 시도하면 LazyInitializationException이 발생합니다.

JAVA
@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. 서비스 계층에서 필요한 데이터를 미리 로딩

JAVA
@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로 변환해서 반환

JAVA
@Service
@Transactional(readOnly = true)
public class TeamService {
    public TeamResponse findTeam(Long id) {
        Team team = teamRepository.findByIdWithMembers(id);
        return TeamResponse.from(team);  // 트랜잭션 안에서 DTO 변환
    }
}

3. @EntityGraph 사용

JAVA
@EntityGraph(attributePaths = {"members"})
Optional<Team> findById(Long id);

함정 — 이걸 모르면 터진다

Spring Boot 기동 시 WARN 로그를 무시하면 안 된다

OSIV가 켜져 있으면 Spring Boot가 기동 시 이 경고를 출력합니다:

PLAINTEXT
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로 설정하고, 필요한 데이터를 서비스 계층에서 미리 로딩하는 것이 안전합니다.

댓글 로딩 중...