Jetpack Compose를 사용하다 보면 자연스럽게 이런 질문이 생긴다.

  • "왜 상태가 바뀌었을 때 전체가 다시 그려지지 않지?"
  • "Composable 함수가 여러 번 호출되는데, 어떻게 이전 상태를 기억하지?"
  • "remember는 정확히 어디에 값을 저장하는 걸까?"

이 모든 질문의 답이 Slot Table에 있다. Slot Table은 Compose 런타임의 핵심 자료구조로, UI 트리의 상태와 구조를 통째로 관리하는 저장소이다.

이 글에서는 Slot Table이 무엇인지, 어떻게 동작하는지, 그리고 개발자가 이를 알아야 하는 이유를 가능한 한 직관적으로 정리하였다.


1. Compose 런타임의 기본 구조

Composable 함수는 "그리는" 함수가 아니다

Composable 함수를 처음 접하면 직접 View를 생성하는 것처럼 느껴지지만, 실제로는 그렇지 않다. Composable 함수는 UI를 선언하는 함수이며, 실제 UI 노드를 생성하고 배치하는 일은 Compose 런타임이 맡는다.

Compose 런타임은 내부적으로 Composable 함수를 실행하면서 세 가지 핵심 컴포넌트를 사용한다.

컴포넌트역할
Composer Composable 실행을 조율하는 진행자
Slot Table UI 트리의 모든 상태·구조를 저장하는 저장소
Applier 실제 UI 노드(LayoutNode 등)를 생성·수정하는 실행자

이 중에서 Slot Table이 Compose 리컴포지션의 핵심이다.


2. Slot Table이란 무엇인가

2-1. 정의

Slot Table은 선형 배열 기반의 자료구조로, Composable 함수 호출로 만들어지는 UI 트리의 상태를 평탄화(flatten)하여 저장한다.

쉽게 말하면, "UI 트리를 하나의 긴 배열로 펼쳐놓은 것" 이다.

UI 트리 (논리적 구조)          Slot Table (실제 저장 형태)
─────────────────────          ──────────────────────────────
Column                         [ Group(Column) | padding=16 | Group(Text) | "Hello" | color=Red | ... ]
  └─ Text("Hello")
       color = Red

2-2. 내부 구조 — Group과 Slot

Slot Table은 두 가지 단위로 구성된다.

Group (그룹)

  • Composable 함수 하나의 "실행 단위"에 해당한다.
  • 자신이 포함하는 Slot의 범위(start ~ end index)와 고유 키(key)를 갖는다.
  • 중첩된 Composable은 중첩된 Group으로 표현된다.

Slot (슬롯)

  • Group 안에 실제 데이터가 저장되는 칸이다.
  • remember로 저장된 값, 파라미터, 내부 상태 등이 여기 들어간다.
  • 각 Slot은 타입 정보와 값을 함께 보관한다.
 
Slot Table 배열 (단순화된 예시)
─────────────────────────────────────────────────────────────
Index:  0        1         2        3        4        5
Value: [Group   |key=column  |padding |Group   |key=txt |"Hello"]
        ←── Column Group ─────────────→←── Text Group ──────────→

2-3. 소스 코드로 보기

Compose 소스(androidx.compose.runtime)를 보면 실제 구현을 확인할 수 있다.

// SlotTable.kt (간략화)
internal class SlotTable {
    var slots = arrayOfNulls<Any>(32)   // 실제 데이터를 저장하는 배열
    var groups = IntArray(32 * Group_Fields_Size) // Group 메타데이터 배열
    var groupsSize = 0
    var slotsSize = 0
    // ...
}

배열을 사용하는 이유는 명확하다 — 메모리 지역성(cache locality) 덕분에 순차 읽기 성능이 매우 우수하기 때문이다.


3. Slot Table의 두 가지 읽기/쓰기 커서

Slot Table에는 항상 두 종류의 커서(cursor)가 존재한다.

SlotReader — 읽기 전용 커서

  • 컴포지션 실행 중 현재 저장된 값을 순서대로 읽는다.
  • 이전 컴포지션에서 저장된 값과 현재 파라미터를 비교하는 데 사용된다.

SlotWriter — 쓰기 전용 커서

  • 새로운 컴포지션 결과를 Slot Table에 기록한다.
  • 새로운 Group을 열고, 값을 쓰고, Group을 닫는 식으로 동작한다.

중요: Reader와 Writer는 동시에 열릴 수 없다. 컴포지션이 시작되면 Writer가 열리고, 완료 후 닫힌다. 이 설계 덕분에 스레드 안전성을 단순하게 유지할 수 있다.


4. 컴포지션과 리컴포지션의 흐름

4-1. 최초 컴포지션 (Initial Composition)

Composable이 처음 실행될 때의 흐름이다.

1. Composer가 Composable 함수를 실행한다
2. SlotWriter가 열린다
3. 각 Composable 진입 시 → Group을 열고 (startGroup)
4. remember, 파라미터 등 → Slot에 값을 기록한다 (set)
5. Composable 종료 시 → Group을 닫는다 (endGroup)
6. 모든 실행 완료 후 SlotWriter가 닫힌다
 
@Composable
fun Greeting(name: String) {
    // startGroup(key = hash of "Greeting")
    val greeting = remember { "Hello, $name" }
    // Slot에 "Hello, $name" 저장
    Text(text = greeting)
    // 내부에서 또 startGroup / endGroup
    // endGroup
}

4-2. 리컴포지션 (Recomposition)

상태가 변경되어 리컴포지션이 발생할 때의 흐름이다.

1. 변경된 State를 읽는 Composable이 무효화(invalidate)된다
2. Recomposer가 해당 Composable의 실행을 예약한다
3. Composable이 다시 실행된다
4. SlotReader가 이전 Slot Table을 읽으면서,
   SlotWriter가 새 결과를 기록한다
5. 이전 값과 새 값을 비교(diffing)한다
6. 변경된 부분만 Applier를 통해 실제 UI에 반영된다

핵심은 "전체를 다시 그리는 것이 아니라, Slot Table의 diff를 통해 변경된 노드만 갱신한다" 는 점이다.

리컴포지션 전 Slot Table:
[ Group(Text) | "Hello, World" | color=Black ]

상태 변경 후:
[ Group(Text) | "Hello, Compose" | color=Black ]
                 ↑ 변경됨 → 이 Text 노드만 갱신

5. remember는 정확히 어디에 저장되는가

remember는 많이 사용하지만 실제로 어디에 저장되는지 명확하게 아는 경우는 드물다.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

remember의 내부를 보면 다음과 같다.

// Composer.kt (간략화)
@Composable
inline fun <T> remember(calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)

currentComposer.cache는 내부적으로 현재 Slot Table의 현재 위치에 값을 읽거나 쓴다. 즉, remember의 저장소는 곧 Slot Table의 Slot이다.

remember 호출 시점동작
최초 컴포지션 calculation을 실행하고 결과를 현재 Slot에 저장
리컴포지션 동일 위치의 Slot에서 값을 읽어 반환 (calculation 미실행)
key 변경 시 이전 Slot을 폐기하고 calculation을 다시 실행하여 재저장

6. Slot Table이 Composable의 순서를 어떻게 추적하는가

6-1. 위치 기반 메모이제이션 (Positional Memoization)

Compose는 Composable을 식별할 때 호출 위치(call site) 를 기반으로 한다. 컴파일러가 각 Composable 호출 지점에 고유한 키를 자동으로 삽입하기 때문이다.

@Composable
fun MyScreen() {
    Text("첫 번째")   // key = hash(파일명, 라인번호, 컬럼번호, ...)
    Text("두 번째")   // key = 위와 다른 hash
}

덕분에 두 Text 호출이 동일한 Composable 함수이더라도 Slot Table에서 서로 다른 Group으로 구분된다.

