OSIV란?
- OSIV(Open Session In View)는 스프링 부트에서 기본적으로 활성화되어 있는 설정이다.
이 설정이 활성화되어 있으면, HTTP 요청을 처리하는 동안 영속성 컨텍스트(Session)가 살아 있음! (Service를 넘어서 Controller 단에서도 Lazy 로딩이 가능)
- 즉, 트랜잭션이 끝난 이후에도 Controller나 View 렌더링 시점까지 Lazy 로딩이 가능하다.
- 이 시점에 Hibernate는 자동으로 DB에 쿼리를 날려서 프록시를 초기화함
OSIV 비활성화와 문제 상황
- 프로덕션 환경에서는 다음 이유로 OSIV를 끄는 것이 일반적이다.
1. 데이터베이스 커넥션을 너무 오래 점유하지 않기 위해
- OSIV가 활성화되어 있으면, HTTP 요청 ~ View 렌더링까지 DB 커넥션과 영속성 컨텍스트(세션)가 계속 살아 있다.
- 실제로 트랜잭션 처리가 끝났어도, View 단이나 JSON 변환 과정(예: Lazy 필드 직렬화)에서 추가 쿼리가 발생할 수 있으므로, 세션과 커넥션이 계속 유지됨.
- 만약 요청량이 많은 대규모 서비스에서 이 방식(OSIV ON)을 쓰면, 데이터베이스 커넥션 풀이 빨리 고갈될 수 있다.
- 외부 API 대기 시간 만큼 커넥션 리소스를 반환하지 못하고, 유지해야 함
- 커넥션이 부족해지면 다른 요청이 DB를 사용하지 못해 장애로 이어질 수 있음
=> OSIV를 비활성화하면, 트랜잭션이 끝나는 즉시 커넥션도 반납되어 자원을 효율적으로 사용할 수 있다.
2. 계층 구조(Controller ↔ Service ↔ Repository)를 깨지 않기 위해
- 소프트웨어 설계 원칙(계층 분리, Layered Architecture)에선 Service에서 트랜잭션 범위가 끝나면 DB와의 연결도 끝나는 것이 원칙이다.
- OSIV가 켜져 있으면 Controller(혹은 View) 계층에서 엔티티의 Lazy 필드를 접근할 때마다 DB 쿼리가 나갈 수 있는데, 이는
→ Controller나 View가 DB와 직접 통신하는 셈
→ 실제로는 서비스 계층을 통하지 않고 DB를 조회하게 됨.
- 이런 구조에서는 비즈니스 로직과 데이터 접근 로직의 경계가 모호해지고, 디버깅, 테스트, 유지보수가 매우 어려워진다.
=> OSIV를 끄면 트랜잭션 경계가 명확해져서, DB 접근을 Service/Repository 계층으로 한정할 수 있음!
jpa:
open-in-view: false
하지만 이 설정을 끄면 다음과 같은 문제가 발생할 수 있다:
- LazyInitializationException
- Lazy 필드를 트랜잭션 밖에서 조회하려고 하면, 이미 영속성 컨텍스트(Session)가 닫혀 있어서
Hibernate가 프록시를 쿼리를 날려 초기화할 수 없고, 예외가 발생한다.
해결 방법
실무에선 성능 이점 상 필요할 때만 쿼리를 날리는 LAZY LOADING이 기본이므로, 해결해주는 것이 필수!
1. Fetch Join으로 트랜잭션 안에서 연관 엔티티 미리 로딩
@Entity
public class User {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id")
private BinaryContent profile;
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private UserStatus userStatus;
}
- profile은 Lazy 로딩이기 때문에, Service에서 엔티티를 Controller에 반환하고
Controller에서 DTO로 변환하면서 .getProfile()을 호출하면
→ 트랜잭션 밖에서 Lazy Loading 발생
→ LazyInitializationException
이를 방지하기 위해 Fetch Join을 사용하여 트랜잭션 내에서 필요한 연관 객체를 모두 조회!
@Query("""
SELECT DISTINCT u FROM User u
LEFT JOIN FETCH u.profile
LEFT JOIN FETCH u.userStatus
""")
List<User> findAllFetch();
- cf) userStatus는 1:1 양방향의 비주인(mappedBy)이기 때문에, 기본적으로 EAGER지만, N+1 문제를 방지하기 위해 함꼐 Fetch Join
- 이렇게 해준다면, 트랜잭션 시점에 모든 데이터를 조회해서 실제 객체를 영속성 컨텍스트와 자바 메모리에 저장
- 영속성 컨텍스트가 닫히더라도, 트랜잭션 시점에 메모리에도 실제 객체를 로드해놨기 때문에 실제 객체를 조회할 수 있음
- 프록시가 아닌 메모리 상 실제 객체를 조회하기 때문에 Lazy 로딩이 발생하지 않는다.
2. Service 계층에서 DTO로 변환 후 반환
@Transactional(readOnly = true)
public FindUserResult find(UUID userId) {
User user = findUserById(userId);
return userMapper.toFindUserResult(user);
}
- 트랜잭션 시점에 DTO에 필요한 데이터만 담아서 반환
- 트랜잭션 시점에 Lazy 필드에 접근 → 연관된 프록시 전부 조회해서 초기화
- Controller/뷰에서는 DTO만 접근하므로 Lazy 필드에 접근할 일이 없음