금융서비스 MSA 전환기- BFF 와 CircuitBreaker 적용(2편)

김형래
finda 기술 블로그
17 min readJul 31, 2023

안녕하세요, FINDA 현금그로스 PG 자산/신용관리 PT 백엔드 개발자 김형래 입니다.

이번 글에서는 자산/신용관리 PT 에서 기존 서비스를 Frontend 에 서빙하기 편리한 구조인 BFF(Backend For Frontend) 와 대량 트래픽에서 운영 서비스에 유입되는 트래픽 조절을 위한 CircuitBreaker 적용에 대해 알아보도록 할게요!

왜 BFF(Backend For Frontend) 서비스를 필요로 할까요?

BFF 패턴

간단히 말하면, Frontend 를 위한 Backend 서버를 말합니다. BFF 패턴이 생겨난 이유는 다양한 디바이스 장치가 생겨나면서 각각의 환경에 맞는 UI 가 필요해졌기 때문입니다(WEB, Mobile, Watch, Tablet 등). 같은 서비스라도 사용하는 디바이스 환경에 맞추어 보여지는 UI 와 Data 가 다르므로 이를 해결하기 위한 방법으로 BFF 패턴이 나타났습니다. BFF 패턴은 모놀리틱 구조라면 필요하지 않지만, MSA 를 이용할 때 유용합니다.

BFF 의 장점

  • Client 별 필요한 데이터 전달 가능(여러 API 호출에 대한 응답 수정, 조합)
  • 서비스 별 API 호출을 최소화 처리
  • Frontend 는 복잡한 로직을 단순화 처리
  • MSA 환경에서 API Endpoint 분리 시, Client 에 변경없이 처리 가능
  • Browser 의 CORS 또는 인증 방식 등과 같은 공통 부분 처리

BFF 의 단점

  • 코드 중복이나 재 작성을 해야하는 단점이 존재합니다.

API Gateway 서비스와 차이점

BFF 는 API Gateway 와 유사하게 보이나 아래의 내용을 보면 명확하게 다릅니다.

  • API Gateway 는 Frontend의 요청에 대한 Backend의 응답을 변경 X
  • BFF 는 Frontend 의 UI/UX에 필요한 API 와 데이터만 제공

자산/신용 관리 PT 에서 BFF 를 적용한 구조

일반적인 API 서비스 패턴(Left), BFF 패턴(Right)

일반적인 API 서비스 패턴과 비교해 볼 때, 중간에 자산 관리 서비스(BFF)가 추가되었습니다. 현재는 WEB 이 Mobile APP 내에 존재하고 독립된 WEB 페이지에 API 를 서빙하고 있지 않으므로 WEB/Mobile APP 에 동일한 BFF 를 적용했습니다. 추후 FINDA 의 WEB 페이지 API 를 제공할 경우, WEB 전용 BFF 를 추가할 예정입니다. 현재는 고객이 FINDA APP 으로 API 요청 시, 여신/수신/MYDATA 정보를 조합해서 WEB/Mobile APP 내의 복잡한 로직을 요구하지 않도록 하며 응답을 처리합니다. 즉, 이를 통해 Frontend 가 소망하는 ‘UI/UX 에 필요한 데이터만 받는 구조' 를 구현할 수 있게 됩니다.

대량의 트래픽으로 장애 전파가 되고 있는 경우, 어떻게 막아야 할까요?

트래픽 증가로 장애 전파의 시작

MYDATA 관리 서비스 장애 발생(CircuitBreaker 적용 전)
자산 관리 서비스 장애 발생(CircuitBreaker 적용 전)

위 그래프를 보면 12:45 부터 초당 오류와 지연시간이 증가하고 있습니다. JVM Non-Heap 와 Pod Memory Usage 를 보면 서비스들이 Running 하는 POD 가 Up & Down 이 되고 있는 것을 볼 수 있습니다. 해당 장애는 아래의 순서대로 전파가 진행되고 있었습니다.

장애전파 흐름

해당 장애전파로 FINDA APP 의 메인 홈에서 렌더링이 지연되고 원활한 기능을 사용하는데 응답시간이 오래 걸리게 되었습니다. MYDATA 관리 서비스에서 트래픽 또는 처리 로직에 지연시간이 증가하거나, 서버에 다수의 오류가 발생할 경우, API 호출을 요청한 서비스들에 장애가 전파되는 이슈가 발생한 겁니다. 얼마 후, 대환대출 서비스가 오픈 되므로 위와 같은 이슈는 전사적으로 큰 장애물이 될 수 있다고 판단했습니다. 그래서 장애 전파를 차단하고 트래픽 조절을 위해 CircuitBreaker 패턴을 도입하기로 결정했습니다. 상세한 내용은 아래에서 자세히 살펴 볼게요.

장애 전파를 CircuitBreaker 패턴으로 막기