6-2. key() 를 사용해야 하는 이유

리스트를 렌더링할 때 key가 중요한 이유가 바로 여기에 있다.

// ❌ key 없이 — 순서로만 추적
items.forEach { item ->
    ItemCard(item)
}

// ✅ key 사용 — 아이템 고유 ID로 추적
items.forEach { item ->
    key(item.id) {
        ItemCard(item)
    }
}

key 없이 리스트 중간에 아이템이 삽입되면, Slot Table은 위치가 밀린 아이템들을 모두 새 아이템으로 인식하여 불필요한 리컴포지션이 발생한다. key를 지정하면 Slot Table이 Group을 올바르게 매핑하여 변경된 아이템만 갱신한다.

// key 없이 중간 삽입 시 Slot Table 인식
Before: [A(pos=0)] [B(pos=1)] [C(pos=2)]
After:  [X(pos=0)] [A(pos=1)] [B(pos=2)] [C(pos=3)]
         ↑ 전부 변경으로 인식

// key 사용 시
Before: [A(id=1)] [B(id=2)] [C(id=3)]
After:  [X(id=4)] [A(id=1)] [B(id=2)] [C(id=3)]
         ↑ 새 항목   ↑ 이동만 인식 (재사용)

7. Slot Table과 성능 최적화의 관계

7-1. 스킵 가능한 Composable (Skippable Composable)

Compose 컴파일러는 파라미터가 안정적(stable)이면 해당 Composable을 스킵 가능(skippable) 으로 표시한다. 리컴포지션 시 Slot Table의 이전 파라미터와 새 파라미터를 비교하여, 동일하면 해당 Group 전체를 건너뛴다.

// 컴파일러가 생성하는 코드 (의사 코드)
if (composer.changed(name).not()) {
    composer.skipToGroupEnd() // Slot Table에서 이 Group을 통째로 건너뜀
    return
}
// 변경되었을 때만 실제 실행

7-2. @Stable, @Immutable의 의미

@Stable
data class UserProfile(
    val name: String,
    val age: Int
)

@Stable을 선언하면 Compose가 해당 타입의 동등성 비교를 신뢰하게 된다. 이로 인해 파라미터가 변경되지 않은 Composable은 Slot Table 비교 후 스킵된다. 즉, @Stable/@Immutable은 Slot Table 기반 스킵 최적화를 활성화하는 힌트 이다.

7-3. derivedStateOf 와의 관계

val sortedList by remember {
    derivedStateOf { items.sortedBy { it.name } }
}

derivedStateOf는 Slot Table에 저장된 상태를 기반으로 파생 값을 계산하되, 실제로 값이 변경되었을 때만 리컴포지션을 트리거한다. items가 변경되더라도 정렬 결과가 같으면 이 State를 읽는 Composable은 리컴포지션되지 않는다.


8. Slot Table의 생명주기 — 언제 생성되고 소멸되는가

시점Slot Table 상태
setContent { } 호출 새 Slot Table 생성, 최초 컴포지션 시작
컴포지션 실행 중 SlotWriter가 열려 있음
컴포지션 완료 SlotWriter 닫힘, Slot Table 확정
리컴포지션 발생 기존 Slot Table 위에서 diff 수행
Composable이 트리에서 제거 해당 Group과 Slot이 Slot Table에서 제거됨
Activity/Fragment 소멸 Slot Table 전체 소멸

DisposableEffect, LaunchedEffect 등의 Side Effect API는 이 생명주기와 연동되어, Group이 Slot Table에서 제거될 때 정리(clean-up) 로직을 실행한다.


9. 정리 — Slot Table이 중요한 이유

Slot Table은 Compose 내부 구현의 세부사항처럼 보이지만, 실제로는 개발자가 작성하는 모든 Composable 코드에 직접적인 영향을 미친다.

현상Slot Table과의 관계
remember가 리컴포지션 후에도 값을 유지 Slot에 값이 보존됨
파라미터가 바뀌지 않으면 Composable이 스킵됨 이전 Slot 값과 비교 후 Group 전체 건너뜀
리스트에서 key를 써야 하는 이유 Group 매핑 정확도 향상
@Stable로 성능이 좋아지는 이유 Slot Table 비교 신뢰도 향상 → 스킵 최적화
DisposableEffect가 cleanup을 실행하는 시점 Group이 Slot Table에서 제거되는 시점

Compose를 단순히 "선언형 UI 프레임워크"로 사용하는 것을 넘어, 왜 이런 API 설계가 존재하는지 이해하려면 Slot Table을 아는 것이 필수적이다.


참고

'Android' 카테고리의 다른 글

[Android] Macrobenchmark에 대해 알아보기  (0) 2026.05.07
[Android] Compose UI - (3)  (0) 2024.12.27
[Android] Compose UI - (2)  (0) 2024.12.26
[Android] Compose UI - (1)  (0) 2024.12.16
[Android] Compose 런타임 - (12)  (0) 2024.12.12

Macrobenchmark 설정 및 사용

목차

  1. 모듈 구조
  2. benchmark 모듈 설정
  3. 앱 측 변경사항
  4. 테스트 코드
  5. am start 명령어 extra 타입
  6. 측정 지표 해석
  7. 추가 Metric
  8. 테스트 실행 명령어
  9. 결과 수치 분석 사례
  10. Perfetto Trace 분석
  11. Baseline Profile
  12. nonMinifiedRelease와 minification

1. 모듈 구조

settings.gradle.kts  →  include(":benchmark") 추가
benchmark/
  ├── build.gradle.kts
  ├── src/main/AndroidManifest.xml
  └── src/main/java/.../benchmark/StartupBenchmark.kt

2. benchmark 모듈 설정

benchmark/build.gradle.kts

plugins {
    id("com.android.test")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.myapp.benchmark"
    compileSdk = 36

    defaultConfig {
        minSdk = 29
        targetSdk = 36
        testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner"
    }

    buildTypes {
        create("nonMinifiedRelease") {
            isDebuggable = false
            signingConfig = getByName("debug").signingConfig
            matchingFallbacks.add("release")
        }
    }
}

dependencies {
    implementation(libs.androidx.benchmark.macro)
    implementation(libs.androidx.benchmark.junit4)
    implementation(libs.androidx.test.uiautomator)
    implementation(libs.androidx.test.ext.junit)
}

androidComponents {
    beforeVariants(selector().all()) { variant ->
        variant.enable = variant.buildType == "nonMinifiedRelease"
    }
}

benchmark/src/main/AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application android:debuggable="false" />
</manifest>

gradle/libs.versions.toml 추가 항목

[versions]
benchmark = "1.3.4"
uiautomator = "2.3.0"
androidx-test-ext = "1.2.1"

[libraries]
androidx-benchmark-macro = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmark" }
androidx-benchmark-junit4 = { group = "androidx.benchmark", name = "benchmark-junit4", version.ref = "benchmark" }
androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" }

3. 앱 측 변경사항

3-1. app/build.gradle.kts (convention plugin)

nonMinifiedRelease 빌드 타입 추가 및 벤치마크 시 APK rename 로직 제외

// nonMinifiedRelease 빌드타입에서는 onVariants 로직 skip
if (variant.buildType == "nonMinifiedRelease") return@onVariants

// nonMinifiedRelease 빌드타입 정의
create("nonMinifiedRelease") {
    initWith(getByName("release"))
    signingConfig = signingConfigs["childDebug"]  // debug 서명 사용 (기기에 설치된 서명과 일치시키기 위함)
    matchingFallbacks.add("release")
}

3-2. MainActivity.kt

벤치마크 실행 시 HomeScreen으로 직접 진입하기 위한 Boolean extra 처리

companion object {
    const val EXTRA_BENCHMARK = "EXTRA_BENCHMARK"
}

