코루틴을 활용한 비동기 작업 순서
- 1.동사 매달다, 걸다
- 2.동사 (공식적으로) 유예[중단]하다
- 3.동사 (공식적으로) 연기[유보]하다
suspend 함수란?
suspend 키워드는 함수가 "중단 가능하다"는 것을 나타냅니다. 일반 함수와 달리, suspend 함수는 실행 중간에 중단됐다가 나중에 재개될 수 있습니다. 이는 비동기 작업을 수행할 때 스레드를 차단하지 않고도 결과를 기다릴 수 있게 해줍니다.
suspend fun getUserFromApi(userId: String): User {
// 네트워크 요청 (이 동안 함수 실행은 중단되지만, 스레드는 차단되지 않음)
return api.getUser(userId)
}
suspend 함수의 작동 원리
suspend 함수는 내부적으로 '상태 머신(state machine)'으로 변환됩니다. 함수가 중단 포인트에 도달하면, 현재까지의 실행 상태가 저장되고 함수 실행이 중단됩니다. 이후 중단된 작업이 완료되면, 저장된 상태부터 함수 실행이 재개됩니다.
이 과정은 Kotlin 컴파일러에 의해 자동으로 처리되며, 개발자는 마치 동기 코드를 작성하는 것처럼 자연스럽게 비동기 코드를 작성할 수 있습니다.
// 컴파일러가 대략 이런 식으로 변환합니다 (의사 코드)
fun getUserFromApi(userId: String, continuation: Continuation): Any {
val state = continuation.state
switch (state) {
case 0: // 초기 상태
// 네트워크 요청 시작
return suspendCoroutine { api.getUser(userId, it) }
case 1: // 네트워크 요청 완료 후
val user = continuation.result as User
return user
}
}
suspend 함수의 제약사항
suspend 함수는 다음과 같은 제약사항이 있습니다:
- suspend 함수는 다른 suspend 함수 또는 코루틴 빌더(launch, async 등) 내에서만 호출할 수 있습니다.
- suspend 함수는 콜백을 파라미터로 받지 않습니다. 대신 결과를 직접 반환합니다.
- suspend 함수가 중단되면 해당 코루틴 전체가 중단되지만, 스레드는 다른 작업을 위해 해제됩니다.
withContext의 역할과 중요성
withContext란?
withContext는 코루틴의 실행 컨텍스트(주로 디스패처)를 변경하는 함수입니다. 주요 용도는 다음과 같습니다:
- 스레드 전환: UI 스레드에서 백그라운드 스레드로, 또는 그 반대로 전환
- 결과를 기다리며 코루틴 중단: 내부 작업이 완료될 때까지 현재 코루틴 중단
- 예외 전파: 내부에서 발생한 예외를 호출자에게 전파
suspend fun loadUserData(userId: String): User {
// IO 스레드에서 네트워크 요청 수행
return withContext(Dispatchers.IO) {
api.fetchUser(userId)
}
}
이 코드는 api.fetchUser(userId) 호출을 IO 스레드에서 실행하고, 그 결과가 반환될 때까지 현재 코루틴을 중단합니다.
withContext vs. launch vs. async
- launch: 결과를 반환하지 않는 "실행 후 잊기(fire-and-forget)" 형태의 코루틴 시작
- async: 결과를 반환하는 코루틴 시작, await()로 결과 대기 가능 ( 여러 API 동시 호출 할때, 여러 이미지를 동시에 처리, 로컬 데이터베이스와 네트워크 요청을 동시,,, 등등 병렬처리할때 주로 사요ㅕㅇ)
- 1.동사 (…을) 기다리다
- 2.동사 (어떤 일이 사람 앞에) 기다리다
- withContext: 현재 코루틴을 지정된 컨텍스트에서 실행하고 결과 반환 (새 코루틴을 시작하지 않음)
// launch: 결과 반환 없음
launch {
repository.saveData(data)
}
// async: Deferred<T> 반환, await()로 결과 대기
val deferred = async { repository.getData() }
val result = deferred.await()
// withContext: 결과 직접 반환, 새 코루틴 생성 없음
val result = withContext(Dispatchers.IO) {
repository.getData()
}
성능 및 메모리 관련 고려사항
1. 코루틴 생성 오버헤드
- launch & async: 새 코루틴을 생성하므로 약간의 오버헤드가 있습니다.
- withContext: 새 코루틴을 생성하지 않으므로 상대적으로 오버헤드가 적습니다.
2. 메모리 사용
- launch: 결과를 저장하지 않으므로 메모리 사용이 적습니다.
- async: 결과를 Deferred 객체에 저장하므로 추가 메모리가 사용됩니다.
- withContext: 중간 객체 없이 결과를 직접 반환하므로 메모리 효율적입니다.
3. 취소 동작
- launch & async: 취소 시 CancellationException을 발생시키고 자원을 정리합니다.
- withContext: 외부 코루틴의 취소에 반응합니다 (동일한 코루틴의 일부이기 때문).
요약: 언제 무엇을 사용해야 할까?
launch 사용 시기:
- 결과가 필요 없는 "실행하고 잊기" 작업
- UI 상태 업데이트와 같이 결과를 다른 방법으로 관찰하는 경우
- 병렬로 실행되어야 하지만 결과를 기다릴 필요 없는 작업
async 사용 시기:
- 결과가 필요한 병렬 작업 (특히 여러 결과를 기다려야 하는 경우)
- 나중에 결과를 검색할 수 있도록 작업을 미리 시작하는 경우
- 결과가 필요하지만 즉시 기다리지 않을 작업
withContext 사용 시기:
- 작업의 결과가 즉시 필요한 경우
- 단일 작업을 다른 스레드(디스패처)에서 실행해야 하는 경우
- 순차적 실행이 필요한 비동기 작업
- suspend 함수 내에서 컨텍스트 전환이 필요한 경우
결제 프로세스와 같이 복잡한 비동기 흐름을 구현할 때는 이 세 함수를 적절히 조합하여 사용하는 것이 중요합니다. 특히 withContext를 사용하면 비동기 작업의 순서를 명확하게 제어할 수 있고, async를 사용하면 독립적인 작업을 병렬로 처리하여 전체 성능을 최적화할 수 있습니다.
1. 새 작업 시작 방식
- launch: 새 작업을 시작하고 그냥 진행 (비동기)
- async: 새 작업을 시작하고 나중에 결과 확인 (비동기)
- withContext: 현재 작업을 잠시 멈추고 다른 환경에서 실행 후 결과 받고 계속 진행 (순차적)
2. 결과 처리
- launch: 결과 없음
- async: 나중에 await()로 결과 확인 가능
- withContext: 작업 완료 시 바로 결과 반환
3. 실행 흐름
launch:
실행 ---> 새 작업 시작 ---> 바로 다음 코드 실행 (기다리지 않음)
|
+---> (별도로 실행)
async:
실행 ---> 새 작업 시작 ---> 바로 다음 코드 실행 (기다리지 않음)
| |
+---> (별도로 실행) +---> 나중에 await()로 완료될 때까지 기다림
withContext:
실행 ---> 다른 환경으로 전환 ---> 작업 완료 ---> 결과 가지고 돌아옴 ---> 다음 코드 실행
디스패처(Dispatchers)의 개념
디스패처(Dispatcher)는 코루틴이 어떤 스레드 또는 스레드 풀에서 실행될지를 결정하는 코루틴 컨텍스트 요소입니다. 쉽게 말해, 코루틴에게 "어디서 일할지"를 지정하는 도구라고 할 수 있습니다.
디스패처의 종류
withContext와 함께 주로 사용되는 디스패처는 다음과 같습니다:
- Dispatchers.Main: 안드로이드 메인 스레드(UI 스레드)에서 코루틴 실행
- Dispatchers.IO: I/O 작업(파일, 네트워크, 데이터베이스)에 최적화된 스레드 풀
- Dispatchers.Default: CPU 집약적 작업(복잡한 계산 등)에 최적화된 스레드 풀
- Dispatchers.Unconfined: 특별한, 주의해서 사용해야 하는 디스패처
결제 프로세스에서의 비동기 작업 순서 관리
실제 결제 프로세스에서는 다음과 같은 비동기 작업이 순차적으로 실행되어야 했습니다:
- 서버 API 호출 (결제 승인 요청)
- 응답 처리 및 로컬 DB 업데이트
- UI 상태 업데이트 및 사용자 피드백
초기에는 단순히 각 작업을 별도의 코루틴으로 실행했지만, 작업 간 순서가 보장되지 않아 문제가 발생했습니다. 예를 들어, DB 업데이트가 완료되기 전에 성공 상태가 UI에 표시되거나, 서버 응답 처리 전에 다음 작업이 실행되는 등의 문제가 있었습니다.
문제점: 비동기 작업의 순서 미보장
// 문제가 있는 구현
fun processPayment(paymentData: PaymentData) {
viewModelScope.launch {
_paymentState.value = PaymentState.Loading
// API 호출 (결과를 기다리지 않고 진행)
launch(Dispatchers.IO) {
val result = api.processPayment(paymentData)
if (result.isSuccessful) {
_paymentState.value = PaymentState.Success(result.data)
} else {
_paymentState.value = PaymentState.Error(result.errorMessage)
}
}
// DB 업데이트 (API 결과와 무관하게 진행될 수 있음)
launch(Dispatchers.IO) {
database.savePaymentInfo(paymentData)
}
}
}
이 코드의 문제점:
- API 호출과 DB 업데이트가 동시에 진행됨
- API 실패해도 DB 업데이트가 진행될 수 있음
- DB 업데이트 완료 전에 성공 상태가 UI에 표시될 수 있음
해결책: suspend 함수와 withContext 활용
suspend 함수와 withContext를 활용하여 비동기 작업의 순서를 보장하는 방식으로 리팩터링했습니다:
// 개선된 구현
fun processPayment(paymentData: PaymentData) {
viewModelScope.launch {
try {
// 1. 로딩 상태 표시
_paymentState.value = PaymentState.Loading
// 2. 서버 API 호출 (IO 스레드에서 실행, 결과 대기)
val apiResult = withContext(Dispatchers.IO) {
api.processPayment(paymentData)
}
// 3. API 결과 확인
if (!apiResult.isSuccessful) {
_paymentState.value = PaymentState.Error(apiResult.errorMessage)
return@launch
}
// 4. 트랜잭션 ID 추출
val transactionId = apiResult.data.transactionId
// 5. 로컬 DB 업데이트 (IO 스레드에서 실행, 완료 대기)
withContext(Dispatchers.IO) {
database.updateTransactionId(paymentData.id, transactionId)
}
// 6. DB 업데이트 완료 후 성공 상태 전달
_paymentState.value = PaymentState.Success(apiResult.data)
} catch (e: Exception) {
// 예외 처리
_paymentState.value = PaymentState.Error("결제 처리 중 오류가 발생했습니다: ${e.message}")
}
}
}
이 개선된 코드의 장점:
- 작업 순서 보장: withContext를 사용하여 각 비동기 작업이 순차적으로 실행되도록 보장했습니다.
- 스레드 최적화: UI 작업은 메인 스레드에서, 네트워크와 DB 작업은 IO 스레드에서 실행됩니다.
- 예외 처리 통합: try-catch 블록으로 모든 예외를 일관되게 처리합니다.
- 읽기 쉬운 코드: 비동기 작업임에도 마치 동기 코드처럼 읽을 수 있어 이해하기 쉽습니다.
실제 성능 측정 및 개선 효과
리팩터링 후 결제 처리의 성능과 안정성을 측정한 결과, 다음과 같은 개선 효과가 있었습니다:
- 오류율 감소: 비동기 작업 간 타이밍 문제로 인한 오류가 95% 감소했습니다.
- 일관된 사용자 경험: 모든 경우에 로딩→성공/실패 흐름이 일관되게 유지됩니다.
- 데이터 일관성 향상: DB 업데이트가 항상 API 호출 성공 후에만 실행되어 데이터 무결성이 보장됩니다.
알게 된 추가 팁들
1. viewModelScope와 생명주기 관리
viewModelScope는 ViewModel의 생명주기에 맞춰 자동으로 관리되는 코루틴 스코프입니다. ViewModel이 파괴될 때 자동으로 모든 코루틴이 취소되므로, 메모리 누수를 방지할 수 있습니다.
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
// ViewModel이 파괴되면 이 코루틴도 자동으로 취소됨
}
}
// onCleared() 오버라이드 필요 없음
}
2. 코루틴의 취소 처리
코루틴은 취소 가능하며, 적절한 지점에서 취소 여부를 확인하는 것이 좋습니다. 특히 긴 작업을 수행할 때는 isActive 체크나 ensureActive() 호출을 통해 취소 여부를 확인해야 합니다.
viewModelScope.launch {
for (i in 1..1000) {
// 코루틴이 취소됐는지 확인
ensureActive()
// 또는
if (!isActive) break
// 작업 수행
}
}
3. 오류 처리와 예외 전파
코루틴에서 처리되지 않은 예외는 부모 코루틴으로 전파됩니다. try-catch 블록을 사용하여 예외를 포착하고 적절히 처리하는 것이 중요합니다.
viewModelScope.launch {
try {
// 비동기 작업
val result = withContext(Dispatchers.IO) {
// 여기서 발생한 예외는 withContext를 통해 상위로 전파됨
api.riskyOperation()
}
// 성공 처리
} catch (e: Exception) {
// 오류 처리 - 로깅, 사용자에게 알림 등
}
}
4. 타임아웃 처리
장시간 실행되는 작업에는 타임아웃을 설정하는 것이 좋습니다. 코루틴은 withTimeout이나 withTimeoutOrNull 함수를 제공합니다.
val result = withTimeoutOrNull(5000L) { // 5초 타임아웃
api.longRunningOperation()
}
if (result == null) {
// 타임아웃 발생
} else {
// 정상 완료
}
결론
참고 자료
https://developer.android.com/kotlin/coroutines
Android의 Kotlin 코루틴 | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. Android의 Kotlin 코루틴 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 코루틴은 비동기적으로 실행되는
developer.android.com
https://outcomeschool.com/blog/suspend-function-in-kotlin-coroutines
suspend function in Kotlin Coroutines
In this blog, we will learn about the suspend function in Kotlin Coroutines.
outcomeschool.com
https://medium.com/androiddevelopers/coroutines-on-android-part-i-getting-the-background-3e0e54d20bb
Coroutines on Android (part I): Getting the background
What problems do coroutines solve?
medium.com