현재 구조의 문제점

모임원의 설문 결과를 기반으로 외부 API로 데이터를 받아오는데,
이 과정에서 모임당 평균 20회 가량의 연쇄적인 API 호출이 발생한다.
요청 처리 시간 대부분이 네트워크 I/O 대기 시간으로 소비되는 구조이므로,
- 외부 API 응답 대기 시간
- 네트워크 Latency
- Google API 내부 처리 시간
이 모든 시간이 요청 스레드에서 그대로 대기 시간으로 전환되고,
이 과정에서 스레드는 계속 점유된 상태로 유지된다.

동시 요청이 늘게 된다면, 시스템 내 많은 스레드가 네트워크 응답을 기다리는 상태가 된다.
이때 실제로 처리할 수 있는 요청 수는 스레드 풀 크기에 의해 강하게 제한된다.
❓ 스레드 풀 크기를 늘리면 해결될까?
초기에는 어느정도 대응이 가능하겠지만, 근본적인 해결책이 되기 어렵다.
이유는 다음과 같다.
- 스레드 수 증가 → 메모리 사용량 증가
- 컨텍스트 스위칭 증가 → CPU 오버헤드 증가
결국 여전히 외부 I/O 대기 시간이 지배적인 구조로, 대기 시간이 긴 네트워크 I/O에서는
스레드 수를 늘릴수록 오히려 시스템 전체 처리량이 떨어질 수 있다.
즉, 문제의 본질은 스레드 수 부족보다는 I/O 대기 동안 리소스(스레드)를 낭비하는 구조에 있었다.
이 문제를 해결하려면 I/O 대기 동안 스레드를 점유하지 않고, 다른 요청을 처리할 수 있는 구조가 필요했다.
선택한 방안 — 코루틴(Coroutine)

이 문제를 해결하기 위해 Coroutine 기반 비동기 구조로 전환하여,
스레드는 블로킹되지 않고 반환되어 다른 요청 처리를 수행하도록 하였다.