private fun getBenchmarkDestination(): Any? =
    intent.getBooleanExtra(EXTRA_BENCHMARK, false)
        .let { isTest -> if (isTest) HomeRoute(isBenchmarkMode = true) else null }

setContent 내부에서

val startDestination = getBenchmarkDestination() ?: IntroRoute

3-3. HomeRoute (HomeNavigation.kt)

@Serializable
data class HomeRoute(
    val isBenchmarkMode: Boolean = false,
)

fun NavGraphBuilder.homeScreen(...) {
    composable<HomeRoute> { backStackEntry ->
        val route = backStackEntry.toRoute<HomeRoute>()
        HomeScreen(
            isBenchmarkMode = route.isBenchmarkMode,
            ...
        )
    }
}

3-4. HomeScreen.kt

1) 시스템 팝업 체크 비활성화 — 벤치마크 실행 중 팝업이 측정을 방해하지 않도록

@Composable
fun HomeScreen(
    isBenchmarkMode: Boolean = false,
    ...
) {
    OnResumeCoroutineEffect {
        if (!isBenchmarkMode && pendingPopupState.isEmpty()) {
            // 시스템 팝업 체크
        }
    }
}

2) ReportDrawn()timeToFullDisplayMs 측정을 위해 HomeContent가 렌더링된 시점을 시스템에 알림

@Composable
private fun HomeContent(...) {
    // ...Column, Toolbar, Pager...

    ReportDrawn()  // HomeContent 진입 시점에 reportFullyDrawn() 호출
}

import androidx.activity.compose.ReportDrawn


4. 테스트 코드

StartupBenchmark.kt

@file:OptIn(ExperimentalMetricApi::class)

import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.ExperimentalMetricApi
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.MemoryUsageMetric
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

private const val PACKAGE_NAME = "com.myapp.dev"
private const val MAIN_ACTIVITY = "com.myapp.MainActivity"
private const val EXTRA_BENCHMARK = "EXTRA_BENCHMARK"
private const val LAUNCH_TIMEOUT_MS = 10_000L

@RunWith(AndroidJUnit4::class)
class StartupBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    // IntroScreen 기준 앱 콜드 스타트 시간
    @Test
    fun startupCold() = benchmarkRule.measureRepeated(
        packageName = PACKAGE_NAME,
        metrics = listOf(StartupTimingMetric()),
        iterations = 5,
        startupMode = StartupMode.COLD,
        compilationMode = CompilationMode.None(),
        setupBlock = { pressHome() },
    ) {
        device.executeShellCommand("am start -n $PACKAGE_NAME/$MAIN_ACTIVITY")
        device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
    }

    // IntroScreen 기준 앱 웜 스타트 시간
    @Test
    fun startupWarm() = benchmarkRule.measureRepeated(
        packageName = PACKAGE_NAME,
        metrics = listOf(StartupTimingMetric()),
        iterations = 5,
        startupMode = StartupMode.WARM,
        compilationMode = CompilationMode.None(),
        setupBlock = { pressHome() },
    ) {
        device.executeShellCommand("am start -n $PACKAGE_NAME/$MAIN_ACTIVITY")
        device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
    }

    // HomeScreen 렌더링 완료까지의 소요 시간
    // timeToInitialDisplayMs: 첫 프레임 시간
    // timeToFullDisplayMs: ReportDrawn() 호출 시점 (HomeContent 렌더 완료)
    @Test
    fun homeScreenRenderTime() = benchmarkRule.measureRepeated(
        packageName = PACKAGE_NAME,
        metrics = listOf(StartupTimingMetric()),
        iterations = 5,
        startupMode = StartupMode.COLD,
        compilationMode = CompilationMode.None(),
        setupBlock = { pressHome() },
    ) {
        device.executeShellCommand(
            "am start -n $PACKAGE_NAME/$MAIN_ACTIVITY --activity-clear-task --ez $EXTRA_BENCHMARK true"
        )
        device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
    }

    // HomeScreen에서 스와이프 인터랙션 시 프레임 성능
    @Test
    fun homeScreenScrollFrameTiming() = benchmarkRule.measureRepeated(
        packageName = PACKAGE_NAME,
        metrics = listOf(
            FrameTimingMetric(),
            MemoryUsageMetric(MemoryUsageMetric.Mode.Max),
        ),
        iterations = 5,
        startupMode = StartupMode.WARM,
        compilationMode = CompilationMode.None(),
        setupBlock = {
            pressHome()
            device.executeShellCommand(
                "am start -n $PACKAGE_NAME/$MAIN_ACTIVITY --activity-clear-task --ez $EXTRA_BENCHMARK true"
            )
            device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
            Thread.sleep(500)
        },
    ) {
        val cx = device.displayWidth / 2
        val cy = device.displayHeight / 2
        repeat(5) {
            device.swipe(cx + 400, cy, cx - 400, cy, 80)
            device.swipe(cx - 400, cy, cx + 400, cy, 80)
        }
        device.waitForIdle()
    }
}

테스트별 역할 요약

테스트 Metric StartupMode 목적
startupCold StartupTimingMetric COLD 앱 최초 기동 시간
startupWarm StartupTimingMetric WARM 백그라운드 복귀 시간
homeScreenRenderTime StartupTimingMetric COLD HomeScreen 렌더 완료 시간
homeScreenScrollFrameTiming FrameTimingMetric, MemoryUsageMetric WARM 인터랙션 중 프레임 성능 + 메모리

StartupMode 차이

StartupMode 동작 용도
COLD 앱 프로세스 kill 후 측정 최초 실행 성능
WARM 프로세스 유지, 백그라운드 상태에서 복귀 재진입 성능
HOT Activity만 종료, 프로세스 유지 화면 전환 성능

5. am start 명령어 extra 타입

타입 플래그 예시
String --es --es KEY value
Boolean --ez --ez KEY true
Int --ei --ei KEY 123
Long --el --el KEY 123

--activity-clear-task 사용 이유

StartupMode.WARM에서 macrobenchmark가 내부적으로 앱을 먼저 실행(LAUNCHER intent, extra 없음)한 뒤, setupBlock의 am start가 기존 Task에 onNewIntent만 트리거한다. onCreate가 호출되지 않아 intent extra가 무시되고 IntroRoute로 진입하는 문제가 생긴다.

--activity-clear-task 플래그로 Task를 초기화하면 onCreate가 새로 호출되어 extra를 정상 수신한다.


6. 측정 지표 해석

StartupTimingMetric 결과값

지표 설명
timeToInitialDisplayMs 앱 실행 후 첫 프레임이 화면에 그려지기까지의 시간
timeToFullDisplayMs ReportDrawn() 호출 시점까지의 시간 (앱이 직접 신고한 "완전 렌더링" 시점)

두 값의 차이가 클수록 초기 데이터 로딩 병목이 있다는 신호다.

FrameTimingMetric 결과값

frameDurationCpuMs

CPU가 한 프레임을 그리는 데 걸린 시간. 기기 주사율에서 파생된 deadline과 비교한다.

deadline = 1000ms ÷ 주사율(Hz)
60Hz → 16.67ms
90Hz → 11.11ms
120Hz → 8.33ms

기기 주사율 확인

adb shell dumpsys display | grep "mRefreshRate"
퍼센타일 초과 시 체감
P50 초과 앱 전체가 버벅임, 심각
P90 초과 스크롤할 때 자주 끊김, 사용자가 인지
P95 초과 가끔 끊김, 민감한 사용자가 인지
P99 초과 드물게 발생, 일반적으로 허용 범위

frameOverrunMs

deadline이 이미 차감된 값. 기준은 0ms (16.67ms와 비교하는 게 아님).