CircuitBreaker 패턴에 대해 알아볼게요. MSA 로 구성된 시스템에서는 다른 서비스를 호출하는 경우가 빈번합니다. 서버들 간에 장애가 발생할 때, 호출한 다른 서비스에 장애가 전파되면서 Client 까지 장애가 전파됩니다. 또한, 장애가 발생한 서버에 계속해서 요청을 보내는 것은 장애를 복구하는데 어려움을 만듭니다. 그러므로 장애가 발생한 서비스를 탐지하고 요청을 차단하는 필요성이 생겼습니다. 이 문제를 해결하기 위해서 CircuitBreaker 패턴이 등장했습니다. 이 패턴은 Release It 이라는 책에서 처음 소개되었어요. 이 패턴을 통해 시스템의 장애 전파를 막고, 장애 복구를 도와 사용자에 불필요한 대기를 방지합니다. 즉, Client 측면에서 장애 전파를 차단하는 도구로 실패할 수 있는 작업을 계속 시도하지 않도록 방지합니다. 자산관리 서버에서는 외부 통신부를 Webclient 를 사용해서 처리하고 있습니다. 그래서 타 서비스 간의 API 통신부에 Resilience4J 를 사용해서 CircuitBreaker 설정을 추가했습니다. Resilience4J 는 함수형 프로그래밍 방식으로 설계된 lightweight fault tolerance 라이브러리로, CircuitBreaker 패턴을 위해 사용됩니다. fault-tolerance 란 하나 이상의 구성 요소에 문제가 생겨도 무중단으로 지속 가능한 시스템을 의미합니다. Resilience4J 를 적용하면 외부 서비스에 장애가 발생해도 서비스는 계속 운영이 가능하다는 것입니다. 참고로 Java 진영의 CircuitBreaker 라이브러리로는 Netflix 에서 만든 Hystrix(오픈소스) 가 있습니다. 하지만, Hystrix 는 deprecated 되었고 Hystrix에서도 Resilience4J 의 사용을 권장하고 있어서 선택하게 되었습니다.

Hystrix Status

아래의 코드는 Resilience4j 를 사용한 것으로 자산관리 서버에서 트래픽 조절하면서 과도한 Thread 생성과 고갈을 막고 있습니다.

우선 CommonWebClientApiComponent class 로 타 서비스 별 API 에 사용되는 Webclient 를 추상화하도록 구현했습니다.

@RequiredArgsConstructor
@Component
public abstract class CommonWebClientApiComponent {
protected WebClient webClient;
protected final String X_AUTH_TOKEN = "X-AUTH-TOKEN";

protected CommonWebClientApiComponent(WebClient webClient) {
this.webClient = webClient;
}

public <T> Mono<T> callApiMono(WebClientApiReqData<T> reqData) {
return webClient.mutate()
.baseUrl(reqData.getBaseUrl())
.build()
.method(reqData.getHttpMethod())
.uri(reqData.getUri())
.accept(MediaType.APPLICATION_JSON)
.header(X_AUTH_TOKEN, reqData.getAuthToken())
.retrieve()
.onStatus(HttpStatus::isError, response -> processError(reqData, response))
.bodyToMono(reqData.getResponseType());
}

protected <T> Mono<Throwable> processError(WebClientApiReqData<T> reqData, ClientResponse response) {
ExternalServerType externalServerType = reqData.getExternalServerType();
return switch (response.rawStatusCode()) {
case 400 -> response.bodyToMono(reqData.getResponseType())
.flatMap(rsp -> Mono.error(
new HttpErrorBadRequestException(externalServerType, AmsResponseCode.CODE40001,
HttpStatus.BAD_REQUEST)));
case 401 -> response.bodyToMono(reqData.getResponseType())
.flatMap(rsp -> Mono.error(
new HttpErrorUnauthorizedException(externalServerType, AmsResponseCode.CODE40101,
HttpStatus.UNAUTHORIZED)));
case 403 -> response.bodyToMono(reqData.getResponseType())
.flatMap(rsp -> Mono.error(
new HttpErrorForbiddenException(externalServerType, AmsResponseCode.CODE40301,
HttpStatus.FORBIDDEN)));
case 404 -> response.bodyToMono(reqData.getResponseType())
.flatMap(rsp -> Mono.error(
new HttpErrorNotFoundException(externalServerType, AmsResponseCode.CODE40401,
HttpStatus.NOT_FOUND)));
case 500 -> response.bodyToMono(Object.class)
.flatMap(rsp -> Mono.error(
new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR, StringConvertUtils.getErrorMessage(externalServerType, AmsResponseCode.CODE50001))));
default -> response.bodyToMono(byte[].class)
.flatMap(rsp -> Mono.error(
new HttpServerErrorException(HttpStatus.valueOf(response.rawStatusCode()), StringConvertUtils.getErrorMessage(externalServerType, AmsResponseCode.CODE50004))));
};
}
}
}
@Component
public DmsWebClientApiComponent(@Qualifier("DmsWebClient") WebClient dmsWebClient) {
webClient = dmsWebClient;
}
@Override
@CircuitBreaker(name = "dms-api-service", fallbackMethod = "callApiMonoFallback")
public <T> Mono<T> callApiMono(WebClientApiReqData<T> reqData) {
return webClient.mutate()
.baseUrl(reqData.getBaseUrl())
.build()
.method(reqData.getHttpMethod())
.uri(reqData.getUri())
.accept(MediaType.APPLICATION_JSON)
.header(X_AUTH_TOKEN, reqData.getAuthToken())
.retrieve()
.onStatus(HttpStatus::isError, response -> processError(reqData, response))
.bodyToMono(reqData.getResponseType())
;
}
@Override
public <T> Mono<T> callApiMonoFallback(WebClientApiReqData<T> reqData, CallNotPermittedException e) {
return Mono.error(new FallbackMethodInvokedException(reqData.getExternalServerType(), AmsResponseCode.CODE50005, HttpStatus.INTERNAL_SERVER_ERROR, e));
}
}