메서드에 suspend를 선언하면 코루틴에서 실행될 수 있는 함수가 되며,
해당 지점에서 스레드는 작업의 완료를 기다리지 않고 다른 코루틴을 처리하기 위해
자유롭게 제어권을 반환하거나 이동할 수 있다.
외부 API 호출과 같은 네트워크 I/O 작업이 발생하면 코루틴은 해당 지점에서 일시 중단된다.
이때, 스레드는 점유된 상태로 대기하지 않고, 즉시 반환되어 다른 작업을 처리할 수 있다.
기존 방식, 다른 대안과의 비교
1. 기존 방식 — CompletableFuture
CompletableFuture는 비동기 실행을 지원하지만, 기본적으로는 스레드 기반의 Blocking 모델 위에서 동작한다. 따라서, 작업 스레드는 응답을 기다리는 동안 계속 점유된다.
- 병렬 호출 증가 → 스레드 수 증가 → 메모리 사용량 & 컨텍스트 스위칭 증가 → CPU 오버헤드
- 병렬 작업 간의 구조적 관계가 명확하지 않음
- 예외 처리, 취소, 부분 성공 조합을 직접 구현해야 하므로 코드 복잡도 증가
단순 병렬 처리에는 적합하지만, 여러 작업을 하나의 흐름으로 제어해야 하는 경우 유지보수성이 크게 저하된다.
2. 다른 대안 — Virtual Thread
Virtual Thread는 JVM 레벨에서의 경량 스레드를 통해 스레드 생성 비용과 컨텍스트 스위칭 부담을 줄여주며 높은 동시성을 확보할 수 있지만,
비교적 최근에 출시된지라 도입하기에 부담이 있었고, 아래와 같은 한계점도 존재하였다.
- Blocking 호출 구조를 그대로 유지하기 때문에, I/O 대기 시간 자체는 줄어들지 않음 (처리량이 늘 수는 있다.)
- 요청 흐름이 여전히 스레드 단위로 구성되므로, 대기 시간 동안 실행 흐름이 묶이는 구조는 동일
- 작업 간의 관계를 구조적으로 파악하기 어렵고, 부분 실패 처리나 취소 제어를 직접 구현해야 하는 부담 존재
분명 강력한 대안이고, 현 구조에서 더 높은 동시성이 필요해진다면 코루틴의 구조적 동시성과 Dispatchers 풀을 Virtual Thread 기반으로 바꿔서 성능과 리소스를 개선하는 방향으로 도입해볼 의향이 있다.
위 두 방식과 비교했을 때, 코루틴은 다음의 강점을 갖기에 도입을 결정하였다.
- NIO 라이브러리와 결합할 경우, I/O 대기 구간에서 스레드를 점유하지 않아 자원을 효율적으로 활용할 수 있다.
CoroutineScope를 기반으로 여러 병렬 작업을 하나의 실행 흐름으로 관리할 수 있으며,supervisorScope을 통해 특정 작업의 실패가 다른 작업에 전파되지 않도록 제어할 수 있다.
NIO 기반 HTTP 클라이언트 — WebClient, Ktor Client
코루틴을 도입하더라도, 실제로 스레드 블로킹을 제거하려면 Non-Blocking I/O 기반의 HTTP 클라이언트가 반드시 필요하다. (BIO 라이브러리일 경우, 호출 스레드만 반환되고 작업 스레드는 블로킹 된다.)
이러한 이유로 NIO 기반 클라이언트를 검토하였고, 다음과 같은 이유로 Ktor Client를 선택하였다.
- 코루틴을 기본으로 지원하는 Native 비동기 구조, 별도의 변환 없이 자연스럽게 연동 가능
- Netty 중심의 WebClient 대비 가벼운 의존성 구조, 기존 MVC 환경에 부담 없이 적용 가능
- 필요한 엔진만 선택할 수 있는 유연한 구성, 프로젝트 특성에 맞는 최적화 가능
결과적으로, 코루틴의 장점을 온전히 활용하면서도 Webflux 의존성 없이 기존 구조를 유지할 수 있었다.
코루틴이 정말 성능상 우위를 가질까?
동일한 조건에서 이전 방식(CompertableFuture)과 코루틴을 성능적으로 비교해보았다.

1. CPU 리소스 사용량

두 그래프가 대부분의 구간에서 평탄하게 유지되는 이유는
해당 작업이 CPU-Bound가 아닌, 응답을 기다리는 I/O 대기 중심 작업이기 때문이다.
기존 방식에서는 여러 요청을 병렬 처리하는 과정에서
스레드 점유, 컨텍스트 스위칭, 스케줄링 오버헤드가 발생했고,
그 결과 일부 구간에서 CPU 사용량이 약 25%, 10% 수준까지 순간적으로 상승했다.
반면, 코루틴의 경우 I/O 대기 중 스레드를 점유하지 않기에,
불필요한 스레드 사용과 컨텍스트 스위칭 비용을 줄일 수 있었다.
그 결과 CPU 사용량이 전 구간 5% 미만으로 안정적으로 유지되었으며,
I/O 대기 시간이 길어져도 CPU 오버헤드가 크게 증가하지 않았다.
2. P95 지표


기존 방식의 p95 응답 시간은 9.76초로 측정되었다.
스레드 풀(100개)이 모두 점유되는 시점 이후, 추가 요청들은 큐에서 대기하게 되었고 이로 인해 응답 지연이 급격히 증가하였다. 그 결과, 사용자 입장에서는 약 10초에 가까운 응답 대기 시간이 발생하는 병목 현상이 나타났다.
반면, 코루틴은 동일한 100명의 동시 요청 상황에서도 p95가 5.95초로 측정되었다.
제한된 스레드로도 더 많은 요청을 처리할 수 있었기에, 병목이 효과적으로 완화되었다.
3. TPS 처리량