frameOverrunMs = 실제 렌더링 시간 - 기기 deadline
의미
음수 deadline 전에 완료 → 정상
0 ~ 5ms 소폭 초과 → 경미한 jank
5ms 이상 명확한 jank
16ms 이상 프레임 드롭 (2배 이상 소요)
frameDurationCpuMs  →  deadline(Hz 기반) 미만이면 OK, 작을수록 좋음
frameOverrunMs      →  0ms 미만이면 OK, 음수일수록 좋음

7. 추가적인 Metric

MemoryUsageMetric

HomeScreen 진입 시 메모리 사용량 측정. heapSizeKb, rssAnonKb 등을 제공한다.

import androidx.benchmark.macro.ExperimentalMetricApi
import androidx.benchmark.macro.MemoryUsageMetric

@OptIn(ExperimentalMetricApi::class)
metrics = listOf(
    FrameTimingMetric(),
    MemoryUsageMetric(MemoryUsageMetric.Mode.Max),
)

TraceSectionMetric

특정 코드 구간의 실행 시간을 직접 측정. ViewModel 데이터 로딩 등 병목 구간 특정에 유용하다.

// 측정할 코드에 trace 블록 추가
import androidx.tracing.trace

fun loadData() = trace("HomeViewModel.loadData") {
    // 기존 로직
}
// 벤치마크에서
import androidx.benchmark.macro.TraceSectionMetric

metrics = listOf(TraceSectionMetric("HomeViewModel.loadData"))

8. 테스트 실행 명령어

전체 테스트 실행

./gradlew :benchmark:connectedDevNonMinifiedReleaseAndroidTest

특정 테스트만 실행

./gradlew :benchmark:connectedDevNonMinifiedReleaseAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.myapp.benchmark.StartupBenchmark#homeScreenRenderTime

클래스명과 메서드명을 #으로 구분해서 지정한다.

Gradle 명령어로 실행해야 하는 이유

Android Studio 실행 버튼(▶)으로도 테스트가 가능하지만, Perfetto trace 파일이 로컬에 자동으로 저장되지 않는다.

trace 파일이 필요하다면 Gradle 명령어를 사용해야 한다.

trace 파일 저장 경로

<project>/build/outputs/connected_android_test_additional_output/
  devNonMinifiedRelease/connected/<기기명>/
    StartupBenchmark_homeScreenRenderTime_iter000.perfetto-trace
    ...

9. 결과 수치 분석 사례

homeScreenRenderTime 실측 결과 (SM-A165N, 60Hz)

timeToInitialDisplayMs  min 718.3,  median 772.3,  max 917.4
timeToFullDisplayMs     min 718.3,  median 772.3,  max 917.4
frameDurationCpuMs      P50 20.6,   P90 420.2,   P95 454.6,   P99 474.2
frameOverrunMs          P50 16.6,   P90 443.5,   P95 480.5,   P99 495.6

timeToInitialDisplayMs = timeToFullDisplayMsReportDrawn()이 HomeContent 진입 즉시 호출되므로 두 값이 같다. 정상 동작이다.

median 772ms → 60Hz 기기 콜드 스타트 기준 보통~느림 경계. 400ms 미만이면 빠름, 800ms 이상이면 느림으로 판단한다.

frameDurationCpuMs P99 474.2ms → 콜드 스타트 초기 프레임은 Hilt DI 초기화, ViewModel 생성 등 무거운 작업이 몰리므로 높게 나오는 것은 정상이다. 스타트업 측정에서 frameDurationCpuMs는 참고용이며, 핵심 지표는 timeToInitialDisplayMs다.

timeToInitialDisplayMs 기준

범위 평가
~400ms 빠름
400~800ms 보통
800ms~ 느림, 사용자 인지

frameCount 신뢰도

frameCount  min 1.0,  median 1.0,  max 1.0

반복당 프레임이 1개면 P50/P90/P95/P99가 모두 단일 샘플 기반이므로 통계적 신뢰도가 낮다. 의미 있는 분포를 얻으려면 최소 50개 이상의 프레임이 필요하다.

HorizontalPager처럼 탭이 1개뿐인 화면은 swipe해도 실제 스크롤이 발생하지 않아 프레임 생성이 거의 없다. LazyColumn처럼 긴 목록이 있는 화면이 FrameTimingMetric 측정에 더 적합하다.

MemoryUsageMetric 실측 결과

memoryGpuMaxKb      ~36MB   GPU 텍스처, Compose 렌더링 버퍼
memoryHeapSizeMaxKb ~9~10MB JVM 힙 (객체 할당)
memoryRssAnonMaxKb  ~56~63MB 앱이 직접 할당한 전체 메모리 (힙 + 네이티브)
memoryRssFileMaxKb  ~106~108MB 코드, 라이브러리, 리소스 등 파일 기반 매핑

RSS 합계(Anon + File)가 170MB 수준. memoryRssFileMaxKb가 크면 라이브러리 로딩량이 많다는 신호다.


10. Perfetto Trace 분석

trace 파일 열기

방법 A — Android Studio 링크 클릭

테스트 결과 패널에서 Traces: Iteration 0 1 2 3 4 링크를 클릭하면 Android Studio Profiler가 자동으로 열린다. 단, SQL 쿼리는 불가능하다. trace를 export한 뒤 Perfetto UI에 올려야 SQL 쿼리를 사용할 수 있다.

방법 B — Perfetto UI (SQL 쿼리 가능)

https://ui.perfetto.dev 에서 Open trace file.perfetto-trace 파일을 업로드한다.

웹 브라우저에서 바로 사용하며 별도 설치가 필요 없다.

Perfetto UI에서 확인할 것

트랙 내용
Choreographer#doFrame 프레임 전체 소요 시간
measure / layout / draw Compose 렌더링 단계별 시간
RenderThread GPU 드로잉 시간
Generating runtime image ART JIT 컴파일 및 앱 이미지 생성 시간 — Baseline Profile로 개선 가능

SQL 쿼리로 개선점 분석

Perfetto UI 우측 하단 Query (SQL) 버튼을 클릭해서 실행한다.

Baseline Profile로 Generating runtime image를 제거한 이후 다음 쿼리로 순차적으로 병목을 찾는다.

우선순위 요약

우선순위 영역 이유
1 메인 스레드 장기 블로킹 전체 병목 상태
2 GC 초기 객체 할당 과다 여부
3 Compose 리컴포지션 화면 렌더링 지연 원인
4 Hilt / 클래스 로딩 콜드 스타트에 직접 영향
5 Binder / 파일 I/O ANR 위험 및 스레드 블로킹
6 이미지 리소스 Coil 디코딩 병목

1. 메인 스레드 장기 블로킹 슬라이스

16ms 이상 메인 스레드를 점유한 작업 전체 조회 — 병목 위치를 한눈에 파악한다.

SELECT
    s.name,
    s.dur / 1e6       AS dur_ms,
    t.name            AS thread_name
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE t.name = 'main'
  AND s.dur > 16000000
ORDER BY s.dur DESC
LIMIT 30

2. GC (Garbage Collection)

콜드 스타트 중 GC가 자주 발생하면 초기 객체 할당이 과도하다는 신호다.

JIT방식이였다면 AOT에 비해 상대적으로 느리기 때문에 객체 할당이 시간에 걸쳐 분산 되었을 것이다.

하지만 BaselineProfile을 적용했다면 ART AOT 방식으로 실행되기에 빠르게 초기화되며 객체가 한꺼번에 대량 할당되어 GC의 수치는 증가할 수 있다.

아래에서 다시 한번 설명한다.

SELECT
    name,
    COUNT(*)        AS count,
    AVG(dur) / 1e6  AS avg_ms,
    SUM(dur) / 1e6  AS total_ms
FROM slice
WHERE name LIKE '%GC%'
   OR name LIKE '%garbage%'
   OR name LIKE '%HeapTask%'
