
suspend 함수를 작성할 때, context switching을 언제, 어느 곳에서 진행하는 것이 맞을까?
안드로이드 프로젝트에서 Kotlin 코루틴을 사용하다 보면, 다음과 같이 전형적인 코드를 작성하곤 합니다.
class UserRepository(private val fileStore: FileStore) {
suspend fun getUser(id: String): User = withContext(Dispatchers.IO) {
// 파일에서 직접 읽기 (IO 작업)
val bytes = fileStore.read("user_${id}.json")
Json.decodeFromString(bytes.decodeToString())
}
}
메인 스레드에서 호출해도 안전하도록, 함수 내부에서 직접 context switching을 수행하는 패턴은 안드로이드 공식 문서에서도 권장하는 방식이기도 합니다.
다만 어떻게 적용하는 것이 최선의 선택일지는 고민해 볼 필요가 있습니다. 특히 아래 두 가지 사항은 context switching 관련 의사결정 시 깊은 고민이 필요할 것입니다.
이 두 질문에 어떻게 답하느냐에 따라, context switching을 함수 내부에서 직접 처리하는 것이 맞을 수도, 호출자에게 책임을 넘기는 것이 맞을 수도 있을 것입니다.
Dispatchers.IO, Dispatchers.Default, Dispatchers.Main의 차이withContext의 동작 방식안드로이드에서 메인 스레드는 하나의 스레드로 다음 작업들을 담당합니다.
onCreate, onResume 등 라이프사이클 콜백 실행이전 코루틴 글들에서도 설명했듯 이 스레드는 하나이므로 조심스럽게 다루어야 합니다. 60fps 기준으로 프레임 당 16ms, 요즘 나오는 120Hz 디스플레이에서는 약 8ms 안에 모든 처리를 완료해야 합니다. 주어진 시간을 초과하면 사용자는 버벅임을 느끼게 되고, 심하면 장시간 스레드를 블로킹하여 ANR이 발생할 수도 있습니다.
함수가 "main-safe"하다는 것 은 메인 스레드에서 안전하게 호출할 수 있다는 의미입니다. 파일 읽기나 JSON 파싱처럼 블로킹을 유발할 수 있는 작업이 실제 블로킹 전에 메인 스레드 밖으로 이동한다는 뜻입니다.
이는 호출자 측의 혼동을 줄이기 위한 약속이라 볼 수 있습니다. Dispatchers.Main 위에 있는 호출자는 main-safe suspend 함수를 호출하며 다음을 신뢰할 수 있어야 합니다.
withContext가 원래 컨텍스트로 복귀)Kotlin 코루틴의 withContext는 OS 스레드 수준의 컨텍스트 스위치를 일으키지 않습니다. TLB 플러시나 레지스터 저장 같은 커널 작업이 발생하지 않습니다. 실제로 하는 일은 Continuation을 캡처하고 코루틴을 다른 Dispatcher의 큐에 게시하는 것입니다. 이 비용은 마이크로초(ms가 아님, µs) 단위입니다.
특히나 withContext를 호출할 때 현재와 동일한 Dispatcher로 전환하도록 하면서 아래 조건을 만족한다면, 전환 자체를 건너뛰는 Fast-Path가 적용되어 비용이 거의 발생하지 않게 됩니다.
특히 현재와 동일한 Dispatcher로 전환 시 isDispatchNeeded() == false
조건을 만족하면 전환 자체를 건너뛰는 Fast-Path가 적용됩니다.
// 이미 IO 컨텍스트에서 실행 중인 경우
withContext(Dispatchers.IO) { // Fast-Path, 실제 전환 없음
source.read(key)
}
다만 switching 횟수가 누적되면 이야기가 달라집니다. 특히 저사양 기기에서는 Default 풀 크기가 코어 수에 묶여 있어(최소 2개), 풀이 포화된 상태에서
복귀 대기가 쌓이면 수십~수백ms의 지연으로 이어질 수 있습니다. 단 한 번의
전환이 문제가 아니라, 핫 패스에서 반복 호출될 때 누적되는 비용이 문제입니다.
Main-Safe는 개발하면서 실수를 줄일 수 있도록 해주는 일종의 약속입니다. 문제는 이 약속을 지키는 방법으로
withContext를 반사적으로 선택할 때 생깁니다. switching이 실제로 필요한지, 그 판단을 함수 내부에서 내리는 것이 맞는지를 따지지 않으면, 불필요한 전환이 코드 곳곳에 쌓이게 됩니다.
앞서 살펴본 전형적인 패턴을 다시 보겠습니다.
class UserRepository(private val fileStore: FileStore) {
suspend fun getUser(id: String): User = withContext(Dispatchers.IO) {
val bytes = fileStore.read("user_$id.json")
Json.decodeFromString(bytes.decodeToString())
}
}
이 함수는 스스로 "나는 IO 작업을 수행하며, 그 전에 반드시 IO 스레드로 전환하겠다"고 선언합니다. 호출자는 어떤 Dispatcher 위에 있든 신경 쓸 필요가 없습니다. 함수가 switching의 필요성과 타이밍을 온전히 책임집니다.
호출자는 내부 구현이 파일을 읽는지, JSON을 파싱하는지 알 필요가 없습니다. switching이 필요한지, 언제 필요한지도 알 필요가 없다는 점에서 휴먼 에러를 줄일 수 있는 좋은 방식이라 볼 수 있습니다.
하지만, 모든 함수가 자신의 context switching 타이밍을 정확히 판단할 수 있는 것은 당연히 아닙니다.
스위칭이 그 타이밍에 일어나는 게 맞는가? 그리고 그 판단을 이 함수가 내리는 게 맞는가?
class FeatureConfigSource(
private val remoteConfig: RemoteConfig
) {
suspend fun getValue(key: String): Boolean = withContext(Dispatchers.IO) {
remoteConfig.read(key)
}
}
remoteConfig.read(key)의 실제 동작이 아래와 같다고 가정해보겠습니다.
이 상황에서, Context Switching을 단순히 Main-Safe에 따른 방식만으로 결정하는 것은 다소 무리가 있을 것입니다.
위의 코드대로라면, 대부분의 호출에서 실제로 IO를 수행하지 않으면서도 매번 IO로 스위칭하게 될 것입니다. 스위칭 자체의 오버헤드는 µs 수준으로 미미하지만, 핫 패스에서 반복되면 누적 비용을 무시할 수 없습니다.
만약 이 함수가 핫 패스에서 반복 호출된다면, 아래와 같은 상황이 벌어질 것입니다.
suspend fun renderItem(item: Item) {
// Default → IO → Default → IO → Default → IO → Default ...
val showBadge = config.getValue("show_badge")
val showRating = config.getValue("show_rating")
val showNewTag = config.getValue("show_new_tag")
holder.bind(item, showBadge, showRating, showNewTag)
}
함수가 정의된 측(getValue)에서는 자신이 이러한 맥락에서 호출될 것인지 알 수 없습니다. 이러한 상황에서는, switching의 타이밍을 판단하기에 가장 좋은 위치는 함수 내부가 아닌 호출자일 수도 있음을 고려해야 합니다.
프로젝트 약속상, 특정 컴포넌트가 항상 특정 컨텍스트에서 실행되는 것이 보장되는 때가 있을 수 있습니다.
프로젝트 약속상 특정 컴포넌트를 항상 특정 컨텍스트에서 다루도록 강제되는 경우입니다. 예를 들어, UiModel을 다루는 모든 ViewModel 함수가 반드시 Dispatchers.IO 위에서 실행된다는 것이 프로젝트 컨벤션으로 정해져 있다면, UiModel 내부 함수는 switching을 스스로 결정할 필요가 없습니다.
class ArticleViewModel(private val repository: ArticleRepository) : ViewModel() {
private val ioScope = viewModelScope + Dispatchers.IO
fun createUiModel(): ArticleUiModel {
return ArticleUiModel(
mapper = ArticleMapper(repository),
scope = ioScope // IO 컨텍스트 보장
)
}
}
class ArticleUiModel(
private val mapper: ArticleMapper,
private val scope: CoroutineScope
) {
fun load(id: String): Job = scope.launch {
val article = mapper.map(id) // IO 작업 필요
// scope의 컨텍스트에서 실행됨. IO 디스패처 보장
}
}
UiModel의 toDisplayState는 실제로 무거운 작업을 수행할 수 있지만, 그 실행 컨텍스트는 ViewModel이 보장합니다.
UiModel 내부에서 다시 withContext(Dispatchers.IO)를 선언하는 것은 불필요한 이중 전환입니다. 이 패턴이 유효하려면 "UiModel을 다루는 모든 작업은 IO에서 실행한다"는 컨벤션이 KDoc이나 팀 문서에 명시되어 있어야 합니다. 일부 ViewModel만 이 약속을 지킨다면, UiModel 내부 함수는 다시 switching을 스스로 책임져야 합니다.
사실 두 접근법은 대립하는 것이 아닙니다. switching이 필요한 시점과 그 판단을 내리기에 가장 좋은 위치가 어디냐에 따라 달라집니다.
함수가 switching을 직접 결정하는 것이 맞는 경우는 명확합니다. 함수 자신이 실제 비용을 가장 잘 알고, 호출자가 그것을 알 필요가 없을 때입니다.
class ArticleRepository(private val fileStore: FileStore) {
suspend fun getArticles(): List<Article> = withContext(Dispatchers.IO) {
val bytes = fileStore.read("articles.json")
Json.decodeFromString(bytes.decodeToString())
}
}
파일에서 JSON을 파싱하는 getArticles는 어떤 호출자가 어떤 빈도로 부르든
항상 실제 IO가 발생합니다. 함수가 switching을 책임지는 것이 자연스럽습니다. 반면 getValue처럼 비용이 가변적이고 핫 패스에서 반복 호출될 수 있다면, 함수 혼자 그 판단을 내리기엔 정보가 부족합니다.
이 경우, 함수는 switching을 수행하지 않는 대신 CI 규칙을 정하거나, KDoc(Kotlin 공식 문서 주석. /**로 시작하며 IDE에서 함수 위에 마우스를 올리면 팝업으로 표시됩니다)으로 호출 계약을 명시하는 것이 협업을 위해 필수적일 것입니다.
/**
* 지정된 키의 설정 값을 반환합니다.
*
* 주의: 이 함수는 main-safe하지 않습니다.
* [Dispatchers.IO] 또는 [Dispatchers.Default] 컨텍스트에서 호출해야 합니다.
*
* 대부분의 호출은 인메모리 캐시에서 처리됩니다(10µs 미만).
* 캐시 미스 시 디스크 파싱이 발생할 수 있습니다(최대 50ms).
*
* main-safe한 버전이 필요하다면 [getValueSafe]를 사용하세요.
*/
suspend fun getValue(key: String): Boolean = source.read(key)
/** main-safe 버전. 성능이 중요하지 않은 컨텍스트에서 사용하세요. */
suspend fun getValueSafe(key: String): Boolean =
withContext(Dispatchers.IO) { getValue(key) }
패스트 패스(fast path) 란 가장 자주 발생하는 경우를 위해 별도로 최적화한 실행 경로입니다. 이 패턴을 사용하면 함수가 switching 여부를 스스로 판단하면서도, 불필요한 switching은 피할 수 있습니다.
suspend fun getValue(key: String): Boolean {
// 패스트패스: 캐시 히트 시 switching 없이 즉시 반환
cached[key]?.let { return it }
// 슬로우패스: 실제로 디스크 접근이 필요할 때만 IO로 switching
return withContext(Dispatchers.IO) {
parseAndCache(key)
}
}
Main safety를 유지하면서도, 대부분의 호출에서 switching 비용과 Default 풀 복귀 경쟁을 모두 피합니다. 스위칭이 필요한지 함수 스스로 판단하면서도, 불필요한 전환은 피한 결과입니다.
리스트 60개 아이템을 렌더링하는 상황을 시뮬레이션합니다. 각 아이템은
show_badge, show_rating, show_new_tag 세 가지 설정 값을 조회하며, 설정 소스는 처음엔 디스크에서 읽고 이후엔 인메모리 맵에서 반환합니다.
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.*
import java.util.concurrent.Executors
private const val TAG = "ContextSwitchBench"
private const val ITEM_COUNT = 60
private val defaultPool2 = Executors.newFixedThreadPool(2).asCoroutineDispatcher()
private val ioPool = Executors.newFixedThreadPool(64).asCoroutineDispatcher()
data class BenchResult(
val label: String,
val durationMs: Long,
val note: String
)
// ── 설정 소스 ──────────────────────────────────────────────────
private suspend fun slowRead(key: String): Boolean {
delay(1)
return true
}
private val memoryCache = mapOf(
"show_badge" to true,
"show_rating" to true,
"show_new_tag" to false
)
// ── 패턴 1: 함수가 항상 IO로 switching ────────────────────────
private suspend fun getValueAlwaysSwitch(key: String): Boolean =
withContext(ioPool) { slowRead(key) }
// ── 패턴 2: 호출자가 한 번만 switching ────────────────────────
private suspend fun getValueRaw(key: String): Boolean = slowRead(key)
// ── 패턴 3: 캐시 패스트패스 ───────────────────────────────────
private suspend fun getValueFastPath(key: String): Boolean {
memoryCache[key]?.let { return it }
return withContext(ioPool) { slowRead(key) }
}
// ── 렌더링 시뮬레이션 ─────────────────────────────────────────
private suspend fun renderAllPattern1(): Long {
val start = System.currentTimeMillis()
coroutineScope {
(1..ITEM_COUNT).map {
async {
getValueAlwaysSwitch("show_badge")
getValueAlwaysSwitch("show_rating")
getValueAlwaysSwitch("show_new_tag")
}
}.awaitAll()
}
return System.currentTimeMillis() - start
}
private suspend fun renderAllPattern2(): Long {
val start = System.currentTimeMillis()
coroutineScope {
(1..ITEM_COUNT).map {
async {
withContext(ioPool) {
getValueRaw("show_badge")
getValueRaw("show_rating")
getValueRaw("show_new_tag")
}
}
}.awaitAll()
}
return System.currentTimeMillis() - start
}
private suspend fun renderAllPattern3(): Long {
val start = System.currentTimeMillis()
coroutineScope {
(1..ITEM_COUNT).map {
async {
getValueFastPath("show_badge")
getValueFastPath("show_rating")
getValueFastPath("show_new_tag")
}
}.awaitAll()
}
return System.currentTimeMillis() - start
}
// ── 실험 진입점 ────────────────────────────────────────────────
suspend fun runBenchmark(): Pair<List<BenchResult>, String> {
val sb = StringBuilder()
val results = mutableListOf<BenchResult>()
fun log(msg: String) { sb.appendLine(msg); Log.d(TAG, msg) }
log("실험 시작: 아이템 ${ITEM_COUNT}개 동시 렌더링")
val d1 = withContext(defaultPool2) { renderAllPattern1() }
log("패턴 1 (항상 IO 전환): ${d1}ms")
val d2 = withContext(defaultPool2) { renderAllPattern2() }
log("패턴 2 (호출자 통합): ${d2}ms")
val d3 = withContext(defaultPool2) { renderAllPattern3() }
log("패턴 3 (캐시 패스트패스): ${d3}ms")
results += BenchResult("패턴 1: 항상 IO 전환", d1, "switching ${ITEM_COUNT * 3}회")
results += BenchResult("패턴 2: 호출자 통합", d2, "switching ${ITEM_COUNT}회")
results += BenchResult("패턴 3: 캐시 패스트패스", d3, "switching 0회 (캐시 히트)")
return Pair(results, sb.toString())
}
// ── UI ────────────────────────────────────────────────────────
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MaterialTheme { BenchmarkScreen() } }
}
}
@Composable
fun BenchmarkScreen() {
val scope = rememberCoroutineScope()
var logs by remember { mutableStateOf("▶ 버튼을 눌러 실험을 시작하세요.\n") }
var running by remember { mutableStateOf(false) }
var results by remember { mutableStateOf<List<BenchResult>>(emptyList()) }
Column(
Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Context Switching 패턴 비교", style = MaterialTheme.typography.titleLarge)
Text(
"아이템 ${ITEM_COUNT}개 동시 렌더링 시뮬레이션\n" +
"패턴 1: 함수 내부에서 항상 IO 전환\n" +
"패턴 2: 호출자가 아이템 단위로 묶어서 전환\n" +
"패턴 3: 캐시 패스트패스 (캐시 히트 시 전환 없음)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary
)
Button(
onClick = {
running = true; logs = "측정 중...\n"; results = emptyList()
scope.launch {
val (r, l) = runBenchmark()
results = r; logs = l; running = false
}
},
enabled = !running,
modifier = Modifier.fillMaxWidth()
) { Text(if (running) "측정 중..." else "▶ 벤치마크 실행") }
results.forEach { ResultCard(it) }
if (logs.isNotBlank()) {
Text("상세 로그", style = MaterialTheme.typography.titleSmall)
Box(Modifier.fillMaxWidth().background(Color(0xFFF5F5F5)).padding(12.dp)) {
Text(logs, fontFamily = FontFamily.Monospace, fontSize = 11.sp, lineHeight = 18.sp)
}
}
}
}
@Composable
fun ResultCard(r: BenchResult) {
val color = when {
r.durationMs > 100 -> Color(0xFFB71C1C)
r.durationMs > 30 -> Color(0xFFE65100)
else -> Color(0xFF2E7D32)
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(r.label, style = MaterialTheme.typography.titleSmall)
Text("${r.durationMs} ms", style = MaterialTheme.typography.headlineSmall, color = color)
Text(r.note, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary)
}
}
}
1. 언제 스위칭해야 하는가? : 패턴 1 vs 패턴 3
withContext(Dispatchers.IO)를 매번 호출합니다. switching 180회2. 어디서 스위칭해야 하는가? : 패턴 1 vs 패턴 2
| 패턴 | 소요 시간 |
|---|---|
| 패턴 1: 함수 내부에서 항상 IO 전환 (180회) | 80ms |
| 패턴 2: 호출자가 아이템 단위로 통합 (60회) | 35ms |
| 패턴 3: 캐시 패스트패스 (0회) | 5ms |
패턴 1과 패턴 3을 비교하면, 실제로 IO가 필요하지 않은 상황에서 switching을
반복하는 것이 그렇지 않은 경우보다 16배 느립니다. "지금 전환이 필요한가"를 먼저 따지는 것이 가장 큰 이득을 줍니다.
패턴 1과 패턴 2를 비교하면, switching 자체가 불가피하더라도 "누가 판단하는가"에 따라 차이가 납니다. 함수 내부에서 키마다 개별 전환하는 것보다, 호출자가 맥락을 파악해 한 번으로 묶었을 때 2배 이상 빠릅니다.
withContext를 작성하기 전 두 가지를 먼저 확인하는 것이 좋을 것입니다.
당장 스위칭이 필요한가?
함수가 실제로 매번 블로킹 작업을 수행하는지 확인하는 것이 좋습니다. 만약 대부분의 호출에서 IO가 발생하지 않는다면, switching 자체가 불필요합니다. 실제 비용이 생길 때만 전환하는 구조를 적용하는 것이 좋습니다.
스위칭을 여기서 하는 게 맞는가?
함수가 자신의 호출 빈도와 맥락을 알 수 없다면, switching의 판단을 호출자에게 넘기는 것도 선택지입니다. 그 경우엔 KDoc으로 호출 계약을 명시해 협업에 혼란이 없도록 해야 합니다.
Main safety는 단순히 withContext를 넣는 것이 아니라, 메인 스레드를 블로킹하지 않겠다는 계약입니다. 그 계약을 어떻게 이행할지는 함수가 처한 맥락에 따라 달라질 것입니다.