Kotlin 코루틴 | 비동기 프로그래밍 완벽 가이드
이 글의 핵심
Kotlin 코루틴: 비동기 프로그래밍 코루틴 설정·코루틴 빌더.
들어가며
코루틴 얘기할 때 스레드 수십 개 띄우는 그림만 먼저 떠올리면 이미 절반은 틀렸다고 본다. 핵심은 “멈췄다가 이어갈 수 있는 일”을 문법으로 쓰게 해 준 suspend랑, 그걸 어디 스레드에 올릴지 고르는 디스패처거든.
Go 고루틴·채널은 “많은 경량 일꾼 + 통신” 쪽 느낌이 강하고, Kotlin은 suspend에서 실행을 양보하는 쪽이 async/await 계열에 가깝다. Rust async/Tokio·Node·JS Promise랑 자주 견줘지는 이유가 그거다.
솔직한 취향: 나는 Dispatchers.Unconfined는 거의 쓰지 말자는 쪽이다. “디버그할 때 누가 어디서 돌아가는지”가 먼저 흐려져서, 이득이 체감될 때까지 기다리느니 처음부터 Main/IO/Default로 쪼개는 게 낫다.
- 경량이라 수천 개 겹쳐도 괜찮은 경우가 많고
- async/await 스타일이라 읽는 사람 뇌엔 덜 상처 준다(콜백 지옥 대비)
- 구조화된 동시성 덕에 취소를 “끄덕”이 아니라 “트리”로 잡을 수 있음
- 취소·타임아웃을 언어 차원에서 이야기할 수 있음
안드로이드에서 겪는 게 제일 잘 감 온다
옛날에 RecyclerView 쪽에서 GlobalScope.launch로 API 쏘고, 스크롤하다가 화면 뺐더니 “어? 콜백은 와 있는데 뷰는 죽었네” 같은 거 한 번쯤 겪어봤다면, 그게 생명주기랑 엮인 코루틴이 제일 잘 터지는 환경이다. 메인스레드는 한 개인데, 일은 비동기로 잔뜩 풀어 놨으니, 누가 취소해 줄지가 곧 UX랑 직결이야.
내가 흔히 봤던 패턴은 이런 느낌이다. 리스트 셀에 썸네일 로딩 붙이면서 viewModelScope는 안 쓰고, 컴포저블/프래그먼트에 직접 launch 박은 다음, 로딩 끝날 때 findViewById가 null이라 터지는. 혹은 Dispatchers.Main에서 JSON 통째로 파싱하다 ANR 한 방 먹는. 둘 다 “코루틴 썼으니 멀쩡한 줄” 알았는데, 스레드랑 생명주기만 틀어져도 끝나는 케이스다.
viewModelScope + Dispatchers.IO + 구조화된 취소(화면 나가면 Job 정리) 이 삼박자가 Android에서 코루틴을 “쓰기”에서 “쓸 만하게” 바꾼다고 생각한다. 이 길 말고 Kotlin Android 쪽이 더 촘촘하니, 여기서는 “코루틴 뼈대”만 잡고 넘기자.
launch vs async, 표 말고 결론부터
launch는 결과를 안 남기는 불—Job이 나오고, 보통 부수 효과(로그, UI 갱신, 이벤트) 쪽에 쓴다. async는 Deferred<T>를 줘서 나중에 값 꺼내 먹는 병렬 작업에 쓴다. 예외는 launch 쪽이 CoroutineExceptionHandler/부모 전파 쪽에 더 잘 터지고, async는 await() 하는 순간에 “아 맞다 여기 터지는구나” 느낌이 강하다.
의견: “그냥 async만 잔뜩” 붙이고 await를 빼 둔 코드, 나중에 조용히 썩는다. 완료·실패를 await나 joinAll 같은 걸로 반드시 건드리게 만드는 쪽이 맞다.
- 결과가 없고 기다리기만 하면
launch+ 필요하면join(). - 여러 군데 동시에 굴리다 합쳐야 하면
async+await()/awaitAll().
fun main() = runBlocking {
launch { println("fire-and-forget") }
val a = async { delay(10); 1 }
val b = async { delay(10); 2 }
println(a.await() + b.await()) // 병렬 후 합산
}
구조화된 동시성, 주방 얘기 말고 그냥
동시에 여러 API 때리는 건 “주방”보다 부모 Job이 죽으면 자식도 같이 죽는다는 쪽이 실무에 더 와닿는다. coroutineScope { }, supervisorScope { }, ViewModel의 viewModelScope가 그 규칙을 쓰는 케이스다.
coroutineScope: 자식 하나라도 실패하면 나머지 취소 + 실패로 전파. “한 묶음이면 같이 죽자”supervisorScope: 자식 실패가 형제한테 퍼지지 않음. “피드는 가져올 수 있는데 프로필만 500” 같은 UI에서 유리
suspend fun loadDashboard() = supervisorScope {
val profile = async { api.profile() } // 실패해도
val feed = async { api.feed() } // feed는 시도 가능
runCatching { profile.await() }.getOrNull() to feed.await()
}
내 입장: Android에서 supervisorScope는 “한 화면에 요청이 여러 갈래로 갈릴 때” 꽤 자주 쓴다. 반대로 “결제 한 번 = 트랜잭션 한 방”이면 coroutineScope가 맞다.
1. 코루틴 설정
build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
2. 코루틴 빌더
runBlocking
import kotlinx.coroutines.*
fun main() = runBlocking {
println("시작")
delay(1000)
println("끝")
}
launch
fun main() = runBlocking {
launch {
delay(1000)
println("World!")
}
println("Hello,")
delay(2000) // 코루틴 완료 대기
}
// 출력:
// Hello,
// World!
async
fun main() = runBlocking {
val deferred = async {
delay(1000)
"결과"
}
println("작업 중...")
val result = deferred.await()
println(result)
}
3. suspend 함수
suspend fun fetchUser(): String {
delay(1000) // 네트워크 요청 시뮬레이션
return "홍길동"
}
suspend fun fetchPosts(): List<String> {
delay(1500)
return listOf("Post 1", "Post 2")
}
fun main() = runBlocking {
val user = fetchUser()
val posts = fetchPosts()
println("사용자: $user")
println("게시글: $posts")
}
4. 코루틴 스코프
CoroutineScope
val scope = CoroutineScope(Dispatchers.Default)
scope.launch {
println("백그라운드 작업")
}
// 종료
scope.cancel()
Dispatchers
// Main: UI 스레드 (Android)
launch(Dispatchers.Main) {
updateUI()
}
// IO: 네트워크, 파일
launch(Dispatchers.IO) {
fetchData()
}
// Default: CPU 집약적
launch(Dispatchers.Default) {
heavyComputation()
}
// Unconfined: 제한 없음
launch(Dispatchers.Unconfined) {
// 작업
}
5. 병렬 실행
async + await
fun main() = runBlocking {
val time = measureTimeMillis {
val user = async { fetchUser() } // 1초
val posts = async { fetchPosts() } // 1.5초
println("사용자: ${user.await()}")
println("게시글: ${posts.await()}")
}
println("총 시간: ${time}ms") // 약 1500ms (병렬)
}
6. Flow
기본 Flow
fun numbers(): Flow<Int> = flow {
for (i in 1..5) {
delay(100)
emit(i)
}
}
fun main() = runBlocking {
numbers().collect { value ->
println(value)
}
}
Flow 연산자
fun main() = runBlocking {
(1..10).asFlow()
.filter { it % 2 == 0 }
.map { it * it }
.collect { println(it) }
// 4, 16, 36, 64, 100
}
StateFlow와 SharedFlow
class ViewModel {
private val _state = MutableStateFlow(0)
val state: StateFlow<Int> = _state
fun increment() {
_state.value++
}
}
fun main() = runBlocking {
val vm = ViewModel()
launch {
vm.state.collect { value ->
println("State: $value")
}
}
delay(100)
vm.increment()
vm.increment()
delay(100)
}
7. 실전 예제
예제 1: API 호출
data class User(val id: Int, val name: String)
suspend fun fetchUserFromApi(id: Int): User {
delay(1000) // 네트워크 지연
return User(id, "사용자$id")
}
fun main() = runBlocking {
val users = (1..5).map { id ->
async { fetchUserFromApi(id) }
}.awaitAll()
users.forEach { println(it) }
}
예제 2: 타임아웃
fun main() = runBlocking {
try {
withTimeout(1500) {
repeat(3) {
println("작업 $it")
delay(1000)
}
}
} catch (e: TimeoutCancellationException) {
println("타임아웃!")
}
}
8. Dispatchers 상세 (Main, IO, Default, Unconfined)
- Main: Android에선 UI 스레드. 여기는 짧게. 네트워크/파일/CPU 작업 넣지 마라가 정석이다(넣는 순간 “코루틴 쓰는데 왜 끊기지” 시작).
- IO: 블로킹 I/O 냄새 나는 풀. 파일, 소켓, DB. 스레드가 좀 더 많다.
- Default: CPU 잡는 일. 정렬, 이미지, 무식한 루프. 코어 수에 맞게 제한.
- Unconfined: “어디서 돌지”를 고정하지 않는다. 첫
suspend전까지는 호출 스레드에 붙을 수도 있고… 나는 테스트·아주 좁은 특수 케이스 아니면 그냥 피한다고 봄. 예측이 어렵고, UI 코드랑 엮이면 잡기 빡세다.
// UI → 백그라운드 → 다시 UI
withContext(Dispatchers.IO) { readFile() }
withContext(Dispatchers.Default) { heavySort(data) }
withContext(Dispatchers.Main) { updateUi() }
9. (아까 썼지만) 주방 얘기 보태면
일상 비유로 이해하기: 동시에 여러 타이머를 돌리는 느낌(스택 하나로 여러 요리)이 동시성에 가깝고, 진짜로 요리사 여럿이 병렬로 굽는 게 병렬성에 가깝다. 코루틴은 둘 다 잡을 수 있지만, Android에서 자주 쓰는 그림은 “메인 1 + IO/디폴트 풀 N”.
10. Flow vs Channel, 표 말고 감으로
Flow는 콜드/핫 스트림 쪽, 특히 flow { }는 “구독할 때” 흐름이 잡히는 쪽이 강하다. UI 상태, 페이지 네이션, 이벤트 스트림 — ViewModel + StateFlow/SharedFlow 루트면 거의 Flow 쪽. 다수 소비자는 SharedFlow / StateFlow로 뿌리면 된다.
Channel은 송신–수신 파이프. 액터 스타일, 워커 큐, “작업 한 건 넣고 한 건 꺼내는” 느낌. 백프레셔가 꼭 필요한 설계에서 자주 이야기된다. 소비자 여럿 붙이는 건 Channel이 더 손이 간다(그래서 Flow 쪽이 UI에 잘 박힘).
실무 팁: 화면 상태·API 결과 스트림이면 Flow가 자연스럽다. “코루틴끼리 일 한 건 던지기”면 Channel. 둘 다 필요하면 channelFlow { }로 합치기도 한다. 취향 싸움 아니고, “누가 누구를 구독하느냐”에 가깝다.
11. 실전 에러 처리 (try-catch, CoroutineExceptionHandler)
suspend안 동기 예외 → 그냥try/catch.launch루트에서 터지는 미처리 예외 →CoroutineExceptionHandler나 부모로 전파.async→ 예외는 대개await()에서 터진다(그 전엔CancellationException쪽이 될 수도).
val handler = CoroutineExceptionHandler { _, e ->
println("Unhandled: ${e.message}")
}
fun main() = runBlocking {
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch {
error("from launch")
}
delay(100)
scope.cancel()
}
async 내부를 기록만 하고 흐르게 하려면 runCatching { }랑 supervisorScope 조합이 익숙하다.
12. 취소와 타임아웃
- 협력적 취소:
delay,yield,isActive이런 애들이 있어야 빨리 멈춘다. Java 블로킹만 있으면 취소가 늦게 먹힌다(그래서 I/O는 중단 가능한 API +Dispatchers.IO조합이 이득인 경우가 많다). withTimeout→TimeoutCancellationException(상위는CancellationException계열).withTimeoutOrNull→ 초과 시null로 실패를 단순화.
suspend fun work() = coroutineScope {
launch {
while (isActive) {
delay(100)
// 취소 시 루프 종료
}
}
}
suspend fun fetchOrNull() = withTimeoutOrNull(3000) {
api.slowCall()
}
실전 심화 보강
실전 예제: coroutineScope로 부분 실패 수집
여러 비동기 작업을 동시에 시도하되, 하나가 실패해도 나머지 결과를 모으고 싶을 때 supervisorScope와 조합할 수 있다. 아래는 표준 라이브러리만으로 성공 문자열과 예외를 분리하는 예이다.
import kotlinx.coroutines.*
suspend fun mayFail(id: Int): String {
delay(50)
if (id % 4 == 0) error("fail on $id")
return "ok-$id"
}
suspend fun gather(ids: List<Int>): Pair<List<String>, List<Throwable>> = coroutineScope {
val results = ids.map { id ->
async {
runCatching { mayFail(id) }
}
}.awaitAll()
val oks = results.mapNotNull { it.getOrNull() }
val errs = results.mapNotNull { it.exceptionOrNull() }
oks to errs
}
fun main() = runBlocking {
val (oks, errs) = gather((1..12).toList())
println("성공: $oks")
println("실패 수: ${errs.size}")
}
자주 하는 실수
- GlobalScope.launch — 생명주기랑 끊으면 “언제 죽는지” 모르는 유령 Job이 남는다. Android에선 거의 금지에 가깝다고 본다.
Main에서 장시간 연산 — 코루틴이 아니라 스레드 문제다.Default로 보내라.async를await없이 — 조용한 실패/자원 낭비.
주의사항
- 뷰모델·Android 쪽에선
SupervisorJob+viewModelScope패턴으로 취소를 맞추는 경우가 많다(세부는 Android 편으로).
실무에서는 이렇게
- I/O는
IO, CPU는Default. Unconfined는 “정말 이유가 있을 때만”. - 부모 취소 = 자식 취소, 이거 안 지키면 “화면 껐는데 API만 도는” 그림이 나온다.
RxJava랑 뭐가 다르냐 (한 줄 취향)
- 코루틴은 Kotlin/구조화된 동시성 쪽 1지망. 새 프로젝트면 이쪽으로 수렴시키는 게 유지보수에 이득인 경우가 많다.
- RxJava는 레거시 Android에서 아직 튼튼하다. “갈아엎는 비용”이 크면 점진적으로
suspend래핑 쪽이 현실적이다. - Java 21 가상 스는 서버/JVM 이야기에 가깝다. ANR이 있는 Android 메인이랑은 문제 정의가 다르다.
추가 리소스
정리
핵심 요약
- launch / async: 부수 효과 vs
Deferred로 병렬 결과;await누락 = 함정 - Dispatchers: Main(UI) 짧게, IO(I/O), Default(CPU), Unconfined는 난 쓰기 싫다는 입장
- 구조화된 동시성: 부모 취소 = 자식 취소;
coroutineScopevssupervisorScope - Flow vs Channel: 스트림/상태는 Flow, 파이프/큐느낌은 Channel. UI는 Flow가 편한 경우가 많다
- 에러 처리:
try/catch,CoroutineExceptionHandler,async는await시점 - 취소·타임아웃: 협력적 취소,
withTimeout/withTimeoutOrNull - suspend / Flow·StateFlow — 비동기의 문법
- Android에선
viewModelScope+Main/IO분리가 체감 품질을 좌우한다
다음 단계
관련 글
- C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- C++ 코루틴 |
- C++ future와 promise |
- C++ Asio Composed Operation | 비동기 함수 설계 [#7]
심화 부록: 구현·운영 관점
이 부록은 앞선 본문에서 다룬 주제(「Kotlin 코루틴 | 비동기 프로그래밍 완벽 가이드」)를 구현·런타임·운영 관점에서 다시 압축한다. 도메인마다 다르지만, 입력 검증 → 핵심 연산 → 부작용 → 관측으로 쪼개면 장애 추적이 빨라진다.
내부 동작과 핵심 메커니즘
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(런타임·게이트웨이·프로세스) participant D as 의존성(API·DB·큐·파일) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
- 불변 조건을 문장으로 적어 두면 디버깅 비용이 준다(버퍼, FD, 격리 수준).
- 순수/비순수 층을 나누면 테스트·장애 분석이 쉬워진다.
- 백프레셔는 “누가 느리게 먹을지”를 미리 정해 두는 쪽이 덜 터진다.
프로덕션 운영에서 자주 박는 질문(표 대신)
- 관측성: 상관 ID, p95/p99, 의존성 타임아웃/재시도가 대시보드에 보이냐. 안 보이면 “감”으로 돌리는 것과 같다.
- 안전성: 권한·검증·감사 로그가 경로마다 일관되냐. 한 군데만 구멍 나도 그게 3시간 야근이다.
- 신뢰성: 재시도는 멱등일 때만. 그 외는 서킷/백오프/DLQ 쪽.
- 성능: N+1, 직렬화, 락, 풀 크기, 캐시. 한 번에 한 가지만 의심하는 게 수확이 좋다.
- 배포: 카나리, 롤백, 마이그레이션, 피처플래그 — 문서에 이름이 박혀 있냐.
- 용량: 피크때 FD·스레드·디스크 상한, 주기적으로 찍어 보냐.
스테이징은 데이터 양·RTT·동시성을 프로덕에 가깝게 맞출수록 “현장 재현”이 쉬워진다.
확장 예시: 엔드투엔드 미니 시나리오
앞 주제를 배포·운영 흐름에 옮긴 체크리스트다. 도메인 이름만 갈아 끼우면 된다.
- 입력 계약 고정: 스키마, 최대 페이로드, 타임아웃, 에러 코드
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 코드
- 실패 주입: 5xx, 타임아웃, 부분 데이터(스테이징)
- 롤백 루트 확인: 설정·마이그·클라이언트
- 부하 후 p99·에러율·알람 임계값 점검
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request)
authorize(validated, ctx)
result = domainCore(validated)
persistOrEmit(result, idempotentKey)
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting), 표 말고 체감
- 간헐: 레이스, DNS, 외부 의존성, 타임아웃. 최소 재현 + 트레이스부터.
- 느려짐: N+1, 동기 I/O, 락, 직렬화, 캐시 미스. 프로파일러로 핫스팟 하나씩.
- 메모리: 캡 없는 캐시, 리스너/구독 누수, 커넥션 미반납. 힌트는 스냅샷 비교.
- 빌드만 깨짐: 환경·lockfile·이미지 핀. CI 로그 vs 로컬.
- 설정 뒤죽박죽: 시크릿/프로필/리전. “단일 소스 + 검증”이 답.
- 데이터 꼬임: 비멱등 재시도, 캐시 무효화 누락, 부분 쓰기. 멱등 키·트랜잭션 다시.
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 (3) 환경 차이 (4) 관측으로 가설 (5) 수정 후 부하. 배포 전엔 git add → commit → push → npm run deploy — 이건 Cloudflare/SSG 쪽 루틴.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 서버도 앱도 “비동기 흐름 꼬이면” 다 여기로 귀결된다. 코루틴은 문법·런타임·운영(로그, 타임아웃)까지 한 번에 생각하라는 뜻에 가깝다.
Q. 선행으로 읽으면 좋은 글은?
A. 이전 글·관련 글 링크를 따라가도 되고, Kotlin 시리즈 목차가 한눈에 편하다.
Q. 더 깊이 공부하려면?
A. Kotlin Coroutines Guide이 본家다. C++ 쪽 끌고 오면 cppreference도 쓰지만, 이 글 주제는 Kotlin 문서가 더 직빵이다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글이다.
- Kotlin 컬렉션 | List, Set, Map 완벽 정리
- Kotlin Android 개발 | Activity, ViewModel, Jetpack
- [Go 2주 완성 #06] Day 10~11: 고루틴과 채널 - 동시성 프로그래밍의 혁명
- Rust 비동기 프로그래밍 | async/await, Tokio
- Node.js 비동기 프로그래밍 | Callback, Promise, Async/Await
- JavaScript 비동기 프로그래밍 | Promise, async/await 완벽 정리
이 글에서 다루는 키워드 (관련 검색어)
Kotlin, 코루틴, 비동기, Coroutines 등으로 검색해 보시면 이 글이 도움이 될 수 있다.