JPA OSIV와 성능 최적화

Agida·2025년 9월 17일

JPA

목록 보기
6/8
post-thumbnail

1. OSIV란 무엇인가

OSIV(Open Session In View)는 JPA의 영속성 컨텍스트를 뷰 렌더링까지 유지하는 패턴이다. 스프링 부트에서는 기본적으로 spring.jpa.open-in-view=true로 설정되어 있어 OSIV가 활성화된 상태이다.

OSIV의 핵심 개념

OSIV는 HTTP 요청이 시작될 때 영속성 컨텍스트를 생성하고, 요청이 완전히 끝날 때까지 이를 유지한다. 이로 인해 지연 로딩(Lazy Loading)이 뷰 레이어에서도 정상적으로 동작한다.

@Entity
public class User {
    @Id
    private Long id;

    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Order> orders = new ArrayList<>();
}

위 엔티티에서 orders는 지연 로딩으로 설정되어 있다. OSIV가 활성화된 상태에서는 컨트롤러나 뷰에서 user.getOrders()를 호출해도 문제없이 데이터를 조회할 수 있다.

2. OSIV 동작 원리

요청 처리 과정

  1. 요청 시작: HTTP 요청이 들어오면 OpenEntityManagerInViewFilter가 동작한다
  2. EntityManager 생성: 새로운 EntityManager와 영속성 컨텍스트를 생성한다
  3. 트랜잭션 처리: 서비스 레이어에서 트랜잭션이 시작되고 종료된다
  4. 뷰 렌더링: 트랜잭션이 종료되었지만 영속성 컨텍스트는 유지된다
  5. 요청 완료: 응답이 완료되면 영속성 컨텍스트를 종료한다

영속성 컨텍스트 생명주기

HTTP Request Start
    ↓
EntityManager 생성
    ↓
Service Layer Transaction Start
    ↓
비즈니스 로직 실행
    ↓
Service Layer Transaction Commit
    ↓
Controller Layer (영속성 컨텍스트 유지)
    ↓
View Layer (지연 로딩 가능)
    ↓
HTTP Response End
    ↓
EntityManager 종료

3. OSIV 활성화 vs 비활성화 시나리오

OSIV 활성화 시 (기본값)

장점:

  • 지연 로딩이 뷰 레이어에서 자유롭게 동작한다
  • 개발 편의성이 높다
  • LazyInitializationException을 피할 수 있다

단점:

  • 데이터베이스 커넥션을 오래 점유한다
  • 성능 문제가 발생할 수 있다
  • 예상치 못한 쿼리가 발생할 수 있다

OSIV 비활성화 시

설정 방법:

spring.jpa.open-in-view=false

장점:

  • 데이터베이스 커넥션을 효율적으로 사용한다
  • 명확한 트랜잭션 경계를 갖는다
  • 성능 예측이 용이하다

단점:

  • 지연 로딍 사용 시 LazyInitializationException 발생
  • 필요한 데이터를 미리 페치해야 한다

4. OSIV 관련 성능 문제점

주요 성능 이슈

1. 커넥션 풀 고갈

OSIV가 활성화되면 HTTP 요청당 하나의 데이터베이스 커넥션을 요청 전체 기간 동안 점유한다. 이는 커넥션 풀 고갈로 이어질 수 있다.

// 문제가 되는 시나리오
@GetMapping("/users")
public String getUsers(Model model) {
    List<User> users = userService.findAll();
    model.addAttribute("users", users);

    // 외부 API 호출 (시간이 오래 걸림)
    externalApiService.callSlowApi();

    return "users"; // 여기까지 커넥션 유지
}

2. 예상치 못한 쿼리 발생

뷰 레이어에서 지연 로딩이 발생하면 N+1 문제가 은밀하게 발생할 수 있다.

// Controller
@GetMapping("/orders")
public String getOrders(Model model) {
    List<Order> orders = orderService.findAll();
    model.addAttribute("orders", orders);
    return "orders";
}

// View (Thymeleaf)
// 각 order마다 user 정보를 조회하는 쿼리가 발생
<div th:each="order : ${orders}">
    <span th:text="${order.user.name}"></span>
</div>

3. 트랜잭션 범위 모호성

비즈니스 로직이 끝났는데도 영속성 컨텍스트가 유지되어 어디서 데이터 변경이 일어나는지 추적하기 어렵다.

5. 성능 개선 방안

방안 1: OSIV 비활성화 + DTO 사용

가장 근본적인 해결책은 OSIV를 비활성화하고 필요한 데이터를 DTO로 조회하는 것이다.

// DTO 클래스
public class UserOrderDto {
    private String userName;
    private String orderNumber;
    private LocalDateTime orderDate;

    // 생성자, getter 생략
}

// Repository 메서드
@Query("SELECT new com.example.dto.UserOrderDto(u.name, o.orderNumber, o.orderDate) " +
       "FROM Order o JOIN o.user u")
List<UserOrderDto> findUserOrderDto();

// Service 메서드
@Transactional(readOnly = true)
public List<UserOrderDto> getUserOrders() {
    return orderRepository.findUserOrderDto();
}

방안 2: Fetch Join 활용

필요한 연관 엔티티를 한 번에 조회하여 지연 로딩을 방지한다.

@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")
List<Order> findOrdersWithUser(@Param("status") OrderStatus status);

// 또는 EntityGraph 사용
@EntityGraph(attributePaths = {"user", "orderItems"})
@Query("SELECT o FROM Order o WHERE o.status = :status")
List<Order> findOrdersWithUserAndItems(@Param("status") OrderStatus status);

방안 3: Batch Size 최적화

N+1 문제를 완전히 해결할 수 없다면 배치 사이즈를 설정하여 쿼리 수를 줄인다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

또는 엔티티별로 설정:

@Entity
@BatchSize(size = 100)
public class Order {
    // 필드 정의
}

방안 4: 쿼리 최적화 패턴

커맨드와 쿼리 분리 (CQRS)

// 명령용 서비스
@Service
@Transactional
public class OrderCommandService {
    public void createOrder(CreateOrderRequest request) {
        // 주문 생성 로직
    }
}

// 조회용 서비스
@Service
@Transactional(readOnly = true)
public class OrderQueryService {
    public List<OrderDto> getOrdersForDisplay() {
        // 조회 전용 최적화된 쿼리
        return orderRepository.findOrderDtoForDisplay();
    }
}

Projection 인터페이스 활용

// Projection 인터페이스
public interface OrderProjection {
    String getOrderNumber();
    String getUserName();
    LocalDateTime getOrderDate();
}

// Repository 메서드
@Query("SELECT o.orderNumber as orderNumber, u.name as userName, o.orderDate as orderDate " +
       "FROM Order o JOIN o.user u")
List<OrderProjection> findOrderProjections();

방안 5: 캐싱 전략

자주 조회되는 데이터는 캐싱을 적용하여 데이터베이스 부하를 줄인다.

@Service
@Transactional(readOnly = true)
public class UserQueryService {

    @Cacheable(value = "users", key = "#id")
    public UserDto getUserById(Long id) {
        return userRepository.findUserDtoById(id);
    }

    @Cacheable(value = "userOrders", key = "#userId")
    public List<OrderDto> getUserOrders(Long userId) {
        return orderRepository.findOrderDtoByUserId(userId);
    }
}
profile
백엔드

0개의 댓글