OSIV(Open Session In View)는 JPA의 영속성 컨텍스트를 뷰 렌더링까지 유지하는 패턴이다. 스프링 부트에서는 기본적으로 spring.jpa.open-in-view=true로 설정되어 있어 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()를 호출해도 문제없이 데이터를 조회할 수 있다.
OpenEntityManagerInViewFilter가 동작한다HTTP Request Start
↓
EntityManager 생성
↓
Service Layer Transaction Start
↓
비즈니스 로직 실행
↓
Service Layer Transaction Commit
↓
Controller Layer (영속성 컨텍스트 유지)
↓
View Layer (지연 로딩 가능)
↓
HTTP Response End
↓
EntityManager 종료
장점:
단점:
설정 방법:
spring.jpa.open-in-view=false
장점:
단점:
OSIV가 활성화되면 HTTP 요청당 하나의 데이터베이스 커넥션을 요청 전체 기간 동안 점유한다. 이는 커넥션 풀 고갈로 이어질 수 있다.
// 문제가 되는 시나리오
@GetMapping("/users")
public String getUsers(Model model) {
List<User> users = userService.findAll();
model.addAttribute("users", users);
// 외부 API 호출 (시간이 오래 걸림)
externalApiService.callSlowApi();
return "users"; // 여기까지 커넥션 유지
}
뷰 레이어에서 지연 로딩이 발생하면 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>
비즈니스 로직이 끝났는데도 영속성 컨텍스트가 유지되어 어디서 데이터 변경이 일어나는지 추적하기 어렵다.
가장 근본적인 해결책은 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();
}
필요한 연관 엔티티를 한 번에 조회하여 지연 로딩을 방지한다.
@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);
N+1 문제를 완전히 해결할 수 없다면 배치 사이즈를 설정하여 쿼리 수를 줄인다.
spring.jpa.properties.hibernate.default_batch_fetch_size=100
또는 엔티티별로 설정:
@Entity
@BatchSize(size = 100)
public class Order {
// 필드 정의
}
// 명령용 서비스
@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 인터페이스
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();
자주 조회되는 데이터는 캐싱을 적용하여 데이터베이스 부하를 줄인다.
@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);
}
}