GROUP BY name
ORDER BY total_ms DESC

결과 해석

GC가 메인 스레드에서 발생했는지 여부가 total_ms보다 중요하다. Perfetto에서 메인 스레드는 main이 아닌 패키지명(프로세스명) 으로 표시된다.

SELECT
    s.name,
    s.dur / 1e6  AS dur_ms
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE t.name LIKE '%<패키지명>%'
  AND (s.name LIKE '%GC%' OR s.name LIKE '%HeapTask%')
ORDER BY s.dur DESC
dur_ms 판단
16ms 이상 프레임 드롭 및 스타트업 지연 영향, 개선 필요
16ms 미만 체감 영향 없음

Baseline Profile 적용 후 GC total_ms가 높아지는 현상

Baseline Profile 적용 후 slice_count는 줄지만 total_ms가 오히려 높게 나오는 경우가 있다. 이는 문제가 아니다.

  • 미적용 시: JIT 컴파일이 느려 객체 할당이 분산 → 작은 GC가 여러 번 발생
  • 적용 후: AOT로 빠르게 초기화되면서 객체가 한꺼번에 대량 할당 → 횟수는 적지만 무거운 GC 한 번 발생

메인 스레드 GC의 dur_ms가 0.05ms 수준이라면 실질적인 영향이 없으며, timeToInitialDisplayMs가 개선됐다면 전체적으로 정상 개선된 것이다.


3. Compose 리컴포지션

count가 비정상적으로 높은 Composable이 있으면 불필요한 리컴포지션을 의심한다.

SELECT
    name,
    COUNT(*)        AS count,
    AVG(dur) / 1e6  AS avg_ms,
    SUM(dur) / 1e6  AS total_ms
FROM slice
WHERE name LIKE '%recompos%'
   OR name LIKE '%Recompos%'
   OR name LIKE '%compose%'
GROUP BY name
ORDER BY count DESC
LIMIT 20

4. Hilt / 클래스 로딩

Hilt 컴포넌트 초기화가 병목인 경우 Lazy injection으로 개선 가능하다.

SELECT
    name,
    COUNT(*)        AS count,
    AVG(dur) / 1e6  AS avg_ms,
    SUM(dur) / 1e6  AS total_ms
FROM slice
WHERE name LIKE '%ClassLoader%'
   OR name LIKE '%Hilt%'
   OR name LIKE '%dagger%'
   OR name LIKE '%Dagger%'
   OR name LIKE '%inflate%'
GROUP BY name
ORDER BY total_ms DESC
LIMIT 20

5. Binder IPC

메인 스레드에서 발생하는 IPC는 ANR 위험 요소다.

SELECT
    s.name,
    COUNT(*)        AS count,
    AVG(s.dur) / 1e6 AS avg_ms,
    SUM(s.dur) / 1e6 AS total_ms
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE t.name = 'main'
  AND (s.name LIKE '%binder%' OR s.name LIKE '%Binder%' OR s.name LIKE 'AIDL%')
GROUP BY s.name
ORDER BY total_ms DESC

6. 파일 I/O (DataStore / Room)

메인 스레드에서 발생 여부를 확인한다. thread_name = 'main' 조건을 추가하면 블로킹 I/O 여부를 바로 확인할 수 있다.

SELECT
    s.name,
    COUNT(*)         AS count,
    AVG(s.dur) / 1e6 AS avg_ms,
    SUM(s.dur) / 1e6 AS total_ms,
    t.name           AS thread_name
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE s.name LIKE '%DataStore%'
   OR s.name LIKE '%SharedPreferences%'
   OR s.name LIKE '%Room%'
   OR s.name LIKE '%SQLite%'
   OR s.name LIKE '%openDatabase%'
GROUP BY s.name, t.name
ORDER BY total_ms DESC

7. 이미지 리소스 (Coil)

SELECT
    name,
    COUNT(*)        AS count,
    AVG(dur) / 1e6  AS avg_ms,
    MAX(dur) / 1e6  AS max_ms,
    SUM(dur) / 1e6  AS total_ms
FROM slice
WHERE name LIKE '%image%'  OR name LIKE '%Image%'
   OR name LIKE '%bitmap%' OR name LIKE '%Bitmap%'
   OR name LIKE '%decode%' OR name LIKE '%Decode%'
   OR name LIKE '%coil%'   OR name LIKE '%Coil%'
GROUP BY name
ORDER BY total_ms DESC

Coil 이미지 로딩에 trace 추가

Coil 슬라이스가 trace에 잡히지 않는 경우 직접 계측을 추가한다.

// build.gradle.kts
implementation("androidx.tracing:tracing-ktx:1.3.0")
import androidx.tracing.trace
import coil.EventListener
import coil.request.ImageRequest

class CoilTraceEventListener : EventListener {
    override fun fetchStart(request: ImageRequest, fetcher: Any, options: Options) {
        trace("Coil.fetch") {}
    }
    override fun decodeStart(request: ImageRequest, decoder: Any, options: Options) {
        trace("Coil.decode") {}
    }
}

// ImageLoader 초기화 시 등록
ImageLoader.Builder(context)
    .eventListener(CoilTraceEventListener())
    .build()

8. 각 카테고리별 total_ms를 한번에 합산

  SELECT                                                                                                                                                                                                                                                                                                                                                                   
      category,                                                                                                                                                                                                                                                                                                                                                            
      COUNT(*)        AS slice_count,                                                                                                                                                                                                                                                                                                                                      
      SUM(dur) / 1e6  AS total_ms                                                                                                                                                                                                                                                                                                                                          
  FROM (
      SELECT 'GC'             AS category, dur FROM slice                                                                                                                                                                                                                                                                                                                  
      WHERE name LIKE '%GC%' OR name LIKE '%garbage%' OR name LIKE '%HeapTask%'                                                                                                                                                                                                                                                                                            

      UNION ALL                                                                                                                                                                                                                                                                                                                                                            

      SELECT 'Compose 리컴포지션' AS category, dur FROM slice                                                                                                                                                                                                                                                                                                              
      WHERE name LIKE '%recompos%' OR name LIKE '%Recompos%' OR name LIKE '%compose%'

      UNION ALL   

      SELECT 'Hilt/클래스로딩' AS category, dur FROM slice
      WHERE name LIKE '%ClassLoader%' OR name LIKE '%Hilt%'
         OR name LIKE '%dagger%' OR name LIKE '%Dagger%' OR name LIKE '%inflate%'                                                                                                                                                                                                                                                                                          

      UNION ALL                                                                                                                                                                                                                                                                                                                                                            

      SELECT 'Binder IPC' AS category, s.dur FROM slice s
      JOIN thread_track tt ON s.track_id = tt.id
      JOIN thread t ON tt.utid = t.utid                                                                                                                                                                                                                                                                                                                                    
      WHERE t.name = 'main'
        AND (s.name LIKE '%binder%' OR s.name LIKE [baseline-prof.txt](../app/src/main/baseline-prof.txt)'%Binder%' OR s.name LIKE 'AIDL%')                                                                                                                                                                                                                                                                                      

      UNION ALL                                                                                                                                                                                                                                                                                                                                                            

      SELECT '파일 I/O' AS category, dur FROM slice
      WHERE name LIKE '%DataStore%' OR name LIKE '%SharedPreferences%'
         OR name LIKE '%Room%' OR name LIKE '%SQLite%' OR name LIKE '%openDatabase%'                                                                                                                                                                                                                                                                                       

      UNION ALL                                                                                                                                                                                                                                                                                                                                                            

      SELECT '이미지 리소스' AS category, dur FROM slice
      WHERE name LIKE '%image%' OR name LIKE '%Image%'
         OR name LIKE '%bitmap%' OR name LIKE '%Bitmap%'                                                                                                                                                                                                                                                                                                                   
         OR name LIKE '%decode%' OR name LIKE '%Decode%'
         OR name LIKE '%coil%'   OR name LIKE '%Coil%'                                                                                                                                                                                                                                                                                                                     
  )                                                                                                                                                                                                                                                                                                                                                                        
  GROUP BY category
  ORDER BY total_ms DESC   

