본문 바로가기

프로젝트/오디

[오디 - 폴링 로직 리팩터링] 2. 계정 로드 밸런싱으로 Request Failed를 잡아보자

 

이 글은 총 6편의 오디 - 폴링 로직 리팩터링 시리즈 중 3번째 글입니다.

 

[오디 -폴링 로직 리팩터링]

 

0. 실험 설계

1. Warm up Code로 CPU 스파이크 해결하기 feat) JIT Compiler

2. 계정 로드 밸런싱으로 Request Failed를 잡아보자

3. 트랜잭션에서 외부 API를 분리하여 응답 속도를 낮춰보자

4. 동시 호출 스파이크에 맞는 스레드풀 최적화

5. 리팩터링 총 정리 및 느낀 점

 

개요 : Request Failed 0% 만들기

 

오디 폴링 로직 현황을 살펴보기 위해 40개 약속 - 100명의 가상유저 시나리오로 30분간 폴링을 지속한 결과 10분간격으로 외부 API를 호출하는 상황에서 60%가량 되는 Request Failed가 발생했습니다.

 

 

현재 오디는 좌표 간 대중교통 소요시간 반환 API에서 '오디세이'와 'Google' API으로 이중화해둔 상황이라 Odsay 요청 시 에러가 발생했다면 Google API를 활용해 API를 호출하는 것이 정상입니다. 그렇기에 응답시간이 늦어질 지언정, 요청 실패가 발생했다는 사실이 이해가 되지 않았습니다. 대체 왜 이런 상황이 발생한 걸까요?

 


에러 원인 : Odsay API는 초당 호출량 제한이 있었다

 

에러를 확인하기 위해 K6 스크립트에서 Request Failed가 발생할 경우 로깅을 진행하였습니다.

    const ok = check(res, { 'status is 200': (r) => r.status === 200 });

    if (!ok) {
        console.error(`❌ Error for memberId=${memberId}, meetingId=${meetingId}`);
        console.error(`Status: ${res.status}`);
        console.error(`Body: ${res.body}`);
    }

 

 

그 결과, 외부 API 호출 시점에 Odsay API 요청을 잘 받아들이다가에서 429 에러코드가 발생하는 모습을 볼 수 있었습니다.

ERRO[0033] ❌ Error for memberId=18, meetingId=9          source=console
ERRO[0033] Status: 400                                   source=console
ERRO[0033] Body: {"type":"about:blank","title":"Bad Request","status":400,"detail":"ODSay BAD REQUEST Error: OdsayResponse[code=Optional[429], message=Optional[Too Many Requests], minutes=OptionalLong.empty]","instance":"/v2/meetings/9/mates/etas"}  source=console
ERRO[0035] ❌ Error for memberId=8, meetingId=4           source=console
ERRO[0035] Status: 400                                   source=console
ERRO[0035] Body: {"type":"about:blank","title":"Bad Request","status":400,"detail":"ODSay BAD REQUEST Error: OdsayResponse[code=Optional[429], message=Optional[Too Many Requests], minutes=OptionalLong.empty]","instance":"/v2/meetings/4/mates/etas"}  source=console

 

Odsay에서 웬만한 에러코드는 다 만나보았는데 429에러 코드는 처음 보는 것이라 굉장히 당황했습니다.

Odsay 공식 API docs에도 429 관련 에러코드가 존재하지 않았습니다.

429 에러코드가 docs에 없다...

 

그러나, 문서를 읽어보니 1초당 호출량을 제한한다는 문구를 발견할 수 있었고 429 에러코드는 초간 호출량 제한을 초과했을 때 발생하는 에러라는 사실을 알 수 있었습니다.

 

오디에서는 외부 API 호출 시, 다음 정책에 따라 외부 API 에러를 애플리케이션 에러로 전환하였습니다.

- Odsay API에서 4XX 반환 시 -> 400 반환
- Odsay API에서 5XX 반환 시 -> Google API로 다시 시도

 

해당 에러는 429로 에러코드가 반환되었기 때문에 Odsay > Google로 요청을 한번 더 수행하지 않고 클라이언트에게 400 에러가 바로 반환되고 있었던 것이었습니다.

 


Task : Odsay API 초당 호출량 제한을 준수하자

 

이쯤에서 문제 상황과 원인, 해결 태스크를 한번 정리하고 가겠습니다.

 

문제 상황 : 10분간격으로 외부 API를 호출하는 과정에서 60%의 Request Failed 발생

원인 : Oday 초당 호출량을 초과하여 외부 API에서 429 에러 반환 -> 클라이언트 에러로 전환

해결 태스크 : Odsay 초당 호출량을 준수하도록 로직을 개선하라

 


Action : 계정 로드 밸런싱으로 Odsay 초당 호출량 준수하기

 

해당 문제를 해결하기 위한 방향은 크게 두가지가 있었습니다.

1) Odsay 429에러를 애플리케이션 500에러로 처리하여 Google API를 활용하도록 하기
2) Odsay 초당 호출량을 준수하도록 로직 개선하기

 

 

그 중 2번째 안을 선택하고자 했습니다. 첫번째 안은 에러 전환만 변경하면 되므로 매우 간단하지만 Google API는 요청당 비용이 나오는 유료 API였기 때문에 최대한 무료 API인 Odsay API를 활용하고자 했습니다.

 

Odsay 초당 호출량은 계정별로 측정된다는 가정을 세우고 여러 개의 계정을 만들어 apiKey를 바꿔낌으로서 계정별로 요청을 로드밸런싱하는 로직을 생각하였습니다.

이러한 리팩터링은 하루 당 1000건의 API 호출만 가능한 오디세이 API를 N개의 계정으로 N배만큼 일간 호출 가능 용량을 늘림과 동시에 동일 계정을 대상으로 초당 제한률을 준수할 수 있는 방향이라 생각했습니다.

 

 