기존 방식에서는 그래프 중간 구간에서 TPS가 급격히 하락하는 V자 형태의 패턴이 나타났다.
이는 동시 요청 증가로 인해 스레드 풀이 일시적으로 고갈되며, 서버의 처리 능력이 급격히 저하된 결과로 볼 수 있다. 스레드가 I/O 대기 상태에 묶이면서 새로운 요청을 처리하지 못하고, 전체 처리량이 크게 감소하여 병목이 발생한 것이다.
반면, 코루틴은 I/O 대기 동안 스레드를 점유하지 않고 재사용하기 때문에,
높은 부하 상황에서도 요청을 지속적으로 처리할 수 있었고 일정하고 안정적인 처리량을 보였다.
만약, 코루틴의 병렬 호출을 제한하지 않는다면?
코루틴은 제한된 스레드 풀 안에서 효율적 스레드 사용을 보장하는 만큼,
외부 API가 감당할 수 있는 호출량을 넘어서게 될 경우 오히려 전체 성능이 저하될 수 있다.
항목 | 일반적인 제한 수치 | 특징 |
분당 호출 수 (QPM) | 약 1,200 ~ 3,000 QPM | - |
초당 호출 수 (QPS) | 약 20 ~ 50 QPS | 단기간 스파이크(Spike)는 허용되나
지속 시 429 에러 발생 |
Google API Console에서 위와 같이 호출 수 제한이 존재함을 확인하였고,
실제로 코루틴 병렬 호출을 늘릴 경우 처리량이 저하되는지 검증해보았다.

- 제한하지 않은 경우 최대 동시 실행 수가 30까지 증가하며 응답 지연과 호출 실패가 함께 발생
- 동시 실행 수를 제한한 경우 최대 동시 실행 수가 안전 범위 내로 유지되었고, 모든 요청이 성공적으로 처리되었으며 평균 응답시간과 p95 응답시간 또한 크게 개선 (613ms → 124ms)
분리한 API 호출 흐름

1. 로컬 세마포어 (reqeustSemaphore)
단일 모임 검색 요청 내부의 과도한 병렬 fan-out을 제한한다.
하나의 요청이 여러 키워드에 대해 API를 동시에 호출하더라도,
호출 수를 제한하여 단일 요청이 전체 자원을 과도하게 점유하지 않도록 하였다.
2. Redis Token Bucket
멀티 서버 환경에서 Google Places API 호출권을 중앙에서 제어한다.
각 서버에서 발생하는 모든 Google Places 호출은 실행 직전에 Redis Token Bucket에서
토큰을 먼저 획득해야 하며, 토큰을 얻지 못한 요청은 호출 전에 대기하거나 Timeout 처리된다.
설계 과정에서 했던 고민들
1. 왜 requestSemaphore와 Redis Token Bucket을 분리했나?
Redis Token Bucket은 서버 전체에서 외부 API 호출량을 제한하기 위한 장치이다.
즉, Google Places API의 QPS/QPM 한도를 넘지 않도록 전체 호출권을 조절한다.
반면 로컬 세마포어는 단일 요청이 동시에 많은 코루틴을 실행하지 못하게 막는 장치다.
즉, 특정 요청 하나가 tocken을 몰아서 획득하는 상황까지 깔끔히 제어하기 어렵다.
2. Redis Token Bucket으로 단일 모임 요청까지 제한하면 안 되나?
가능은 하지만 적합하지 않다.
단일 요청 내부 병렬성은 프로세스 안에서 발생하는 짧은 생명주기의 동시성 제어이다.
- 모든 코루틴 fan-out마다 네트워크 왕복이 추가되고,
- Redis 장애나 지연이 단일 요청 처리 흐름에도 직접 영향을 준다.
위와 같은 이유 때문에 요청 내부의 동시성 제한은 로컬 프로세스 레벨에서
처리하는 편이 역할과 비용 측면에서 더 합리적이라고 판단했다.
3. Resilience4j RateLimiter 대신 로컬 세마포어를 쓴 이유
여기서 필요한 것은 “초당 몇 건만 실행”하는 Rate Limit보다,
현재 요청 안에서 동시에 실행 중인 코루틴 수를 제한하는 backpressure에 가까웠다.
Resilience4j RateLimiter는 시간 단위 호출량을 제한하는 데 적합하다.
설계 검증
아래 표는 검증 시나리오의 동작 흐름에 해당한다.
구간 | 부하 시나리오 | Token Bucket 동작 |
초기 상태 | - | Refill Rate 15/sec, Bucket 크기 30으로 시작 |
Overload (0~60s) | 80 TPS | 요청 유입이 호출 예산을 초과하며 Available Token이 빠르게 0까지 소진 |
호출권 제한 | 지속 | 토큰을 획득한 요청만 Google Places API 호출 수행, 나머지 요청은 최대 800ms 대기 후 Timeout 처리 |
유량 조정 (약 40s 이후) | Timeout 증가 | Timeout 및 Rate Limit 신호 증가를 감지하여 Refill Rate를 15 → 8 → 5로 단계적으로 감소 |
Recovery (100~150s) | 12 TPS | 유입량 감소로 Available Token은 일부 회복되지만,
낮아진 Refill Rate를 유지하며 실제 외부 API 호출량은 약 5 TPS 수준으로 제한 |
1. 유입 TPS와 외부 API 호출량 분리