수신관리(DMS) 서비스가 CommonWebClientApiComponent 를 상속받아서 사용합니다. callApiMono method 를 호출 시 CircuitBreaker 설정을 통해 실패 시, callApiMonoFallback method 를 호출 시킵니다. 그 후에 리턴으로FallbackMethodInvokedException 를 발생시키면서 CircuitBreaker 의 Fail rate 를 증가시킵니다.

resilience4j.circuitbreaker:
configs:
default:
registerHealthIndicator: true
failureRateThreshold: 20
slowCallRateThreshold: 20
slowCallDurationThreshold: 60s
permittedNumberOfCallsInHalfOpenState: 10
slidingWindowType: COUNT_BASED
slidingWindowSize: 100
minimumNumberOfCalls: 10
automaticTransitionFromOpenToHalfOpenEnabled: false
waitDurationInOpenState: 30s
recordFailurePredicate: com.finda.ams.common.exception.predicate.WebClientRecordFailurePredicate
recordExceptions:
- org.springframework.web.client.HttpServerErrorException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- com.finda.ams.common.exception.FindaHttpErrorException
instances:
dms-api-service:
baseConfig: default
...

위에 application.yml 설정값은 아래 표로 기본값과 설명을 자세히 추가했습니다. 설정값들은 각 서비스의 특성별로 정의할 수 있으며, instances 밑에 baseConfig 를 선택해서 사용할 수 있습니다. 자산관리 서비스에서는 latency 가 빠른지 느린지로 서비스 별 config 를 나누어 적용했습니다. 좀 더 상세하게 예를 들어 설명해 볼게요. minimumNumberOfCalls 이 10으로 설정되어 있어서 만약 A 서비스에서 B 서비스를 9번 호출하더라도 상태변화는 없습니다. 10번째로 호출될 때 성공/실패 횟수를 계산하여 그 비율로 CircuitBreaker 를 OPEN 할지 여부를 판단합니다. 그렇다면, A 서비스에서 B 서비스를 100번 호출했고, 80번은 성공하고 20번은 실패할 경우, 비율은 20/100 ✕ 100 = 20% 가 되므로 CircuitBreaker 의 상태는 OPEN 으로 변경됩니다. 즉, 실패율 계산은 아래와 같습니다.

실패율 = 실패횟수 / slidingWindowSize 또는 minimumNumberOfCalls 100

분모 값이 slidingWindowSize < minimumNumberOfCalls 경우라면 slidingWindowSize 값을 사용하고, 반대의 경우라면 minimumNumberOf Calls 값을 사용합니다.

CircuitBreaker Configuration

application.yml 에서 보면 recordFailurePredicate 설정이 있는데, 해당 설정은 어떤 예외를 실패로 기록할 것인지를 결정하기 위한 Predicate 설정이며, 커스터마이징이 가능해서 Exception 에 대해 좀 더 디테일한 설정을 하고자 적용했습니다. 상세한 정보는 Resilience4j Document Site 를 참고하시면 좋습니다.

적용 후, 결과 분석

자산 관리 서비스(CircuitBreaker 적용 후)
자산 관리 서비스(CircuitBreaker 적용 후)
CircuitBreaker Open Alert

자, 이제 CircuitBreaker 적용한 뒤 결과를 분석해 볼게요. Metric 에 Circuit Breaker failure rate 를 추가했고 Grafana 모니터링에서 위와 같은 지표를 확인했습니다. 초당 오류율이 더 많은 상황에서도 서비스가 운영되는 POD 가 Up & Down 되지 않고 있습니다. 또한, CircuitBreaker failure rate 를 보면서 API 를 호출한 서비스 별로 CircuitBreaker 가 Open 된 상황을 Slack 알람을 통해 확인할 수 있도록 설정했습니다. 결과적으로 불필요한 API 요청에 따른 Thread 사용이 줄어들면서 Thread 의 효율성이 증가했고, 외부로부터 유입되는 자산관리 서비스의 트래픽을 조절하고 fault tolerance 를 유지할 수 있었습니다.

여기까지 긴 글을 읽어주셔서 감사합니다.

다음 글은 금융서비스 MSA 전환기- 서버 간 비동기 메시지 기반 통신 처리(3편)으로 알아보겠습니다.

--

--