[Spring] 꺼진 OSIV 도 다시보자

Hocaron·2024년 2월 27일
2

Spring

목록 보기
41/44
post-thumbnail
post-custom-banner

클라이언트에서 필요한 정보를 클라이언트에서 모두 호출하기 보다는 하나의 서버가 DB, 다른 서버 호출한 데이터를 조합해서 응답하고 있다고 해보자.

B 데이터를 조회하는 서버에서 장애 발생시에 다른 서비스로 장애 전파가 되면 안 되기 때문에, B 데이터를 조회하는 서버에는 서킷 브레이커가 달려있다. A는 동기적으로 조회하고, B 와 C 는 병렬로 조회하는 방식이다. B 와 C 서버 호출시에 타임 아웃은 2초로 잡혀있다.

배포 후에 B 서버에서 부하가 심해져, API 호출시 레이턴시가 100ms 에서 20 ~ 30초로 급격하게 늘었다. B 서버의 레이턴시 증가로 인해 우리 서버에서 커넥션 풀이 부족해져, 우리 서버에서 사용하는 모든 쿼리에 지연 시간이 발생하기 시작했다🚨

org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction
	at org.springframework.orm.jpa.JpaTransactionManager.doBegin(JpaTransactionManager.java:466)
...

B 서버 호출 구간에 서킷 브레이커를 달아놓았고, 레이턴시가 증가하자 서킷도 잘 열렸다. 근데 왜 커넥션 풀이 부족해지는 이슈가 있었을까?