유입 TPS는 70~80까지 증가했지만, 실제 Token을 얻은 요청은 5~10 TPS 수준으로 유지되었다.
실제 API 호출량이 Tocken Bucket 정책에 따라 제한된 것으로,
초과 요청은 Google API 호출로 전달되지 않고, Token Timeout(노란 그래프)으로 처리되었다.
2. Tocken Bucket 기반 Burst 흡수

초기 설정은 Refill Rate 15 tokens/sec, Bucket 크기는 30으로 구성하였다.
부하가 시작되자 Availabe Tokens(주황)이 빠르게 0까지 소진되었으며,
18:14:45 이후의 지속적인 고부하 상황에서는 토큰이 모두 소진되며, Refill Rate 기준으로
호출량이 제한되는 것을 확인할 수 있었다.
3. 유량 기반 동적 Token Refill Rate

과부하 구간에서 Timeout이 증가하자 Refill Rate는 15 → 8 → 5 tokens/sec로 감소하였다.
이를 통해 고정된 호출량 제한이 아니라,
Google API 상태 기반의 허용 유량을 조정하는 제어가 정상적으로 동작함을 확인하였다.
4. 응답 지연 상한 관리

과부하 구간 이후에 p95 응답시간이 0.9s → 1.18s로 상승하였다.
이는 Rate Limit 보호 장치가 동작한 결과로,
- 초과 요청이 즉시 실패하지 않고 최대 800ms까지 API 호출권을 위해 대기
- Timeout 상한을 통해 과부하 상황에서도 응답 지연이 무한히 누적되지 않도록 제어됨
이를 통해 Google Places 호출량을 안정적으로 제어했으며,
장애 전파와 429 응답 발생을 방지하고 HTTP 실패율도 0%로 유지할 수 있었다.
결론
코루틴은 I/O 대기 구간의 리소스 낭비를 줄여 시스템의 처리량을 높여주지만,
외부 API가 감당할 수 있는 수준 이상의 병렬 호출까지 해결해주지는 않는다.
이번 개선에서는 코루틴을 통해 요청 처리 효율을 높이고, Request Semaphore와
Redis Token Bucket을 통해 외부 API 호출량을 제어하는 구조를 적용하였다.
그 결과 높은 TPS 환경에서도 Google Places API를 안정적으로 보호하면서,
서비스 응답 품질 또한 일정 수준 내에서 유지할 수 있었다.



