코루틴 기초 — suspend, launch, async의 동작 원리
코루틴을 처음 접했을 때 "경량 스레드"라는 설명을 듣고, 그냥 작은 스레드인가 보다 하고 넘어갔습니다. 그런데 면접에서 "코루틴이 정확히 뭔가요?"라는 질문을 받으면 그 설명만으로는 부족합니다. suspend가 뭘 중단하는 건지, launch와 async는 왜 따로 있는 건지 — 이 글에서 정리해 봅니다.
코루틴이란 — "경량 스레드"가 아닌 진짜 이유
코루틴(Coroutine)은 협력적으로 멀티태스킹을 수행하는 프로그래밍 구성 요소입니다.
OS 스레드와 비교하면 이렇습니다.
| 구분 | OS 스레드 | 코루틴 |
|---|---|---|
| 생성 비용 | 높음 (스택 메모리 ~1MB) | 낮음 (수십 바이트) |
| 스케줄링 | OS 커널 (선점형) | 코루틴 런타임 (협력형) |
| 컨텍스트 스위칭 | 비쌈 (커널 모드 전환) | 저렴 (유저 영역에서 처리) |
| 동시 실행 수 | 수천 개가 한계 | 수십만 개도 가능 |
핵심은 코루틴이 스레드를 대체하는 게 아니라, 스레드 위에서 실행된다는 점입니다. 중단(suspend)되면 스레드를 블로킹하지 않고 양보하기 때문에, 하나의 스레드에서 여러 코루틴이 번갈아 실행될 수 있습니다.
// 10만 개의 코루틴을 만들어도 문제없음
fun main() = runBlocking {
repeat(100_000) {
launch {
delay(1000L)
print(".")
}
}
}
// 같은 걸 스레드로 하면? OutOfMemoryError
면접에서 "코루틴 = 경량 스레드"라고만 답하면 후속 질문이 날아옵니다. **"그럼 스레드와 어떻게 다른데요?"**라는 질문에 "협력적 멀티태스킹"과 "suspend/resume 메커니즘"을 설명할 수 있어야 합니다.
suspend 함수 — 중단하는 건 스레드가 아니라 코루틴
suspend 키워드는 이 함수가 코루틴을 중단할 수 있다는 표시입니다.
suspend fun fetchUser(): User {
// 네트워크 요청 동안 코루틴이 중단됨
// 이 사이에 스레드는 다른 코루틴을 실행할 수 있음
return apiService.getUser()
}
컴파일러 관점에서 보면, suspend 함수는 내부적으로 **Continuation Passing Style(CPS)**로 변환됩니다.
// 우리가 작성하는 코드
suspend fun fetchUser(): User
// 컴파일러가 변환한 코드 (개념적)
fun fetchUser(continuation: Continuation<User>): Any?
Continuation은 "나중에 여기서부터 이어서 실행해줘"라는 콜백입니다- 반환값이
COROUTINE_SUSPENDED이면 실제로 중단된 것 - 중단 후 결과가 준비되면
continuation.resume()으로 재개
이게 면접에서 자주 나오는 포인트입니다. suspend는 스레드를 블로킹하는 게 아니라, 코루틴의 실행을 잠시 멈추고 나중에 이어서 실행할 수 있게 하는 것입니다.
suspend 함수 호출 규칙
// 코루틴 또는 다른 suspend 함수에서만 호출 가능
suspend fun loadData() {
val user = fetchUser() // OK — suspend 함수에서 호출
}
fun main() {
// fetchUser() // 컴파일 에러! 일반 함수에서 호출 불가
runBlocking {
fetchUser() // OK — 코루틴 빌더 안에서 호출
}
}
CoroutineScope — 코루틴의 생명주기 관리
코루틴은 반드시 스코프(Scope) 안에서 생성됩니다. 스코프는 코루틴의 생명주기를 관리하고, 취소가 전파되는 범위를 결정합니다.
class MyViewModel : ViewModel() {
// viewModelScope: ViewModel이 소멸되면 모든 코루틴 자동 취소
fun loadData() {
viewModelScope.launch {
val data = repository.fetchData()
_state.value = data
}
}
}
주요 스코프를 정리하면 이렇습니다.
- GlobalScope: 앱 전체 생명주기. 누수 위험이 있어서 실무에서는 지양
- runBlocking: 현재 스레드를 블로킹하며 코루틴 실행. 테스트나 main 함수에서 사용
- coroutineScope: suspend 함수 안에서 새로운 스코프 생성. 자식 코루틴이 모두 끝나야 반환
- viewModelScope / lifecycleScope: Android에서 생명주기에 맞춰 자동 취소
suspend fun loadAllData() = coroutineScope {
val users = async { fetchUsers() }
val posts = async { fetchPosts() }
// 둘 다 끝나야 coroutineScope가 반환됨
combineData(users.await(), posts.await())
}
launch vs async — 언제 뭘 쓰나
둘 다 코루틴을 시작하는 빌더이지만, 반환값이 다릅니다.
launch — "실행하고 잊기 (Fire and Forget)"
val job: Job = scope.launch {
// 결과를 반환하지 않음
saveToDatabase(data)
sendNotification()
}
// 필요하면 취소하거나 완료를 기다릴 수 있음
job.cancel()
job.join() // 완료될 때까지 대기
async — "결과가 필요할 때"
val deferred: Deferred<User> = scope.async {
// 결과를 반환함
fetchUser(userId)
}
val user: User = deferred.await() // 결과를 기다림
병렬 실행 패턴
// 순차 실행 — 총 2초
suspend fun sequential() {
val user = fetchUser() // 1초
val posts = fetchPosts() // 1초
}
// 병렬 실행 — 총 1초
suspend fun parallel() = coroutineScope {
val user = async { fetchUser() } // 동시에 시작
val posts = async { fetchPosts() } // 동시에 시작
println("${user.await()}, ${posts.await()}")
}
면접에서 가장 많이 나오는 질문 중 하나가 "launch와 async의 차이"입니다. **launch는 Job(생명주기 관리용), async는 Deferred(결과값 반환용)**이라고 기억하면 됩니다.
Dispatchers — 어떤 스레드에서 실행할 것인가
코루틴이 실행될 스레드(풀)를 결정하는 것이 Dispatcher입니다.
| Dispatcher | 용도 | 스레드 풀 |
|---|---|---|
Dispatchers.Default | CPU 집약적 연산 | CPU 코어 수만큼 |
Dispatchers.IO | 파일, 네트워크, DB | 최대 64개 (또는 코어 수) |
Dispatchers.Main | UI 업데이트 (Android) | 메인 스레드 1개 |
Dispatchers.Unconfined | 특수 용도 (테스트 등) | 호출한 스레드에서 시작 |
scope.launch(Dispatchers.IO) {
// I/O 작업
val data = readFile("data.json")
withContext(Dispatchers.Main) {
// UI 업데이트
textView.text = data
}
}
withContext는 코루틴을 중단하고 다른 디스패처에서 재개하는 함수입니다. 스레드 전환이 필요할 때 사용합니다.
// 잘못된 예: I/O 작업을 메인 스레드에서 실행
scope.launch(Dispatchers.Main) {
val data = readFile("data.json") // 메인 스레드 블로킹!
}
// 올바른 예: withContext로 I/O 디스패처 전환
scope.launch(Dispatchers.Main) {
val data = withContext(Dispatchers.IO) {
readFile("data.json")
}
textView.text = data // 다시 메인 스레드
}
코루틴 취소 — 협력적 취소의 의미
코루틴의 취소는 **협력적(cooperative)**입니다. 그냥 강제로 중단시키는 게 아니라, 코루틴이 스스로 취소를 확인해야 합니다.
val job = launch {
repeat(1000) { i ->
println("작업 중: $i")
delay(100) // delay는 취소 가능한 suspend 함수
}
}
delay(500)
job.cancel() // 취소 요청
job.join() // 취소 완료 대기 (cancelAndJoin()으로 합칠 수 있음)
delay, yield 같은 suspend 함수는 내부적으로 취소 여부를 확인합니다. 하지만 CPU 집약적인 루프에서는 직접 확인해야 합니다.
val job = launch {
var i = 0
while (isActive) { // isActive로 취소 여부 확인
i++
// CPU 집약적 작업
}
}
정리
코루틴의 핵심 포인트를 면접용으로 요약하면 이렇습니다.
- 코루틴 ≠ 스레드: 스레드 위에서 협력적으로 실행되는 경량 실행 단위
- suspend: 코루틴을 중단/재개할 수 있다는 표시. CPS로 변환됨
- launch vs async: 결과가 필요 없으면 launch(Job), 결과가 필요하면 async(Deferred)
- Dispatcher: 코루틴이 실행될 스레드(풀)를 결정. IO/Default/Main 구분 중요
- 취소는 협력적: suspend 함수 또는 isActive로 취소 지점을 만들어야 함