odsay-utils 오픈소스 수정하기

 

현재 프로젝트 오디는 제가 직접 구현한 odsay-utils라는 오픈소스를 주입받아 사용하고 있습니다.

implementation 'io.github.coli-geonwoo:odsay-utils:X.X.X'
 

GitHub - coli-geonwoo/odsay-utils: ODSAY API를 활용해 두 좌표간의 가장 빠른 대중교통 이동시간을 반환하

ODSAY API를 활용해 두 좌표간의 가장 빠른 대중교통 이동시간을 반환하는 오픈소스 라이브러리 입니다. - coli-geonwoo/odsay-utils

github.com

 

odsay-utils 오픈소스는 Odsay API를 래핑하여 더욱 쉽게 좌표간 대중교통 소요시간을 반환하는 라이브러리입니다.

손쉽게 좌표간 대중교통 소요시간을 반환받을 수 있으며 모든 에러를 200 코드로 반환하는 Odsay API의 단점을 극복하고자 각 에러 상황별로 다른 커스텀 에러를 반환해주고 있습니다.

 

 

그리고, 429에러에 대응하기 위해 odsay-utils의 1.1.0버전으로 로드밸런싱 기능을 추가하였습니다.

public class OdsayApiKeys {

    private final List<String> apiKeys;
    private final AtomicInteger counter = new AtomicInteger(0);

    public OdsayApiKeys(String[] apiKeys) {
        this.apiKeys = Stream.of(apiKeys)
                .map(key -> URLEncoder.encode(key, StandardCharsets.UTF_8))
                .toList();
    }

    public String getNextKey() {
        return apiKeys.get(counter.getAndIncrement() % apiKeys.size());
    }
}

public class OdsayRouteClient {

    private final RestClient restClient;
    private final OdsayApiKeys odsayAPiKeys;
    
    ...

    public long calculateRouteMinutes(Coordinates origin, Coordinates target) {
        String apiKey = odsayAPiKeys.getNextKey();
        OdsayResponse response = getOdsayResponse(apiKey, origin, target);
        return responseToRouteTime(response);
    }
}

 

ApiKey를 주입받은 후, 동시 요청에 대응하기 위해 AtomicInteger의 카운터를 두어 요청별로 다른 계정을 순회하며 요청을 진행하도록 하였습니다. 물론 AtomicInteger를 활용하는 것은 동시성 요청의 성능이슈가 있지만 100개의 동시 요청에 있어서 초당 제한율을 넘어 소요시간 갱신에 실패하면 단순이 본인이 아닌 약속에 참여한 모든 사람들이 정확하지 않은 정보를 제공받게 되므로 초당 제한을 성공시키는 것이 최우선이라 생각했습니다. 시간이 조금 걸리더라도 스레드 안전성이 있는 코드로 API 요청을 한번만에 성공시키는 것이 무엇보다 중요했습니다.

 

수정된 1.1.0버전을 다시 Maven Repository에 배포한 이후, 계정 6개를 만들어 로드밸런싱 결과를 측정해보았습니다.

 

// common.yml

testodsay:
  api-keys:
    - ENC(060CxJdbrdoqS9I5zpaf0unxJS8BceYFES4VVjZJSbuALmYhocLaU7zMQ6bY5oxDF3UqmK2/I/U=)
    - ENC(2+eyfQ8qSHcVGr0xrvqWCeK3+Z8krEh+rk7f5otb1ccKXCOZWCNLvagNh2zNzVWbsQRxkgmNsbw=)
    - ENC(ws5pkqBLpCn0+KDBBvP0jsaitK7zNX5K+E+0fbTTH53tHQnsjB42rNZDn5LUZh6Y7hfnQdHa3EQ=)
    - ENC(j7Pn0f+Vecvbxy52FyBFo4F0oFSrOZiG/1zoIxHS/tMj/XFpvYV1wW+d1RViMjFwzR2YOZDCb74=)
    - ENC(H6BytUSHA9dPLaOccRLtjbpN4etoieIEKLhzDIJ7Aoc6DceHuNHhDmm7FRkE2Vpq4ao7E62RaSE=)
    - ENC(ONezsMfCAlhRQD0bM2TwuyePte1qu9xoDVpDLMn0dwLkAwLru2upqyCrb+dlENjmpsJaAYOQ//w=)

Result

 

그 결과 모든 요청에 성공한 모습을 볼 수 있었습니다.

 

그러나, API 호출 시점마다 응답속도가 10초 가량 지연되고 TPS가 3.3정도로 낮아지는 문제는 잔존했습니다.

 

 

다음 글에서는 10분간격으로 외부 API 호출 시, TPS가 줄어들고 응답속도라 늘어나는 문제를 개선한 과정을 다루어 보겠습니다.

 

 

요약

10분마다 외부 API 100개 동시요청 시점에서 60%의 요청 실패 발생

- 외부 API 계정 당 초당 호출량 제한이 있어 429에러 발생 > 애플리케이션 400에러로 처리되어 그대로 Failed로 처리됨

- 계정 여러개를 만들어 로드밸런싱할 수 있도록 개인 오픈소스 수정

- Request Faield 0% 달성하였으나 Request Duration / TPS 문제는 남아있는 상태

 


[오디 -폴링 로직 리팩터링]

 

0. 실험 설계

1. Warm up Code로 CPU 스파이크 해결하기 feat) JIT Compiler

2. 계정 로드 밸런싱으로 Request Failed를 잡아보자

3. 트랜잭션에서 외부 API를 분리하여 응답 속도를 낮춰보자

4. 동시 호출 스파이크에 맞는 스레드풀 최적화

5. 리팩터링 총 정리 및 느낀 점