Baseline Profile

ART가 앱 시작 시 JIT 컴파일하며 .art 이미지를 생성하는 Generating runtime image 작업을 앱 설치 시점으로 앞당겨 콜드 스타트를 개선한다.

설정

libs.versions.toml

profileinstaller = "1.4.1"
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }

app/build.gradle.kts

implementation(libs.androidx.profileinstaller)

BaselineProfileGenerator.kt

@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {

    @get:Rule
    val baselineProfileRule = BaselineProfileRule()

    @Test
    fun generate() = baselineProfileRule.collect(
        packageName = PACKAGE_NAME,
    ) {
        pressHome()

        // IntroScreen 진입 경로
        device.executeShellCommand("am start -n $PACKAGE_NAME/$MAIN_ACTIVITY")
        device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)

        // HomeScreen 직접 진입 경로
        device.executeShellCommand(
            "am start -n $PACKAGE_NAME/$MAIN_ACTIVITY --activity-clear-task --ez $EXTRA_BENCHMARK true"
        )
        device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
    }
}

프로필 생성 및 적용

# 1. 프로필 생성
./gradlew :benchmark:connectedDevNonMinifiedReleaseAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.myapp.benchmark.BaselineProfileGenerator#generate

# 2. 생성된 파일 위치
# benchmark/build/outputs/connected_android_test_additional_output/devNonMinifiedRelease/connected/<기기명>/BaselineProfileGenerator_generate-baseline-prof.txt

# 3. app/src/main/baseline-prof.txt 로 복사 (AGP가 자동 인식)

효과 비교 테스트

@Test
fun homeScreenRenderTimeWithBaselineProfile() = benchmarkRule.measureRepeated(
    packageName = PACKAGE_NAME,
    metrics = listOf(StartupTimingMetric()),
    iterations = 5,
    startupMode = StartupMode.COLD,
    compilationMode = CompilationMode.Partial(BaselineProfileMode.Require),
    setupBlock = { pressHome() },
) {
    device.executeShellCommand(
        "am start -n $PACKAGE_NAME/$MAIN_ACTIVITY --activity-clear-task --ez $EXTRA_BENCHMARK true"
    )
    device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), LAUNCH_TIMEOUT_MS)
}
CompilationMode 의미
CompilationMode.None() JIT만 사용 (기준값)
CompilationMode.Partial(BaselineProfileMode.Require) Baseline Profile AOT 적용
CompilationMode.Full() 전체 AOT 컴파일 (이론적 최대치)

baseline-prof.txt 내 클래스 유형

유형 예시 설명
앱 자체 코드 L/... isMinifyEnabled = false이면 풀네임 유지
서드파티 라이브러리 La0/k;, La1/e1; 라이브러리 AAR 자체가 이미 R8으로 배포된 것
Compose 컴파일러 생성 클래스 Lgc/r; Composable 함수의 람다/동반 클래스

Baseline Profile 동작 원리

Baseline Profile은 ART의 하이브리드 컴파일 전략(JIT + AOT)을 그대로 유지하되, AOT 커버리지의 시작점을 설치 시점으로 앞당기는 방식이다.

Baseline Profile 없을 때

앱 설치
    ↓
앱 실행 → 모든 코드를 JIT으로 시작
    ↓
ART가 자주 실행되는 코드를 감지해 Cloud Profile / 사용자 프로파일 누적
    ↓
백그라운드 dex2oat → 점점 AOT 비율 증가 (워밍업 기간 필요)

Baseline Profile 있을 때

앱 설치
    ↓
profileinstaller가 baseline-prof.txt를 참조해 ART가 AOT 컴파일 수행
    ↓
앱 실행 → 프로파일에 포함된 코드는 이미 네이티브 코드 → JIT 없이 즉시 실행
         프로파일에 없는 코드는 여전히 JIT으로 실행

Baseline Profile은 "이 코드들은 AOT로 미리 컴파일해줘"라는 힌트를 ART에 제공하는 파일이며, JIT이 완전히 사라지는 것이 아니라 프로파일에 포함된 코드에 한해 JIT이 발생하지 않는다.

Perfetto Trace에서 확인되는 차이

항목 Baseline Profile 미적용 Baseline Profile 적용
Generating runtime image 66ms 소요 (런타임 JIT 컴파일) 항목 자체가 사라짐

Generating runtime image가 사라진 것은 해당 코드들이 설치 시점에 이미 AOT 컴파일되어 런타임 JIT이 불필요해진 것이다.

프로파일 커버리지 범위

BaselineProfileGeneratorgenerate() 내에서 실제로 탐색한 화면과 코드 경로만 프로파일에 기록된다. 실행되지 않은 화면(예: LoginScreen)은 프로파일에 포함되지 않아 해당 화면 진입 시 JIT이 여전히 발생할 수 있다. 핵심 사용자 플로우를 따라 실제 UI 인터랙션을 추가할수록 AOT 커버리지가 넓어진다.

배포 플로우

generate() 코드 작성 (핵심 플로우 커버)
    ↓
./gradlew generateBaselineProfile  (디바이스 연결 필요)
    ↓
baseline-prof.txt 생성 → app/src/main/에 복사 후 커밋
    ↓
./gradlew assemble  → baseline-prof.txt가 APK에 번들링됨
    ↓
Play Store 배포 → 설치 시 AOT 컴파일 수행

generate() 코드 수정 후 반드시 generateBaselineProfile을 재실행해야 변경사항이 반영된다. 코드만 수정하고 assemble하면 이전 프로파일이 그대로 패키징된다.

Baseline Profile 효과가 미미할 때

nonMinifiedRelease 빌드로 테스트하면 효과가 작게 나올 수 있다. Baseline Profile은 R8 minification이 켜진 production release 빌드에서 최대 효과를 발휘한다. 또한 병목이 JIT 컴파일이 아닌 Hilt DI 초기화, Room 열기, DataStore 읽기, API 호출 등이라면 Baseline Profile로는 개선이 어렵다.


12. nonMinifiedRelease와 minification

convention plugin에서 nonMinifiedReleaseinitWith(getByName("release"))로 release의 모든 설정을 복사한다. isMinifyEnabled를 별도로 재정의하지 않으면 release의 isMinifyEnabled = true가 그대로 상속된다.

release {
    isMinifyEnabled = true  // ← 이게
}

create("nonMinifiedRelease") {
    initWith(getByName("release"))  // ← 여기서 복사됨
    // isMinifyEnabled 재정의 없음 → 결국 true
}

목적에 따라 선택

선택 설정 특징
trace 가독성 우선 isMinifyEnabled = false 명시 추가 Perfetto에서 클래스명 풀네임으로 표시, 생성된 Baseline Profile은 production과 클래스명 불일치
production 환경 재현 현재 상태 유지 (minification 켜짐) Baseline Profile이 production release 빌드와 클래스명 일치하여 그대로 적용 가능

'Android' 카테고리의 다른 글

[Android] Compose 리컴포지션 원리  (0) 2026.06.09
[Android] Compose UI - (3)  (0) 2024.12.27
[Android] Compose UI - (2)  (0) 2024.12.26
[Android] Compose UI - (1)  (0) 2024.12.16
[Android] Compose 런타임 - (12)  (0) 2024.12.12

Kotlin 코루틴에 대해 좀 더 자세히 정리하기

코루틴은 구조화된 동시성과 비동기 작업을 쉽게 관리할 수 있도록 편의를 제공하는 기능입니다.

