Lazy Loading과 OSIV

양성준·2025년 4월 18일

스프링

목록 보기
35/49

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 변환
}
  • 트랜잭션 시점에 DTO에 필요한 데이터만 담아서 반환
    • 트랜잭션 시점에 Lazy 필드에 접근 → 연관된 프록시 전부 조회해서 초기화
  • Controller/뷰에서는 DTO만 접근하므로 Lazy 필드에 접근할 일이 없음
profile
백엔드 개발자를 꿈꿉니다.

0개의 댓글