커넥션 풀 부족하게 만든 원인 코드

    public MyDataDto getMyData(long id) {

        var aData = ARepository.findById(id)				-- A 데이터 DB 조회
                .orElseThrow();

        Mono<Respoonse> bResponse = BWebClient.getData(id); -- B 데이터 API 조회

        Mono<Respoonse> cResponse = CWebClient.getData(id); -- C 데이터 API 조회
...

@Transactional 때문에 해당 메서드가 실행되는 동안 커넥션을 물고 있나 했지만, 컨트롤러에서 호출하는 제일 상위 메서드인 getMyData() 에는 @Transactional 이 없다.

HikariCP 로그 찍어보기

logging:
  level:
    org.springframework.transaction.interceptor: trace
    com.zaxxer.hikari.HikariConfig: DEBUG
    com.zaxxer.hikari: TRACE

A 데이터 조회 후에 커넥션 풀을 바로 반환해야하는데, getMyData() 라는 메서드 실행 내내 커넥션을 물고 있는 것을 볼 수 있다. 읭?

커넥션 풀 부족의 원인은 OSIV

OSIV 가 켜져있는 상태에서 메서드 지연시간이 늘어난다면

    public MyDataDto getMyData(long id) {

        var aData = ARepository.findById(id)				-- 쿼리 소요 시간: 0.12-> 21.84.orElseThrow();

        Mono<Respoonse> bResponse = BWebClient.getData(id); -- 2초 지연 발생하도록 설정

        Mono<Respoonse> cResponse = CWebClient.getData(id);
...

동시 요청 3000개 시에, 초반에는 데이터 조회 시에 0.12 초 걸리던 쿼리가 커넥션이 부족해지자 21.84 초까지 지연시간이 생겼고, 결국 커넥션이 부족하다는 에러가 나기 시작했다.

OSIV 는 어떻게 동작할까

  1. OpenSessionInViewFilter 초기화
    OpenSessionInViewFilter는 Hibernate 세션을 요청 전체 처리 동안 열어두기 위한 서블릿 필터이다.
    이 필터는 SessionFactory의 openSession 메서드를 호출하여 새로운 Hibernate 세션을 얻는다.
    얻은 세션은 TransactionSynchronizationManager에 바인딩되어 전체 요청 동안 사용할 수 있다.
  2. 요청 처리 시작
    doFilter 메서드가 FilterChain 객체에 의해 호출되어 요청의 계속 처리를 허용한다.
  3. DispatcherServlet 및 컨트롤러 호출
    DispatcherServlet이 호출되어 HTTP 요청을 기본 컨트롤러(여기서는 PostController)로 라우팅한다.
  4. 서비스 레이어 트랜잭션
    PostController는 PostService를 호출하여 Post 엔터티 목록을 가져온다.
    PostService는 새로운 트랜잭션을 시작하며, HibernateTransactionManager는 OpenSessionInViewFilter에서 열린 동일한 Hibernate 세션을 재사용한다.
  5. 데이터 액세스 레이어
    PostService는 PostDAO (데이터 액세스 객체)에게 Post 엔터티 목록을 가져오도록 위임한다.
    PostDAO는 어떠한 게으른 연관성도 초기화하지 않고 Post 엔터티 목록을 검색한다. 게으른 연관성은 Hibernate에서 즉시 가져오지 않고 필요할 때 로드되는 관계이다.
  6. 트랜잭션 커밋
    PostService는 기본 트랜잭션을 커밋한다. 그러나 세션이 외부에서 열렸기 때문에( OpenSessionInViewFilter에서), 이 시점에서 세션은 닫히지 않는다.
  7. 뷰 렌더링 시작
    DispatcherServlet이 사용자 인터페이스 (UI)를 렌더링하기 시작한다.
  8. 게으른 연관성 초기화
    렌더링 과정 중에 UI는 Post 엔터티의 게으른 연관성을 탐색한다.
    이 탐색은 게으른 연관성의 초기화를 트리거하며 추가적인 데이터베이스 쿼리를 날린다.
  9. 세션 닫힘
    OpenSessionInViewFilter는 이제 Hibernate 세션을 닫을 수 있습니다. 렌더링 프로세스가 완료되었기 때문이다.
    세션과 관련된 데이터베이스 연결이 해제된다.

기본적으로 spring.jpa.open-in-view는 활성화되어있고, 서버 실행 시에 아래와 같은 로그를 볼 수 있다.

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

OSIV 가 켜져있는 상태에서 조심해야 하는 경우

혼합 I/O 피하기

트랜잭션 컨텍스트에서 데이터베이스 I/O를 다른 유형의 I/O와 혼합하는 것은 가능한 피해야 한다.

서비스에서 @Transactional 어노테이션을 제거했으므로 안전하다고 생각할 수 있지만, OSIV가 활성화되어 있으면 @Transactional을 제거하더라도 요청 범위에는 항상 세션이 있다. 이 세션은 처음에는 연결되어 있지 않지만 첫 번째 데이터베이스 I/O 이후에 연결되며 요청의 끝까지 연결된 상태로 유지된다.

앞에서 말한 지연시간이 생겼던 이슈처럼 새로운 원격 서비스의 응답 시간이 평소보다 더 느린 경우를 가정하면 혼합 I/O는 피해야 한다.

불필요한 쿼리

세션은 전체 요청 수명 주기 동안 열려 있으므로 일부 속성 탐색은 트랜잭션 컨텍스트 외부에서 원치 않는 쿼리를 몇 개 더 트리거할 수 있다. 어디서 n+1 쿼리가 날아가는지 인지하지 못하는 경우도 발생할 수 있다.

설상가상으로 세션은 자동 커밋 모드 에서 모든 추가 쿼리를 실행한다. 자동 커밋 모드에서는 각 SQL 문이 트랜잭션으로 처리되어 실행 직후 자동으로 커밋된다. 이는 결과적으로 데이터베이스에 많은 부담을 준다.

결론

원격 서비스를 많이 호출하거나 트랜잭션 컨텍스트 외부에서 많은 일이 발생하는 경우 OSIV를 모두 비활성화하는 것이 좋다.

해결 방법

OSIV 를 비활성화 시킨다

spring:
  jpa:
    open-in-view: false

동시 요청 3000개 테스트 시에 커넥션 풀 부족 에러가 줄어든 것을 알 수 있었다. 프로젝트 구성할 때 부터, OSIV 는 끄는 것이 좋을 것 같다.

custom filter 구현

위의 설정을 바꾸면 되지만, 이미 규모가 있는 프로젝트에서 OSIV 를 비활성화 하기에는 부담이 있을 것이다. @Transactional 밖에서 Lazy Loading 을 사용할 수도 있으니..

그렇다면 특정 url 만 osiv 에서 제외시켜주는 커스텀 필터를 구현해보자.

import org.springframework.dao.DataAccessException;
import org.springframework.lang.Nullable;
import org.springframework.web.context.request.WebRequest;
import org.springframework.orm.jpa.support.OpenEntityManagerInViewInterceptor;

@Configuration
public class MyOpenEntityManagerInViewInterceptor extends OpenEntityManagerInViewInterceptor {

    private boolean isExcludeOsiv(WebRequest request) {
        // 특정 URL에 대한 체크 로직을 여기에 구현
        // 예: "/v1/datas/**" 경로를 포함하는 경우 OSIV를 비활성화하도록 설정
        String uri = ((ServletWebRequest) request).getRequest().getRequestURI();
        return uri != null && uri.contains("/v1/datas/");
    }

    @Override
    public void preHandle(WebRequest request) throws DataAccessException {
        if (isExcludeOsiv(request)) {
            return;
        }
        super.preHandle(request);
    }

    @Override
    public void afterCompletion(WebRequest request, @Nullable Exception ex) throws DataAccessException {
        if (isExcludeOsiv(request)) {
            return;
        }
        super.afterCompletion(request, ex);
    }
}

정리

  • I/O 가 많은 서비스라면 OSIV 비활성화를 고려해보자.
  • 커넥션을 오래 갖고 있지는 않은지 커넥션 풀 로그로 확인해보자.

References

profile
기록을 통한 성장을
post-custom-banner

0개의 댓글