코루틴의 핵심 개념인 구조화된 동시성, 동작 메커니즘, 병렬 처리, Dispatcher 에 대해 다시 정리해보겠습니다.


구조화된 동시성 (Structured Concurrency)

구조화된 동시성이란 코루틴 내에서 수행되는 모든 작업들을 일괄적으로 취소하거나 지속적인 수행을 제어할 수 있다는 의미입니다.

대표적인 예로 JobSupervisorJob 의 차이를 들 수 있습니다.

Job

코루틴 블록 내에서 작업을 수행하던 중 에러가 발생하면, 해당 시점 이후의 모든 형제 코루틴이 취소되고 부모 코루틴도 함께 취소됩니다.

val scope = CoroutineScope(Job())

scope.launch {
    launch { throw Exception("에러 발생") }  // 이 코루틴이 실패하면
    launch { delay(1000); println("실행되지 않음") }  // 형제도 취소
}

SupervisorJob

에러가 발생하더라도 해당 작업만 취소되고 나머지 형제 코루틴은 지속적으로 실행됩니다.

val scope = CoroutineScope(SupervisorJob())

scope.launch {
    launch { throw Exception("에러 발생") }  // 이 코루틴만 실패
    launch { delay(1000); println("나는 계속 실행") }  // 형제는 영향 없음
}

또한 부모 코루틴이 LifeCycle에 의해 취소되거나 명시적으로 취소된 경우 자식 코루틴은 일괄적으로 취소됩니다. 이것이 코루틴이 구조화된 동시성을 보장한다는 의미입니다.


동작 메커니즘 — 왜 경량 스레드라 불릴까?

코루틴이 비동기 작업 관리를 편리하게 해주는 이유는 내부 메커니즘에 기반합니다.

코루틴은 상태 머신(State Machine)CPS(Continuation Passing Style) 기반으로 동작합니다.

컴파일 타임에 Continuation이라는 객체가 자동으로 추가되는데, 이 객체는 작업 상태를 관리하는 저장소 역할을 합니다. suspend 함수의 호출 위치마다 label로 상태를 나타내고, 이를 Continuation에 저장하며 상태에 따른 작업을 수행합니다.

suspend fun fetchData() {
    val result = apiCall()   // label 0 → suspend
    process(result)          // label 1 → resume 후 실행
}

이때 작업이 전환되는 과정은 중지(suspend) → 재개(resume) 의 개념으로, OS 레벨의 스레드 간 컨텍스트 스위칭이 아닌 사용자 레벨에서의 실행 흐름 전환이기 때문에 전환 비용이 압도적으로 저렴합니다.

이 특성 때문에 코루틴을 경량 스레드(Light-weight Thread) 라고 부릅니다.


병렬 처리 — async / await

async 블록을 통해 병렬 처리도 손쉽게 구현할 수 있습니다.

async 블록 내부의 코루틴은 또 다른 자식 코루틴으로 실행되며, 독립적인 Continuation 상태를 가집니다. 부모 코루틴에서 await()을 호출하기 전까지는 별도의 중단 없이 이후 작업을 계속 수행할 수 있습니다.

val deferred1 = async { fetchUserData() }
val deferred2 = async { fetchPostData() }

// 두 작업이 병렬로 실행되다가 await 시점에 결과를 수집
val user = deferred1.await()
val posts = deferred2.await()

Dispatcher — 어떤 스레드에서 실행할까?

코루틴이 실행되는 스레드는 Dispatcher를 통해 지정할 수 있습니다. 총 4가지 종류가 있습니다.

Dispatchers.Unconfined

별도의 스레드를 지정하지 않고 해당 코루틴을 재개하는 스레드가 실행을 담당합니다. 어떤 스레드에서 작업이 수행될지 예측할 수 없기 때문에 실무에서는 사용을 자제하고, 쓴다면 주로 테스트 환경에서 활용합니다.

Dispatchers.Default

CPU 점유가 높은 복잡한 연산 작업에 특화되어 있습니다. 이미지 처리, 텐서플로우를 통한 분류 작업 등 CPU 집약적인 작업에 적합합니다.

Dispatchers.IO

네트워크 통신, 로컬 DB 접근 등 입출력 작업에 특화되어 있습니다. 담당 스레드 수는 CPU 코어 수에 따르거나 기본 상한선이 64개이며, 모든 라이브러리가 함께 사용하는 공유 스레드풀이므로 무분별한 사용은 자제하는 것이 좋습니다.

Dispatchers.Main / Main.immediate

UI 관련 작업에 사용합니다.

Main과 Main.immediate의 차이는 다음과 같습니다.

구분 동작 방식

Dispatchers.Main 작업을 메인 스레드 핸들러 큐에 post() 하여 다음 루프에서 실행
Dispatchers.Main.immediate 이미 메인 스레드라면 큐에 넣지 않고 즉시 실행

이미 메인 스레드에서 실행 중인 경우 Main은 불필요한 큐 왕복이 발생하지만, Main.immediate는 바로 실행되므로 즉각적인 UI 반영이 필요한 곳에 더 적합합니다.


정리

개념 핵심

구조화된 동시성 부모-자식 코루틴 간 생명주기 연동, Job/SupervisorJob으로 에러 전파 제어
경량 스레드 OS 스레드 전환이 아닌 유저 레벨 Continuation 재개로 전환 비용 최소화
병렬 처리 async/await으로 자식 코루틴을 독립 실행 후 결과 수집
Dispatcher 작업 성격에 맞는 스레드풀 지정 (IO, Default, Main, Unconfined)

'Kotlin' 카테고리의 다른 글

[Kotlin] runCatching  (0) 2022.12.19
[Kotlin] 스레드와 코루틴  (0) 2022.05.12
[Kotlin] CoroutineBuilder  (0) 2022.05.12
[Kotlin] Object  (0) 2022.03.25
[Kotlin] Delegated Properties  (0) 2022.03.23

UI 변경 사항 반영하기

composition과 후속으로 이루어지는 recomposition의 과정을 거치면서 발생되는 모든 변경사항들은 실제 UI에 반영하여 상용자가 경험할 수 있도록 하는 통합 과정이 필요하다.

이 과정은 흔히 노드 트리의 구체화라고 불리며 Compose UI와 같은 클라이언트단의 라이브러리의 책임이다.

다양한 타입의 Applier들

Applier는 라이브러리가 사용되는 플랫폼에 대해 런타임이 완전히 알지는 못하도록 의존성을 반전시킨다.

이 추상화 계층은 Compose UI와 같은 클라이언트 라이브러리가 자체적으로 Applier 구현을 할 수 있도록 하기 위해 플랫폼과의 통합에 사용될 자신의 노드 타입을 선택하게 된다.

Compose Runtime에서 다양한 Applier들이 공통 로직을 공유하기 위해 AbstractApplier라는 공통 로직을 제공한다.

AbstractApplier는 방문한 노드들을 스택에 저장하여 현재 방문 중인 노드에 대한 참조를 유지한다.

트리의 아랫쪽에서 새로운 노드에 방문할 때마다 Composer는 applier#down(node: N)을 호출하여 Applier에게 알리면 Applier는 이를 stack에 푸시하고 노드에 필요한 모든 동작을 수행할 수 있다.

하위 노드 탐색을 마치면 노드의 부모로 다시 돌아가야할 때 Composer는 applier#ip()을 호출하여 마지막으로 방문한 노드를 스택에서 팝업 시킨다.

Column {
	Row {
		Text("Some Text")
		if (condition) {
			Text("Some conditional text")
		}
	}
	if (condition) {
		Text("Some more conditional text")
	}
}

