(Kotlin/코틀린) 코루틴 기초 완전 정리 - suspend fun, launch vs async

개요


1. 코루틴이란

코루틴은 중단(suspend)하고 재개(resume)할 수 있는 실행 단위입니다.

일반 함수:  호출 → 실행 → 반환  (중단 불가)
코루틴:     호출 → 실행 → 중단 → 재개 → 반환  (중단 가능)

스레드 vs 코루틴

항목 스레드 코루틴
생성 비용 높음 (OS 자원) 낮음 (가벼운 객체)
동시 실행 수 수백~수천 수만~수십만
블로킹 스레드 전체 대기 해당 코루틴만 중단
문맥 전환 OS 스케줄러 코루틴 런타임
코드 스타일 콜백/블로킹 순차적 코드
// 스레드 방식 — 콜백 지옥
fun loadUserThread(userId: Long, callback: (User) -> Unit) {
    Thread {
        val user = api.getUser(userId)   // 스레드 블로킹
        runOnUiThread { callback(user) }
    }.start()
}

// 코루틴 방식 — 순차적으로 읽히는 코드
suspend fun loadUser(userId: Long): User {
    return api.getUser(userId)   // 코루틴만 중단, 스레드는 자유
}

2. 의존성 설정

// build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")

    // Android ViewModel에서 viewModelScope 사용
    implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
}

3. suspend fun — 중단 가능한 함수

suspend 키워드는 “이 함수는 중단될 수 있다” 는 선언입니다.

// suspend fun 선언
suspend fun fetchUser(userId: Long): User {
    delay(1000)              // 1초 중단 (스레드는 블로킹되지 않음)
    return api.getUser(userId)
}

suspend fun의 규칙

// ✅ suspend fun은 코루틴 또는 다른 suspend fun 안에서만 호출 가능
suspend fun fetchData(): String {
    delay(500)
    return "data"
}

suspend fun processData() {
    val data = fetchData()   // ✅ suspend fun 안에서 호출
    println(data)
}

// ❌ 일반 함수에서 직접 호출 불가
fun normalFunction() {
    val data = fetchData()   // 컴파일 에러
}

delay vs Thread.sleep

suspend fun example() {
    // ✅ delay — 코루틴만 중단, 스레드는 다른 코루틴 처리 가능
    delay(1000)

    // ❌ Thread.sleep — 스레드 자체를 블로킹 (코루틴 이점 사라짐)
    Thread.sleep(1000)
}

4. CoroutineScope — 코루틴의 범위

코루틴은 반드시 CoroutineScope 안에서 시작됩니다. Scope는 코루틴의 생명주기를 관리합니다.

// CoroutineScope 직접 생성
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
    // IO 스레드에서 실행
}
scope.cancel()   // 모든 자식 코루틴 취소

// Android 제공 Scope — 생명주기 자동 관리
class MyViewModel : ViewModel() {
    init {
        viewModelScope.launch { ... }  // ViewModel 소멸 시 자동 취소
    }
}

class MyFragment : Fragment() {
    override fun onViewCreated(...) {
        viewLifecycleOwner.lifecycleScope.launch { ... }  // View 소멸 시 자동 취소
    }
}

CoroutineContext — 코루틴의 실행 환경

// CoroutineContext = Dispatcher + Job + CoroutineName + ...
val scope = CoroutineScope(
    Dispatchers.IO +           // 어떤 스레드에서 실행할지
    SupervisorJob() +          // 자식 실패가 부모에게 전파되지 않음
    CoroutineName("MyScope")   // 디버깅용 이름
)
Dispatcher 실행 스레드 사용 시점
Dispatchers.Main 메인(UI) 스레드 UI 업데이트
Dispatchers.IO IO 스레드 풀 네트워크, 파일, DB
Dispatchers.Default CPU 스레드 풀 계산, 파싱
Dispatchers.Unconfined 호출자 스레드 특수 목적

5. launch — 결과 없는 코루틴

launch반환값이 필요 없는 코루틴을 시작합니다. Job 객체를 반환합니다.

val job: Job = scope.launch {
    println("코루틴 시작")
    delay(1000)
    println("1초 후")
}

// Job으로 코루틴 제어
job.cancel()    // 취소
job.join()      // 완료까지 대기 (suspend fun)
job.isActive    // 실행 중 여부

launch 실전 예제

