JPA 성능 튜닝 #3 - OSIV

PEPPERMINT100·2024년 11월 15일
0

OSIV는 open-session-in-view의 약자이다. 스프링 JPA의 설정 값 중 하나로 application.properties 혹은 yml 파일에 작성할 수 있다.

뷰에서 세션을 연다. JPA에서 대강 이런 의미인데, 이전까지 조금 궁금했던 내용중에 하나였다.

이 설정값도 JPA의 성능에 지대한 영향을 끼칠 수 있는 설정값으로 존재는 알았지만 어떤 영향을 주는지 잘 몰랐고 이 설정값이 평소에 궁금하던 내용이었다는 것을 강의에서 배우고 조금 놀랐다.

OSIV는 영속성 컨텍스트의 유지 라이프사이클을 설정한다.

영속성 유지

JPA를 이용해서 persist 메소드를 사용하면 해당 영속성에 대해 컨텍스트가 열린다. 이 영속 상태는 언제까지 유지 될까?

OSIV의 디폴트 값은 true이므로 기본적으로는 이러하다.

Repository로부터 데이터를 가져오고 Service에서 비즈니스 로직을 처리하고 컨트롤러에 의해 API 응답을 반환하거나 html template을 통해 View로 나갈때까지 유지가 된다.

한 요청에 의해 완전한 응답을 할 때 까지 유지 된다는 것이다. 이 설정에 의해 어떤 결과가 생길까?

// OrderController
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
    List<Order> all = orderService.findAll();
    for (Order order : all) {
        order.getMember().getName(); //Lazy 강제 초기화
        order.getDelivery().getAddress(); //Lazy 강제 초기화
        List<OrderItem> orderItems = order.getOrderItems();
        orderItems.stream().forEach(o -> o.getItem().getName()); //Lazy 강제 초기화
    }
    return all;
}

// OrderService
@Transactional
public List<Order> findAll() {
    return repository.findAll();
}

바로 위와 같은 코드가 작성이 가능하다. true 설정에 의해 Service에서 Repository로부터 받은 영속상태를 Controller단까지 유지한다.

그리고 Controller단까지 영속상태가 유지되기 때문에 루프문을 통해서 Lazy load가 가능하다.

OSIV를 true, 즉 디폴트 값으로 설정해두면 영속상태가 계속해서 유지가 되기 때문에 어디서든지 Lazy load하여 데이터를 꺼내올 수 있게 된다.

하지만 OSIV를 false로 설정하게 되면,

이렇게 영속상태 유지 사이클이 변한다. 정확히 하자면 한 트랜잭션 내에서만 영속 상태를 유지하도록 한다.

일반적으로 서비스단의 메소드에 트랜잭션을 걸고 로직을 처리하므로 위 도식처럼 @Transactional 메소드 안에서만 영속상태가 유지된다는 것이다.

그러면

// OrderController
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
    List<Order> all = orderService.findAll();
    for (Order order : all) {
        order.getMember().getName(); // 프록시 에러!!
        order.getDelivery().getAddress(); // 프록시 에러!!
        List<OrderItem> orderItems = order.getOrderItems();
        orderItems.stream().forEach(o -> o.getItem().getName()); // 프록시 에러!!
    }
    return all;
}

트랜잭션 범위를 벗어난 컨트롤러에서는 JPA가 미리 담아둔 프록시에 접근하는데 영속 상태가 종료되었으므로 프록시 에러가 발생하게 된다.

따라서 모든 Lazy 로딩 처리부터 영속 상태를 이용하는 코드는 트랜잭션 안에서 처리를 해주어야 한다.

// OrderController
@GetMapping("/api/v1/orders")
public List<Order> ordersV1() {
    return orderService.findAll();
}

// OrderService
@Transactional
public List<Order> findAll() {
    List<Order> all = repository.findAll();
    
    // Lazy 로드 코드 이동
    for (Order order : all) {
      order.getMember().getName(); //Lazy 강제 초기화
      order.getDelivery().getAddress(); //Lazy 강제 초기화
      List<OrderItem> orderItems = order.getOrderItems();
      orderItems.stream().forEach(o -> o.getItem().getName()); //Lazy 강제 초기화
    }
    
    return all;
}

그렇다면 이렇게 굳이 트랜잭션을 신경써서 영속 상태를 고민해야하는 OSIV 옵션을 끄는 이유는 뭘까?

그건 바로 데이터베이스의 커넥션에 있다. 스프링부트는 데이터베이스 한정된 커넥션 풀에서 데이터베이스에 접근할 때 커넥션을 얻어와서 요청을 처리한다.

하지만 만약 이 API에 포함된 코드중에 데이터베이스와 관련이 없는 코드가 시간을 오래 끈다면 API가 응답할 때 까지 데이터베이스의 커넥션을 잡아두게 된다.

그런 요청이 또 많이 들어오게 된다면 순식간에 데이터베이스의 커넥션 풀은 소진되고, 다음 요청을 처리할 수 없는 상태가 된다.

또 개발 패턴의 관점에서 OSIV를 false로 두면 개발자들이 트랜잭션 내에서 모든 엔티티의 변화를 처리하게 되므로 트랜잭션 외부에서 엔티티의 변화가 없음을 명시하는 역할도 할 수 있다.

따라서 OSIV 옵션을 false로 두고, 트랜잭션 범위를 잘 설정해서 빠르게 커넥션을 사용하고 반납하는 방향이 좋다.

느낀 점

이렇게 세 편으로 김영한님의 JPA 고급 강의를 통해 JPA 성능 튜닝에 대한 내용을 배웠다. 김영한님 피셜로 현업의 90%는 이정도 최적화로 문제가 해결된다고 하며 강의를 시작하셔서 굉장히 놀랐는데,

사실 나는 현업에서 비슷한 문제를 정말 많이 겪었고 김영한님이 제시해주신 방법을 혼자 구글링하면서 찾아서 적용을 해왔었다.

다만 해당 상황에서 내가 적용한 기법에 맞았는지에 대해서는 확실하지 않다. 예를 들어 JPA를 조금 잘 쓰면 해결 가능한 상황인데에도, 결국 JPQL을 쌩으로 때리거나 Native Query를 활성화 시켜서 MySQL 코드를 작성한 경험도 있다.

이것 말고도 일대다 문제를 해결하느라 메모리에 데이터를 따로 가져와서 쿼리를 여러번 날리지 않고 계산하는 방식도 이런저런 고민을 하다가 적용한 경험도 많았다.

당시에는 그게 적절한 방법인지 모르고 아쉽다고 생각했었는데, 강의에서 현업에서는 좋은 대안이라고 소개시켜주었을 때에는 회사에서 머리 싸매며 코드를 작성한 시간이 헛되지 않았구나 라는 생각에 기분이 굉장히 좋기도 했다.

하지만 정확한 기준 없이 그때그때 쿼리의 효율을 챙겨서 성능 문제는 해결해왔지만 유지보수하기가 어려운 코드가 되었고 네이티브 쿼리 옵션까지 사용할 때에는 JPA를 사용하는데에도 MySQL 코드를 사용하는게 조금 어색하게 느껴진적도 있었다.

JPA를 현업에서 부딫치면서 사용했을 때는 트레이드오프가 결론이다라는 것을 깨닫지 못하고 그때그때 다른 주관을 가지고 그때그때 다른 기술, 방법으로 적용해왔는데 이 기술을 깊게 알고 있는 개발자님의 강의를 들으니 JPA에 대한 주관이 확실해지는 것 같다.

profile
기억하기 위해 혹은 잊어버리기 위해 글을 씁니다.

0개의 댓글