⚡목차⚡

 
안녕하세요! 저는 디프만 17기 3팀에서 ‘모임원의 취향 설문 기반 맛집 추천 서비스’ 백엔드 개발을 맡았습니다.
저희 서비스는 아래 화면처럼 모임원들의 취향을 기반으로 다양한 맛집 데이터를 사용자에게 제공하고 있고, 지하철이 있는 지역이라면 모두 이용이 가능해야 했습니다.
따라서, 자체적인 데이터 구축보다는 Google에서 제공하는 여러 API들을 통해 식당 이름, 별점, 리뷰 수, 대표 리뷰, 음식/매장 이미지, 위치 정보, 도보 소요 시간 등을 외부에서 가져오는 방식을 택하였습니다.
notion image
notion image
 
마침 지난 주말 런칭데이 부스를 운영하면서 맛집 데이터들을 어떻게, 어떤 플로우로 가져오는지에 대한 질문들을 많이 받았었기에, 이번 기회를 통해 다량의 외부 API를 어떤 방식으로 호출하는지, 이를 효율적으로 처리하기 위해 도입한 코루틴의 동작 방식, 실제 사용 예시들을 정리해보았습니다.

0. 도입

맛집 데이터를 가져오는 플로우는 대략 아래와 같은 흐름입니다. 모임은 보통 3~10명의 사용자로 구성되고, 설문이 완료되면 ‘추천 결과 보기’를 통해 10개의 맛집을 추천받게 됩니다.
notion image
데이터의 선정 기준은 모임원들의 설문 결과와 데이터의 별점 및 리뷰 수를 종합하여 산출됩니다.
  1. 모임원들의 설문 응답을 기반으로 Leaf(찌개, 파스타 등) → Branch(한식, 양식 등) 순으로 가중치를 계산하고 우선순위가 높은 데이터를 추출
  1. 이를 기반으로 최대 5개의 검색 키워드 생성
  1. 각 키워드에 대해 Google Places TextSearch API 를 호출한 뒤, 후보 장소들에 대해 Places Details API를 호출하여 영업 정보, 가격대, 이미지 등 상세 데이터를 수집합니다.
 
⚠️ 이 과정에서 모임 하나에 평균 10~20회의 외부 API 호출이 발생하고, 꽤 많은 네트워크 I/O 작업이 필요한 구조입니다.
서비스 초기에는 충분할 수 있지만, 사용자 수가 많아지고 동시 요청이 증가한다면 스레드 점유 이슈를 생각하지 않을 수 없습니다.
  • 외부 API 응답 대기 시간 동안 스레드가 블로킹되어 다른 요청 처리 X
  • 스레드 풀을 늘려도 결국 스레드 고갈 발생 ⇒ 전체 응답 지연 발생 (특히 Google API처럼 대기 시간이 긴 네트워크 I/O에서는 더 치명적)
 
이 문제를 방지하기 위해 저희 팀은 Kotlin 라이브러리인 Coroutine(코루틴)을 도입했습니다.
코루틴은 스레드를 점유하지 않고도 실행을 중단·재개할 수 있어, 다수의 외부 API를 병렬로 호출하면서도 Kotlin 코드 레벨에서 스레드를 효율적으로 제어할 수 있다고 판단했습니다.

1. 코루틴과 서브루틴

서브 루틴

notion image
notion image
 
우리가 가장 자주 접하는 경우로 스레드가 함수 단위의 서브 루틴에 진입하고, return 문을 만나면 서브 루틴을 호출했던 부분으로 탈출하게 됩니다.
이때 진입점과 탈출점 사이에 스레드는 블럭되어 있습니다. 즉, 시작부터 끝까지 순차 실행되고 스레드 제어권을 밖으로 넘기지 않는 동기 방식에 해당합니다.

서브 루틴은 비동기가 될 수 없을까?

서브루틴은 호출한 쪽이 기다리고 있는 구조로, 돌아와야 할 리턴 주소가 이미 stack frame으로 잡혀 있어, Call Stack을 벗어날 수 없기에 중간에 멈추는 것 없이 끝까지 가야만하는 구조입니다.
코루틴은 이 제약을 없애기 위해, 내부적으로 Continuation(상태 저장 객체) + Dispather 구조를 사용합니다.
 