@HiltViewModel
class UserViewModel @Inject constructor(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    fun loadUser(userId: Long) {
        viewModelScope.launch {                          // ① 코루틴 시작
            _uiState.update { it.copy(isLoading = true) }

            try {
                val user = userRepository.getUser(userId)  // ② suspend fun 호출
                _uiState.update { it.copy(isLoading = false, user = user) }
            } catch (e: Exception) {
                _uiState.update { it.copy(isLoading = false, error = e.message) }
            }
        }
    }

    fun deleteUser(userId: Long) {
        viewModelScope.launch {
            userRepository.deleteUser(userId)
            // 반환값 필요 없음 — launch 적합
        }
    }
}

6. async — 결과 있는 코루틴

async반환값이 있는 코루틴을 시작합니다. Deferred<T> 객체를 반환하며, await()로 결과를 받습니다.

val deferred: Deferred<String> = scope.async {
    delay(1000)
    "결과값"    // 마지막 표현식이 반환값
}

val result: String = deferred.await()   // 완료까지 대기 후 결과 반환
println(result)   // "결과값"

async의 핵심 — 병렬 실행

async의 진가는 여러 작업을 동시에 실행할 때 발휘됩니다.

suspend fun loadDashboard(): Dashboard {

    // ❌ 순차 실행 — 총 3초 소요
    val user    = fetchUser()      // 1초
    val posts   = fetchPosts()     // 1초
    val notices = fetchNotices()   // 1초

    return Dashboard(user, posts, notices)
}

suspend fun loadDashboardFast(): Dashboard {

    // ✅ 병렬 실행 — 총 1초 소요 (가장 느린 작업 기준)
    val userDeferred    = coroutineScope { async { fetchUser() } }
    val postsDeferred   = coroutineScope { async { fetchPosts() } }
    val noticesDeferred = coroutineScope { async { fetchNotices() } }

    return Dashboard(
        user    = userDeferred.await(),
        posts   = postsDeferred.await(),
        notices = noticesDeferred.await()
    )
}

더 간결하게는 coroutineScope { } 안에서 함께 선언합니다.

suspend fun loadDashboardFast(): Dashboard = coroutineScope {
    val user    = async { fetchUser() }
    val posts   = async { fetchPosts() }
    val notices = async { fetchNotices() }

    Dashboard(
        user    = user.await(),
        posts   = posts.await(),
        notices = notices.await()
    )
}

7. launch vs async 완전 비교

항목 launch async
반환 타입 Job Deferred<T>
결과 수신 없음 await()로 수신
예외 발생 시 즉시 전파 await() 호출 시 전파
주요 용도 부수효과 (저장, 전송) 값 계산, 병렬 처리
취소 job.cancel() deferred.cancel()
// launch — 반환값 없는 작업
viewModelScope.launch {
    repository.saveUser(user)   // 저장 후 결과 불필요
}

// async — 반환값 있는 작업
viewModelScope.launch {
    val result = async { repository.getUser(userId) }.await()
    // result 활용
}

// async 병렬 처리 — 가장 일반적인 사용
viewModelScope.launch {
    val (profile, settings) = coroutineScope {
        val p = async { repository.getProfile(userId) }
        val s = async { repository.getSettings(userId) }
        Pair(p.await(), s.await())
    }
    // profile, settings 동시에 준비됨
}

8. runBlocking — 코루틴과 일반 코드 연결

runBlocking현재 스레드를 블로킹하면서 코루틴을 실행합니다. 주로 테스트와 main() 함수에서 사용합니다.

// main 함수에서 코루틴 진입점으로 사용
fun main() = runBlocking {
    val result = async { fetchData() }.await()
    println(result)
}

// 테스트 코드에서 사용
@Test
fun `유저 데이터를 올바르게 로드한다`() = runBlocking {
    val user = userRepository.getUser(1L)
    assertEquals("홍길동", user.name)
}

runBlocking 주의사항

// ❌ Android 메인 스레드에서 runBlocking 금지 — ANR 발생
class MainActivity : AppCompatActivity() {
    override fun onCreate(...) {
        runBlocking {          // UI 스레드 블로킹 → ANR
            delay(3000)
        }
    }
}

// ✅ 메인 스레드에서는 lifecycleScope 사용
class MainActivity : AppCompatActivity() {
    override fun onCreate(...) {
        lifecycleScope.launch {    // 메인 스레드 블로킹 없음
            delay(3000)
        }
    }
}

9. withContext — 스레드 전환

withContext코루틴 안에서 Dispatcher를 전환할 때 사용합니다. 새 코루틴을 시작하지 않고 현재 코루틴의 실행 스레드만 바꿉니다.

// 네트워크 → 메인 스레드 전환 패턴
suspend fun loadAndDisplay(userId: Long) {
    val user = withContext(Dispatchers.IO) {
        api.getUser(userId)       // IO 스레드에서 네트워크 호출
    }
    withContext(Dispatchers.Main) {
        tvName.text = user.name   // 메인 스레드에서 UI 업데이트
    }
}

