클라이언트에서 필요한 정보를 클라이언트에서 모두 호출하기 보다는 하나의 서버가 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
이 없다.
logging:
level:
org.springframework.transaction.interceptor: trace
com.zaxxer.hikari.HikariConfig: DEBUG
com.zaxxer.hikari: TRACE
A 데이터 조회 후에 커넥션 풀을 바로 반환해야하는데, getMyData() 라는 메서드 실행 내내 커넥션을 물고 있는 것을 볼 수 있다. 읭?
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 초까지 지연시간이 생겼고, 결국 커넥션이 부족하다는 에러가 나기 시작했다.
기본적으로 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
트랜잭션 컨텍스트에서 데이터베이스 I/O를 다른 유형의 I/O와 혼합하는 것은 가능한 피해야 한다.
서비스에서 @Transactional 어노테이션을 제거했으므로 안전하다고 생각할 수 있지만, OSIV가 활성화되어 있으면 @Transactional을 제거하더라도 요청 범위에는 항상 세션이 있다. 이 세션은 처음에는 연결되어 있지 않지만 첫 번째 데이터베이스 I/O 이후에 연결되며 요청의 끝까지 연결된 상태로 유지된다.
앞에서 말한 지연시간이 생겼던 이슈처럼 새로운 원격 서비스의 응답 시간이 평소보다 더 느린 경우를 가정하면 혼합 I/O는 피해야 한다.
세션은 전체 요청 수명 주기 동안 열려 있으므로 일부 속성 탐색은 트랜잭션 컨텍스트 외부에서 원치 않는 쿼리를 몇 개 더 트리거할 수 있다. 어디서 n+1 쿼리가 날아가는지 인지하지 못하는 경우도 발생할 수 있다.
설상가상으로 세션은 자동 커밋 모드 에서 모든 추가 쿼리를 실행한다. 자동 커밋 모드에서는 각 SQL 문이 트랜잭션으로 처리되어 실행 직후 자동으로 커밋된다. 이는 결과적으로 데이터베이스에 많은 부담을 준다.
원격 서비스를 많이 호출하거나 트랜잭션 컨텍스트 외부에서 많은 일이 발생하는 경우 OSIV를 모두 비활성화하는 것이 좋다.
spring:
jpa:
open-in-view: false
동시 요청 3000개 테스트 시에 커넥션 풀 부족 에러가 줄어든 것을 알 수 있었다. 프로젝트 구성할 때 부터, OSIV 는 끄는 것이 좋을 것 같다.
위의 설정을 바꾸면 되지만, 이미 규모가 있는 프로젝트에서 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);
}
}