코루틴

notion image
notion image
 
코루틴은 진입 후 return 문이 없더라도, 임의 지점에서 실행 중 동작을 중단하고 이후 해당 지점에서부터 실행 재개가 가능합니다.
바로 위에서 다루었듯 상태 저장 객체 Continuation을 전달하며 각 함수의 작업이 완료되면Continuation을 호출합니다. 이를 통해 상태를 연속적으로 전달하며 컨텍스트를 유지하고 코루틴 실행 관리를 위한 State machine에 따라 코드 블록을 구분해 실행하는 것입니다.
 

2. 코루틴은 병행성과 병렬성 두 특성을 모두 갖는다.

코루틴의 핵심은 Thread를 블로킹하지 않고 실행 흐름을 중단/재개할 수 있다는 점입니다.
덕분에 기본적으로 하나의 스레드에서도 여러 작업이 동시에 실행되는 것처럼 보이는 병행성, Dispatcher를 통해 여러 스레드에서 실제로 동시에 실행되는 병렬성이 제공됩니다.
  • 병행성: 하나의 스레드에서 여러 작업이 번갈아가며 실행, 동시에 실행되는 것처럼 보이는 상태
  • 병렬성 : 여러 개의 물리적 스레드가 동시에 작업이 수행되는 상태
 
따라서 코루틴이 병행으로 동작할지 병렬로 동작할지는 코루틴을 어떤 Dispatcher(실행 컨텍스트)에 올리느냐에 따라 결정됩니다.
  • newSingleThreadContext → 단일 스레드 → 병행성
  • Dispatchers.Default / Dispatchers.IO → 다중 스레드 → 병렬성
 
1. 병행 처리 - 단일 스레드 Dispatcher에서 실행한 경우
notion image
notion image
  • 모든 작업(A/B/C)이 하나의 스레드(pool-2-thread-1)에서 순차적으로 실행됨
  • 코루틴은 각각 async로 생성되었지만 singleDispatcher는 하나의 스레드만 갖기에, suspend 지점이 없는 CPU 바운드 작업에서는 코루틴이 스레드 제어권을 넘기지 않음
 
 
2. 병렬 처리 - 멀티 스레드(Default Dispatcher)에서 실행한 경우
notion image
notion image
위의 싱글 스레드 경우도 마찬가지이지만, corutineScope에 들어오기 전까지는 main 스레드에서 작업이 진행됩니다. 이후 withContext(Dispatchers) 블록 내에선 main이 아닌, 위의 실행 결과처럼 Dispatchers로부터 할당 받은 DefaultDispatcher-worker 스레드를 통해 작업이 진행됩니다.
  • 각 작업이 서로 다른 worker 스레드에서 시작됨 → 실제 병렬 실행
  • CPU 바운드 작업 3개가 각각 다른 스레드에서 동시에 수행되므로, 전체 시간은 단일 스레드 대비 약 절반 이하로 감소
코루틴이 동작하는 방식은 같은 async일지라도, Dispatcher(실행 컨텍스트)에 의해 결정됨을 알 수 있습니다.
 

2-1. 코루틴 Dispatchers

코루틴은 직접 스레드를 만들지 않고, 어떤 스레드 풀 위에서 실행될지를 Dispatcher가 결정합니다. 같은 코드를 실행하더라도 Dispatcher에 따라 실행되는 스레드와 동작 특성이 달라집니다.
launch(Dispatchers.Default) { /* CPU 연산 작업 */ } launch(Dispatchers.IO) { /* I/O 작업 */ }
 
Dispatchers.Default (CPU 코어 수 기반의 스레드 풀 사용(CPU 바운드 작업, 병렬 처리 가능)): : 데이터 변환, JSON 파싱, 복잡한 연산이나 알고리즘 처리 등 CPU에서 수행되는 작업들에 해당합니다. 코어 수만큼 스레드를 유지하면서 병렬 처리에 최적화되어 있습니다.
 
Dispatchers.IO (I/O 바운드 작업을 위한 대규모 스레드 풀(DB, 네트워크, 파일 I/O 등))
  • DB 조회 / 저장
  • 외부 API 호출(HTTP 통신)
  • 파일 읽기/쓰기
  • 메시지 큐, 캐시 서버(Redis 등) 호출