// Repository 계층에서 Dispatcher 지정
class UserRepositoryImpl(
    private val userApi: UserApi,
    private val userDao: UserDao
) : UserRepository {

    override suspend fun getUser(userId: Long): User {
        return withContext(Dispatchers.IO) {   // IO 스레드 강제
            userDao.getUser(userId)
                ?: userApi.getUser(userId).also { userDao.insert(it) }
        }
    }
}

withContext vs launch vs async 비교

// withContext — 결과를 반환하며 현재 코루틴에서 실행 (새 코루틴 없음)
val result = withContext(Dispatchers.IO) { fetchData() }

// launch — 새 코루틴 시작, 결과 없음
launch(Dispatchers.IO) { saveData() }

// async — 새 코루틴 시작, 결과 있음
val deferred = async(Dispatchers.IO) { fetchData() }

10. coroutineScope vs GlobalScope

// ✅ coroutineScope — 부모 코루틴의 범위를 상속, 구조화된 동시성
suspend fun doWork() = coroutineScope {
    val a = async { task1() }
    val b = async { task2() }
    a.await() + b.await()
    // 자식 코루틴 하나가 실패하면 나머지도 취소됨
    // 부모가 취소되면 자식도 취소됨
}

// ❌ GlobalScope — 앱 생명주기와 연결, 메모리 누수 위험
GlobalScope.launch {
    // 취소 불가, 생명주기 관리 안 됨 — 사용 자제
    delay(Long.MAX_VALUE)
}

11. Android 실전 예제 — 병렬 API 호출

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val userRepository: UserRepository,
    private val postRepository: PostRepository,
    private val noticeRepository: NoticeRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    fun loadHome(userId: Long) {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }

            try {
                // 세 API를 병렬 호출 — 총 소요시간 = 가장 느린 하나
                val (user, posts, notices) = coroutineScope {
                    val u = async { userRepository.getUser(userId) }
                    val p = async { postRepository.getPosts(userId) }
                    val n = async { noticeRepository.getNotices() }
                    Triple(u.await(), p.await(), n.await())
                }

                _uiState.update {
                    it.copy(
                        isLoading = false,
                        user      = user,
                        posts     = posts,
                        notices   = notices
                    )
                }
            } catch (e: Exception) {
                _uiState.update {
                    it.copy(isLoading = false, errorMessage = e.message)
                }
            }
        }
    }
}

12. 주의사항

❌ suspend fun을 일반 스레드에서 블로킹 호출하지 않는다

// ❌ runBlocking으로 메인 스레드 블로킹
fun onClick() {
    val user = runBlocking { repository.getUser(1L) }   // ANR 위험
}

// ✅ launch로 비동기 실행
fun onClick() {
    lifecycleScope.launch {
        val user = repository.getUser(1L)
        tvName.text = user.name
    }
}

❌ async 결과를 즉시 await하면 병렬의 의미가 없다

// ❌ 이렇게 쓰면 launch와 동일 — 순차 실행
val user = async { fetchUser() }.await()   // 완료 대기
val post = async { fetchPost() }.await()   // 그다음 시작

// ✅ async 먼저 모두 시작하고, 나중에 await
val userDeferred = async { fetchUser() }
val postDeferred = async { fetchPost() }
val user = userDeferred.await()   // 둘 다 동시 진행 중
val post = postDeferred.await()

❌ GlobalScope 사용을 피한다

// ❌ GlobalScope — 생명주기 미관리, 메모리 누수
GlobalScope.launch { repository.saveLog(event) }

// ✅ 적절한 scope 사용
viewModelScope.launch { repository.saveLog(event) }

13. 정리

항목 내용
코루틴 중단/재개 가능한 실행 단위 — 스레드보다 가볍고 효율적
suspend fun 중단 가능한 함수 — 코루틴 또는 다른 suspend fun 안에서만 호출
CoroutineScope 코루틴의 생명주기 관리 — Android는 viewModelScope, lifecycleScope 사용
launch 반환값 없는 코루틴 — Job 반환
async 반환값 있는 코루틴 — Deferred<T> 반환, await()로 결과 수신
async 병렬 처리 여러 async를 먼저 시작하고 나중에 await() — 총 소요시간 단축
withContext 스레드 전환 — 새 코루틴 없이 Dispatcher 변경
runBlocking 메인/테스트 진입점 — Android 메인 스레드 사용 금지
coroutineScope 구조화된 동시성 — 자식 실패 시 나머지 취소

참고



Related Posts