위 코드의 조건문에서 쓰이는 condition이 바뀔 경우 Applier는 아래와 같이 동작한다.

  • Column에 대한 down 호출
  • Row로 들어가기 위한 또 다른 down 호출
  • 조건에 따른 자식 Text에 대한 삭제 혹은 삽입 작업 수행
  • 다시 column으로 돌아가기 위한 up 호출
  • 두 번째 조건부 텍스트에 대한 삭제 작업 수행

AbstractApplier에 스택과 down 및 up 작업이 포함되어 있어 자식 Applier들이 노드 타입에 관계없이 동일한 탐색 로직을 공유할 수 있다.

이는 앞전에서 언급되었던 Applier의 노드 트리 하향식 / 상향식 구축을 의미한다.

'Android' 카테고리의 다른 글

[Android] Compose 리컴포지션 원리  (0) 2026.06.09
[Android] Macrobenchmark에 대해 알아보기  (0) 2026.05.07
[Android] Compose UI - (2)  (0) 2024.12.26
[Android] Compose UI - (1)  (0) 2024.12.16
[Android] Compose 런타임 - (12)  (0) 2024.12.12

Compose UI 관점에서의 SubComposition

Composition은 루트 레벨 외에도 Composable 트리의 더 깊은 수준에서 생성될 수 있으며, 부모 Composition과 연결될 수도 있다.

이러한 특성을 Subcomposition이라고 한다.

각각의 모든 Composition은 부모의 Composition을 나타내는 부모의 CompositionContext에 대한 참조를 가지고 있으며 Compose UI에서 Subcomposition을 생성하는 이유는 크게 2가지가 있다.

  1. 초기 composition 과정에서 특정 정보를 알 때까지 지연하기 위해
  2. 하위 트리에서 생성되는 노드 타입 변경을 위해

 

초기 Composition 과정의 지연

SubcomposeLayout은 Layout과 유사하지만 레이아웃 단계에서 독립적인 Composition을 생성하고 실행한다.

이를 통해 SubcomposeLayout은 자식 Composable이 자신의 내부에서 계산된 값에만 의존하도록 한다.

한 예로 BoxWithConstraints는 부모의 제약사항을 content block 내부에 제공함으로 그 값에 따라 내용을 다르게 구성할 수 있다.

BoxWithConstraint {
	val rectangleHeight = 100.dp
	if (maxHeight < retangleHeight * 2) {
		Box(Modifier.size(50.dp, rectangleHeight).background(Color.Blue))
	} else {
		Column {
			Box(Modifier.size(50.dp, rectangleHeight).background(Color.Blue))
			Box(Modifier.size(50.dp, rectangleHeight).background(Color.Gray))
		}
	}
}

Subcomposition은 부모 Composition과 독립적으로 recompose 할 수 있도록 한다.

SubcomposeLayout을 예로 보면 layout 단계에서 SubcomposeLayout의 람다에 전달되는 매개변수가 변경될 수 있으며, 이로 인해 recomposition이 발생하게 된다.

반면 Subcomposition에서 읽은 state가 변경되면, 초기 composition이 수행된 후 부모 Composition에 대한 recomposition이 예약된다.

 

서브 트리의 노드 타입 변경

서브 트리에서 노드 타입을 변경하는 사례가 있는데, 벡터 그래픽을 생성하고 관리하는 rememberVectorPainter가 그 예시이다.

벡터 Composable은 벡터 그래픽을 트리로 모델링하기 위해 자체 Subcomposition을 생성한다.

Vector Composable이 compose될 때, VNode라는 다른 노드 타입으로 Subcoposition을 구성하게 되는데, VNode는 재귀적인 타입으로서 경로나 경로의 그룹을 모델링한다.

@Composable
fun MenuButton(onMenuClick: () -> Unit) {
	Icon(
		painter = rememberVectorPainter(image = Icons.Rounded.Menu),
		contentDescription = "Menu Button",
		modifier = Modifier.clickable { onMenuClick() }
	)
}

위의 코드 스니펫과 같이 일반적으로 Image, Icon 또는 유사한 Composable 내에서 VectorPainter를 사용한다.

이는 벡터를 포함한 Composable이 Layout이므로 관련된 Composition에 LayoutNode를 방출한다는 의미다.

그러나 동시에 VectorPainter는 벡터를 표현하기 위해 자체적인 Subcomposition을 생성하고, 이를 이전 Composition에 연결하여 부모로 만든다.

'Android' 카테고리의 다른 글

[Android] Macrobenchmark에 대해 알아보기  (0) 2026.05.07
[Android] Compose UI - (3)  (0) 2024.12.27
[Android] Compose UI - (1)  (0) 2024.12.16
[Android] Compose 런타임 - (12)  (0) 2024.12.12
[Android] Compose 런타임 - (11)  (0) 2024.12.11

Compose UI와 런타임의 통합

Compose Runtime을 위한 클라이언트 라이브러리로써 Compose UI를 결합하는 이유는 사용자가 보게될 화면의 레이아웃 트리를 구축하기 위해서이다.

이 트리는 Composable 함수를 실행함으로써 만들어지고, 트리에 사용되는 노드 타입은 Compose UI만 알고 있기 때문에 런타임은 이를 알지 못한다.

Compose UI와 같은 클라이언트 라이브러리에서 생성하는 노드 타입은 클라이언트 라이브러리만 알고 있어야 하기 때문에, 런타임은 트리에서 노드를 삽입, 삭제, 이동 또는 교체 작업을 위임한다.

예약된 변경 목록을 실제 트리의 변경 목록으로 매핑

Composition이나 recomposition에 의해 Composable 함수가 자신의 변경 사항을 방출할 때 composition이라는 side table이 사용된다.

이 table은 실제 노드 트리의 변경으로 매핑하고 이러한 작업과 관련된 정보를 가지고 있다.

Compose UI 관점에서의 Composition

안드로이드를 예로 들면, 하나의 화면에서 setContent를 호출할 때 Compose UI 라이브러리에서 runtime 단계로 진입된다.

class MainActivity: ComponentActivity() {
	oerride fun onCreate(savedInstanceState: Bundle?) {
		super.onCreate(savedInstanceState)
		setContent {
			MaterialTheme {
				Text("Hello Compose")
			}
		}
	}
}

하지만 꼭 화면에서 setContent를 호출할 수 있는건 아니다. setContent는 View 계층의 중간에서도 호출될 수 있고, 아래와 같이 호출될 수도 있다.

ComposeView(requireContext()).apply {
	setContent {
		MaterialTheme {
			Text("Hello Compose")
		}
	}
}

setContent 함수는 새로운 루트 Composition을 생성하고, 최대한 재사용 되고자 한다.

루트 Composition이라 부르는 이유는 각각 독립적인 Composable 트리를 호스트 하기 때문이고, 이들은 서로 연결되어 있지 않다.

이러한 관점으로 하나의 앱에는 여러 개의 노드를 쌓아 관리하는 트리가 있으며, 각기 다른 Composition에 연결된 모습을 상상하면 된다.

레이아웃 계층을 생성하기 위해 Composer가 setContent안에 있는 모든 Composable 함수들을 실행하며, 변경 사항이 방출되어 UI의 노드들이 삽입, 삭제, ,이동, 교체 되며 UI 요소들에 반영된다.

이런 Composable들은 일반적으로 서로 다른 라이브러리(foundation, matrial3 등등)에 속하지만, 결국 모두 Layout으로 정의되므로 동일하다고 볼 수 있다.

'Android' 카테고리의 다른 글

[Android] Compose UI - (3)  (0) 2024.12.27
[Android] Compose UI - (2)  (0) 2024.12.26
[Android] Compose 런타임 - (12)  (0) 2024.12.12
[Android] Compose 런타임 - (11)  (0) 2024.12.11
[Android] Compose 런타임 - (10)  (0) 2024.12.09

+ Recent posts