⇒ 아래 기본 스레드 풀(Default)의 크기를 확장한 형태로, 많은 수의 I/O 대기 작업을 동시에 처리하도록 최적화
기다리는 시간이 많은 작업을 위한 디스패처로 CPU를 많이 쓰지 않고 대부분을 네트워크/디스크 대기로 시간을 보내기에, Dispathers.Default와 같은 일반 CPU용과 섞여버리면 비효율이 발생할 수 있습니다.
 

❓Dispatchers.IO 를 사용하는 이유

1. I/O 대기 시간 동안 스레드를 낭비하지 않기 위함 코루틴은 suspend를 통해 대기 시 스레드를 풀에 반환합니다. I/O가 많은 API 서버에서는, 이러한 코루틴 + I/O Dispatcher 조합으로 적은 수의 스레드로도 많은 동시 요청을 처리하기 위함입니다.
2. CPU 바운드 작업과 I/O 바운드 작업을 분리하기 위함
3. 코드 레벨에서 의도를 명확히 드러내기 위함 withContext(Dispatchers.IO)를 명시하면, 해당 구간이 I/O 위주의 구간 or 블로킹/대기 가능성이 있음을 코드로 표현할 수 있습니다.
 

⚡ Dispatchers.IO 사용 시 꼭 알아야할 점

1. Blocking이라고 해서 Non-Blocking으로 바뀌는 것은 아니다. JDBC 같은 블로킹 방식의 I/O 작업을 Dispatcher.IO에 넣는다고 해서, 코루틴이 Blocking이 되는 것은 아닙니다. (단지 I/O 전용 스레드 풀에서 블로킹을 감당해주는 구조가 될 뿐임)
2. CPU 작업을 IO로 보내면 안된다. CPU를 많이 쓰는 작업을 Dispatchers.IO에 넣으면 I/O 전용 풀을 불필요하게 바쁘게 만들고, 처리 지연이 생길 수 있습니다.
3. 모든 I/O 작업을 withContext(Dispatchers.IO)로 감싸는 패턴은 지양 사용 중인 라이브러리가 이미 내부에서 적절한 스레드 관리나 비동기 처리를 하고 있을 수 있기에, 코루틴과 어떻게 연동되는지 확인하는 것이 좋습니다.
 

3. 코루틴은 스레드를 점유하지 않는다.

코루틴은 Thread 위에서 수행되는 하나의 논리적 실행 단위이며, 고유한 CoroutineContext를 통해 실행 환경을 오버라이드하며 동작합니다.
notion image
  • 코루틴은 Kotlin 런타임(JVM 위에서 동작)에 의해 관리된다.
  • Thread는 OS에 의해 관리된다.
따라서 하나의 스레드 위에서 여러 코루틴이 실행(resume), 중단(suspend)을 반복하며 병행성을 제공합니다. suspend 지점에서 스레드를 즉시 반환할 수 있어 블로킹 없이 대량의 작업을 효율적으로 처리할 수 있으며, 스레드 점유 없이 번갈아가며 실행될 수 있습니다.
 
1. Blocking I/O
val rs = statement.executeQuery("SELECT ...") // 여기서 블로킹
여기서 DB 응답을 기다리는 동안,
  1. 애플리케이션(JVM)에서 DB 클라이언트 라이브러리를 통해 쿼리 요청
  1. JVM이 OS에게 TCP 소켓을 통해 패킷 전송 요청
  1. OS 커널이 네트워크 스택을 통해 DB 서버로 패킷(요청) 전달
  1. DB 서버가 요청 처리한 뒤, 응답 패킷 전송
  1. OS 커널이 응답을 수신하여 JVM에 이벤트 전달
  1. JVM이 결과를 읽어 코드 실행에 반영
 
