네트워크 호출이 포함된 항목 200개를 처리한다고 해보자. 각 항목당 약 50ms가 걸린다면, 전체는 10초다. 코드를 보면 잘못된 게 없다. 로직도 맞고, 예외 처리도 했다. 그런데 왜 이렇게 느릴까?
범인은 list.map {} 자체다. 아니, 더 정확히는 map을 IO-bound 작업에 그냥 쓰는 선택이다.
sequential map이 느린 구조적 이유
map은 항목을 하나씩 순서대로 처리한다. 1번 항목이 끝나야 2번이 시작된다. CPU를 많이 쓰는 연산이라면 이게 맞다. 코어 수보다 더 많은 스레드를 돌려봤자 컨텍스트 스위칭 비용만 는다.
문제는 IO-bound 작업에서 CPU는 거의 놀고 있다는 점이다. 네트워크 응답을 기다리는 50ms 동안 CPU가 하는 일은 없다. 그 시간에 다른 항목을 처리하면 될 텐데, sequential map은 그냥 기다린다. 200번을.
이 구조에서 병목은 코어 수가 아니라 "기다리는 시간을 어떻게 겹치느냐"다.
mapParallel 구현
코루틴의 핵심은 스레드를 점유하지 않고 일시 중단할 수 있다는 것이다. 이를 활용하면 여러 항목의 대기 시간을 겹칠 수 있다.
suspend fun <T, R> List<T>.mapParallel(
consumer: suspend (T) -> R
): List<R> = supervisorScope {
val dispatcher = Dispatchers.IO.limitedParallelism(50)
map { item ->
async(dispatcher) { consumer(item) }
}.awaitAll()
}
supervisorScope를 쓰는 이유가 있다. 일반 coroutineScope에서는 자식 코루틴 하나가 실패하면 전체 스코프가 취소된다. 배치 처리 중 한 항목 오류로 전체가 날아가는 건 대부분 원하는 동작이 아니다. supervisorScope는 각 자식의 실패를 독립적으로 처리한다.
limitedParallelism(50)은 동시에 실행되는 코루틴 수를 제한한다. 이게 없으면 항목 수만큼 코루틴이 동시에 뜬다. 10만 개 항목이면 10만 개 코루틴이고, 이건 제어 불가 상태다. 적절한 숫자는 서버 제한, 연결 풀 크기, 메모리 여유에 따라 다르다.
왜 parallelStream보다 빠른가
parallelStream()도 병렬 처리 아닌가? 맞다. 하지만 parallelStream은 JVM의 공유 ForkJoinPool을 사용하고, 이 풀의 크기는 CPU 코어 수로 제한된다. 4코어 머신이면 최대 4개 항목이 동시에 처리된다.
Dispatchers.IO는 코어 수를 신경 쓰지 않는다. IO 대기는 CPU를 쓰지 않기 때문이다. limitedParallelism(50)을 주면 실제로 50개를 동시에 대기 상태로 유지한다. 코어가 4개든 16개든 관계없다.
실제 벤치마크(200개 항목, 각 50ms IO 시뮬레이션)에서 결과는 이렇다:
sequential map: 12.5초
parallelStream: 3.3초 (코어 수 제한)
mapParallel(10): 1.3초
mapParallel(50): 352ms (~35배 향상)
parallelStream은 수치적으로는 빨라졌지만, 여기서 더 나아갈 수가 없다. 코어 수 장벽에 막혀 있기 때문이다.
어디에 쓰고, 어디에 쓰지 말아야 하나
이 방식이 효과적인 상황은 명확하다. Retrofit 호출, Room 쿼리, 파일 I/O처럼 코루틴 기반 suspend 함수를 호출하는 경우다. 각 항목이 독립적이고 순서가 결과에 영향을 주지 않아야 한다.
반대로 맞지 않는 경우도 분명하다. 이미지 디코딩, JSON 파싱 같은 CPU-bound 작업이라면 Dispatchers.Default를 쓰고 코어 수 이상으로 병렬화하는 건 오히려 역효과다. 또한 결과의 순서가 중요하거나, 항목 간에 의존성이 있다면 이 구조는 적합하지 않다.
외부 API를 호출하는 경우라면 서버 측 rate limit도 반드시 고려해야 한다. limitedParallelism 값을 API 제한에 맞게 설정하지 않으면 429 응답을 대량으로 받게 된다.
실패 처리를 어떻게 할 것인가
supervisorScope는 실패를 격리하지만, 실패한 항목을 어떻게 처리할지는 직접 결정해야 한다. 실패 항목을 무시할지, 수집해서 재처리할지, 아니면 결과에 에러 상태로 포함시킬지.
suspend fun <T, R> List<T>.mapParallelCatching(
consumer: suspend (T) -> R
): List<Result<R>> = supervisorScope {
val dispatcher = Dispatchers.IO.limitedParallelism(50)
map { item ->
async(dispatcher) { runCatching { consumer(item) } }
}.awaitAll()
}
runCatching을 사용하면 각 항목의 성공/실패를 Result로 받아서 호출 측에서 처리할 수 있다. 전체 배치를 중단하지 않고 실패 항목만 추려내거나 로깅하는 데 유용하다.
핵심 구분
병렬성(parallelism)과 동시성(concurrency)은 다른 개념이다. 병렬성은 물리적으로 동시에 실행되는 것으로 코어 수에 의존한다. 동시성은 여러 작업을 진행 중인 상태로 유지하되, 대부분이 대기 중인 상태다.
IO-bound 작업은 동시성의 영역이다. 기다리는 데는 코어가 필요 없다. 이 차이를 이해하면 Dispatchers.Default와 Dispatchers.IO의 구분이 단순한 관례가 아니라 설계 의도임을 알 수 있다.
※ 본 글은 정보 제공 목적이며 특정 제품·서비스의 추천이 아닙니다.
'안드로이드' 카테고리의 다른 글
| Android 17이 운영체제를 그만두려는 이유 (0) | 2026.06.29 |
|---|---|
| 크래시 리포트만 보고 있다면, 장애의 절반을 놓치고 있는 것이다 (0) | 2026.06.29 |
| Kotlin 2.4가 바꾸는 것: 15년 된 언어가 지금 가장 흥미로운 이유 (0) | 2026.06.25 |
| Compose에 Mesh Gradient가 생겼다, 단 함정이 있다 (0) | 2026.06.24 |
| Gradle이 재컴파일 범위를 결정하는 방법: ABI 해싱 완전 해부 (0) | 2026.06.22 |