김영한 님의 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화 강의를 보고 작성한 내용입니다.
OSIV 는 Open Session In View 의 약자로 하이버네이트에서 Session 이 JPA 에서는 Entity Manager 입니다.
애플리케이션을 실행하면 WARN 로그로 아래와 같은 문구가 출력되며, spring.jpa.open-in-view
는 기본값이 true 입니다.
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
JPA 에서 영속성 컨텍스트가 동작을 하려면 내부적으로 DB 커넥션을 1 : 1
로 사용해야 합니다. 그래야만 지연로딩과 같은 것들을 사용할 수 있습니다. 이처럼 영속성 컨텍스트와 DB 커넥션은 굉장히 밀접하게 매칭이 되어 있습니다.
JPA 는 DB 트랜잭션을 시작할 때 JPA 의 영속성 컨텍스트가 DB 커넥션을 가져옵니다. DB 커넥션을 획득한 후에 다시 돌려줄 때 차이가 조금씩 존재합니다.
OSIV 가 켜져있으면 Service 에서 트랜잭션이 종료된 후 Controller 에 다시 돌아가도 커넥션을 반환하지 않습니다. 왜냐하면 지연로딩 시에 프록시 객체를 채우기 위해서는 DB에서 데이터를 가져와야 하기 때문에 영속성 컨텍스트가 DB 커넥션을 계속 가지고 있어야 하기 때문입니다.
그래서 OSIV 는 트랜잭션 작업이 끝나도 이 영속성 컨텍스트를 API 의 경우 API 가 유저에게 반환될 때까지, 화면의 경우 view template 을 가지고 렌더링해서 유저에게 응답이 나갈 때까지, 즉, 응답을 다 만들었기 때문에 더 이상 필요없는 단계가 될 때까지 끝까지 살려두고, 그동안 DB 커넥션을 계속 가지고 있다가 응답이 나가야 DB 커넥션을 돌려주고 영속성 컨텍스트가 사라지게 됩니다.
이렇듯 OSIV 전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지하기 때문에 View Template 이나 API 컨트롤러에서 지연 로딩이 가능했던 것입니다. ( 지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지합니다. )
하지만 이 전략은 너무 오랜시간동안 데이터베이스 커넥션 리소스를 사용하기 때문에, 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있으며 장애로 이어질 수도 있습니다.
OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환합니다. 따라서 커넥션 리소스를 낭비하지 않습니다.
그렇게 되면 모든 지연로딩을 트랜잭션 안에서 처리해야 합니다. 따라서 지금까지 작성한 많은 지연 로딩 코드를 트랜잭션 안으로 넣어야 하는 단점이 있습니다. 그리고 view template 에서 지연로딩이 동작하지 않게 됩니다.
결론적으로 패치조인을 사용하거나 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 하는 것입니다.
public class OrderApiController {
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName();
order.getDelivery().getAddress();
List<OrderItem> orderItems = order.getOrderItems();
orderItems.forEach(orderItem -> orderItem.getItem().getName());
}
return all;
}
}
예를 들어 살펴보겠습니다. OSIV 가 켜져있으면 findAllByString()
를 통해 조회하고 List<Order>
를 반환받았을 때도 DB 커넥션이 살아 있습니다. 그래서 아래 for 문에서 지연로딩이 일어나 데이터를 가져올 수 있는 것입니다.
만약 spring.jpa.open-in-view
를 false 로 설정하면 아래와 같은 에러가 나게 됩니다.
org.hibernate.LazyInitializationException: could not initialize proxy - no Session
findAllByString()
후에 Member 는 프록시 객체이고, for 문에서 이를 초기화하려고 하니까 초기화를 하지 못해 오류가 발생한 것입니다.
왜냐하면 getMember().getName()
을 호출하는 순간 영속성 컨텍스트를 통해서 프록시를 초기화해야 하지만 Service 에서 트랜잭션이 끝나는 순간 영속성 컨텍스트가 다 날라가 버리기 때문입니다.
public class OrderApiController {
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
return orderQueryService.findAllV1();
}
}
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderQueryService {
private final OrderRepository orderRepository;
public List<Order> findAllV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName();
order.getDelivery().getAddress();
List<OrderItem> orderItems = order.getOrderItems();
orderItems.forEach(orderItem -> orderItem.getItem().getName());
}
return all;
}
}
새로운 서비스를 만들어서 @Transactional
을 readOnly 로 설정한 후에 Controller 에서 사용하던 모든 로직을 새로운 서비스에서 수행하도록 합니다.
그러면 지연로딩이 트랜잭션 안에서 일어나게 되어 OSIV 를 off 해도 정상적으로 응답이 나가게 됩니다.
QueryDSL 은 자바 코드로 JPQL 을 작성할 수 있도록 도와주며, 컴파일 시점에 타입 안정성을 보장합니다. 무엇보다 동적으로 쿼리를 생성하는 데 용이하며, 복잡한 조건을 간단하게 표현할 수 있습니다.
dependencies {
//Querydsl 추가
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}
clean {
delete file('src/main/generated')
}
QueryDSL 을 사용하기 위해 위의 의존성을 추가합니다. 저는 generated 가 생성되지 않았지만 Q 클래스를 사용할 수 있었고, 기능도 정상적으로 동작하였습니다.
public class OrderRepository {
...
public List<Order> findAll(OrderSearch orderSearch) {
JPAQueryFactory query = new JPAQueryFactory(em);
QOrder order = QOrder.order;
QMember member = QMember.member;
return query.select(order)
.from(order)
.join(order.member, member)
.where(statusEq(orderSearch.getOrderStatus()),
nameLike(orderSearch.getMemberName()))
.limit(1000)
.fetch();
}
private BooleanExpression statusEq(OrderStatus statusCond) {
if (statusCond == null) {
return null;
}
return QOrder.order.status.eq(statusCond);
}
private BooleanExpression nameLike(String memberName) {
if (!StringUtils.hasText(memberName)) {
return null;
}
return QMember.member.name.like(memberName);
}
}
위의 코드는 주문 내역을 조회할 때 동적쿼리를 QueryDSL 로 작성한 모습입니다.
where 에 member.name.like(...)
를 하면 정적 쿼리가 되므로, 따로 메서드를 생성해 전달된 값이 없으면 null 이 반환되도록 합니다.