위처럼 DB에서 실제로 쿼리를 처리하는 시간 동안 DB 서버는 바쁘지만, 우리 애플리케이션 JVM 스레드는 수행하는 작업이 거의 없습니다.
그런데 블로킹 I/O를 사용하면,
  • JVM 스레드는 read() / executeQuery() 호출에서 응답 도착할 때까지 묶인 상태가 됨
  • 하지만 스레드는 여전히 “점유된 상태”, 다른 요청 처리가 불가능
  • 요청이 몰릴 경우 스레드 부족 → 처리 지연 → 시스템 병목이 발생
 
2. suspend + Non-Blocking I/O
suspend fun getUser(): User { return userRepository.findByIdSuspend(1L) // suspend 지점 }
findByIdSuspend()에서 DB 응답을 기다리는 동안,
  • 코루틴은 중단(suspend) 되고 실행 상태만 저장
  • 코루틴이 사용하던 스레드는 스레드 풀로 즉시 반환 → 다른 코루틴/요청을 수행
이후 DB 응답이 준비되면, 코루틴이 다시 어떤 스레드 위에서 재개(resume) 되어 이후 코드를 실행됩니다.
따라서 DB I/O 작업이나 외부 API 호출 같은 시간이 걸리는 작업을 마냥 기다리며 스레드를 낭비하지 않고, 다른 작업을 진행할 수 있도록 해줍니다.

4. 🔖 실제 사용 예시

notion image
fetchPlacesFromGoogle() 메서드는 Google Places Text Search API를 호출하는 네트워크 I/O 작업을 수행합니다. (요청 전송 → 네트워크 대기 → 응답 수신 과정에서 CPU가 거의 관여하지 않고, 대부분 대기 시간)
이러한 네트워크 I/O는 코루틴의 suspend 방식으로 처리하여, 스레드를 점유하지 않고 효율적으로 사용합니다.
 
notion image
 
동작 방식
  • withContext(Dispatchers.IO) : I/O 전용 스레드 풀로 전환
  • placeQuery.textSearch(): Non-blocking I/O로 Google Places API 호출
 
Google API 클라이언트(placeQuery.textSearch)는 내부적으로 블로킹 I/O를 사용하기에, 기본dispatcher에서 실행할 경우 아래와 같은 문제가 발생합니다.
  • 블로킹 호출이 스레드를 완전히 점유함
  • Default Dispatcher(= CPU 전용 스레드 풀)의 스레드는 제한적이므로 → CPU 작업과 I/O 작업이 경쟁하게 되어 전체 처리량 감소 가능
따라서 withContext(Dispatchers.IO)를 통해 여러 네트워크 요청이 동시에 들어오더라도 전체 서비스의 스레드 자원 고갈을 방지하고, I/O 대기 시간이 긴 요청을 병렬 처리하여 스레드 사용을 최적화할 수 있었습니다.
 

🔖 4-1. 외부 API 호출 시 suspend의 스레드 반납 구조

routineA / routineB 코루틴이 Dispatchers.IO로 배정됨

notion image
현재 구조를 가장 잘 나타낸 그림을 가져와봤습니다.
launch() 는 호출 시점의 컨텍스트를 그대로 이어받은 새로운 코루틴을 생성하며, 해당 코루틴은 I/O 스레드 풀에서 가용한 Worker 스레드 하나를 할당받아 실행됩니다.
 
notion image
 

Google Place API 호출 지점에서 suspend 발생

네트워크 API 호출 구간에 도달하면 코루틴은 suspend 상태가 되며 실행이 중단됩니다. routineA/B를 실행하던 Worker 스레드는 즉시 스레드 풀로 반납되고, 이후의 작업은 OS 커널 + 외부 Google API 서버가 처리하는 영역입니다. 즉, 서버 스레드는 전혀 점유되지 않음.

응답이 완료되면 resume 요청 발생

Google API 응답을 받게 되면, 코루틴은 DIspatcher.IO로부터 다시 할당 받은 스레드로 중단된 지점 이후부터 다시 실행됩니다.
 

5. 부하 테스트

현재 서비스가 사용하는 코루틴 + Non-blocking I/O 방식의 성능을 부하테스트를 통해 검증하였습니다. 검증 목적은 프로젝트 초기에 사용 했던 싱글 스레드 + Blocking 구조와 현재 구조의 성능상의 차이를 확인하고 싶었고, 두 방식의 중간인 Multi-thread + Blocking I/O 방식도 비교 대상으로 검증하였습니다.
이를 위해 동일한 API 엔드포인트(api/v1/places), 동일 요청 파라미터, 동일 부하 조건에서 스레드 처리 방식만 다르게 적용하였습니다.
 

0. 테스트 환경 및 조건

부하 툴로는 nGrinder를 활용하였고, ngrinder-controller 컨테이너 1대와 에이전트 역할의 ngrinder-agent 컨테이너 2대를 사용했습니다.
notion image
notion image
기본 설정
  • nGrinder 에이전트 2대(실제 부하를 발생시키는 실행 노드, 컨트롤러의 지시에 따라 테스트 스크립트를 실행하고 트래픽을 서버로 전송하는 역할 수행)
  • 가상 유저 200명(에이전트 당 100명), 프로세스 2대, 스레드 50개 할당, 3분 가량 실행
Ramp-Up
  • 0명에서 시작해 10초(10,000ms) 간격으로 20명씩 부하를 증가시키는 방식
 
 

1. Blocking I/O

: 단일 스레드가 모든 API 호출을 순차 처리하는 방식으로, 코루틴을 사용하지 않고 Google Place API를 Blocking(동기) 방식으로 호출하도록 구현하였습니다.
[테스트 결과]
notion image
  • 평균 TPS 105로 단일 스레드가 외부 API 대기 동안 묶이기에, TPS가 상승하다가도 쉽게 떨어지는 패턴이 반복됨 (지속적으로 60~130 사이에서 넓게 흔들리며 불안정한 패턴)
➡️ 예상했던 대로, 외부 API가 Blocking으로 묶이기 때문에 TPS 증가에 가장 취약한 결과를 보였습니다.
 
 

2. Multi-thread + Blocking I/O

: 고정 크기의 ThreadPool을 사용해 여러 요청을 병렬 처리하지만, 각 스레드가 외부 API 대기 동안 점유되는 방식으로 코루틴을 사용하지 않은 Blocking 방식으로 호출하도록 구현하였습니다.
[테스트 결과]
notion image
  • 평균 TPS 118로 스레드 풀 덕분에 어느 정도 부하를 분산시키지만, 여전히 스레드가 외부 API 대기 중 점유되기 때문에 한계 존재
  • 110~150 사이에서 비교적 좁은 범위로 유지되는 패턴, 중간에 튀는 구간은 있으나 single-thread처럼 급격한 하락은 드뭄
  • 평균 1,566ms의 응답시간으로, 스레드 수만큼 동시에 처리되지만 I/O 대기 때문에 점유되어 평균 응답시간이 크게 증가
➡️ 예상대로 처리량은 증가했지만, 스레드 점유로 평균 응답시간이 가장 길게 나왔습니다.
 
 

3. 코루틴 기반

: 스레드가 네트워크 대기 중 점유되지 않아 최대 동시성을 확보하는 방식으로, 스레드가 대기하지 않고 다른 작업 수행 가능합니다.
[테스트 결과]
notion image
  • 최고 TPS는 Multi-thread + Blocking에 비해 낮지만 평균 TPS가 더 안정적
  • 평균 TPS 124로 3가지 방식 중 가장 높은 요청 처리량 초반에 피크가 있었지만, 110~140 TPS로 동기 방식의 Multi-thread 보다 안정적인 처리 흐름
  • 평균 1,472ms의 응답시간로, 동기 방식의 Multi-thread 보다 요청 수가 많은데도, 응답시간이 더 작게 나옴으로써 Non-blocking이 더 우세함을 확인
  ➡️ 효율적인 스레드 자원 사용과 높고 안정적인 TPS 사용량 패턴을 보여줬습니다.
 

[결과 정리]

방식
평균 TPS
최고 TPS
평균 응답시간
해석
Single-thread + Blocking
105.8
184
828ms
스레드 1개라 TPS 낮고 불안정하지만 응답시간은 짧음
Multi-thread + Blocking
118.0
256
1,566ms
TPS 증가했지만 Blocking 대기로 응답시간 최악
Coroutine + Non-blocking
124.0
194
1,472ms
TPS 최고, 처리량 최대, 가장 안정적 흐름
 
 
 

[레퍼런스]