<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>미래 가젯 연구소</title>
    <link>https://angrypodo.tistory.com/</link>
    <description>Android 개발자가 되기까지.</description>
    <language>ko</language>
    <pubDate>Wed, 10 Jun 2026 02:16:10 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>아키001</managingEditor>
    <image>
      <title>미래 가젯 연구소</title>
      <url>https://tistory1.daumcdn.net/tistory/6967266/attach/b53a9e64e7974d11a52976b4a5882f5b</url>
      <link>https://angrypodo.tistory.com</link>
    </image>
    <item>
      <title>GMA Next Gen SDK 도입기 feat. 공식 문서 안믿기</title>
      <link>https://angrypodo.tistory.com/41</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요 아키입니다. 이번엔 Hilingual 프로젝트에 GMA Next Gen SDK를 도입하면서 겪은 일들을 정리해봤어요. 공식 문서를 따라가다 지뢰 밟은 이야기, Compose에 광고를 제대로 녹이기 위해 고민한 이야기, 그리고 혼자 해결 못 해서 Google 엔지니어한테 직접 제보한 이야기를 해보려 합니다. :)&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 하필 베타 SDK였나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 GMA SDK는 수익화엔 필수였지만 메인 스레드에서 초기화되는 구조 때문에 앱 시작 시간에 영향을 줬어요. 광고 SDK 때문에 앱이 느려지는 건 좀 억울하잖아요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next Gen SDK는 이걸 구조적으로 해결하려 했습니다. 핵심 변화 세 가지를 꼽자면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;초기화가 반드시 백그라운드 스레드에서 이뤄져야 하도록 강제해서 ANR을 원천 차단&lt;/li&gt;
&lt;li&gt;SDK 자체가 Kotlin으로 재작성되어 코루틴과 자연스럽게 어울림&lt;/li&gt;
&lt;li&gt;광고를 미리 백그라운드에서 받아두는 프리로딩 API 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베타 버전이지만 성능 이점이 명확해서 선제적으로 도입해보기로 했어요. 자세한 특징은 아래 글에서 다루고 있으니 참고해보셔도 좋아요.&lt;/p&gt;
&lt;figure id=&quot;og_1774858520764&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Google Mobile Ads Next-Gen SDK, 왜 만든건데?&quot; data-og-description=&quot;안녕하세요, 이번에 광고 SDK를 도입하면서 알게된 소식을 전달하고자 이번 글을 작성합니다. 도움이 되길 바랍니다 들어가며Google이 Mobile Ads SDK를 처음부터 다시 만들었습니다. &amp;quot;차세대(Next&quot; data-og-host=&quot;angrypodo.tistory.com&quot; data-og-source-url=&quot;https://angrypodo.tistory.com/34&quot; data-og-url=&quot;https://angrypodo.tistory.com/34&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bw667P/dJMb9efd0v7/UBEKK1DmiwTSzhKWxCRrK0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/F2PXy/dJMb9eTPIUW/9l7KaPGPU78BsckVWtruv1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/W2oic/dJMb9cBH91l/kmikVn4vwI1LvvQRkcLpq1/img.png?width=831&amp;amp;height=1188&amp;amp;face=0_0_831_1188&quot;&gt;&lt;a href=&quot;https://angrypodo.tistory.com/34&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://angrypodo.tistory.com/34&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bw667P/dJMb9efd0v7/UBEKK1DmiwTSzhKWxCRrK0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/F2PXy/dJMb9eTPIUW/9l7KaPGPU78BsckVWtruv1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/W2oic/dJMb9cBH91l/kmikVn4vwI1LvvQRkcLpq1/img.png?width=831&amp;amp;height=1188&amp;amp;face=0_0_831_1188');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Google Mobile Ads Next-Gen SDK, 왜 만든건데?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;안녕하세요, 이번에 광고 SDK를 도입하면서 알게된 소식을 전달하고자 이번 글을 작성합니다. 도움이 되길 바랍니다 들어가며Google이 Mobile Ads SDK를 처음부터 다시 만들었습니다. &quot;차세대(Next&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;angrypodo.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;공식 문서를 따라갔다가 생긴 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 SDK를 쓸 때 가장 먼저 보는 건 공식 문서입니다. 배너 광고 문서에는 Kotlin, Java, XML, Jetpack Compose 탭이 나뉘어 있어요. Compose로 개발 중이었으니 당연히 &lt;b&gt;Jetpack Compose 탭&lt;/b&gt;을 봤고 이렇게 나와 있었어요.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;// 공식 문서 Jetpack Compose 탭 - 광고 로드
LaunchedEffect(context) {
    when (val result = BannerAd.load(BannerAdRequest.Builder(AD_UNIT_ID, adSize).build())) {
        is AdLoadResult.Success -&amp;gt; bannerAdState = result.ad
        is AdLoadResult.Failure -&amp;gt; { /* 에러 처리 */ }
    }
}

// 공식 문서 Jetpack Compose 탭 - 뷰 렌더링
AndroidView(
    modifier = modifier.wrapContentSize(),
    factory = { bannerAd.getView(requireActivity()) },
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콜백 장풍 없이 &lt;code&gt;suspend&lt;/code&gt; 함수로 깔끔하게 처리할 수 있어서 좋네?? 싶었어요. 그대로 적용하고 빌드를 돌렸는데, IDE가 경고를 뱉었습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;w: 'suspend fun load(adRequest: BannerAdRequest): AdLoadResult&amp;lt;BannerAd&amp;gt;' is deprecated.
   Use AdView.loadAd() or BannerAdPreloader instead.
w: 'fun getView(activity: Activity): View' is deprecated.
   Use AdView.registerBannerAd() instead.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서 Compose 탭에서 복사해 온 두 API, &lt;code&gt;BannerAd.load()&lt;/code&gt;와 &lt;code&gt;BannerAd.getView()&lt;/code&gt; 모두 deprecated 상태였던 거예요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웃긴 건 같은 문서의 &lt;b&gt;Kotlin/Java 탭은 이미 &lt;code&gt;AdView&lt;/code&gt; + &lt;code&gt;loadAd()&lt;/code&gt; 방식으로 업데이트&lt;/b&gt;가 돼 있다는 점입니다... Compose 탭만 업데이트를 빠뜨린 건데요&amp;hellip; deprecation 자체는 제가 도입하던 &lt;code&gt;0.24.0-beta02&lt;/code&gt;보다 훨씬 이전인 &lt;b&gt;&lt;code&gt;0.22.0-beta01&lt;/code&gt; (2025년 11월)&lt;/b&gt; 에 이미 이뤄진 상태였어요. 그러니까 몇 달이 지난 시점에도 공식 문서 Compose 탭은 아직 저 코드를 그대로 올려두고 있었던 거죠. &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose를 쓰는 개발자라면 공식 문서가 함정이 되는 상황이에요. 네, 제가 당했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 GitHub을 뒤졌습니다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서가 못 믿겠으니 그다음으로 믿을만한 구글 엔지니어들이 직접 관리하는 공식 샘플 레포지토리를 참고했습니다. 최신 코드를 뜯어보니 &lt;code&gt;/preloading&lt;/code&gt; 폴더에서 진짜 권장 방식을 확인할 수 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;BannerAdPreloader.start()&lt;/code&gt;로 미리 광고를 내려받아 두고&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BannerAdPreloader.pollAd()&lt;/code&gt;로 캐싱된 광고를 꺼내 쓸 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 샘플 코드도 완벽하진 않았어요. deprecated 경고를 보니 &lt;code&gt;getView()&lt;/code&gt;는 &lt;code&gt;AdView.registerBannerAd()&lt;/code&gt;로 교체하라고 되어 있었는데 샘플 앱은 아직도 &lt;code&gt;getView()&lt;/code&gt;를 그대로 쓰고 있었어요. 샘플도 문서 업데이트를 못 따라가고 있는 상황입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 deprecated 경고 메시지를 직접 따라 &lt;code&gt;registerBannerAd()&lt;/code&gt; 방식을 적용했습니다. 결론적으로 공식 문서도 아니고 샘플 앱도 아닌 deprecated 경고 메시지가 가장 최신 정보였네요.  &lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Compose에 광고를 녹이기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 방향은 잡혔는데 이걸 실제로 Compose 아키텍처에 어떻게 녹일지가 다음 고민이었어요. 공식 문서도, 샘플 앱도 Fragment/View 기반이라 제대로 참고할 만한 Compose 패턴이 없었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AdView 생명주기를 Composition과 분리하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 처음엔 이렇게 짰어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun HilingualBannerAd(adUnitId: String) {
    var isAdLoaded by remember { mutableStateOf(false) }

    Box(modifier = Modifier.fillMaxWidth()) {
        if (!isAdLoaded) {
            Image(painter = painterResource(R.drawable.loading_feed))
        }

        AndroidView(
            factory = { context -&amp;gt;
                AdView(context).apply {
                    val preloadedAd = BannerAdPreloader.pollAd(adUnitId)
                    if (preloadedAd != null) {
                        registerBannerAd(preloadedAd, activity)
                        isAdLoaded = true
                    } else {
                        loadAd(adRequest, object : AdLoadCallback&amp;lt;BannerAd&amp;gt; {
                            override fun onAdLoaded(ad: BannerAd) { isAdLoaded = true }
                        })
                    }
                }
            },
            onRelease = { it.destroy() }
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Presentation 레이어가 &lt;code&gt;AdView&lt;/code&gt;나 &lt;code&gt;Preloader&lt;/code&gt;의 존재를 전혀 몰라도 되고, 로딩 중엔 스켈레톤도 보여주는 구조입니다. 그런데 이 코드를 화면 여러 곳에서 재사용하다 보니 문제가 생겼어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;AndroidView&lt;/code&gt;의 &lt;code&gt;factory&lt;/code&gt;는 컴포저블이 컴포지션에 진입할 때 한 번 실행되는데, &lt;code&gt;AdView&lt;/code&gt;를 만들고 광고를 붙이는 과정이 전부 여기 묶여 있다 보니 상태와 View의 생명주기가 컴포저블에 강하게 결합된 구조가 됐습니다. 특히 동일한 광고를 다른 화면에서도 사용해야 할 경우 상태를 외부에서 주입하는 게 불가능했어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;BannerAdHolder&lt;/code&gt;를 따로 빼냈습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@ConsistentCopyVisibility
@Stable
data class BannerAdHolder internal constructor(
    internal val adView: AdView,
    private val _isLoaded: State&amp;lt;Boolean&amp;gt;,
) {
    val isLoaded: Boolean get() = _isLoaded.value
}

@Composable
fun rememberBannerAdView(type: BannerAdType): BannerAdHolder {
    val context = LocalContext.current
    val activity = LocalActivity.current
    val isLoadedState = remember { mutableStateOf(false) }

    val adView = remember {
        createAndLoadAdView(
            context = context,
            activity = activity,
            adUnitId = type.adUnitId,
            screenWidth = context.screenWidthDp,
            maxHeight = type.maxHeight,
            onLoaded = { isLoadedState.value = true },
        )
    }

    DisposableEffect(type) {
        onDispose { adView.destroy() }
    }

    return remember { BannerAdHolder(adView, isLoadedState) }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;rememberBannerAdView()&lt;/code&gt;로 상태를 호이스팅해두면 &lt;code&gt;HilingualBannerAd&lt;/code&gt; 컴포저블은 holder만 받으면 되고, 필요에 따라 &lt;code&gt;BannerAdHolder&lt;/code&gt;를 상위에서 만들어서 여러 곳에 전달하는 것도 가능해집니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 타입만 넘기면 내부에서 알아서 처리
HilingualBannerAd(type = BannerAdType.INLINE_BANNER)

// 또는 상위에서 미리 만들어두고 주입
val adHolder = rememberBannerAdView(BannerAdType.BOTTOM_BANNER)
HilingualBannerAd(adHolder = adHolder)&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;슬롯 타입을 타입 세이프로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;광고 슬롯이 늘어날수록 adUnitId 문자열을 여기저기 넘기는 게 지저분해져요(&lt;/p&gt;
&lt;p&gt;&lt;del&gt;보기 싫어요&lt;/del&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;). 그래서 &lt;code&gt;BannerAdType&lt;/code&gt; enum 하나로 정리했습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;enum class BannerAdType(
    val adUnitId: String,
    val maxHeight: Int? = null,
) {
    BOTTOM_BANNER(
        adUnitId = BuildConfig.ADMOB_BOTTOMBANNER_UNIT_ID,
        maxHeight = 70,
    ),
    INLINE_BANNER(
        adUnitId = BuildConfig.ADMOB_INLINEBANNER_UNIT_ID,
    ),
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Presentation 레이어는 &lt;code&gt;BannerAdType.INLINE_BANNER&lt;/code&gt; 같은 enum 값만 알면 되고, adUnitId나 maxHeight 같은 설정값은 &lt;code&gt;core:ads&lt;/code&gt; 모듈 안에서만 관리됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;네이티브 광고를 Compose로 만들자&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이티브 광고는 아예 공식 문서에 Compose 통합 예시가 없었어요. 네이티브 광고를 렌더링하려면 &lt;code&gt;NativeAdView&lt;/code&gt;를 써야 하는데 이건 View 기반 컴포넌트입니다&amp;hellip;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose에서 이걸 어떻게 다룰까 고민하다가 &lt;code&gt;NativeAdView&lt;/code&gt; 안에 &lt;code&gt;ComposeView&lt;/code&gt;를 넣는 방식으로 해결했어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;internal fun createNativeAdView(
    context: Context,
    nativeAd: NativeAd,
): NativeAdView {
    val composeView = ComposeView(context).apply {
        setContent {
            NativeLineAdContent(
                title = nativeAd.headline ?: &quot;&quot;,
                body = nativeAd.callToAction ?: nativeAd.body ?: &quot;&quot;,
            )
        }
    }

    return NativeAdView(context).apply {
        addView(composeView)
        headlineView = composeView
        callToActionView = composeView
        registerNativeAd(nativeAd, null)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK가 클릭 트래킹이나 노출 측정을 위해 &lt;code&gt;NativeAdView&lt;/code&gt; 안의 뷰들을 바인딩하는 구조인데, &lt;code&gt;ComposeView&lt;/code&gt; 하나를 headline이자 CTA로 등록하는 방식으로 SDK 요구사항을 만족시키면서 내부는 Compose로 그릴 수 있게 됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생명주기 관리도 신경을 써봤습니다. 광고 로드 요청 후 화면이 먼저 사라지면 응답이 와도 destroy된 컴포저블에 상태를 쓰는 문제가 생길 수 있기 때문에 &lt;code&gt;isDisposed&lt;/code&gt; 플래그로 막아놨습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
internal fun rememberNativeAd(adUnitId: String): NativeAd? {
    var loadedAdState by remember { mutableStateOf&amp;lt;NativeAd?&amp;gt;(null) }

    DisposableEffect(adUnitId) {
        var isDisposed = false

        NativeAdLoader.load(adRequest, object : NativeAdLoaderCallback {
            override fun onNativeAdLoaded(nativeAd: NativeAd) {
                // 컴포저블이 사라진 뒤에 콜백이 오면 즉시 파기
                if (isDisposed) nativeAd.destroy() else loadedAdState = nativeAd
            }
            override fun onAdFailedToLoad(adError: LoadAdError) { ... }
        })

        onDispose {
            isDisposed = true
            loadedAdState?.destroy()
        }
    }

    return loadedAdState
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;잘리는 문제를 다르게 풀기&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1970&quot; data-origin-height=&quot;580&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QI4mP/dJMcaduDSJZ/kNUWUarRInggCIybznAcu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QI4mP/dJMcaduDSJZ/kNUWUarRInggCIybznAcu1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QI4mP/dJMcaduDSJZ/kNUWUarRInggCIybznAcu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQI4mP%2FdJMcaduDSJZ%2FkNUWUarRInggCIybznAcu1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1970&quot; height=&quot;580&quot; data-origin-width=&quot;1970&quot; data-origin-height=&quot;580&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이티브 광고는 바텀 네비게이션 위에 한 줄짜리 띠 형태로 배치하도록 디자인됐어요. 문제는 광고 body 텍스트가 길 때 잘리지 않고 '롤링' 효과를 적용해야 했습니다. 말 줄임표로 끊으면 광고 정보 전달이 제대로 안 되니까요!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 텍스트가 잘리는 지점을 이용할까 고민하다가, &lt;code&gt;onTextLayout&lt;/code&gt; 콜백에서 &lt;code&gt;hasVisualOverflow&lt;/code&gt;를 감지하면 어떤 글자까지 보이는지 정확히 알 수 있다는 걸 이용했습니다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;Text(
    text = chunks[index],
    maxLines = 1,
    overflow = TextOverflow.Ellipsis,
    onTextLayout = { result -&amp;gt;
        if (result.hasVisualOverflow &amp;amp;&amp;amp; chunks.size == index + 1) {
            val end = result.getLineEnd(lineIndex = 0, visibleEnd = true)
            val nextChunk = text.substring(end).trim()
            if (nextChunk.isNotEmpty()) chunks = chunks + nextChunk
        }
    },
)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 줄에 들어가는 만큼만 첫 번째 청크로, 나머지를 두 번째 청크로 쪼개서 5초마다 &lt;code&gt;AnimatedContent&lt;/code&gt;로 슬라이드합니다. 텍스트가 아무리 길어도 잘리지 않고 끝까지 보여줄 수 있어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;광고 로딩을 줄일래요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI 캡슐화는 끝났지만 한 가지 고민이 남았어요. 프리로딩을 &lt;b&gt;언제, 어디서&lt;/b&gt; 트리거할 것인가 입니다. 프리로딩은 말 그대로 광고를 표시하기 전에 미리 로딩해놓는 방법인데요. 이걸 사용하는 이유는 사용자가 화면에 들어왔을 때 로딩 없이 광고가 바로 뜨도록 하기 위해서예요. 광고는 &lt;code&gt;노출 == 수입&lt;/code&gt; 이니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &lt;code&gt;MainAppState&lt;/code&gt;의 네비게이션 콜백에서 화면 전환 직전에 프리로드를 수행했습니다. 화면 전환 애니메이션이 도는 짧은 시간 동안 광고가 준비되겠지 라는 가설이었는데 완전히 틀렸습니다ㅎ Compose 렌더링이 네트워크 통신보다 훨씬 빨랐기 때문입니다... 탭을 누르고 0.1초 뒤에 화면이 그려지는데 그 시점엔 광고가 아직 다운로드 중이고 결국 폴백 로직을 타면서 기존과 똑같이 로딩 딜레이가 생겼어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타이밍도 문제였지만 네비게이션 레이어가 앱 안의 모든 광고 규칙을 알아야 하는 관심사 위반 문제도 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 마이페이지의 &lt;code&gt;MyPageViewModel&lt;/code&gt; &lt;code&gt;init&lt;/code&gt;에서 걸면 어떨까 싶어서 시도해봤는데 이건 '프리로딩'이 아니라 그냥 '로딩'이었어요. 탭을 눌러 뷰모델이 생성되는 순간에 통신을 시작하니까 최초 진입 시엔 어차피 스켈레톤을 봐야 했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결국 답은 유의미한 퍼널이다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;광고가 뜰 화면에서 지시하면 늦고, 네비게이션에서 지시하면 결합도가 올라가고. 이 딜레마에서 빠져나오는 방법은 퍼널의 시작점을 보는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;광고 로딩에 필요한 1~2초를 확보하려면 사용자가 해당 화면에 들어오기 전에 자연스럽게 머무는 시간을 이용해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마이페이지 배너의 경우, 유저는 앱을 켜자마자 0.1초 만에 마이페이지를 누르지 않습니다. 홈 화면에서 3~5초 정도는 머뭅니다. 그 시간이 바로 광고를 미리 받아둘 수 있는 타이밍이에요. 그래서 앱 최상단 뷰모델인 &lt;code&gt;MainViewModel&lt;/code&gt;의 &lt;code&gt;init&lt;/code&gt;에서 프리로딩을 지시했어요.&lt;/p&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;@HiltViewModel
internal class MainViewModel @Inject constructor(
    private val adsPreloadManager: AdsPreloadManager
) : ViewModel() {
    init {
        adsPreloadManager.preloadBanner(
            adUnitId = BuildConfig.ADMOB_BANNER_UNIT_ID,
            maxHeight = 70
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전면 광고도 같은 논리로 설계했는데, 수익 비중이 크다 보니 지레짐작으로 결정하기보단 PM에게 데이터를 먼저 요청했어요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;나)&lt;/b&gt;&lt;br /&gt;현재 DAU 대비 일기 작성 수 보면 약 39%의 유저만 일기를 완성하고 있는데요. 혹시 일기 작성 화면에 진입했다가 '완료'를 누르지 않고 나가는 유저들의 구체적인 이탈 지점을 알 수 있을까요?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PM)&lt;/b&gt;&lt;br /&gt;일기 작성 화면 &amp;rarr; 피드백 요청까지 도달하는 유저 &lt;b&gt;82.0%&lt;/b&gt;&lt;br /&gt;평균 일기 작성 시간: &lt;b&gt;5분 26초&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피드백 확인 &amp;rarr; 피드 공유까지 도달하는 유저 &lt;b&gt;33.4%&lt;/b&gt;&lt;br /&gt;평균 피드백 확인 시간: &lt;b&gt;1분 10초&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DAU는 Active action 기준이라 홈 화면이나 피드만 본 사람도 포함된 수치라 39%가 나왔지만, 실제 작성 화면 진입 후 완료를 누르지 않는 유저는 &lt;b&gt;18%&lt;/b&gt; 정도 됩니다. 대부분 쓰지 않고 바로 나가는 유저들이었어요.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;나)&lt;/b&gt;&lt;br /&gt;열에 여덟은 쓰네요!! 감사합니다. 이번 광고 도입으로 로딩 없이 광고를 제공하기 위해 '일기 작성' 페이지 진입 시 미리 로딩하는 선택을 하고자 하는데 유의미한 근거가 될 것 같습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일기 작성 화면에 진입한 유저의 82%가 피드백 요청까지 도달하고, 평균 체류 시간이 5분 26초라는 데이터가 나왔어요. 대용량 전면 광고를 미리 받아두기에 충분한 시간이 확보되는 퍼널이라는 게 수치로 확인됐어요. 그래서 &lt;code&gt;DiaryWriteViewModel&lt;/code&gt;의 &lt;code&gt;init&lt;/code&gt;에서 전면 광고를 프리로드하도록 선택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 프리로딩 아키텍처의 정답은 '어느 레이어에 둘 것인가'가 아니라 &lt;b&gt;'사용자가 어디서부터 힌트를 주는가'&lt;/b&gt; 를 파악하는 것에 있었습니다. 광고 노출이 예상되는 행동의 시작점을 관장하는 ViewModel에게 프리로딩의 책임을 위임함으로서 모듈 간 결합도를 높이지 않고도 Zero-latency를 달성할 수 있어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다 잘했는데 크래시 나버리기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계는 끝났는데 빌드를 돌리자 앱이 클래스 생성중에 뻗었습니다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;java.lang.NoClassDefFoundError: Failed resolution of: Lokhttp3/internal/Util;
    at ads_mobile_sdk.v8.a(SourceFile:1121)
    at ads_mobile_sdk.u51.invokeSuspend(SourceFile:432)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전형적인 의존성 충돌인데요. GMA Next Gen SDK &lt;code&gt;0.24.0-beta02&lt;/code&gt;는 내부적으로 &lt;b&gt;OkHttp 4.x&lt;/b&gt;에 의존하고 있었는데 Hilingual은 이미 &lt;b&gt;OkHttp 5.3.2&lt;/b&gt;를 쓰고 있던 문제였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OkHttp 5.x에서 &lt;code&gt;internal.Util&lt;/code&gt; 같은 내부 패키지 구조가 바뀌었고, 4.x를 기대하던 GMA SDK가 런타임에 클래스를 못 찾고 터진건데요.. OkHttp를 다운그레이드하는 선택은 찜찜해서 서치를 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다 구글 모바일 광고 공식 디스코드 채널을 뒤지다가 커뮤니티 매니저 Eric의 답변을 찾았어요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Next Gen SDK는 현재 OkHttp 4에 의존하고 있습니다. 하지만 다음 릴리즈에서 OkHttp 의존성을 아예 제거할 계획입니다.&quot;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 때마침 문서에는 없지만 Maven에 조용히 올라온 &lt;code&gt;0.24.0-beta03&lt;/code&gt;을 발견했어요. 여담이지만 당시에는 공식문서도 &amp;lsquo;한국어&amp;rsquo;가 아니라 &amp;lsquo;English&amp;rsquo;를 선택하면 beta03이 최신으로 나왔었습니다..&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1724&quot; data-origin-height=&quot;472&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bj3wiB/dJMcacCz0Nf/s1eLKdMNxy93m3ehMTv791/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bj3wiB/dJMcacCz0Nf/s1eLKdMNxy93m3ehMTv791/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bj3wiB/dJMcacCz0Nf/s1eLKdMNxy93m3ehMTv791/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbj3wiB%2FdJMcacCz0Nf%2Fs1eLKdMNxy93m3ehMTv791%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;219&quot; data-origin-width=&quot;1724&quot; data-origin-height=&quot;472&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1722&quot; data-origin-height=&quot;470&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lwzhx/dJMcaipfYtj/FkwsaS4NPlNfNxm1e4lsc0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lwzhx/dJMcaipfYtj/FkwsaS4NPlNfNxm1e4lsc0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lwzhx/dJMcaipfYtj/FkwsaS4NPlNfNxm1e4lsc0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Flwzhx%2FdJMcaipfYtj%2FFkwsaS4NPlNfNxm1e4lsc0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;800&quot; height=&quot;218&quot; data-origin-width=&quot;1722&quot; data-origin-height=&quot;470&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 적용 후에 의존성 트리를 확인하니 뭔가 달라진 것 같았습니다. OkHttp를 다시 5.3.2로 올리고 빌드를 눌렀어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이번엔 앱이 켜지기도 전에 빌드 단계에서 터졌습니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;Execution failed for task ':app:processDebugMainManifest'.
&amp;gt; Manifest merger failed with multiple errors, see logs

[org.chromium.net:cronet-fallback:141.7340.3] .../AndroidManifest.xml Error:
Namespace 'org.chromium.net' is used in multiple modules and/or libraries:
org.chromium.net:cronet-fallback:141.7340.3,
org.chromium.net:httpengine-native-provider:141.7340.3,
org.chromium.net:cronet-common:141.7340.3,
org.chromium.net:cronet-api:141.7340.3,
org.chromium.net:cronet-shared:141.7340.3.
Please ensure that all modules and libraries have a unique namespace.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;beta03&lt;/code&gt;에서 뭔가 바뀐 게 분명했는데, 일단 디스코드에 제보부터 했어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;범인찾기 시작&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;나 (3/9 오후 4:10)&lt;/b&gt;&lt;br /&gt;Hi, I just upgraded to 0.24.0-beta03 and confirmed that the OkHttp issue is resolved &amp;mdash; thank you for the fix!&lt;br /&gt;However, I'm now hitting a new build error at the manifest merge step.&lt;br /&gt;Namespace &lt;code&gt;org.chromium.net&lt;/code&gt; is used in multiple modules: &lt;code&gt;cronet-fallback&lt;/code&gt;, &lt;code&gt;httpengine-native-provider&lt;/code&gt;, &lt;code&gt;cronet-common&lt;/code&gt;, &lt;code&gt;cronet-api&lt;/code&gt;, &lt;code&gt;cronet-shared&lt;/code&gt;.&lt;br /&gt;Is there a known workaround for this? Or is a fix planned for the next release?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;답변이 빠르게 왔는데 예상 밖이었어요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Eric (Google, 3/10 오전 2:23)&lt;/b&gt;&lt;br /&gt;Interesting. I'm still seeing &lt;code&gt;com.squareup.okhttp3&lt;/code&gt; in the 0.24.0-beta03 release. The removal of OkHttp I believe is planned for the 0.25.0 release.&lt;br /&gt;&lt;code&gt;cronet-fallback&lt;/code&gt; is new to 0.24.0-beta03, I'm not sure if that is part of the change or was for some other unrelated reason. Will find out, thanks for the heads up.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 SDK 팀 엔지니어 solarski가 추가로 답했어요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;solarski (Google)&lt;/b&gt;&lt;br /&gt;We aren't removing OkHttp yet. We are just supporting both Cronet + OkHttp for now. We recognize that OkHttp versioning is a large blocker for many of you, but want to evaluate the performance difference between the two libraries for everyone else.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OkHttp를 아직 빼지 않았고, Cronet을 추가한 거였어요. 그러면서 Eric이 직접 &lt;code&gt;NextGenExample&lt;/code&gt; 샘플 앱을 &lt;code&gt;0.24.0-beta03&lt;/code&gt;으로 올려서 빌드를 돌려봤다고 했어요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Eric (3/10 오전 7:04)&lt;/b&gt;&lt;br /&gt;I updated the sample to 0.24.0-beta03 on my local machine and was able to build just fine. Do you separately depend on cronet or okhttp or have other dependencies that cause your environment to be different?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니까 Google 샘플 앱에서는 재현이 안 된다는 건데... 내 환경에서만 터지는 건데 뭐가 다른 걸까요?&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;범인은 &lt;code&gt;android.uniquePackageNames=true&lt;/code&gt; 다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Eric 말대로 샘플 앱과 내 환경이 뭐가 다른지 비교하기 시작했어요. 의존성은 거의 비슷했는데, 한 가지가 눈에 걸렸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 &lt;code&gt;android.uniquePackageNames=true&lt;/code&gt; 라는 플래그입니다. 이 플래그는 AGP 9.0부터 기본값으로 강제되는데요, Hilingual은 &lt;code&gt;AGP 9.1.0&lt;/code&gt; 을 사용중이었어요. 이 플래그는 AGP 9.0부터 기본값으로 강제되는 설정입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 플래그가 활성화되면 AGP가 모든 라이브러리의 네임스페이스가 고유해야 한다고 강제합니다. 그런데 &lt;code&gt;beta03&lt;/code&gt;에서 새로 들어온 Cronet 5개 모듈이 전부 &lt;code&gt;org.chromium.net&lt;/code&gt;이라는 &lt;b&gt;같은 네임스페이스&lt;/b&gt;를 공유하고 있었던 것임니다&amp;hellip;. 플래그가 없는 샘플 앱에선 멀쩡히 빌드되고 플래그가 있는 내 프로젝트에선 터지는 이유입니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;나 (3/10 오후 12:13)&lt;/b&gt;&lt;br /&gt;Finally figured out why the Manifest Merger fails in my project but works fine in your sample app &amp;mdash; it comes down to a single Gradle property.&lt;br /&gt;&lt;code&gt;android.uniquePackageNames=true&lt;/code&gt;&lt;br /&gt;When this is set, AGP enforces unique namespaces across all libraries. GMA SDK 0.24.0-beta03 bundles five Cronet modules that all share the same namespace (&lt;code&gt;org.chromium.net&lt;/code&gt;).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Here's the awkward part though: this flag is literally &lt;b&gt;what Google recommends&lt;/b&gt; for AGP 8.x/9.x environments. So I'm kind of stuck choosing between following Google's own guidelines and using Google's own SDK.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이게 제일 황당했어요. Google 가이드라인을 따르면 Google SDK를 못 쓰는 상황인데요, 게다가 최초 발견자가 저인점도 신기했습니다. 아무도 AGP 9.x 이상에서 시도를 안해봤구나&amp;hellip;싶었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Eric에게 답변이 다시왔어요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Eric (3/11 오전 2:32)&lt;/b&gt;&lt;br /&gt;Good find, thanks for sharing. I can see the same thing now where 0.24.0-beta03 fails and 0.24.0-beta02 doesn't with that same property.&lt;br /&gt;&lt;code&gt;cronet-api:119.6045.31&lt;/code&gt; didn't have any deps, so there was only 1 library using &lt;code&gt;org.chromium.net&lt;/code&gt;. &lt;code&gt;cronet-api:141.7340.3&lt;/code&gt; must have changed its architecture and depends on &lt;code&gt;cronet-common&lt;/code&gt;. And then &lt;code&gt;cronet-fallback&lt;/code&gt; added by &lt;code&gt;beta03&lt;/code&gt; ends up including the other 3 deps.&lt;br /&gt;Current thought is that the &lt;code&gt;cronet 141.7340.3&lt;/code&gt; release mishandles something. In the short term, I would suggest sticking with 0.24.0-beta02.&lt;br /&gt;In the meantime, we'll need to look into how to resolve, as I understand &lt;code&gt;android.uniquePackageNames=true&lt;/code&gt; is the &lt;b&gt;default in Gradle 9&lt;/b&gt; and many will run into this problem.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버전이 올라가면서 Cronet의 내부 구조 자체가 바뀌었습니다. 기존엔 &lt;code&gt;cronet-api&lt;/code&gt; 혼자 의존성이 없었는데 141 버전부터 다른 모듈들에 의존하게 됐고 &lt;code&gt;cronet-fallback&lt;/code&gt;까지 추가되면서 같은 네임스페이스를 가진 라이브러리가 5개로 불어난 거였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 얼마 뒤 해결책이 왔어요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Eric (3/11 오전 5:32)&lt;/b&gt;&lt;br /&gt;Cronet fixed this in version &lt;code&gt;143.7445.0&lt;/code&gt;. The best workaround today is to manually depend on the newer version of each of those 5 dependencies yourself, for Gradle to pick those up.&lt;br /&gt;In the next release, we will bump to these.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;임시 해결, 그리고 0.25.0-beta01&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 일회성 워크어라운드를 전역 버전 카탈로그에 등록하면 추후 관리가 지저분해지는건 당연하기 때문에 광고를 담당하는 &lt;code&gt;:core:ads&lt;/code&gt; 모듈의 &lt;code&gt;build.gradle.kts&lt;/code&gt;에만 주석과 함께 박았습니다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;dependencies {
    implementation(libs.gma.ads) // 0.24.0-beta03

    // Workaround for GMA Next Gen SDK beta03 Cronet namespace bugExpand
    val cronetVersion = &quot;143.7445.0&quot;
    implementation(&quot;org.chromium.net:cronet-api:$cronetVersion&quot;)
    implementation(&quot;org.chromium.net:cronet-shared:$cronetVersion&quot;)
    implementation(&quot;org.chromium.net:cronet-common:$cronetVersion&quot;)
    implementation(&quot;org.chromium.net:cronet-fallback:$cronetVersion&quot;)
    implementation(&quot;org.chromium.net:httpengine-native-provider:$cronetVersion&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그로부터 약 2주 뒤 &lt;b&gt;&lt;code&gt;0.25.0-beta01&lt;/code&gt;&lt;/b&gt; 이 릴리즈됐어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성 트리를 확인하니 Cronet이 &lt;code&gt;143.7445.0&lt;/code&gt;으로 올라가 있었고 워크어라운드 블록 없이도 빌드가 통과했습니다. OkHttp 5.3.2도 문제없이 유지됐고! TODO 주석을 포함한 임시 블록 전체를 제거했습니다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;// 아주 깔끔한 지금의 gradle
dependencies {
    implementation(projects.core.common)
    implementation(projects.core.designsystem)
    implementation(libs.gma.ads)
    implementation(libs.timber)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;여차저차 마무리...&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GMA Next Gen SDK 도입은 &quot;광고 하나 붙이기&quot;가 아니라 꽤 큰 인프라 교체 작업이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 각오는 했지만 베타 SDK를 쓰다 보면 공식 문서가 실제 SDK를 못 따라가는 경우가 생각보다 많다는 걸 이번에 몸으로 배웠어요. 이번엔 Compose 탭만 업데이트가 빠진 경우였는데 결국 공식 문서도 샘플도 아닌 deprecated 경고 메시지가 가장 정확한 가이드였다는 게 좀 웃겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성 충돌 문제는 혼자 2,3일을 헤맸는데, 결국 원인은 &lt;code&gt;android.uniquePackageNames=true&lt;/code&gt; 하나였고 내 환경에서만 터지는 문제는 &quot;라이브러리 버그겠지&quot;보다 &quot;내 환경이 뭐가 다르지?&quot;를 먼저 보는 게 낫다는 걸 다시 한번 느꼈어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이번에 처음으로 오픈소스 기여가 아니라 커뮤니티에 직접 제보를 해봤는데 생각보다 훨씬 빠르게 반응이 왔어요. 엔지니어가 직접 Cronet 버전 히스토리까지 파고들어서 원인을 추적해줬고 실제로 다음 릴리즈에 반영까지 됐습니다. 혼자 끙끙대다 포기했을 수도 있었는데 제보 한 번이 그 흐름을 바꿨던 것 같아요. 베타 SDK는 불안정하지만 그만큼 피드백이 빠르게 반영되고 직접 생태계를 같이 만들어가는 느낌이 있어서 나름 뜻깊은 경험이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글이 레퍼런스가 매마른 환경에서 한글로된 유의미한 글이 되기를 바라며 이만 마칩니다. 긴글 읽어 주셔서 감사합니다.  &amp;zwj;♂️&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 자료&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/admob/android/next-gen/quick-start&quot;&gt;GMA Next Gen SDK 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/admob/android/next-gen/rel-notes&quot;&gt;GMA Next Gen SDK 릴리즈 노트&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ads-developers.googleblog.com/2026/01/announcing-google-mobile-ads-next-gen.html&quot;&gt;Google Ads Developer Blog - SDK 발표&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/googleads/gma-next-gen-sdk-android-examples&quot;&gt;GitHub 공식 샘플 레포지토리&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Android</category>
      <category>AdMob</category>
      <category>gma</category>
      <category>Google</category>
      <category>Next Gen SDK</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/41</guid>
      <comments>https://angrypodo.tistory.com/41#entry41comment</comments>
      <pubDate>Mon, 30 Mar 2026 17:41:16 +0900</pubDate>
    </item>
    <item>
      <title>도넛 홀 스킵핑과 상태 읽기 지연, 뭐가 다를까?</title>
      <link>https://angrypodo.tistory.com/40</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얼마전에 레아가 리컴포지션 관련해서 &lt;a href=&quot;https://www.jetpackcompose.app/articles/donut-hole-skipping-in-jetpack-compose&quot;&gt;아티클&lt;/a&gt;을 공유해주셨어요. 그래서 제가 알고있던 한가지 최적화 기법과 닮아있다고 생각이 들었고 의문이 들었어요.  &lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;도넛 홀 스킵핑이랑 Defer State Read가 결국 같은 말 아닌가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘 다 '상태를 어디서 읽느냐'의 문제 아님???&quot;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직관적으로는 틀린 말이 아니라고 생각해요. 실제로 두 기법의 공통 철학은 &lt;b&gt;&quot;상태를 읽는 위치를 최대한 좁혀라&quot;&lt;/b&gt; 이면서 &quot;&lt;b&gt;불필요한 재실행을 줄이는 것.&quot; &lt;/b&gt;이라는 목표도 같아요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;차이점은 동작하는 레이어가 다릅니다. 목표는 같지만 다른 계층에서 작동하는 두 개의 최적화에 대해서 이 글은 그 차이를 명확하게 짚는 것을 목표로 합니다.  &lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 전제조건 - Compose의 렌더링 파이프라인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 기법의 차이를 이해하려면 Compose가 화면을 그리는 3단계 파이프라인을 먼저 이해해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;Composition  &amp;rarr;  Layout  &amp;rarr;  Draw&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Composition&lt;/b&gt; &amp;mdash; &lt;code&gt;@Composable&lt;/code&gt; 함수를 실행해 UI 트리를 구성합니다. 무엇을 그릴지 결정하는 단계입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Layout&lt;/b&gt; &amp;mdash; 각 요소의 크기와 위치를 결정합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Draw&lt;/b&gt; &amp;mdash; 실제로 화면에 픽셀을 그립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태가 변경되면 Compose는 이 파이프라인을 다시 실행합니다. 핵심은 &lt;b&gt;상태를 어느 단계에서 읽느냐에 따라 어느 단계부터 재실행되는지가 결정된다&lt;/b&gt;는 점입니다!!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;904&quot; data-origin-height=&quot;330&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmRyZ6/dJMcag5Q0co/eQzWMFAnc7rwQ7e2GdZLdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmRyZ6/dJMcag5Q0co/eQzWMFAnc7rwQ7e2GdZLdk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmRyZ6/dJMcag5Q0co/eQzWMFAnc7rwQ7e2GdZLdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmRyZ6%2FdJMcag5Q0co%2FeQzWMFAnc7rwQ7e2GdZLdk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;904&quot; height=&quot;330&quot; data-origin-width=&quot;904&quot; data-origin-height=&quot;330&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 파이프라인을 머릿속에 두고, 두 기법을 각각 살펴봅시다!&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 도넛 홀 스킵핑 - Composition 안에서의 최적화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 아이디어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도넛 홀 스킵핑은 &lt;b&gt;Composition Phase 안에서&lt;/b&gt; 작동합니다. Composition은 발생하되 모든 &lt;code&gt;@Composable&lt;/code&gt; 함수를 재실행하지 않고 &lt;b&gt;상태를 읽는 가장 작은 스코프만 재실행&lt;/b&gt;하는 최적화인데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose 런타임은 함수를 통째로 하나의 단위로 보지 않습니다. &lt;code&gt;@Composable&lt;/code&gt; 람다 블록 단위로 독립적인 &lt;b&gt;리컴포지션 스코프&lt;/b&gt;를 나눠 추적합니다.&lt;/p&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;Composable 함수 본체          &amp;rarr;  하나의 스코프
@Composable 람다 인자         &amp;rarr;  별도의 독립 스코프
일반 람다 (() -&amp;gt; Unit 등)     &amp;rarr;  스코프 아님&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드를 봐야한다!!&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun ParentComponent() {
    val counter by remember { mutableStateOf(0) }

    // ParentComponent 스코프에서 counter를 직접 읽음 &amp;rarr; 재실행 대상
    val label = &quot;현재 카운터: $counter&quot;
    Text(label)

    MiddleComponent {
        // @Composable 람다 &amp;rarr; 독립 스코프
        // counter를 여기서도 읽음
        Text(&quot;Counter: $counter&quot;)
        Button(onClick = { counter++ }) { Text(&quot;증가&quot;) }
    }
}

@Composable
fun MiddleComponent(content: @Composable () -&amp;gt; Unit) {
    // counter를 읽지 않음, 파라미터도 변경 없음
    Box(modifier = Modifier.padding(16.dp)) {
        content()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;counter가 변경되면:&lt;/p&gt;
&lt;pre class=&quot;julia&quot;&gt;&lt;code&gt;✅ ParentComponent 본체   &amp;mdash; counter를 직접 읽으므로 재실행
  ⏭ MiddleComponent 본체 &amp;mdash; 스킵! (도넛)
    ✅ @Composable 람다    &amp;mdash; counter를 읽으므로 재실행 (도넛 홀)
      ✅ Text              &amp;mdash; 입력 변경, 재실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;MiddleComponent&lt;/code&gt;(도넛)는 건너뛰고, 그 안의 &lt;code&gt;@Composable&lt;/code&gt; 람다(도넛 홀)만 실행됩니다. 이것이 &quot;도넛 홀 스킵핑&quot;이라는 이름의 유래입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;hellip;이게 왜 됨..?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose 팀의 Leland Richardson은 다음과 같이 설명합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;...@chuckjaz had the brilliant realization that &lt;b&gt;if the lambdas were state objects, and invokes were reads&lt;/b&gt;, then this is exactly the result.&quot;&lt;br /&gt;(뭐래..)&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose는 &lt;code&gt;@Composable&lt;/code&gt; 람다를 일종의 &lt;b&gt;상태 객체&lt;/b&gt;로 취급합니다. 람다 호출이 곧 상태 읽기이기 때문에 런타임이 해당 스코프를 직접 찾아 재실행할 수 있습니다. 부모 함수를 거칠 필요가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 스킵핑이 동작하려면 전제조건이 있습니다. &lt;b&gt;파라미터가 Stable해야&lt;/b&gt; 합니다. Unstable 타입이 파라미터로 넘어오면 Compose 컴파일러는 해당 함수를 &lt;code&gt;non-skippable&lt;/code&gt;로 분류하고 매번 재실행합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;UnStable? Stable?&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose 컴파일러 관점에서 Stable이란 값이 변경될 때 반드시 Compose에게 알림이 온다는 보장이 있는 타입입니다. 원시 타입과 MutableState는 Stable하지만 일반 List나 Map은 내부가 바뀌어도 Compose가 감지할 수 없어 Unstable로 분류됩니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// ❌ List는 Unstable &amp;rarr; MiddleComponent 스킵 불가
@Composable
fun MiddleComponent(items: List&amp;lt;String&amp;gt;, content: @Composable () -&amp;gt; Unit)

// ✅ ImmutableList는 Stable &amp;rarr; 스킵 가능
// (kotlinx.collections.immutable 의존성 필요)
@Composable
fun MiddleComponent(items: ImmutableList&amp;lt;String&amp;gt;, content: @Composable () -&amp;gt; Unit)&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Defer State Read &amp;mdash; Phase 자체를 바꾸는 최적화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 아이디어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Defer State Read(상태 읽기 지연)는 &lt;b&gt;Composition Phase 자체를 건너뛰는&lt;/b&gt; 최적화입니다. 상태 읽기를 람다로 감싸 Layout 또는 Draw Phase로 미루면, 해당 상태가 바뀌어도 Composition이 아예 발생하지 않습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// ❌ Composition Phase에서 읽음 &amp;rarr; 상태 변경 시 Composition 재실행
Modifier.offset(offsetState.value.dp)

// ✅ Layout Phase로 읽기를 미룸 &amp;rarr; 상태 변경 시 Composition 스킵
Modifier.offset { IntOffset(offsetState.value, 0) }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 코드는 동일한 결과를 화면에 그립니다. 하지만 &lt;code&gt;offset&lt;/code&gt; 람다 버전은 상태를 Layout Phase까지 읽지 않기 때문에 &lt;code&gt;offsetState&lt;/code&gt;가 변경되어도 Composition이 발생하지 않고 Layout부터 재실행됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Modifier 람다 API들&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose의 여러 Modifier가 이 패턴을 지원합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// Composition에서 읽음 &amp;rarr; Layout/Draw까지 파이프라인 전체 재실행
Modifier.alpha(alphaState.value)
Modifier.offset(offsetX.value.dp, offsetY.value.dp)

// Layout Phase로 지연
Modifier.offset { IntOffset(offsetX.value, offsetY.value) }

// Draw Phase로 지연
Modifier.drawBehind {
    drawRect(color = colorState.value)  // Draw Phase에서 읽힘
}

Modifier.graphicsLayer {
    alpha = alphaState.value  // Draw Phase에서 읽힘
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;graphicsLayer&lt;/code&gt;와 &lt;code&gt;drawBehind&lt;/code&gt;는 Draw Phase 람다이므로, 내부에서 읽는 상태가 변경되면 Composition과 Layout을 모두 건너뛰고 Draw만 재실행됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 둘의 차이, 제대로 비교하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 각각을 살펴봤으니 이제 두개를 비교해봐야겠죠?&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;868&quot; data-origin-height=&quot;573&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2X55X/dJMcaiJjMCZ/3h4f0SB0T8DPZ3xhjCA6Gk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2X55X/dJMcaiJjMCZ/3h4f0SB0T8DPZ3xhjCA6Gk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2X55X/dJMcaiJjMCZ/3h4f0SB0T8DPZ3xhjCA6Gk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2X55X%2FdJMcaiJjMCZ%2F3h4f0SB0T8DPZ3xhjCA6Gk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;868&quot; height=&quot;573&quot; data-origin-width=&quot;868&quot; data-origin-height=&quot;573&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;table style=&quot;height: 115px;&quot; width=&quot;863&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;구분&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;도넛 홀 스킵핑&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;Defer State Read&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;작동 레이어&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Composition 안 (Scope 단위)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Composition 앞 (Phase 단위)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;누가?&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;런타임이 자동으로&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;개발자가 직접&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Composition 발생 여부&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;발생함 (일부 스코프만 재실행)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;발생 안 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;최적화 강도&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;상대적으로 약함&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;더 강력&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;주요 사용처&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;컴포넌트 트리 구조 설계&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;애니메이션, 스크롤 등 빈번한 상태 변경&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;같은 상황, 다른 작동 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 기법이 어떻게 다르게 작동하는지 같은 상황으로 보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 시나리오: 스크롤 오프셋에 따라 위치가 바뀌는 헤더

val scrollState = rememberScrollState()

// ❌ Composition Phase에서 상태를 읽는 방식
// scrollState.value가 바뀔 때마다 Header의 Composition이 발생
@Composable
fun Header(scrollOffset: Int) {
    Box(modifier = Modifier.offset(y = (-scrollOffset).dp)) {
        Text(&quot;헤더&quot;)
    }
}

// ✅ Defer State Read 적용
// scrollState.value가 아무리 바뀌어도 Composition 발생 없음
@Composable
fun Header(scrollState: ScrollState) {
    Box(modifier = Modifier.offset { IntOffset(0, -scrollState.value) }) {
        Text(&quot;헤더&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크롤은 초당 수십 번 상태가 변경되는데요.   첫 번째 방식은 그때마다 Composition이 발생하지만 두 번째 방식은 Layout Phase만 재실행됩니다. 글로만 읽어도 성능에 차이가 생기겠죠?&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;헷갈리기 쉬운데요..&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 개념이 비슷해 보이는 이유는 &lt;b&gt;둘 다 &quot;상태를 읽는 위치를 좁힌다&quot;는 철학을 공유&lt;/b&gt;하기 때문이에요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;도넛 홀 스킵핑 &amp;rarr; 읽는 &lt;b&gt;스코프&lt;/b&gt;를 좁힌다&lt;/li&gt;
&lt;li&gt;Defer State Read &amp;rarr; 읽는 &lt;b&gt;Phase&lt;/b&gt;를 늦춘다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;좁힌다&quot;는 행위가 같아 보이지만, 스코프와 Phase는 완전히 다른 개념입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 언제 무엇을 선택할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 기법은 하나만 선택해야하는 이지선다가 아닙니다. 함께 사용할 수 있고 상황에 따라 적합한 기법이 다릅니다.  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;도넛 홀 스킵핑에 집중해야 할 때&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;컴포넌트 트리의 중간 노드가 불필요하게 재실행될 때&lt;/li&gt;
&lt;li&gt;파라미터 타입의 Stability를 관리할 때 (&lt;code&gt;@Stable&lt;/code&gt;, &lt;code&gt;@Immutable&lt;/code&gt;, &lt;code&gt;ImmutableList&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;상태를 읽는 위치를 최대한 하위 &lt;code&gt;@Composable&lt;/code&gt; 람다로 내릴 때&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Defer State Read를 적극 활용해야 할 때&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;애니메이션처럼 상태가 매우 빈번하게 변경될 때&lt;/li&gt;
&lt;li&gt;스크롤 오프셋, 드래그 위치처럼 연속적으로 바뀌는 값을 Modifier에 반영할 때&lt;/li&gt;
&lt;li&gt;&lt;code&gt;graphicsLayer&lt;/code&gt;, &lt;code&gt;drawBehind&lt;/code&gt;, &lt;code&gt;offset { }&lt;/code&gt; 등 람다를 받는 Modifier API를 사용할 때&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론을 냅시다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 기법의 차이를 한 문장으로 압축하면 이렇습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도넛 홀 스킵핑은 &lt;b&gt;Composition 안에서&lt;/b&gt; 불필요한 함수 본체를 건너뛰고 Defer State Read는 &lt;b&gt;Composition 자체를&lt;/b&gt; 건너뜁니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;결국 같은 말 아님?&quot; 했는데, 파고들수록 작동하는 레이어가 다르다는 게 명확하게 보였어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘을 함께 이해하고 상황에 맞게 꺼내 쓸 수 있다면 Compose 마스터 한거 아닐까요? (제발)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;틀린 내용이 있거나 의견이 있으시다면 편하게 남겨주세요 &amp;zwj;♂️&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 문서&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/jetpack/compose/phases&quot;&gt;Compose phases 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/jetpack/compose/performance/bestpractices#defer-reads&quot;&gt;Defer state reads 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/jetpack/compose/performance/stability&quot;&gt;Compose Stability 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/jetpack/compose/performance&quot;&gt;Compose 성능 최적화 가이드&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>우아한테크코스/레벨1</category>
      <category>Android</category>
      <category>compose</category>
      <category>recomposition</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/40</guid>
      <comments>https://angrypodo.tistory.com/40#entry40comment</comments>
      <pubDate>Mon, 16 Mar 2026 17:45:16 +0900</pubDate>
    </item>
    <item>
      <title>??님 Preview가 흐릿한데 왜그래요?</title>
      <link>https://angrypodo.tistory.com/39</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요 아키입니다. 오늘은 우테코 미션을 진행하다가 마주쳤던 Compose Preview 렌더링 이슈를 공유해보려 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@Preview(device = Devices.DESKTOP)&lt;/code&gt;을 사용하면 텍스트와 경계선이 심하게 흐릿하게 렌더링되는 반면 &lt;code&gt;device = Devices.TABLET&lt;/code&gt;으로 바꾸면 훨씬 선명하게 보이는 현상을 겪어본 적 있으신가요? 저는 아래와 같은 경우를 경험했어요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2320&quot; data-origin-height=&quot;958&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8IdVP/dJMcadA68bH/YckSJgdOq6V7kIJ0shOcek/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8IdVP/dJMcadA68bH/YckSJgdOq6V7kIJ0shOcek/img.png&quot; data-alt=&quot;위의 부분이 흐릿하지 않으신가요? 그렇다고 하십쇼.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8IdVP/dJMcadA68bH/YckSJgdOq6V7kIJ0shOcek/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8IdVP%2FdJMcadA68bH%2FYckSJgdOq6V7kIJ0shOcek%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2320&quot; height=&quot;958&quot; data-origin-width=&quot;2320&quot; data-origin-height=&quot;958&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;위의 부분이 흐릿하지 않으신가요? 그렇다고 하십쇼.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 또 프리뷰가 말썽이구나 했지만 원인은 코드가 아니라 &lt;b&gt;Android Studio 렌더링 엔진의 스케일링 방식과 기기 프로파일별 DPI 설정의 차이&lt;/b&gt;에 있었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인 분석&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Devices.DESKTOP의 낮은 DPI&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;660&quot; data-origin-height=&quot;152&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bd1AEM/dJMcadHUxr4/8BcAJhKpGpisSjZKF4KFaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bd1AEM/dJMcadHUxr4/8BcAJhKpGpisSjZKF4KFaK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bd1AEM/dJMcadHUxr4/8BcAJhKpGpisSjZKF4KFaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbd1AEM%2FdJMcadHUxr4%2F8BcAJhKpGpisSjZKF4KFaK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;660&quot; height=&quot;152&quot; data-origin-width=&quot;660&quot; data-origin-height=&quot;152&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Android Studio의 Compose Tooling에서 정의한 &lt;code&gt;Devices.DESKTOP&lt;/code&gt;의 기본 스펙은 해상도 1920&amp;times;1080에 &lt;b&gt;DPI 160&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데스크탑 모니터는 스마트폰보다 눈에서 멀리 떨어진 환경을 가정하기 때문에 물리적 픽셀 밀도가 낮게 설정되어 있어요. DPI 160은 Android 밀도 체계에서 &lt;code&gt;mdpi&lt;/code&gt;에 해당하는 기준 밀도로 1dp = 1px로 대응됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유하자면 도화지는 엄청 넓은데 그 안을 채우는 픽셀 입자의 밀도는 듬성듬성한 상태입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Devices.TABLET의 높은 DPI&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;643&quot; data-origin-height=&quot;153&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WJivv/dJMcabDnHgC/9zveF7iZPGh7jnB55DKmHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WJivv/dJMcabDnHgC/9zveF7iZPGh7jnB55DKmHk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WJivv/dJMcabDnHgC/9zveF7iZPGh7jnB55DKmHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWJivv%2FdJMcabDnHgC%2F9zveF7iZPGh7jnB55DKmHk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;643&quot; height=&quot;153&quot; data-origin-width=&quot;643&quot; data-origin-height=&quot;153&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 &lt;code&gt;Devices.TABLET&lt;/code&gt;은 스마트폰과 유사한 시청 환경을 가정하기 때문에 &lt;code&gt;hdpi&lt;/code&gt;~&lt;code&gt;xhdpi&lt;/code&gt; 수준의 고밀도 프로파일을 가집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도화지는 넓으면서 그 안을 채우는 픽셀 입자도 촘촘하게 박혀 있는 상태입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Layoutlib의 소수점 배율 한계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 Android Studio의 렌더링 엔진인 Layoutlib은 100%, 200% 같은 정수 배율에는 강하지만 디스플레이 설정이 125%, 150% 같은 &lt;b&gt;소수점 배율일 때 픽셀 보간 오류&lt;/b&gt;를 일으켜 Preview 화면을 흐리게 렌더링하는 고질적인 이슈도 있습니다. &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데스크탑 환경에서 소수점 배율을 쓰고 있다면 DPI 문제와 겹쳐 흐릿함이 더욱 심해집니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;저는 선명하고 넓은 Preview가 보고 싶은데요?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 알았으니 해결책은 간단해요. 단순히 넓은 레이아웃을 나열해 보고 싶다면 흐릿한 &lt;code&gt;Devices.DESKTOP&lt;/code&gt;을 굳이 고집할 필요가 없습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 커스텀 Spec 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스마트폰 수준의 높은 DPI를 유지하면서 캔버스 크기만 강제로 늘리는 방법입니다. 줌아웃이 되더라도 픽셀이 촘촘해 흐릿해지지 않아요!&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Preview(device = &quot;spec:width=1500dp,height=800dp,dpi=480&quot;)
@Composable
fun CrispDesktopPreview() {
    // 선명하게 렌더링됩니다
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Tablet 프로파일 활용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Devices.DESKTOP&lt;/code&gt; 대신 기본적으로 DPI가 높게 설정된 &lt;code&gt;Devices.TABLET&lt;/code&gt;이나 &lt;code&gt;Devices.PIXEL_TABLET&lt;/code&gt;을 사용하는 것도 추천드려요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. widthDp 파라미터 직접 조작&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기기 프로파일을 바꾸지 않고 현재 기기의 캔버스 가로 길이만 직접 늘리는 방식도 있어요. 기존 DPI를 그대로 유지하면서 넓은 화면을 얻을 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Preview(widthDp = 1500)
@Composable
fun WidePreview() {
    // 내용
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose Preview는 아직도 여러모로 불편합니다. 반영이 느리거나 인터랙티브 모드가 맛이 가거나 없는 컴포넌트가 갑자기 등장하거나&amp;hellip;. 그래서 이번에도 &quot;또 프리뷰 버그겠지&quot;라고 생각했었어요. &lt;br /&gt;내 코드보다 도구를 먼저 의심하는 게 늘 옳은 건 아니지만 도구의 동작 원리를 이해하는 것이 결국 더 나은 디버깅으로 이어진다는 점은 분명합니다.&lt;br /&gt;만약 넓은 Preview가 필요할 때는 고밀도 DPI가 설정된 Tablet 프로파일을 쓰거나 커스텀 Spec을 활용해 보시길 추천합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://issuetracker.google.com/issues/112289658&quot;&gt;Android Issue Tracker: Preview rendering/DPI scaling issues&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://stackoverflow.com/questions/27494191/android-studio-layout-preview-is-blurry-on-hidpi-display&quot;&gt;Stack Overflow: Android Studio layout preview is blurry on HiDPI display&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/studio/preview/compose-preview&quot;&gt;Android Developers: Compose Preview 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>우아한테크코스/레벨1</category>
      <category>android studio</category>
      <category>dpi</category>
      <category>Preview</category>
      <category>px</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/39</guid>
      <comments>https://angrypodo.tistory.com/39#entry39comment</comments>
      <pubDate>Sun, 8 Mar 2026 15:51:15 +0900</pubDate>
    </item>
    <item>
      <title>레벨0 - 지연 초기화와 위임, 그리고 4주간의 마무리</title>
      <link>https://angrypodo.tistory.com/38</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;마지막 주를 시작하면서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드디어 4주차입니다. 솔직히 마지막 주가 가장 실용적인 주차라고 생각했어요. &lt;code&gt;lateinit&lt;/code&gt;이랑 &lt;code&gt;by lazy&lt;/code&gt;는 안드로이드 프로젝트에서 거의 매일 쓰는 키워드인데 그냥 &quot;Hilt 필드 주입에는 &lt;code&gt;lateinit&lt;/code&gt;, 무거운 연산에는 &lt;code&gt;by lazy&lt;/code&gt;&quot; 정도로만 사용해왔습니다. 이번 주차도 디컴파일로 학습을 진행했어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;lateinit과 by lazy, 뭐가 다른가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 둘을 &quot;lateinit은 var에만, by lazy는 val에만&quot; 같은, 사용 상황으로만 구분하는 글은 많지만 바이트코드를 보면 두 개념이 얼마나 다른 방식으로 구현되는지 훨씬 명확하게 드러납니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;lateinit은 그냥 null임&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose 환경에서 &lt;code&gt;lateinit&lt;/code&gt;이 가장 자주 등장하는 곳은 Hilt 필드 주입이에요. 생성자 주입이 불가능한 상황에서 DI 프레임워크가 주입 시점을 보장해주기 때문에 &lt;code&gt;lateinit&lt;/code&gt;을 씁니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@HiltViewModel
class ProfileViewModel @Inject constructor() : ViewModel() {

    @Inject
    lateinit var repository: UserRepository

    fun loadProfile(userId: String) = repository.findById(userId)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 디컴파일하면 꽤 단순합니다.&lt;/p&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;@HiltViewModel
public final class ProfileViewModel extends ViewModel {
    public UserRepository repository;  // 그냥 null로 초기화, Hilt가 나중에 채워줌

    public final Object loadProfile(@NotNull String userId) {
        return this.repository.findById(userId);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;lateinit var&lt;/code&gt;는 JVM 레벨에서 특별한 게 없어요. 그냥 &lt;code&gt;null&lt;/code&gt;로 시작하는 일반 필드입니다. 코틀린이 하는 일은 &lt;b&gt;접근 시점에 null 체크 코드를 삽입하는 것&lt;/b&gt;뿐이에요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;val profile = repository.findById(&quot;1&quot;)  // Hilt 주입 전에 접근하면?&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;UserRepository var1 = this.repository;
if (var1 == null) {
    Intrinsics.throwUninitializedPropertyAccessException(&quot;repository&quot;);
}
return var1.findById(&quot;1&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;repository&lt;/code&gt;가 null이면 &lt;code&gt;NullPointerException&lt;/code&gt;이 아니라 &lt;code&gt;UninitializedPropertyAccessException&lt;/code&gt;을 던져요. 에러 메시지에 변수 이름이 포함되니까 어디서 초기화를 빠뜨렸는지 바로 알 수 있어서 디버깅에 유리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;code&gt;lateinit&lt;/code&gt;이 하는 건 딱 두 가지예요. &quot;나는 나중에 반드시 초기화할 거야&quot;라는 컴파일러와의 약속, 그리고 접근 시 null 체크 자동 삽입. 그 이상도 이하도 아닙니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;by lazy는 함수다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 &lt;code&gt;by lazy&lt;/code&gt;는 구현 방식이 완전히 달라요. Compose에서는 ViewModel 안에서 초기화 비용이 큰 객체를 지연 생성할 때 자주 씁니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class SearchViewModel : ViewModel() {

    // Flow를 결합하거나 복잡한 초기 상태를 계산할 때 by lazy를 활용
    val recommendations: List&amp;lt;String&amp;gt; by lazy {
        println(&quot;추천 목록 계산 중...&quot;)
        computeRecommendations()
    }

    private fun computeRecommendations(): List&amp;lt;String&amp;gt; {
        // 무거운 연산
        return listOf(&quot;Kotlin&quot;, &quot;Compose&quot;, &quot;Coroutines&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디컴파일하면 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;public final class SearchViewModel extends ViewModel {
    @NotNull
    private final Lazy recommendations$delegate;

    public SearchViewModel() {
        this.recommendations$delegate = LazyKt.lazy(new Function0&amp;lt;List&amp;lt;String&amp;gt;&amp;gt;() {
            public List&amp;lt;String&amp;gt; invoke() {
                System.out.println(&quot;추천 목록 계산 중...&quot;);
                return SearchViewModel.this.computeRecommendations();
            }
        });
    }

    @NotNull
    public final List&amp;lt;String&amp;gt; getRecommendations() {
        return (List) this.recommendations$delegate.getValue();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;data&lt;/code&gt; 필드가 아예 없어요. 대신 &lt;code&gt;Lazy&amp;lt;List&amp;lt;String&amp;gt;&amp;gt;&lt;/code&gt; 타입의 &lt;code&gt;data$delegate&lt;/code&gt;라는 위임 객체가 생겼습니다. &lt;code&gt;data&lt;/code&gt;에 접근할 때마다 &lt;code&gt;getData()&lt;/code&gt;가 호출되고 그게 내부적으로 &lt;code&gt;delegate.getValue()&lt;/code&gt;를 부릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;LazyKt.lazy()&lt;/code&gt;의 기본 모드는 &lt;code&gt;LazyThreadSafetyMode.SYNCHRONIZED&lt;/code&gt;예요. 내부를 보면 이중 확인 잠금(Double-Checked Locking) 패턴으로 구현돼 있어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;private class SynchronizedLazyImpl&amp;lt;out T&amp;gt;(initializer: () -&amp;gt; T) : Lazy&amp;lt;T&amp;gt; {
    private var initializer: (() -&amp;gt; T)? = initializer
    @Volatile private var _value: Any? = UNINITIALIZED_VALUE
    private val lock = this

    override val value: T
        get() {
            val v1 = _value
            if (v1 !== UNINITIALIZED_VALUE) return v1 as T  // 이미 초기화됨

            return synchronized(lock) {
                val v2 = _value
                if (v2 !== UNINITIALIZED_VALUE) v2 as T  // 다른 스레드가 먼저 초기화했을 수도
                else {
                    val typedValue = initializer!!()
                    _value = typedValue
                    initializer = null  // GC 대상이 되도록
                    typedValue
                }
            }
        }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 접근 시 &lt;code&gt;synchronized&lt;/code&gt; 블록 안에서 한 번만 계산하고, 이후에는 캐시된 값을 바로 반환해요. &lt;code&gt;@Volatile&lt;/code&gt;로 메모리 가시성을 보장합니다. 주목할 점은 초기화 후 &lt;code&gt;initializer = null&lt;/code&gt;로 람다 참조를 날려버린다는 건데요. 람다가 캡처한 외부 변수들이 GC될 수 있도록 의도적으로 참조를 끊습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;lateinit&lt;/code&gt;은 null 위에서 작동하는 약속이고, &lt;code&gt;by lazy&lt;/code&gt;는 위임 객체 위에서 작동하는 캡슐화된 상태 기계입니다. 같아 보이는 &quot;지연 초기화&quot;지만 내부는 완전히 다른 메커니즘이에요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;by 키워드를 상속 없이 확장하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;by lazy&lt;/code&gt;에서 썼던 &lt;code&gt;by&lt;/code&gt; 키워드는 사실 더 넓은 개념이에요. 클래스 위임(Class Delegation)이라고 해서 인터페이스를 구현할 때 구현을 다른 객체에게 떠넘길 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;interface Printer {
    fun print(message: String)
    fun printLine(message: String) = println(message)
}

class ConsolePrinter : Printer {
    override fun print(message: String) = print(message)
}

class LoggingPrinter(private val printer: Printer) : Printer by printer {
    override fun print(message: String) {
        println(&quot;[LOG] ${System.currentTimeMillis()}&quot;)
        printer.print(message)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;LoggingPrinter&lt;/code&gt;는 &lt;code&gt;Printer by printer&lt;/code&gt;로 선언했어요. &lt;code&gt;print()&lt;/code&gt;는 직접 오버라이드하고 &lt;code&gt;printLine()&lt;/code&gt;은 &lt;code&gt;printer&lt;/code&gt;에게 위임합니다. 디컴파일하면 위임된 메서드들이 자동 생성돼요.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;public final class LoggingPrinter implements Printer {
    private final Printer printer;

    // 위임된 메서드 - 컴파일러가 자동 생성
    public void printLine(@NotNull String message) {
        this.printer.printLine(message);
    }

    // 직접 오버라이드한 메서드
    public void print(@NotNull String message) {
        System.out.println(&quot;[LOG] &quot; + System.currentTimeMillis());
        this.printer.print(message);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상속을 쓰면 부모 클래스의 모든 구현에 의존하게 되지만, 위임은 특정 인터페이스의 구현만 위임하고 나머지는 독립적으로 제어할 수 있어요. 2주차에서 &quot;코틀린이 &lt;code&gt;final&lt;/code&gt;을 기본으로 하는 이유&quot;를 공부했는데 위임 패턴은 그 대안으로서 딱 맞는 자리에 있었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Scope Function 언제 뭘 쓰는데&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;let&lt;/code&gt;, &lt;code&gt;run&lt;/code&gt;, &lt;code&gt;with&lt;/code&gt;, &lt;code&gt;apply&lt;/code&gt;, &lt;code&gt;also&lt;/code&gt;. 다섯 개나 되는 스코프 함수를 상황에 맞게 쓰는 규칙을 이번 주에 정리했어요. 그동안은 &lt;code&gt;apply&lt;/code&gt;가 편하다고 걸 잡아다 쓰는 식이었는데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스코프 함수를 구분하는 기준은 두 가지예요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;람다 안에서 객체를 어떻게 참조하는가&lt;/b&gt;: &lt;code&gt;this&lt;/code&gt;(확장 함수 형태) vs &lt;code&gt;it&lt;/code&gt;(파라미터 형태)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;무엇을 반환하는가&lt;/b&gt;: 람다의 결과 vs 객체 자신&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 표로 정리하면 딱 떨어집니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;함수&lt;/th&gt;
&lt;th&gt;객체 참조&lt;/th&gt;
&lt;th&gt;반환값&lt;/th&gt;
&lt;th&gt;주 용도&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;let&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;it&lt;/td&gt;
&lt;td&gt;람다 결과&lt;/td&gt;
&lt;td&gt;null 체크 후 변환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;this&lt;/td&gt;
&lt;td&gt;람다 결과&lt;/td&gt;
&lt;td&gt;객체 설정 후 결과 계산&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;with&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;this&lt;/td&gt;
&lt;td&gt;람다 결과&lt;/td&gt;
&lt;td&gt;객체를 수신자로 연산 묶기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apply&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;this&lt;/td&gt;
&lt;td&gt;객체 자신&lt;/td&gt;
&lt;td&gt;빌더 패턴, 객체 초기화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;also&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;it&lt;/td&gt;
&lt;td&gt;객체 자신&lt;/td&gt;
&lt;td&gt;사이드 이펙트 (로깅, 디버깅)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 코드에서 제가 가장 잘못 쓰고 있던 건 &lt;code&gt;apply&lt;/code&gt;였어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// apply를 로그 출력에 쓰는 경우
val user = User(&quot;Alice&quot;).apply {
    println(&quot;생성됨: $name&quot;)  // 사이드 이펙트인데 apply를?
}

// 사실은 also가 맞는 자리
val user = User(&quot;Alice&quot;).also {
    println(&quot;생성됨: ${it.name}&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;apply&lt;/code&gt;는 객체를 초기화하거나 설정할 때, &lt;code&gt;also&lt;/code&gt;는 객체를 건드리지 않고 부수 효과를 실행할 때 씁니다. 두 개 모두 객체 자신을 반환하지만 &quot;객체를 변경하는가(&lt;code&gt;apply&lt;/code&gt;) vs 객체를 관찰하는가(&lt;code&gt;also&lt;/code&gt;)&quot;로 구분할 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;let&lt;/code&gt;은 null 체크와 아주 잘 맞습니다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;val trimmed = input?.let {
    it.trim().takeIf { s -&amp;gt; s.isNotEmpty() }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;?.let&lt;/code&gt;은 null이 아닐 때만 블록을 실행하고, 블록의 결과를 반환해요. null 체크, 변환, 필터링을 체이닝으로 깔끔하게 엮을 수 있는 패턴입니다. 3주차에서 null 안전성이 컴파일러의 null 체크 삽입으로 구현된다는 걸 배웠는데 스코프 함수는 그 null 체크를 더 선언적으로 표현하는 수단이라는 연결이 됐어요. 하지만 단순 null 체킹의 경우는 &lt;code&gt;if&lt;/code&gt; 가 더 가독성이 좋다고 생각해요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;불변성을 지향한다는 것의 의미&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4주간 공부하면서 가장 자주 돌아온 키워드가 &lt;b&gt;불변성&lt;/b&gt;이에요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1주차: Expression으로 &lt;code&gt;val&lt;/code&gt; 변수를 한 번만 할당하는 패턴&lt;/li&gt;
&lt;li&gt;2주차: &lt;code&gt;data class&lt;/code&gt;가 &lt;code&gt;final&lt;/code&gt;인 이유, &lt;code&gt;copy()&lt;/code&gt;로 새 인스턴스를 만드는 방식&lt;/li&gt;
&lt;li&gt;3주차: &lt;code&gt;List&lt;/code&gt;가 읽기 전용이라 공변성(&lt;code&gt;out&lt;/code&gt;)이 가능한 원리&lt;/li&gt;
&lt;li&gt;4주차: &lt;code&gt;by lazy&lt;/code&gt;의 &lt;code&gt;val&lt;/code&gt;, 스코프 함수로 상태 변경을 최소화하는 패턴&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린은 언어 설계 자체가 불변성을 지향하도록 유도하고 있어요. &lt;code&gt;val&lt;/code&gt;이 기본이고 &lt;code&gt;var&lt;/code&gt;이 예외적인 선택이 되도록, 컬렉션은 기본이 읽기 전용이고 &lt;code&gt;Mutable&lt;/code&gt;을 붙여야 변경 가능하도록, &lt;code&gt;data class&lt;/code&gt;는 &lt;code&gt;copy()&lt;/code&gt;로 새 인스턴스를 만들도록.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 단순한 컨벤션이 아니라 언어 차원에서 강제하는 방향이라는 걸 이번 4주 동안 바이트코드를 통해 계속 확인했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4주, 전체를 돌아보며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 세웠던 목표를 다시 읽어봤어요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동작 원리의 시각화, 정확한 용어 정립, Java와의 비교를 통한 설계 철학 이해&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4주 전의 저는 &quot;Hilt 주입이 어떤 순서로 일어나는지&quot;, &quot;&lt;code&gt;by lazy&lt;/code&gt;와 &lt;code&gt;lateinit&lt;/code&gt;의 내부 차이가 뭔지&quot; 설명하기 어려운 상태였어요. 기능은 쓰는데 원리는 모르는 상태요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 지금은 코틀린 코드를 보면 자연스럽게 &quot;이게 JVM에서 어떻게 돌아가지?&quot;라는 질문이 떠올라요. (지금의 나는 다르다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;lateinit&lt;/code&gt;을 쓸 때 내부적으로 null 필드라는 게 보이고 &lt;code&gt;by lazy&lt;/code&gt;를 쓸 때 위임 객체와 이중 잠금이 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &quot;왜 이렇게 설계했을까?&quot;를 묻는 습관이 더욱 굳어졌어요. &lt;code&gt;out&lt;/code&gt; 키워드가 왜 필요한지, 클래스가 왜 기본적으로 &lt;code&gt;final&lt;/code&gt;인지, 부 생성자가 왜 주 생성자를 반드시 호출해야 하는지. 이 질문들에 공식 문서와 바이트코드를 통해 직접 답을 찾는 과정이 4주간의 핵심이었어요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예상 못 했던 수확&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회고를 쓰면서 이해가 정리됐다는 게 가장 컸어요. 디컴파일해서 &quot;이렇게 되네&quot;까지는 했는데, 그걸 타인에게 설명하는 글로 쓰다 보면 논리에 구멍이 어디 있는지 보였어요. 3주차에 &quot;변성은 여전히 어렵다&quot;고 썼던 것도, 글로 정리하면서 내가 아직 완전히 이해 못 했다는 걸 인식하게 만들어줬어요. &lt;b&gt;쓰는 것 자체가 메타인지의 도구&lt;/b&gt;였습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;앞으로의 방향&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨1이 시작되면 이 4주간의 공부를 실제 코드 설계에 연결해보고 싶어요. &quot;왜 이렇게 만들었는가&quot;를 공부했다면, 다음은 &quot;그 이유를 알고 있으니 이렇게 설계하겠다&quot;로 이어져야 한다고 생각해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 리뷰를 받을 때 &quot;이렇게 짰어요&quot;가 아니라 &quot;이렇게 짠 이유는 이 설계 원칙 때문이에요&quot;라고 말할 수 있는 상태. 4주간의 공부가 그 방향으로 가는 첫 발판이 됐다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4주간 수고 많으셨습니다.  &lt;/p&gt;</description>
      <category>우아한테크코스/레벨0</category>
      <category>by lazy</category>
      <category>Kotlin</category>
      <category>lateinit</category>
      <category>레벨0</category>
      <category>우테코</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/38</guid>
      <comments>https://angrypodo.tistory.com/38#entry38comment</comments>
      <pubDate>Tue, 24 Feb 2026 03:10:10 +0900</pubDate>
    </item>
    <item>
      <title>Android 17 BETA, 공부 많이 된다</title>
      <link>https://angrypodo.tistory.com/37</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNQWzo/dJMcahXLzMc/7Oj7LG3J0aOrIouyizOTnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNQWzo/dJMcahXLzMc/7Oj7LG3J0aOrIouyizOTnk/img.png&quot; data-alt=&quot;nano banana로 생성한 이미지 입니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNQWzo/dJMcahXLzMc/7Oj7LG3J0aOrIouyizOTnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNQWzo%2FdJMcahXLzMc%2F7Oj7LG3J0aOrIouyizOTnk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1024&quot; height=&quot;559&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;nano banana로 생성한 이미지 입니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Android 17 Beta 1이 2월 13일에 공개됐습니다.&lt;/p&gt;
&lt;figure id=&quot;og_1771731763958&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;The First Beta of Android 17&quot; data-og-description=&quot;News and insights on the Android platform, developer tools, and events.&quot; data-og-host=&quot;android-developers.googleblog.com&quot; data-og-source-url=&quot;https://android-developers.googleblog.com/2026/02/the-first-beta-of-android-17.html&quot; data-og-url=&quot;https://android-developers.googleblog.com/2026/02/the-first-beta-of-android-17.html&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/JjS9o/dJMb9jgtXGk/GRpSCKZqnbRVIUXKx0iOv1/img.png?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000,https://scrap.kakaocdn.net/dn/bySxKl/dJMb81fPnTN/k8iSEMzxQpQHK3hAhDO45K/img.png?width=1024&amp;amp;height=630&amp;amp;face=0_0_1024_630,https://scrap.kakaocdn.net/dn/mMhkD/dJMb9jOjI3J/HJquOXxdBhPcrbcoJn4xY0/img.png?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000&quot;&gt;&lt;a href=&quot;https://android-developers.googleblog.com/2026/02/the-first-beta-of-android-17.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://android-developers.googleblog.com/2026/02/the-first-beta-of-android-17.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/JjS9o/dJMb9jgtXGk/GRpSCKZqnbRVIUXKx0iOv1/img.png?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000,https://scrap.kakaocdn.net/dn/bySxKl/dJMb81fPnTN/k8iSEMzxQpQHK3hAhDO45K/img.png?width=1024&amp;amp;height=630&amp;amp;face=0_0_1024_630,https://scrap.kakaocdn.net/dn/mMhkD/dJMb9jOjI3J/HJquOXxdBhPcrbcoJn4xY0/img.png?width=1000&amp;amp;height=1000&amp;amp;face=0_0_1000_1000');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;The First Beta of Android 17&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;News and insights on the Android platform, developer tools, and events.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;android-developers.googleblog.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구글이 생각보다 빠르게 출시를 했는데요? 릴리즈 노트를 훑다가 한 줄이 눈에 걸렸습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;removes the developer opt-out for orientation and resizability restrictions on large screen devices&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Android 16에서 이미 방향/크기 조절 제한이 도입됐고 당시에는 opt-out 방법을 함께 제공했습니다. 그걸 이번에 막겠다는 얘기입니다. 단순히 &quot;이제 강제야&quot;라고 선언한 게 아니라 Android 16에서 왜 opt-out을 열어뒀는지 그리고 왜 지금 닫는지를 생각해보면 이게 꽤 의미 있는 타이밍이라고 생각을 했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;opt-out을 열어두는 건 무슨 의미인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Android 16에서 Google이 opt-out 경로를 함께 제공한 건 사실 의외라고 생각했습니다. 일반적으로 targetSdk를 올리면 그 버전의 동작 변경사항을 그냥 따라가게 하면서 이건 명시적으로 &quot;너희가 준비될 때까지 기다려줄게&quot;라고 한 거니까요. &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 개발자 생태계 전체를 한 번에 바꾸는 게 현실적으로 불가능하죠.. 방향 고정에 의존하는 앱이 얼마나 많은지 생각해보면(일단 나부터) 구글 입장에서도 그냥 강제했다가 앱들이 태블릿이나 폴더블에서 무너지면 플랫폼 이미지 자체가 손상되니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Android 16에서 1년의 유예 기간을 준 거고 Android 17에서 그 기간이 &lt;b&gt;벌써&lt;/b&gt; 끝납니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;지금 닫는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 폴더블이 본격적으로 주류 시장에 들어서는 시점입니다. 이미 국내는 삼성의 폴드와 플립이 꽤나 메이저하게 퍼져있죠. 애플도 폴더블에 대한 움직임을 가지고 있는 만큼, 접히는 기기는 미래의 기술이 아닙니다. 이미 기기파편화가 심한 안드로이드 생태계에서 구글이 정비를 안 하면 사용자들은 폴더블에서 방향이 고정된 앱들을 계속 마주치게 될텐데요. 검은 여백이 생기거나 세로 고정인 채로 펼쳐진 화면을 채우지 못하는 앱이 되겠네요. 이게 쌓이면 결국 안드로이드의 폴더블 경험 전체에 대한 인식으로 번질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역시나 2027년 8월부터 신규 앱과 업데이트는 API 37 타겟팅이 필수입니다. 플랫폼 강제와 스토어 정책을 동시에 사용하는 방식이죠.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;속성아 속성아, 왜요쌤 왜요쌤&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API 37을 타겟팅하는 앱이 최소 너비 600dp 이상 화면에서 실행되면 다음 속성과 API가 무시됩니다.&lt;/p&gt;
&lt;table style=&quot;height: 87px; width: 865px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px; width: 281px;&quot;&gt;Manifest 속성 / API&lt;/th&gt;
&lt;th style=&quot;height: 20px; width: 584px;&quot;&gt;무시되는 값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 281px;&quot;&gt;&lt;code&gt;screenOrientation&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 584px;&quot;&gt;portrait, landscape 계열 전부&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 10px;&quot;&gt;
&lt;td style=&quot;height: 10px; width: 281px;&quot;&gt;&lt;code&gt;setRequestedOrientation()&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 10px; width: 584px;&quot;&gt;동일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 281px;&quot;&gt;&lt;code&gt;resizeableActivity&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 584px;&quot;&gt;모든 값&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px; width: 281px;&quot;&gt;&lt;code&gt;minAspectRatio&lt;/code&gt; / &lt;code&gt;maxAspectRatio&lt;/code&gt;&lt;/td&gt;
&lt;td style=&quot;height: 19px; width: 584px;&quot;&gt;모든 값&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예외는 두 가지입니다. &lt;code&gt;android:appCategory=&quot;game&quot;&lt;/code&gt;으로 분류된 게임 앱과 600dp 미만 폰 형태 화면은 이 제한을 받지 않습니다. 그리고 사용자는 시스템 설정에서 앱별로 동작을 개별 오버라이드할 수 있어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Configuration Change에 면접질문 추가요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적응형 UI 강제화와 같은 방향의 변화가 하나 더 있는데 같이 짚어두고 싶습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Android 17부터 일부 Configuration Change가 발생해도 Activity를 기본적으로 재시작하지 않습니다. 대신 &lt;code&gt;onConfigurationChanged()&lt;/code&gt;만 호출합니다. 해당하는 변경은 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;키보드 연결/해제 (&lt;code&gt;CONFIG_KEYBOARD&lt;/code&gt;, &lt;code&gt;CONFIG_KEYBOARD_HIDDEN&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;내비게이션 방식 변경 (&lt;code&gt;CONFIG_NAVIGATION&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;터치스크린 변경 (&lt;code&gt;CONFIG_TOUCHSCREEN&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;색상 모드 변경 (&lt;code&gt;CONFIG_COLOR_MODE&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;데스크 모드 UI 변경 (&lt;code&gt;CONFIG_UI_MODE&lt;/code&gt; - UI_MODE_TYPE_DESK만 해당)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 동작을 생각해보면 블루투스 키보드를 연결하거나 폴더블 기기를 펼칠 때 Activity가 처음부터 재시작됐습니다. 재시작이 일어나면 스크롤 위치가 초기화되거나 입력 중이던 내용이 날아가는 일이 생깁니다. 앱이 UI를 다시 그릴 이유가 없는데도 재시작이 일어났던 겁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 변화가 중요한 이유는 방향 고정 제거와 맞물려서 훨씬 큰 의미를 갖기 때문입니다. 방향 제한이 풀리면 앱의 창 크기가 변할 기회가 훨씬 많아집니다. 기기 회전, 폴드/언폴드, 멀티 윈도우 크기 조정이 모두 Configuration Change입니다. 여기서 매번 Activity를 재시작하면 상태 손실이 너무 잦겠죠. 구글 입장에선 방향 제한을 강제로 풀면서 동시에 &quot;하지만 Configuration Change로 인한 재시작은 줄여줄게&quot;라는 식의 보완책을 함께 넣은 거라고 생각합니다. 동시에 Compose에 친화적이기도 하고요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단 앱이 이 이벤트에서 리소스를 다시 로드하기 위해 재시작에 의존하고 있다면 문제가 됩니다. 이 경우 Manifest에 새로 추가된 &lt;code&gt;android:recreateOnConfigChanges&lt;/code&gt; 속성으로 명시적으로 opt-in해야 합니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;&amp;lt;activity
    android:name=&quot;.MyActivity&quot;
    android:recreateOnConfigChanges=&quot;keyboard|keyboardHidden|navigation&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이게 단순히 레이아웃 수정으로 끝나지 않는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방향 고정을 제거한다고 하면 &quot;레이아웃 좀 수정하면 되겠지&quot;라고 생각하기 쉬운데 실제로 가장 많이 깨지는 부분은 카메라 프리뷰입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카메라 프리뷰가 깨지는 근본 원인은 앱이 센서 방향과 기기 방향 사이의 관계를 고정으로 가정하기 때문이에요. 기기가 항상 세로라고 가정하면 센서 방향(보통 90도 회전)과 디스플레이 방향 사이의 변환을 계산할 때 논리가 단순해집니다. 그런데 가로나 임의 각도로 열리는 폴더블이 나오면 이 가정이 깨집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 창 크기까지 자유롭게 바뀌면 문제가 더 복잡해지는데, 멀티 윈도우나 데스크탑 윈도잉 환경에서는 앱이 화면의 일부만 차지하기 때문에 화면 크기를 기준으로 뷰파인더 크기를 계산하면 늘어집니다. 뷰파인더 크기는 반드시 Window Metrics 기준으로 계산해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 이유 때문에 구글은 Jetpack CameraX의 &lt;code&gt;PreviewView&lt;/code&gt; 사용을 공식적으로 권장합니다. &lt;code&gt;PreviewView&lt;/code&gt;는 센서 방향, 기기 회전, 스케일 조정을 내부적으로 처리하기 때문에 방향이 어떻게 바뀌든 프리뷰를 올바르게 표시해줍니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그 외 눈에 띄는 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적응형 UI 강제화 외에도 개인적으로 흥미로운 변경이 몇 가지 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ART에 세대별 가비지 컬렉션이 들어왔습니다.&lt;/b&gt; ART의 CMC(Concurrent Mark-Compact) 수집기에 Generational GC가 도입됐습니다. 대부분의 객체가 생성 직후 빠르게 죽는다는 사실을 이용해 짧게 사는 객체는 빈번하지만 비용이 낮은 수집으로 처리하고 오래 사는 객체는 덜 자주 수집합니다. GC가 메인 스레드를 멈추는 pause time을 줄이는 데 직접적인 영향을 줍니다.&lt;/p&gt;
&lt;p&gt;&lt;del&gt;GC 최고&lt;/del&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;MessageQueue&lt;/code&gt;가 lock-free 구현으로 교체됩니다.&lt;/b&gt; 기존 &lt;code&gt;MessageQueue&lt;/code&gt;는 내부에 락이 있어서 메인 스레드와 다른 스레드가 동시에 메시지를 넣으려 할 때 경합이 생겼습니다. SDK 37 타겟팅 앱은 lock-free 구현을 사용하게 되는데 주의할 점은 &lt;code&gt;MessageQueue&lt;/code&gt;의 private 필드나 메서드에 리플렉션으로 접근하는 코드가 있다면 깨질 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;b&gt;&lt;code&gt;static final&lt;/code&gt; 필드 수정이 막힙니다.&lt;/b&gt; SDK 37부터 &lt;code&gt;static final&lt;/code&gt;로 선언된 필드를 리플렉션으로 수정하면 &lt;code&gt;IllegalAccessException&lt;/code&gt;이 발생합니다. JNI의 &lt;code&gt;SetStatic&amp;lt;Type&amp;gt;Field&lt;/code&gt;로 수정하면 즉시 크래시가 납니다. Mockito 등으로 테스트 코드에서 상수를 교체하는 패턴이 있다면 targetSdk 올리기 전에 확인이 필요합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1,2년 사이에 하드웨어적으로 많은 발전이 이뤄졌다고 생각해요. 덕분에 트라이폴드 같은 기기가 상용화까지 가능하지 않았나 싶습니다. 하지만 대응은 언제나 개발자의 몫&amp;hellip;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방향 고정을 쓰고 있던 앱은 targetSdk 37로 올리기 전에 대형 화면과 폴더블에서 어떻게 보이는지 먼저 확인해봐야 합니다. Android Studio에서 에뮬레이터를 열어서 &lt;code&gt;targetSdkPreview = &quot;CinnamonBun&quot;&lt;/code&gt;으로 설정하면 지금 당장 테스트해볼 수 있습니다. &lt;code&gt;Retain API&lt;/code&gt; 도 활용해보면 좋을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글은 여기서 마칩니다. 긴글 읽어 주셔서 감사합니다 &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;참고: &lt;a href=&quot;https://android-developers.googleblog.com/2026/02/the-first-beta-of-android-17.html&quot;&gt;Android 17 Beta 1 공식 발표&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://developer.android.com/about/versions/17/release-notes?hl=ko&quot;&gt;Android 17 출시 노트&lt;/a&gt; &amp;middot; &lt;a href=&quot;https://android-developers.googleblog.com/2026/02/prepare-your-app-for-resizability-and.html&quot;&gt;적응형 UI 대응 가이드&lt;/a&gt;&lt;/i&gt;&lt;/p&gt;</description>
      <category>Android</category>
      <category>16</category>
      <category>17</category>
      <category>Android</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/37</guid>
      <comments>https://angrypodo.tistory.com/37#entry37comment</comments>
      <pubDate>Tue, 24 Feb 2026 02:16:28 +0900</pubDate>
    </item>
    <item>
      <title>레벨0 - 제네릭과 널, 그리고 3주간의 흐름</title>
      <link>https://angrypodo.tistory.com/36</link>
      <description>&lt;h1&gt;&amp;nbsp;&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;세 번째 주를 시작하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계획중 가장 머리 아픈 계획이었던 3주차 입니다. 제네릭이랑 널 안전성. 특히 여태까지 안드로이드 개발을 하면서 공변성, 반공변성 같은 개념은 적당이 이해만 하고 넘겼었어요. 사실 직관적인 개념은 아니라고 생각합니다.  안드로이드 개발하면서 &lt;code&gt;List&amp;lt;String&amp;gt;&lt;/code&gt;이 &lt;code&gt;List&amp;lt;Any&amp;gt;&lt;/code&gt;의 하위 타입이 아니라는 건 알았지만 왜 그런지는 제대로 이해하지는 않았어요. 그냥 &quot;제네릭이 그렇게 작동한다&quot;고만 받아들였던 것 같아요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주는 그 &quot;왜&quot;를 파고들었습니다. 2주차에서 인터페이스와 추상 클래스의 차이를 바이트코드로 확인했던 것처럼 제네릭도 디컴파일해보면 뭔가 보일거라고 생각해서 확인했어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;나야, 제네릭&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 간단한 코드부터 시작했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;val strings: List&amp;lt;String&amp;gt; = listOf(&quot;a&quot;, &quot;b&quot;)
val anys: List&amp;lt;Any&amp;gt; = strings  // 이게 왜 가능함?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드가 컴파일되는 이유는 &lt;code&gt;List&lt;/code&gt;가 &lt;code&gt;out T&lt;/code&gt;로 선언되어 있기 때문이에요. 공식 문서를 읽어보니 이걸 &quot;공변성(&lt;b&gt;covariance&lt;/b&gt;)&quot;이라고 부르는 개념입니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;interface List&amp;lt;out E&amp;gt; : Collection&amp;lt;E&amp;gt; {
    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;out&lt;/code&gt; 키워드가 붙으면 &lt;code&gt;List&amp;lt;String&amp;gt;&lt;/code&gt;을 &lt;code&gt;List&amp;lt;Any&amp;gt;&lt;/code&gt;로 안전하게 업캐스팅할 수 있습니다. 왜냐하면 &lt;code&gt;List&lt;/code&gt;는 읽기 전용이고 &lt;code&gt;E&lt;/code&gt; 타입의 값을 반환만 하지 받지는 않기 때문이에요. 값을 꺼내기만 하면 &lt;code&gt;String&lt;/code&gt;을 &lt;code&gt;Any&lt;/code&gt;로 다루는 건 항상 안전한걸 보장하는 원리입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 &lt;code&gt;MutableList&lt;/code&gt;는 어떨까요?&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;val strings: MutableList&amp;lt;String&amp;gt; = mutableListOf(&quot;a&quot;)
val anys: MutableList&amp;lt;Any&amp;gt; = strings  // 컴파일 에러남&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러가 납니다. 왜냐하면 &lt;code&gt;MutableList&lt;/code&gt;는 &lt;code&gt;add()&lt;/code&gt; 메서드로 값을 넣을 수 있어요. 만약 &lt;code&gt;MutableList&amp;lt;String&amp;gt;&lt;/code&gt;을 &lt;code&gt;MutableList&amp;lt;Any&amp;gt;&lt;/code&gt;로 업캐스팅할 수 있다면 이런 상황이 생겨요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;val strings: MutableList&amp;lt;String&amp;gt; = mutableListOf(&quot;a&quot;)
val anys: MutableList&amp;lt;Any&amp;gt; = strings  // 만약 이게 된다면
anys.add(123)  // Any이니까 Int도 넣을 수 있어야함
val str: String = strings[1]  // 런타임 에러남&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;strings&lt;/code&gt;는 &lt;code&gt;String&lt;/code&gt;만 담을 수 있는데 &lt;code&gt;Int&lt;/code&gt;가 들어가버리면 타입 안전성이 깨집니다. 그래서 &lt;code&gt;MutableList&lt;/code&gt;는 &lt;code&gt;out&lt;/code&gt;도 &lt;code&gt;in&lt;/code&gt;도 없는 무공변(&lt;b&gt;invariant&lt;/b&gt;)으로 선언돼요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바와 비교해보니 더 명확했어요. 자바는 이걸 와일드카드로 표현합니다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;List&amp;lt;? extends Object&amp;gt; anys = strings;  // 공변성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린의 &lt;code&gt;out&lt;/code&gt;이 자바의 &lt;code&gt;? extends&lt;/code&gt;와 같은 역할인데요, 자바는 사용하는 쪽에서 와일드카드를 써야 하는데 코틀린은 선언하는 쪽에서 &lt;code&gt;out&lt;/code&gt;을 붙이면 끝입니다.(최고다 코틀린) 이걸 &quot;선언 지점 변성(declaration-site variance)&quot;이라고 부릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;in&lt;/code&gt; 키워드는 반대로 작동합니다. 값을 받기만 하고 반환하지 않을 때 사용해요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;interface Comparable&amp;lt;in T&amp;gt; {
    fun compareTo(other: T): Int
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Comparable&amp;lt;Number&amp;gt;&lt;/code&gt;를 받는 함수에 &lt;code&gt;Comparable&amp;lt;Int&amp;gt;&lt;/code&gt;를 넘길 수 있어요. &lt;code&gt;Int&lt;/code&gt;는 &lt;code&gt;Number&lt;/code&gt;의 하위 타입이기 때문에 가능하고 이게 반공변성(&lt;b&gt;contravariance&lt;/b&gt;)입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 이 부분은 아직도 머리가 복잡해요. &quot;값을 소비하면 in, 생산하면 out&quot;이라는 규칙은 이해해도 실제 설계할 때 어떤 타입 파라미터에 변성을 줘야 할지 판단하는 건 또 다른 문제인 것 같아요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;reified, 스트롱 스트롱&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제네릭 타입 소거는 자바의 고질적인 문제입니다. 런타임에 &lt;code&gt;List&amp;lt;String&amp;gt;&lt;/code&gt;의 &lt;code&gt;String&lt;/code&gt; 정보가 사라집니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun &amp;lt;T&amp;gt; checkType(value: Any): Boolean {
    return value is T  // 컴파일 에러남
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 코틀린은 &lt;code&gt;inline&lt;/code&gt; 함수와 &lt;code&gt;reified&lt;/code&gt; 키워드로 이걸 해결했어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;inline fun &amp;lt;reified T&amp;gt; checkType(value: Any): Boolean {
    return value is T  // 가능!
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 이게 가능한지 디컴파일해봤어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;inline fun &amp;lt;reified T&amp;gt; printType() {
    println(T::class.simpleName)
}

fun main() {
    printType&amp;lt;String&amp;gt;()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 디컴파일하면 &lt;code&gt;inline&lt;/code&gt; 함수는 호출 지점에 코드가 복사되고 &lt;code&gt;T&lt;/code&gt;는 실제 타입으로 치환됩니다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public static final void main() {
    String var0 = &quot;kotlin.String&quot;;
    System.out.println(var0);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;printType&amp;lt;String&amp;gt;()&lt;/code&gt;이 통째로 인라인되면서 &lt;code&gt;T::class.simpleName&lt;/code&gt;이 &lt;code&gt;&quot;kotlin.String&quot;&lt;/code&gt;으로 바뀌었어요. 함수 호출이 아예 사라지고 코드가 그 자리에 복사/붙여넣기 된 형태입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 &lt;code&gt;reified&lt;/code&gt;의 원리입니다. 컴파일 타임에 타입 정보를 코드에 직접 박아넣는 건데요. 제네릭 타입 소거가 일어나기 전에 타입을 구체적인 값으로 바꿔치기하는 거예요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안드로이드에서 &lt;code&gt;viewModel&amp;lt;MyViewModel&amp;gt;()&lt;/code&gt;처럼 쓸 수 있는 이유입니다. &lt;code&gt;reified&lt;/code&gt; 덕분에 런타임에 &lt;code&gt;MyViewModel::class&lt;/code&gt;를 얻을 수 있어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;inline fun &amp;lt;reified VM : ViewModel&amp;gt; viewModel(): VM {
    return ViewModelProvider(this).get(VM::class.java)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단 &lt;code&gt;reified&lt;/code&gt;는 &lt;code&gt;inline&lt;/code&gt; 함수에서만 쓸 수 있어요. 인라인되지 않으면 타입 정보를 코드에 박아넣을 수 없기 때문이에요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코틀린은 Null을 싫어해&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린의 널 안전성은 컴파일 타임에만 존재하는 걸까요 아니면 런타임에도 뭔가 차이가 있을까요?&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;val name: String? = null
val length = name?.length&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 디컴파일해봤어요.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;String name = null;
Integer length = name != null ? name.length() : null;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;?.&lt;/code&gt;이 null 체크 삼항 연산자로 변환됐습니다. 그리고 &lt;code&gt;String?&lt;/code&gt;의 &lt;code&gt;?&lt;/code&gt;는 자바 코드에서 사라졌어요. JVM 바이트코드에는 nullable 타입이라는 개념이 없기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 &lt;code&gt;@Nullable&lt;/code&gt; 어노테이션이 붙습니다.&lt;/p&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;@Nullable
String name = null;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 어노테이션은 런타임에는 아무 역할도 하지 않아요. 컴파일 타임에 코틀린 컴파일러나 IntelliJ 같은 도구가 경고를 표시하는 역할 뿐이에요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 null 안전성은 어떻게 보장될까요? 답은 &quot;컴파일러가 null 체크 코드를 자동으로 삽입한다&quot;입니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun printLength(name: String) {
    println(name.length)
}

printLength(null)  // 컴파일 에러&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 컴파일 자체가 불가능 합니다. 컴파일러가 &quot;name은 non-null이어야 하는데 null을 넣으려고 하네?&quot;라고 막아버립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 자바에서 코틀린 함수를 호출하면 어떻게 될까요?&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;KotlinClass.printLength(null);  // 런타임 에러!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴파일은 되지만 런타임에 &lt;code&gt;NullPointerException&lt;/code&gt;이 아니라 &lt;code&gt;IllegalArgumentException&lt;/code&gt;이 발생해요. 코틀린이 함수 시작 부분에 null 체크를 넣기 때문이에요.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;public static final void printLength(@NotNull String name) {
    Intrinsics.checkNotNullParameter(name, &quot;name&quot;);
    System.out.println(name.length());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Intrinsics.checkNotNullParameter()&lt;/code&gt;가 null이면 예외를 던집니다. 자바 코드에서 넘어온 null을 최대한 빨리 잡으려고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 코틀린의 널 안전성은&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;컴파일 타임에 정적 분석으로 null 가능성을 추적&lt;/li&gt;
&lt;li&gt;런타임에는 null 체크 코드를 자동 삽입해서 방어&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 두 가지로 구현됩니다!&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;DSL은 어떻게 쓰는거에요?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주 계획에 있던 DSL 패턴도 확인해봤어요. 코틀린으로 HTML DSL 같은 걸 만들 수 있는 원리가 궁금했어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun buildString(action: StringBuilder.() -&amp;gt; Unit): String {
    val sb = StringBuilder()
    sb.action()
    return sb.toString()
}

val result = buildString {
    append(&quot;Hello&quot;)
    append(&quot; World&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;action: StringBuilder.() -&amp;gt; Unit&lt;/code&gt;이 핵심이에요. 이걸 람다 리시버라고 하는데요, 일반 람다 &lt;code&gt;() -&amp;gt; Unit&lt;/code&gt;과 뭐가 다를까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 람다는 외부 컨텍스트에 접근할 때 명시적으로 객체를 참조해야 해요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun buildStringNormal(action: (StringBuilder) -&amp;gt; Unit): String {
    val sb = StringBuilder()
    action(sb)  // sb를 파라미터로 전달
    return sb.toString()
}

val result = buildStringNormal { sb -&amp;gt;
    sb.append(&quot;Hello&quot;)  // sb.을 명시해야 함
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 람다 리시버는 &lt;code&gt;this&lt;/code&gt;를 생략할 수 있어요.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;val result = buildString {
    this.append(&quot;Hello&quot;)  // this는 StringBuilder
    append(&quot; World&quot;)       // this 생략 가능
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디컴파일해보면 이 차이가 명확합니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;public static final String buildString(Function1&amp;lt;? super StringBuilder, Unit&amp;gt; action) {
    StringBuilder sb = new StringBuilder();
    action.invoke(sb);
    return sb.toString();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;람다 리시버도 결국 파라미터로 객체를 받는 일반 함수로 변환돼요. 하지만 코틀린 컴파일러가 람다 안에서 &lt;code&gt;this&lt;/code&gt;를 자동으로 리시버 객체로 바인딩해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포즈의 &lt;code&gt;Column&lt;/code&gt;도 이 원리입니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Composable
fun Column(content: @Composable ColumnScope.() -&amp;gt; Unit) {
    // ...
}

Column {
    Text(&quot;Hello&quot;)        // ColumnScope의 확장 함수
    Spacer(height = 8.dp)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;람다 안에서 &lt;code&gt;this&lt;/code&gt;가 &lt;code&gt;ColumnScope&lt;/code&gt;니까 &lt;code&gt;ColumnScope&lt;/code&gt;의 확장 함수들을 직접 호출할 수 있습니다. &lt;code&gt;Modifier.align()&lt;/code&gt; 같은 것도 마찬가지고 &lt;code&gt;fun BoxScope.HelloText()&lt;/code&gt; 처럼 Box의 &lt;code&gt;this&lt;/code&gt; 를 제공할 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴이 좋은 이유는 &quot;특정 컨텍스트에서만 사용 가능한 API&quot;를 만들 수 있기 때문이라고 생각합니다. &lt;code&gt;Text()&lt;/code&gt;는 &lt;code&gt;Column&lt;/code&gt; 안에서만 쓸 수 있게 설계할 수 있어요. 타입에 안전하게 만드는 좋은 방법중 하나입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;잘된 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디컴파일이 습관이 됐어요. 이제는 궁금한 코드가 있으면 자연스럽게 Show Kotlin Bytecode를 누르고 보는 흐름이 익숙해요. &quot;이게 왜 이렇게 작동하지?&quot;라는 생각에는 &quot;한번 디컴파일해보자&quot;가 첫 번째 반응이 됐습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 주차와의 연결 고리를 찾는 능력이 생겼어요. 2주차 회고에서 &quot;1주차 개념을 바로 연결하지 못했다&quot;고 아쉬워했는데 이번 주는 좀 나아진 것 같습니다. backing field가 인터페이스에도 적용되고 Expression이 null 체크에도 쓰이고 람다 리시버가 컴포즈 DSL의 기반이 된다는 걸 자연스럽게 연결할 수 있었어요. &lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아쉬운 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;변성은 여전히 어려워요...&lt;/b&gt; 공변성, 반공변성, 무공변성 개념은 이해했는데 실제로 &quot;이 타입 파라미터에 out을 붙여야 하나, in을 붙여야 하나?&quot; 같은 판단은 여전히 헷갈리는 것 같아요. 좀더 실제 경험에서 상황을 겪어봐야 할것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;code&gt;reified&lt;/code&gt;로 우회할 수는 있지만 왜 자바가 처음부터 타입 소거를 선택했는지, 그게 어떤 트레이드오프였는지는 아직 명확하지 않아요. 레거시 호환성 때문이라는 설명은 봤지만 구체적으로 어떤 문제를 해결하기 위한 선택이었는지 더 깊이 알고 싶습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 주 계획 = 마지막 주&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 4주차 회고에서는 전체 4주를 돌아보는 시간을 가지려고 합니다. 처음 세웠던 목표를 얼마나 달성했는지 앞으로 어떻게 공부를 이어갈지 정리해보려고 해요. 그리고 진행중인 프로젝트가 4천 다운로드를 넘겼습니다.  이번 스프린트부터 더 어려운 기능이 들어가는데 이것도 열심히 병행해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주도 여기까지입니다. 3주 차를 무사히 마쳤네요. 남은 1주도 집중해서 마무리하겠습니다!  &lt;/p&gt;</description>
      <category>우아한테크코스/레벨0</category>
      <category>DSL</category>
      <category>Generic</category>
      <category>Kotlin</category>
      <category>NULL</category>
      <category>레벨0</category>
      <category>우테코</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/36</guid>
      <comments>https://angrypodo.tistory.com/36#entry36comment</comments>
      <pubDate>Tue, 17 Feb 2026 13:21:12 +0900</pubDate>
    </item>
    <item>
      <title>레벨0 - 2주차 회고, 그리고 회고를 회고하기</title>
      <link>https://angrypodo.tistory.com/35</link>
      <description>&lt;h1&gt;&amp;nbsp;&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;글을 쓰는 방식에 대하여&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1주차 회고를 다시 읽어봤습니다. &quot;~~했다. 일까?&quot; &quot;~~이다.&quot; 같은 문체로 쓰면서 읽고 나니까 별로 와닿지 않았어요. 디컴파일 결과를 나열하고 &quot;이게 이렇게 작동합니다&quot;라고 설명하는 건 맞는데 정작 내가 그 과정에서 뭘 느꼈는지 뭘 고민했는지는 잘 안 보였어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 주부터는 평소에 아티클 쓰듯이 문체를 쓰려고 합니다. 항상 자연스러운 문체를 써보려고 하는데 쉽지 않네요.  &lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 주에 한 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2주차 계획은 상속과 인터페이스였습니다. 평소 개발하면서 인터페이스는 자주 썼지만 코틀린의 프로퍼티 선언이 내부적으로 어떻게 처리되는지는 제대로 확인해본 적이 없었어요. 그리고 코틀린이 왜 모든 클래스를 기본적으로 &lt;code&gt;final&lt;/code&gt;로 만들었는지도 원리를 알고 싶었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 간단한 인터페이스부터 만들어봤습니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;interface User {
    val name: String
    val greeting: String
        get() = &quot;Hello, $name&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 디컴파일하면 자바 인터페이스에 getter만 생겨요. &lt;code&gt;name&lt;/code&gt;도 getter고 &lt;code&gt;greeting&lt;/code&gt;도 getter입니다. 상태를 저장하는 필드가 없어요. 인터페이스는 구현을 위임받는 쪽에서 어떻게든 값을 제공해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1주차에 backing field를 공부했을 때 &quot;커스텀 getter만 있으면 backing field가 생성 안 된다&quot;는 걸 배웠는데 인터페이스도 정확히 같은 원리였습니다. 인터페이스의 프로퍼티는 항상 backing field가 없는 프로퍼티인데요. getter만 있거나 구현 클래스가 오버라이드해서 제공해야 해요. 추상 클래스랑 다른 지점이 여기예요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;abstract class BaseUser {
    abstract val name: String
    val createdAt: Long = System.currentTimeMillis()  // 상태를 가질 수 있음
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추상 클래스는 상태를 가질 수 있습니다. &lt;code&gt;createdAt&lt;/code&gt;은 실제로 필드로 저장돼요. 디컴파일해보니 &lt;code&gt;private final long createdAt&lt;/code&gt; 필드가 생성된점이 인터페이스와의 결정적 차이였어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;인터페이스는 계약이고 추상 클래스는 부분 구현&quot;이라는 말을 자주 들었는데 바이트코드로 보니까 그 의미가 더 명확해졌습니다. 인터페이스는 &quot;이런 기능을 제공해야 한다&quot;는 약속만 하고 추상 클래스는 &quot;이런 상태를 가지면서 이 기능들을 제공한다&quot;는 베이스를 제공하는 느낌..?&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;open과 final 사이에서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린은 모든 클래스가 기본적으로 &lt;code&gt;final&lt;/code&gt;입니다. 상속받으려면 명시적으로 &lt;code&gt;open&lt;/code&gt;을 붙여야 해요. 사실 안드로이드 개발을 하면서 오히려 편할 때가 많았는데, 왜 이런 설계를 택했는지 공식 문서에서 확인하고 싶었어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서를 읽다가 Effective Java의 &quot;상속을 위한 설계와 문서를 갖추거나 그럴 수 없다면 상속을 금지하라&quot;는 원칙이 언급된 걸 봤습니다. 결국 언어 차원에서 이 원칙을 강제하고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상속은 강한 결합을 만들게 됩니다. 부모 클래스를 변경하면 자식 클래스가 깨질 수 있습니다. 그래서 상속을 허용할 거면 &quot;어떤 메서드를 오버라이드할 수 있고 어떤 순서로 호출되고 무엇을 가정해도 되는지&quot; 같은 걸 문서화하거나 정확하게 알고 있어야합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린은 그래서 기본값을 &lt;code&gt;final&lt;/code&gt;로 가져가요. &quot;상속 가능하게 만들려면 의도적으로 &lt;code&gt;open&lt;/code&gt;을 붙이세요&quot;라고 강제합니다. 이게 &quot;상속보다 조합&quot;이라는 원칙을 언어 차원에서 밀어주는 방식이구나 싶었어요. (저는 자바를 싫어해서 상속이 잘 와닿지 않습니다 )&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 &lt;code&gt;data class&lt;/code&gt;도 &lt;code&gt;final&lt;/code&gt;이라서 상속이 불가능해요. 불변성을 보장하기 위한 설계예요. &lt;code&gt;copy()&lt;/code&gt; 메서드가 새 인스턴스를 만드는데 만약 상속을 허용하면 하위 클래스에서 예상 못 한 상태를 추가할 수 있고 그럼 &lt;code&gt;copy()&lt;/code&gt;가 제대로 작동한다는 보장이 불가능 하기 때문입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;object의 내부 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;object&lt;/code&gt;로 싱글톤을 만들 수 있다는 건 당연히 알고 있었는데 이것도 내부적으로 어떻게 구현되는지는 바이트코드로 확인해본 적이 없었어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;object DatabaseConfig {
    val maxConnections = 10
    fun connect() {
        println(&quot;Connecting...&quot;)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 디컴파일하면 꽤 흥미로운 구조가 나옵니다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public final class DatabaseConfig {
   public static final DatabaseConfig INSTANCE;
   private static final int maxConnections = 10;

   static {
      DatabaseConfig var0 = new DatabaseConfig();
      INSTANCE = var0;
   }

   private DatabaseConfig() {
   }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;static&lt;/code&gt; 블록에서 인스턴스를 생성하고 생성자를 &lt;code&gt;private&lt;/code&gt;으로 막아놨어요. 이게 자바의 싱글톤 패턴인걸 또하나 배웠습니다. Bill Pugh Singleton이라고 부르는 그 패턴입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 코틀린에서는 그냥 &lt;code&gt;object&lt;/code&gt; 키워드 하나로 이게 다 처리됩니다. 스레드 안전성도 보장되고 lazy initialization도 알아서 됩니다(만세). JVM의 클래스 로더가 static 블록을 실행할 때 동기화를 보장하기 때문이에요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안드로이드에서 Hilt를 쓸 때 &lt;code&gt;@InstallIn(SingletonComponent::class)&lt;/code&gt; 같은 걸 붙이는데 그게 결국 이런 싱글톤 패턴을 DI 컨테이너가 관리해주는 거라는 게 이제 좀 이해가 가요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 주 잘된 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디컴파일을 계속 돌려보면서 &quot;코틀린 문법 &amp;rarr; 자바 코드 &amp;rarr; 실제 동작&quot;의 연결고리가 점점 명확해지고 있습니다. 1주차에는 생성자와 프로퍼티의 실행 순서를 봤고, 이번 주에는 인터페이스와 추상 클래스의 차이를 바이트코드 레벨에서 확인했어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 공식 문서를 읽는 습관이 생겼습니다. 평소 개발하면서 쓰던 기능도 공부할 시간이 적다는 핑계로 평균적으로 중간까지만 파봤다면 &quot;왜 이렇게 설계했는지&quot;까지 찾아보는 과정에 시간이 짧아졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계획한 대로 data class의 &lt;code&gt;copy()&lt;/code&gt; 구현과 object의 싱글톤 패턴도 모두 확인했습니다. 특히 &lt;code&gt;copy()&lt;/code&gt; 메서드가 생성자를 호출하면서 지정된 파라미터만 바꾸고 나머지는 그대로 복사하는 방식이라는 걸 바이트코드로 직접 보니까 더 확실하게 이해됐어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 주 아쉬운 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1주차에서 배운 개념을 2주차 내용에 바로 연결하지 못한 게 아쉬웠습니다. &quot;커스텀 getter만 있으면 backing field가 안 생긴다&quot;는 걸 배웠으면 인터페이스에도 당연히 적용될 거라고 생각했어야 하는데 막상 인터페이스를 디컴파일할 때는 따로 확인해봐야 했네요. 개념을 알고 있는 것과 다른 맥락에 적용하는 건 역시 다른 문제같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 추상 클래스와 인터페이스의 선택 기준을 이론으로는 알아도 실제 설계에서 판단하기는 또 다를 것 같아요. &quot;이 설계는 상태가 필요한가?&quot;를 판단하는 게 생각보다 미묘한 경우가 많다고 생각해요. 그리고 아직까지 추상 클래스의 필요성을 잘 느끼지 못하고 있어요. 인터페이스와 구현체 분리를 통한 DI가 훨씬 좋지 않나&amp;hellip;이 생각도 구체화 해봐야 겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회고를 쓰는 방식도 계속 고민이 돼요. 1주차처럼 기술 내용을 자세히 쓸 것인가, 이번 주처럼 과정과 고민을 더 담을 것인가. 둘 다 필요한 것 같은데 균형을 어떻게 맞출지 아직 잘 모르겠어요. 일단은 이번 주 방식으로 계속 써보고 나중에 다시 조정해봐야겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 주 계획&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3주차는 제네릭과 널 안전성입니다. 사실 제일 머리아픈 주차라고 생각해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 &lt;code&gt;reified&lt;/code&gt; 키워드가 궁금합니다. 자바는 제네릭 타입 소거 때문에 런타임에 &lt;code&gt;T&lt;/code&gt;의 타입을 알 수 없는데, 코틀린의 &lt;code&gt;inline&lt;/code&gt; 함수는 어떻게 &lt;code&gt;is T&lt;/code&gt; 검사가 가능한지 디컴파일로 확인해볼 생각이에요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;code&gt;String?&lt;/code&gt;이 컴파일 타임에만 존재하는 건지, 런타임에도 뭔가 차이가 있는지 바이트코드를 통해 보고 싶습니다. 아마 &lt;code&gt;@Nullable&lt;/code&gt; 어노테이션이랑 null 체크 로직이 추가될 것 같은데 정확히 어떤 형태로 변환되는지 확인해보려고 해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번주도 이렇게 마무리를 합니다. 긴 글 읽어 주셔서 감사합니다. &lt;/p&gt;</description>
      <category>우아한테크코스/레벨0</category>
      <category>Kotlin</category>
      <category>레벨0</category>
      <category>우테코</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/35</guid>
      <comments>https://angrypodo.tistory.com/35#entry35comment</comments>
      <pubDate>Tue, 10 Feb 2026 01:05:28 +0900</pubDate>
    </item>
    <item>
      <title>Google Mobile Ads Next-Gen SDK, 왜 만든건데?</title>
      <link>https://angrypodo.tistory.com/34</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요, 이번에 광고 SDK를 도입하면서 알게된 소식을 전달하고자 이번 글을 작성합니다. 도움이 되길 바랍니다 &lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Google이 Mobile Ads SDK를 처음부터 다시 만들었습니다. &quot;차세대(Next-Gen)&quot;라는 이름을 붙인 이 SDK는 2026년 7월 정식 출시 예정이고 지금은 오픈 베타로 공개되어 있습니다.&lt;br /&gt;광고 SDK를 처음부터 다시 쓰는 건 쉬운 선택이 아니라고 생각합니다.. 하위 호환성이 깨질 수 있고, 마이그레이션 비용도 만만치 않고 예상 못한 버그도 나올 수 있으니까요.&lt;br /&gt;그런데도 Google이 이런 결정을 내린 데는 이유가 있었습니다. Next-Gen SDK가 뭘 해결하려고 하는지 어떻게 접근했는지 실제 개발에서는 어떤 의미를 갖는지 정리해봤습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 SDK의 한계&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 앱 시작 시간에 미치는 영향&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모바일 광고 SDK는 좀 애매한 존재입니다. 수익화엔 필수지만 앱의 핵심 기능은 아니거든요. 그래서 SDK 초기화가 앱 실행을 느리게 만들면 UX에 바로 영향을 끼칩니다.&lt;br /&gt;기존 SDK는 메인 스레드에서 초기화를 했습니다. 앱이 쓸 수 있는 상태가 되려면 SDK 초기화가 끝날 때까지 기다려야 했어요. 미디에이션 어댑터까지 쓰면 더 오래 걸렸습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;679&quot; data-origin-height=&quot;690&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/D9Fqn/dJMcagj5149/uRyXh3wFJnrvr0SlAAdGr1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/D9Fqn/dJMcagj5149/uRyXh3wFJnrvr0SlAAdGr1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/D9Fqn/dJMcagj5149/uRyXh3wFJnrvr0SlAAdGr1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FD9Fqn%2FdJMcagj5149%2FuRyXh3wFJnrvr0SlAAdGr1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;610&quot; data-origin-width=&quot;679&quot; data-origin-height=&quot;690&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. ANR의 원인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Android에서 ANR은 UX를 해치는 치명적인 문제입니다. Android는 메인 스레드가 5초 이상 블로킹되면 시스템이 앱을 강제 종료할 수 있습니다.&lt;br /&gt;베타 테스터 중 한 게임 개발사는 기존 SDK에서 ANR이 많이 발생했다고 합니다. 단순히 성능 문제가 아니라 Play 스토어의 앱 품질 점수에도 직접 영향을 줘서 검색 순위까지 떨어뜨릴 수 있는 문제입니다(억울하다).&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. APK 크기 증가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모바일 앱에서 설치 파일 크기는 다운로드 전환율과 직결됩니다. Google Play Console 데이터를 보면, APK 크기가 6MB 증가할 때마다 설치 전환율이 약 1% 감소한다고 해요. 광고 SDK가 차지하는 용량이 크다면 기회 비용이 되겠죠.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Next-Gen SDK의 설계 철학&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kotlin으로의 전환&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next-Gen SDK는 Kotlin으로 완전히 재작성되었습니다. 단순히 언어만 바꾼 게 아니라, 코루틴이나 sealed class, null safety 같은 Kotlin 특성을 제대로 활용해서 더 안전하고 효율적인 코드를 만들었다는 뜻입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Java 기반 SDK에서는 콜백 체인이 깊어지면서 콜백 헬(장풍)이 생기고 메모리 누수 가능성도 있었습니다. Kotlin 코루틴을 쓰면 이런 비동기 작업을 훨씬 직관적이고 안전하게 처리할 수 있죠.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 기존 방식 (Callback Hell의 가능성)
loadAd(request, object : AdLoadCallback {
    override fun onAdLoaded(ad: Ad) {
        setupAd(ad, object : SetupCallback {
            override fun onSetupComplete() {
                showAd(ad, object : ShowCallback {
                    // 계속 중첩...
                })
            }
        })
    }
})

// Kotlin Coroutines 활용 (예상되는 개선)
lifecycleScope.launch {
    val ad = loadAd(request).await()
    setupAd(ad).await()
    showAd(ad)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;백그라운드 초기화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next-Gen SDK에서 가장 중요한 변화 중 하나가 백그라운드 스레드에서의 초기화입니다. 공식 문서를 보면 이런 패턴을 권장하고 있어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class SplashActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val backgroundScope = CoroutineScope(Dispatchers.IO)
        backgroundScope.launch {
            MobileAds.initialize(
                this@SplashActivity,
                InitializationConfig.Builder(APP_ID).build()
            ) { initializationStatus -&amp;gt;
                // 어댑터 초기화 완료
            }
            // SDK 초기화 완료
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 스레드를 점유하지 않으니까 UI 렌더링과 사용자 인터랙션이 SDK 초기화 영향을 안 받습니다. 앱의 다른 초기화 작업과 동시에 진행할 수도 있고, 초기화가 얼마나 오래 걸리든 ANR이 발생하지 않는 게 가장 큰것 같아요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1043&quot; data-origin-height=&quot;610&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pGgMa/dJMcafrXpe4/kqXaUZfbOsxIPUYYy2dWL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pGgMa/dJMcafrXpe4/kqXaUZfbOsxIPUYYy2dWL0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pGgMa/dJMcafrXpe4/kqXaUZfbOsxIPUYYy2dWL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpGgMa%2FdJMcafrXpe4%2FkqXaUZfbOsxIPUYYy2dWL0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;409&quot; data-origin-width=&quot;1043&quot; data-origin-height=&quot;610&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 여기서 주의할 점이 있습니다. 문서에 명시적으로 &quot;&lt;b&gt;백그라운드 스레드에서 호출해야 하며, 그렇지 않으면 ANR 오류가 발생할 수 있습니다&lt;/b&gt;&quot;라고 경고하고 있어요. SDK 내부적으로 여전히 블로킹 작업이 있다는 얘기입니다. &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;미디에이션 어댑터 초기화의 타임아웃&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK 초기화는 30초 타임아웃이 걸려 있습니다. 모든 미디에이션 어댑터 초기화가 끝나거나, 30초가 지나면 완료 콜백이 호출됩니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;MobileAds.initialize(context, config) { initializationStatus -&amp;gt;
    // 이 시점에:
    // 1. 모든 어댑터 초기화 완료, 또는
    // 2. 30초 타임아웃 도달

    // 미디에이션을 사용한다면 여기서 광고 로드 시작
    loadFirstAd()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;완벽한 초기화를 무한정 기다리지 않는다&quot;는 철학을 반영한 설계입니다. 일부 어댑터 초기화가 지연되더라도 앱은 계속 진행되어야 하니까요. 실제로 30초 안에 초기화 못한 어댑터는 나중에 초기화되면서 광고 요청에 참여할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;광고 프리로딩&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next-Gen SDK는 광고 프리로딩이라는 새로운 패러다임을 도입했습니다. 아직 정식 릴리스 전이지만, &lt;a href=&quot;https://github.com/googleads/gma-next-gen-sdk-android-examples/pull/19&quot;&gt;GitHub PR #19&lt;/a&gt;를 통해 작동 방식을 확인할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 방식은 &quot;광고가 필요할 때 로드&quot;입니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 기존 방식
fun loadAd(context: Context) {
    if (isLoadingAd || isAdAvailable()) return

    isLoadingAd = true
    AppOpenAd.load(request) { ad -&amp;gt;
        appOpenAd = ad
        isLoadingAd = false
        loadTime = Date().time
    }
}

// 광고가 필요한 시점에
fun showAdIfAvailable(activity: Activity) {
    if (!isAdAvailable()) {
        // 아직 로드 안됨 - 사용자는 기다려야 함
        return
    }
    appOpenAd?.show(activity)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리로딩 방식은 &quot;미리 로드해두고 필요할 때 바로 꺼내 쓰는&quot; 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 프리로딩 방식
fun startPreloading() {
    val adRequest = AdRequest.Builder(AD_UNIT_ID).build()
    // 광고를 1개 미리 로드해둠
    val preloadConfig = PreloadConfiguration(adRequest, 1)
    AppOpenAdPreloader.start(AD_UNIT_ID, preloadConfig)
}

// 광고가 필요한 시점에
fun showAdIfAvailable(activity: Activity) {
    // 미리 로드된 광고를 즉시 가져옴
    val appOpenAd = AppOpenAdPreloader.pollAd(AD_UNIT_ID)
    if (appOpenAd == null) {
        // 아직 준비 안됨
        return
    }
    appOpenAd.show(activity)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 방식의 차이를 시각화해볼까요?&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;831&quot; data-origin-height=&quot;1188&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bn1ZY2/dJMcacaXx3N/KfsvjllkKd96r1IP4YJKe1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bn1ZY2/dJMcacaXx3N/KfsvjllkKd96r1IP4YJKe1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bn1ZY2/dJMcacaXx3N/KfsvjllkKd96r1IP4YJKe1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbn1ZY2%2FdJMcacaXx3N%2FKfsvjllkKd96r1IP4YJKe1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;1001&quot; data-origin-width=&quot;831&quot; data-origin-height=&quot;1188&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리로딩이 좋은 이유는 간단합니다. 미리 준비해두니까 사용자가 기다릴 필요가 없습니다. 광고가 미리 준비되어 있으니 표시 실패 확률도 낮아지고, 화면 전환도 끊김 없이 자연스럽게 됩니다.&lt;br /&gt;PR #19 코드를 보면, 프리로딩 시작 시점이 중요하다는 걸 알 수 있어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class SplashActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        // ...
        if (googleMobileAdsConsentManager.canRequestAds) {
            // SDK 초기화 완료 시점에 프리로딩 시작
            AppOpenAdManager.startPreloading()
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, SDK 초기화가 완료되자마자 백그라운드에서 광고를 미리 로드하기 시작합니다. 사용자가 앱을 사용하는 동안, 광고는 이미 준비되고 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;측정 가능한 개선&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Google이 공개한 수치를 보면, Next-Gen SDK의 영향이 구체적으로 드러납니다&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 지표&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;배너 광고 요청 레이턴시: 최대 27% 감소&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;광고 요청에서 응답까지의 시간이 줄어든다는 건, 사용자가 광고 보기까지 대기 시간이 줄어든다는 얘기입니다. 사용자가 스크롤을 빠르게 하는 피드 형태 UI에서 특히 중요하겠죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;APK 크기: 17% 감소&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 파일 크기만 작아진 게 아닙니다. Android의 dex 제한이나 instant app 크기 제약도 있고, 무엇보다 사용자의 다운로드 결정에 영향을 줍니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 사례&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베타 프로그램에 참여한 퍼블리셔들의 결과는 더욱 인상적이에요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ANR rate 33% 감소&lt;/b&gt; - 앱 안정성이 직접 개선됨&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Cold start 50% 빨라짐&lt;/b&gt; - 사용자가 앱 켜고 쓰기까지 시간 단축&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Fill rate 16% 증가&lt;/b&gt; - 더 많은 광고 요청이 실제 광고로 채워짐&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ARPDAU 91.5% 증가&lt;/b&gt; - 일일 활성 사용자당 수익이 거의 2배&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 마지막 ARPDAU 지표가 핵심이라고 생각합니다. SDK가 빨라진 것만이 아니라, 더 많은 광고 노출 기회를 확보했고, fill rate 증가로 인해 실제 수익으로 이어졌다는 뜻이니까요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마이그레이션 시 고려사항&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 변경사항&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next-Gen SDK는 새로운 패키지 구조를 사용합니다&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;// 기존
import com.google.android.gms.ads.MobileAds

// Next-Gen
import com.google.android.libraries.ads.mobile.sdk.MobileAds&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;패키지명이 &lt;code&gt;com.google.android.gms.ads&lt;/code&gt;에서 &lt;code&gt;com.google.android.libraries.ads.mobile.sdk&lt;/code&gt;로 바뀌었어요. Play Services와 결합도를 낮추고, 독립적인 릴리스 사이클을 가능하게 하려는 의도로 보입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;초기화 패턴의 변화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 중요한 변화는 초기화가 &lt;b&gt;반드시&lt;/b&gt; 백그라운드 스레드에서 이루어져야 한다는 점입니다&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 잘못된 방법 (메인 스레드)
override fun onCreate() {
    super.onCreate()
    MobileAds.initialize(this, config) { } // ANR 위험!
}

// 올바른 방법 (백그라운드 스레드)
override fun onCreate() {
    super.onCreate()
    CoroutineScope(Dispatchers.IO).launch {
        MobileAds.initialize(this@MyActivity, config) { }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순하게 기존 코드를 단순히 패키지명만 바꿔서는 안전하게 마이그레이션할 수 없다는 뜻입니다. 초기화 로직 전체를 검토해야 해요. &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;광고 로딩 패턴의 변화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PR #19에서 확인할 수 있듯이, 광고 로딩 패턴도 변화하고 있습니다. 특히 App Open Ad 같은 전면 광고의 경우, 프리로딩 패턴으로 전환하는 것이 권장됩니다&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 기존 패턴 (Load on Demand)
class AppOpenAdManager {
    private var appOpenAd: AppOpenAd? = null

    fun loadAd(context: Context) {
        AppOpenAd.load(request) { ad -&amp;gt;
            appOpenAd = ad
            loadTime = Date().time
        }
    }

    fun showAdIfAvailable(activity: Activity) {
        if (appOpenAd == null) return // 로드 안됨
        appOpenAd?.show(activity)
    }
}

// 새로운 패턴 (Preloading)
class AppOpenAdManager {
    fun startPreloading() {
        val preloadConfig = PreloadConfiguration(adRequest, 1)
        AppOpenAdPreloader.start(AD_UNIT_ID, preloadConfig)
    }

    fun showAdIfAvailable(activity: Activity) {
        // 미리 로드된 광고를 즉시 가져옴
        val ad = AppOpenAdPreloader.pollAd(AD_UNIT_ID) ?: return
        ad.show(activity)
    }

    fun stopPreloading() {
        AppOpenAdPreloader.destroy(AD_UNIT_ID)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리로딩 방식의 핵심은 앱 시작할 때 &lt;code&gt;startPreloading()&lt;/code&gt; 호출해서 백그라운드에서 광고 준비시키고, 광고 표시 시점에 &lt;code&gt;pollAd()&lt;/code&gt;로 즉시 가져오고, 앱 종료할 때 &lt;code&gt;stopPreloading()&lt;/code&gt;으로 리소스 정리하는 겁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App Open Ad처럼 사용자가 앱 실행할 때마다 표시되는 광고에 특히 효과적이에요. 광고가 미리 준비되어 있으니 사용자는 로딩을 기다릴 필요가 없어져요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아키텍처 관점에서의 변화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;라이프사이클 관리가 간단해짐&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PR #19를 보면, 광고 관리 로직이 어떻게 진화했는지 확인할 수 있습니다. 기존에는 &lt;code&gt;MyApplication&lt;/code&gt; 클래스가 복잡한 라이프사이클 로직을 직접 관리했어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 기존 방식 (복잡한 Application 클래스)
class MyApplication : Application(),
    Application.ActivityLifecycleCallbacks,
    DefaultLifecycleObserver {

    private var currentActivity: Activity? = null

    override fun onCreate() {
        super.onCreate()
        registerActivityLifecycleCallbacks(this)
        ProcessLifecycleOwner.get().lifecycle.addObserver(this)
    }

    override fun onStart(owner: LifecycleOwner) {
        currentActivity?.let { activity -&amp;gt;
            val isAppOpenFragment = // 복잡한 Fragment 체크 로직
            if (shouldShowAd || isAppOpenFragment) {
                AppOpenAdManager.showAdIfAvailable(activity, null)
            }
        }
    }

    override fun onActivityStarted(activity: Activity) {
        currentActivity = activity
    }
    // ... 더 많은 콜백들
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리로딩 방식으로 전환하면서 이 모든 로직이 &lt;code&gt;AppOpenAdManager&lt;/code&gt;로 이동했습니다&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 새로운 방식 (간단한 Application 클래스)
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // 라이프사이클 관리를 AdManager에 위임
        registerActivityLifecycleCallbacks(AppOpenAdManager)
        ProcessLifecycleOwner.get().lifecycle.addObserver(AppOpenAdManager)
    }
}

// 광고 관련 로직은 AdManager에 집중
object AppOpenAdManager : DefaultLifecycleObserver,
    Application.ActivityLifecycleCallbacks {

    override fun onStart(owner: LifecycleOwner) {
        if (isColdStart &amp;amp;&amp;amp; isAppOpenAdOnColdStartEnabled()) {
            showAdIfAvailable(currentActivity)
        }
    }

    // 광고 관련 모든 로직
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떠신가요? 구조가 깔끔해졌다고 생각되나요? Application 클래스는 앱 초기화에만 집중하고, AdManager를 독립적으로 테스트할 수 있게 되었습니다. 그리고 광고 로직을 변경해도 Application 클래스에 영향을 끼치지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;975&quot; data-origin-height=&quot;797&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c0dpf8/dJMcahpMUiR/nqIq91tGkSSVbnOxNZJ7B0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c0dpf8/dJMcahpMUiR/nqIq91tGkSSVbnOxNZJ7B0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c0dpf8/dJMcahpMUiR/nqIq91tGkSSVbnOxNZJ7B0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc0dpf8%2FdJMcahpMUiR%2FnqIq91tGkSSVbnOxNZJ7B0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;572&quot; data-origin-width=&quot;975&quot; data-origin-height=&quot;797&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;콜백 처리의 개선&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문서를 보면 &quot;백그라운드 스레드에서 콜백 처리&quot;라는 마이그레이션 가이드가 있습니다. SDK의 콜백이 이제 백그라운드 스레드에서 호출될 수 있다는 의미입니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;adView.loadAd(request, object : AdLoadCallback&amp;lt;BannerAd&amp;gt; {
    override fun onAdLoaded(ad: BannerAd) {
        // 이 콜백은 백그라운드 스레드에서 호출될 수 있음
        // UI 업데이트 시 주의!
        runOnUiThread {
            // UI 작업
        }
    }
})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능을 위한 트레이드 오프라고 생각합니다. 메인 스레드를 블로킹하지 않으려면 개발자가 스레드 관리를 더 명시적으로 해야하니까요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;광고 객체의 라이프사이클&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next-Gen SDK는 광고 객체의 라이프사이클을 더 명확히 관리합니다&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class BannerFragment : Fragment() {
    private var bannerAd: BannerAd? = null

    override fun onDestroyView() {
        binding.adView.destroy() // 명시적 정리 필요
        super.onDestroyView()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 SDK에서도 &lt;code&gt;destroy()&lt;/code&gt; 호출이 권장되었지만, Next-Gen에서는 더욱 강조하고 있어요. 메모리 누수를 방지하고 불필요한 광고 새로고침을 막기 위함입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;릴리스 전략의 변화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next-Gen SDK는 &lt;b&gt;월간 릴리스 주기&lt;/b&gt;를 목표한다고 합니다. 기존의 분기별 릴리스보다 훨씬 빠른 속도인데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 빠른 릴리스 주기는 버그 수정도 빠르게 진행되고 새로운 기능도 계속해서 도입되기 때문에 자동화된 테스트나 CI/CD 파이프라인이 더욱 중요해지지 않을까 추측해봅니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Google이 Mobile Ads SDK를 처음부터 다시 만든건 모바일 앱 환경이 많이 바뀌었기 때문인 것 같습니다. 요즘 사용자들은 느린 앱을 절대 참지 않고 플랫폼은 앱 품질을 깐깐하게 보고 광고주들은 성과를 더 요구하니까요. &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Next-Gen SDK의 17% 작은 APK, 27% 빠른 광고 로딩, 33% 낮은 ANR rate. 단순한 숫자가 아니라 실제로 UX가 좋아지고 수익도 늘어날 수 있습니다. 개발자한테는 특히 반갑지 않을까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 주목할 만한 건 광고 프리로딩 같은 새로운 패턴입니다. &lt;a href=&quot;https://github.com/googleads/gma-next-gen-sdk-android-examples/pull/19&quot;&gt;아직 머지되지 않은 PR #19&lt;/a&gt;에서 확인할 수 있듯이 Google은 광고 로딩 자체의 설계를 바꾸려 하고 있습니다. &quot;사용자가 기다리게 하지 않는다&quot;는 건 모바일 앱의 기본 원칙이고, 이제 광고 SDK도 따라가고 있는것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 마이그레이션에는 비용이 듭니다. API 변경을 반영하고 새로운 패턴을 학습하고, 테스트하고, 모니터링해야 해요. 특히 프리로딩 같은 새로운 기능은 메모리와 네트워크 사용량 같은 새로운 고려사항을 만듭니다. 하지만 베타 테스터들의 결과를 보면 충분히 가치가 있어 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2026년 7월 정식 출시까지는 아직 시간이 있습니다. 급하게 적용할 필요는 없지만, 준비는 미리 시작하는 건 어떨까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 저도 GitHub의 예제 레포지토리를 자주 보려고 합니다. 아직 머지되지 않은 PR들을 통해 Google이 어떤 방향으로 SDK를 발전시키려 하는지 파악하기에 도움이 많이 되었거든요. 개인적인 욕심으로는 하루빨리 코루틴 친화적이게 되었음 합니다. &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;긴 글 읽어 주셔서 감사합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 자료&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.google.com/admob/android/next-gen/quick-start&quot;&gt;GMA Next-Gen SDK 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ads-developers.googleblog.com/2026/01/announcing-google-mobile-ads-next-gen.html&quot;&gt;Google Ads Developer Blog - SDK 발표&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/googleads/gma-next-gen-sdk-android-examples&quot;&gt;GitHub 예제 코드&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/googleads/gma-next-gen-sdk-android-examples/pull/19&quot;&gt;PR #19 - App Open Ad Preloading&lt;/a&gt; (진행 중)&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Android</category>
      <category>AdMob</category>
      <category>Android</category>
      <category>gma</category>
      <category>Google Mobile Ads</category>
      <category>SDK</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/34</guid>
      <comments>https://angrypodo.tistory.com/34#entry34comment</comments>
      <pubDate>Mon, 9 Feb 2026 01:30:16 +0900</pubDate>
    </item>
    <item>
      <title>레벨0 - 생성자와 프로퍼티, 그리고 컴파일 과정</title>
      <link>https://angrypodo.tistory.com/33</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 주 목표&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;744&quot; data-origin-height=&quot;291&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/riNhk/dJMcahpKFAs/u2XEuxphg7Y7kWKAAVGMbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/riNhk/dJMcahpKFAs/u2XEuxphg7Y7kWKAAVGMbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/riNhk/dJMcahpKFAs/u2XEuxphg7Y7kWKAAVGMbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FriNhk%2FdJMcahpKFAs%2Fu2XEuxphg7Y7kWKAAVGMbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;744&quot; height=&quot;291&quot; data-origin-width=&quot;744&quot; data-origin-height=&quot;291&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1주차 계획은 주 생성자와 부 생성자, &lt;code&gt;init&lt;/code&gt; 블록, 프로퍼티의 관계를 파악하는 것이었다. 특히 Hilt를 사용하면서 늘 궁금했던 의존성 주입 시점을 이해하기 위해 생성자의 실행 순서를 디컴파일로 확인하는 것이 핵심 목표였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;주 생성자의 실행 순서&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린 클래스의 생성 과정을 이해하기 위해 간단한 코드를 작성하고 바이트코드로 변환해봤다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class User(
    val name: String,
    age: Int
) {
    val isAdult = age &amp;gt;= 18

    init {
        println(&quot;User created: $name&quot;)
    }

    val greeting = &quot;Hello, $name&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 자바로 디컴파일하면 다음과 같은 형태가 나온다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;public final class User {
   @NotNull
   private final String name;
   private final boolean isAdult;
   @NotNull
   private final String greeting;

   public User(@NotNull String name, int age) {
      this.name = name;
      this.isAdult = age &amp;gt;= 18;
      System.out.println(&quot;User created: &quot; + name);
      this.greeting = &quot;Hello, &quot; + name;
   }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은 실행 순서다. 주 생성자의 파라미터가 먼저 필드로 할당되고, 그다음 프로퍼티 초기화와 &lt;code&gt;init&lt;/code&gt; 블록이 작성된 순서대로 실행된다. &lt;code&gt;val isAdult&lt;/code&gt;가 먼저 초기화되고, &lt;code&gt;init&lt;/code&gt; 블록이 실행된 뒤, &lt;code&gt;val greeting&lt;/code&gt;이 초기화되는 순서인 거다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성자 본문을 자세히 보면 이렇다&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;public User(@NotNull String name, int age) {
   this.name = name;              // 1. 주 생성자 파라미터 할당
   this.isAdult = age &amp;gt;= 18;      // 2. 첫 번째 프로퍼티 초기화
   System.out.println(...);       // 3. init 블록 실행
   this.greeting = &quot;Hello, &quot; + name; // 4. 두 번째 프로퍼티 초기화
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린은 이 모든 초기화 로직을 하나의 생성자 안에 순서대로 배치한다. 자바에서는 이런 초기화 순서가 암묵적이지만, 코틀린은 코드 작성 순서가 곧 실행 순서가 되도록 명시적으로 만들었다. 이게 Hilt 같은 의존성 주입 프레임워크를 사용할 때도 예측 가능한 초기화 순서를 보장하는 기반이 된다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로퍼티와 Backing Field&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린 공식 문서를 읽으면서 가장 흥미로웠던 부분은 프로퍼티의 &lt;code&gt;field&lt;/code&gt; 키워드였다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;class Counter {
    var count: Int = 0
        set(value) {
            if (value &amp;gt;= 0) field = value
        }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드의 &lt;code&gt;field&lt;/code&gt;는 실제 값이 저장되는 backing field를 가리킨다. 자바로 변환하면 &lt;code&gt;private int count&lt;/code&gt;라는 필드가 생기고, setter에서 &lt;code&gt;this.count = value&lt;/code&gt; 형태로 변환된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바에서는 필드와 getter/setter를 각각 선언해야 한다(귀찮다..)&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;public class Counter {
    private int count = 0;

    public int getCount() {
        return count;
    }

    public void setCount(int value) {
        if (value &amp;gt;= 0) {
            this.count = value;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린은 여기서 &lt;b&gt;필드와 접근자를 분리하지 않고 하나의 프로퍼티 개념으로 통합&lt;/b&gt;했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이런 선택을 했을까? 자바에서는 필드를 직접 노출하면 나중에 getter/setter로 바꾸기 어렵다. 그래서 항상 private 필드 + public getter/setter 패턴을 사용한다. 하지만 이건 보일러플레이트 코드가 많아진다는 단점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린은 &quot;어차피 대부분의 경우 필드에 getter/setter가 필요하다&quot;는 전제 아래, 프로퍼티 문법을 만들었다. 개발자는 프로퍼티만 선언하면 컴파일러가 필요에 따라 backing field와 접근자를 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 backing field가 없는 경우도 있다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class Rectangle(val width: Int, val height: Int) {
    val area: Int
        get() = width * height
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;area&lt;/code&gt; 프로퍼티는 값을 저장하지 않고 매번 계산해서 반환한다. 디컴파일하면&lt;/p&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;public final class Rectangle {
   private final int width;
   private final int height;

   public final int getArea() {
      return this.width * this.height;
   }

   // area 필드는 생성되지 않음
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;getArea()&lt;/code&gt; 메서드만 생성되고 &lt;code&gt;area&lt;/code&gt; 필드는 생기지 않는다. 코틀린 컴파일러는 커스텀 getter만 있고 값을 직접 참조하지 않으면 backing field가 필요 없다고 판단한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 중요한 이유는, 프로퍼티 문법으로 &quot;필드처럼 보이지만 실제로는 계산된 값&quot;을 만들 수 있기 때문이다. 외부에서는 &lt;code&gt;rectangle.area&lt;/code&gt;로 접근하지만, 내부적으로는 메서드 호출이다. Java에서는 이걸 명시적으로 &lt;code&gt;getArea()&lt;/code&gt; 메서드로 만들어야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린이 backing field를 자동으로 관리하는 이유는 균일한 접근 원칙(Uniform Access Principle)을 따르기 위해서다. 저장된 값이든 계산된 값이든, 사용하는 쪽에서는 동일한 방식으로 접근한다. 구현을 나중에 바꿔도 호출 코드는 변경할 필요가 없다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Expression과 Statement&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린이 자바와 다르게 &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;when&lt;/code&gt;, &lt;code&gt;try-catch&lt;/code&gt;를 식(Expression)으로 사용할 수 있다는 점도 이번 주에 확인했다.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;val message = if (score &amp;gt;= 60) &quot;Pass&quot; else &quot;Fail&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바에서는 이런 코드를 삼항 연산자로 작성하거나 if문 안에서 변수를 할당해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 코틀린은 모든 제어 구조를 Expression으로 만들었을까? 자바의 &lt;code&gt;if&lt;/code&gt;는 Statement다. Statement는 값을 반환하지 않고 동작만 수행한다. 그래서 조건에 따라 변수에 다른 값을 할당하려면 변수를 미리 선언하고 if문 안에서 재할당해야 한다&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;String message;
if (score &amp;gt;= 60) {
    message = &quot;Pass&quot;;
} else {
    message = &quot;Fail&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식의 문제는 &lt;code&gt;message&lt;/code&gt;를 &lt;code&gt;var&lt;/code&gt;로 선언해야 한다는 점이다. 불변성을 지키기 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린은 함수형 프로그래밍의 영향을 받아 &quot;모든 제어 구조는 값을 반환해야 한다&quot;는 원칙을 따른다. 이렇게 하면 불변 변수를 더 자연스럽게 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바이트코드를 비교해보면 흥미로운 점이 있다.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;val message = if (score &amp;gt;= 60) &quot;Pass&quot; else &quot;Fail&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 디컴파일하면&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;String message = score &amp;gt;= 60 ? &quot;Pass&quot; : &quot;Fail&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삼항 연산자로 변환된다. 자바에서 Statement로 작성한 버전과 비교해보자&lt;/p&gt;
&lt;pre class=&quot;cmake&quot;&gt;&lt;code&gt;// Statement 버전
String message;
if (score &amp;gt;= 60) {
    message = &quot;Pass&quot;;
} else {
    message = &quot;Fail&quot;;
}

// Expression 버전 (코틀린 디컴파일 결과)
String message = score &amp;gt;= 60 ? &quot;Pass&quot; : &quot;Fail&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바이트코드를 보면 두 방식의 차이가 명확하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Statement 버전은&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;0: aload_1        // 변수 선언 (초기화되지 않은 상태)
1: iload_2        // score 로드
2: bipush 60      // 60 로드
4: if_icmplt 15   // 비교 후 점프
7: ldc &quot;Pass&quot;     // &quot;Pass&quot; 로드
9: astore_1       // message에 저장
10: goto 18
13: ldc &quot;Fail&quot;    // &quot;Fail&quot; 로드
15: astore_1      // message에 저장&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Expression 버전은&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;0: iload_1        // score 로드
1: bipush 60      // 60 로드
3: if_icmplt 12   // 비교 후 점프
6: ldc &quot;Pass&quot;     // &quot;Pass&quot; 로드
8: goto 14
11: ldc &quot;Fail&quot;    // &quot;Fail&quot; 로드
13: astore_0      // message에 한 번만 저장&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Statement 버전은 변수에 값을 두 번 할당(&lt;code&gt;astore&lt;/code&gt;)하지만, Expression 버전은 한 번만 할당한다. 코드 실행 경로가 더 단순하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 중요한 건 컴파일러가 변수를 최적화할 수 있다는 점이다. Expression으로 선언된 변수는 딱 한 번만 할당되므로 컴파일러가 상수로 취급하거나 인라인할 수 있다. Statement 버전은 여러 경로에서 재할당 가능성이 있어서 최적화가 제한된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;when&lt;/code&gt;도 마찬가지다&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;val result = when (type) {
    &quot;A&quot; -&amp;gt; processA()
    &quot;B&quot; -&amp;gt; processB()
    else -&amp;gt; processDefault()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바에서는 switch가 Expression이 아니라서 Java 14부터 switch expression이 추가됐다. 코틀린은 처음부터 이 방식을 택했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;부 생성자의 위치&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부 생성자(Secondary Constructor)는 주 생성자가 없거나 추가적인 초기화 로직이 필요할 때 사용한다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class User(val name: String) {
    var age: Int = 0

    constructor(name: String, age: Int) : this(name) {
        this.age = age
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부 생성자는 반드시 주 생성자를 호출해야 한다. 디컴파일해보면&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;public final class User {
   @NotNull
   private final String name;
   private int age;

   // 주 생성자
   public User(@NotNull String name) {
      this.name = name;
      this.age = 0;
   }

   // 부 생성자
   public User(@NotNull String name, int age) {
      this(name);  // 주 생성자 호출
      this.age = age;
   }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부 생성자가 주 생성자를 먼저 호출하고, 그다음 자신의 본문을 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바에서는 생성자 오버로딩이 자유롭다&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class User {
    private String name;
    private int age;

    public User(String name) {
        this.name = name;
        this.age = 0;
    }

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린은 왜 &quot;반드시 주 생성자를 호출해야 한다&quot;는 제약을 뒀을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주 생성자에 프로퍼티를 선언하는 코틀린의 특성 때문이다. 주 생성자의 파라미터가 프로퍼티가 되므로, 모든 생성 경로가 주 생성자를 거쳐야 프로퍼티 초기화가 보장된다. 자바는 필드를 클래스 본문에 선언하므로 각 생성자에서 자유롭게 초기화할 수 있지만, 코틀린은 프로퍼티 초기화를 주 생성자에 집중시켰다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 기본값을 가진 파라미터가 있다면 부 생성자 대부분을 대체할 수 있다&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class User(val name: String, val age: Int = 0)

// 두 가지 방식으로 생성 가능
val user1 = User(&quot;Alice&quot;)      // age는 0
val user2 = User(&quot;Bob&quot;, 25)    // age는 25
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린 공식 문서에서도 &quot;가능하면 기본값 파라미터를 사용하고, 부 생성자는 자바 라이브러리 호환성이 필요할 때만 사용하라&quot;고 권장한다. 부 생성자의 사용 빈도가 낮은 이유다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이번 주 배운 것&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 생성자 초기화는 순서가 아니라 원칙이다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린의 생성자 실행 순서는 주 생성자 파라미터 &amp;rarr; 프로퍼티 초기화/init 블록(작성 순서대로) &amp;rarr; 부 생성자 본문이다. 중요한 건 이 순서가 &quot;규칙&quot;이 아니라 &quot;예측 가능한 초기화를 보장하기 위한 설계&quot;라는 점이다. 코드에 작성한 순서가 곧 실행 순서가 되므로, 초기화 의존성을 쉽게 파악할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Backing Field는 구현 세부사항을 숨긴다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로퍼티는 backing field가 있을 수도, 없을 수도 있다. 커스텀 getter만 있고 값을 저장하지 않으면 backing field가 생성되지 않는다. 이건 단순한 최적화가 아니라 &quot;균일한 접근 원칙&quot;을 따르기 위한 설계다. 저장된 값이든 계산된 값이든, 호출하는 쪽에서는 구분할 필요가 없다. 나중에 구현을 바꿔도 인터페이스는 유지된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. Expression 중심 설계는 불변성을 강제한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린은 if, when, try-catch를 Expression으로 만들어서 값을 반환하게 했다. 이렇게 하면 변수를 한 번만 할당하는 패턴을 자연스럽게 유도할 수 있다. 바이트코드 수준에서도 할당 횟수가 줄어들고 컴파일러 최적화 여지가 생긴다. 자바가 Java 14에서야 switch expression을 도입한 것과 비교하면, 코틀린은 처음부터 함수형 프로그래밍의 영향을 받았다는 걸 알 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 부 생성자의 제약은 프로퍼티 초기화 보장을 위한 것이다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부 생성자가 반드시 주 생성자를 호출해야 하는 이유는, 주 생성자에 프로퍼티가 선언되기 때문이다. 모든 생성 경로가 주 생성자를 거쳐야 프로퍼티 초기화가 보장된다. 자바처럼 자유로운 생성자 오버로딩보다, 기본값 파라미터를 사용하는 게 코틀린스러운 방식이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다음 주 계획&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2주차에는 상속, 인터페이스, 추상 클래스를 다룬다. 이번 주에 프로퍼티와 backing field를 이해했으니, 인터페이스에서 프로퍼티를 선언했을 때 어떻게 변환되는지 확인할 수 있을 것 같다. 인터페이스는 상태를 가질 수 없다는 자바의 제약을 코틀린이 어떻게 다루는지 보고 싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 코틀린이 기본적으로 모든 클래스를 &lt;code&gt;final&lt;/code&gt;로 만든 이유를 알아볼 예정이다. &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;abstract&lt;/code&gt; 키워드가 왜 필요한지, 이게 &quot;상속보다 조합&quot;이라는 원칙과 어떻게 연결되는지 공식 문서를 통해 정리하려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;data class&lt;/code&gt;와 &lt;code&gt;object&lt;/code&gt;의 내부 구현도 디컴파일로 확인해볼 계획이다. 특히 &lt;code&gt;copy()&lt;/code&gt; 메서드가 어떻게 생성되고, &lt;code&gt;object&lt;/code&gt;가 싱글톤을 어떤 방식으로 구현하는지 바이트코드 수준에서 파악하고 싶다.&lt;/p&gt;</description>
      <category>우아한테크코스/레벨0</category>
      <category>Kotlin</category>
      <category>레벨0</category>
      <category>우테코</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/33</guid>
      <comments>https://angrypodo.tistory.com/33#entry33comment</comments>
      <pubDate>Mon, 2 Feb 2026 22:11:20 +0900</pubDate>
    </item>
    <item>
      <title>ProcessPhoenix 제거, 자기전에 생각날거야</title>
      <link>https://angrypodo.tistory.com/32</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;저는 안드로이드 개발을 하면서 에뮬레이터, 실제 디바이스를 오가면서 앱을 테스트하고 있어요. 디바이스의 경우에는 패드와 저/고사양 기기로 총 3가지를 사용하는데요. 그중에서 저사양 기기에서 앱이 재시작 되는 로직에 프레임드랍이 생기거나 재시작이 무시되는 경우가 종종 발견됐습니다. 처음엔 단순히 기기 성능 문제라고 생각했습니다. 그러나 충분히 최적화 가능하지 않을까? 라는 생각으로 코드를 뜯어보니 사용하던 &lt;code&gt;ProcessPhoenix&lt;/code&gt; 라이브러리가 생각보다 무거운 작업을 하고 있었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;좀 무거운듯&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hi-lingual/Hilingual-Android&quot;&gt;하이링구얼&lt;/a&gt; 프로젝트에서는 토큰 만료, 로그아웃, 회원탈퇴의 경우 앱을 재시작하고 있습니다. 그래서 &lt;code&gt;ProcessPhoenix&lt;/code&gt;라는 라이브러리를 사용했습니다. 많은 안드로이드 프로젝트에서 사용하는 검증된 라이브러리였으니까요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 기존 코드, 아주 간단하다
override fun restartApp() {
    ProcessPhoenix.triggerRebirth(context)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단 한 줄로 앱 재시작을 구현할 수 있으니 편리했습니다. 하지만 이 편리함 뒤에는 우리가 모르던 비용이 숨어 있었어요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;ProcessPhoenix가 하는 일&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브러리 내부를 들여다보니 재시작을 위해 별도의 프로세스 &lt;code&gt;:phoenix&lt;/code&gt;를 생성하고 있었습니다. 앱을 종료한 뒤 새 프로세스가 다시 앱을 실행시키는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티 프로세스 생성은 메모리 오버헤드를 동반합니다. 특히 RAM이 부족한 저사양 기기에서는 시스템이 새 프로세스 생성을 거부하거나 기존 프로세스를 강제 종료할 수 있어요. 실제로 저사양 기기의 경우 하드웨어 스펙은 4GB였지만 실제 여유공간은 평균 1~2GB였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 앱을 재시작하는 기능에 멀티 프로세스가 필요할까요? 우리가 원하는건 메모리 상의 앱 데이터와 액티비티를 재시작하는건데&amp;hellip; 때문에 가볍게 만들기 위해서 라이브러리를 제거하고 네이티브 API로 직접 구현해보기로 했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;네이티브 API로 다시 만들기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안드로이드는 앱 재시작을 위한 표준 방법을 제공합니다. Intent로 런처 액티비티를 다시 시작하고 기존 태스크를 정리하면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;override fun restartApp() {
    val intent = context.packageManager
        .getLaunchIntentForPackage(context.packageName)
        ?.apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
        } ?: return

    context.startActivity(intent)
    killCurrentProcess()
}

private fun killCurrentProcess() {
    Process.killProcess(Process.myPid())
    exitProcess(0)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 조금 길어졌지만 동작은 명확합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;현재 앱의 런처 인텐트를 가져옵니다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FLAG_ACTIVITY_NEW_TASK&lt;/code&gt;와 &lt;code&gt;FLAG_ACTIVITY_CLEAR_TASK&lt;/code&gt; 플래그로 기존 태스크를 정리합니다&lt;/li&gt;
&lt;li&gt;새로운 액티비티를 시작합니다&lt;/li&gt;
&lt;li&gt;현재 프로세스를 종료합니다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별도의 프로세스를 만들지 않고 단일 프로세스 내에서 모든 작업이 처리됩니다. 라이브러리 의존성도 사라졌어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Compose에 맞는 아키텍처 적용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이티브 구현으로 바꾸면서 UI 레이어의 구조도 개선했습니다. 기존에는 각 화면에서 &lt;code&gt;Context&lt;/code&gt;를 직접 참조했어요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 기존 방식
val context = LocalContext.current
// ...
ProcessPhoenix.triggerRebirth(context)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI 컴포넌트가 &lt;code&gt;Context&lt;/code&gt;와 외부 라이브러리에 직접 의존하는 구조였습니다. 또한 UI모듈에서 앱을 재시작하기 위해서는 &lt;code&gt;ProcessPhoenix&lt;/code&gt; 의존성을 꼭 추가해야 하는 구조기도 해요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CompositionLocal 도입&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;CompositionLocal&lt;/code&gt;을 사용해서 UI 컴포넌트가 추상화된 인터페이스에만 의존하도록 바꿨습니다.&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;val LocalAppRestarter = staticCompositionLocalOf&amp;lt;AppRestarter&amp;gt; {
    error(&quot;No AppRestarter provided&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;code&gt;MainActivity&lt;/code&gt;에서 Hilt로 주입받은 구현체를 제공하고 각 화면에서는 &lt;code&gt;LocalAppRestarter.current&lt;/code&gt;로 접근합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// UI 레이어
val appRestarter = LocalAppRestarter.current
// ...
is MyPageSideEffect.RestartApp -&amp;gt; appRestarter.restartApp()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Context&lt;/code&gt; 의존성이 사라지면서 UI 컴포넌트는 순수한 인터페이스에만 의존하게 되었어요. 추후 테스트 혹은 다른 구현체로의 교체도 수월해졌습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;생각보다 간단했던 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;검증된 라이브러리를 왜 굳이 직접 만들어?&quot; 하는 의구심도 있었습니다. 하지만 막상 구현해보니 생각보다 간단했어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 외부 라이브러리를 사용할 때는 내부 구현을 자세히 살펴보지 않게 됩니다. ProcessPhoenix를 도입할 때도 &quot;재시작이 잘 되네&quot;라는 결과만 확인했지 멀티 프로세스를 생성한다는 구현 방식까지는 신경 쓰지 않았어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 직접 구현한 코드는 모든 동작이 눈에 보입니다. Intent 생성, 플래그 설정, 프로세스 종료까지 각 단계가 무엇을 하는지 명확해요. 문제가 생겼을 때 디버깅하기도 훨씬 쉽습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;의존성에 대해 다시 생각하다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업을 하면서 외부 라이브러리를 도입할 때 고민해야 할 질문들이 생겼습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;이 라이브러리가 정확히 무엇을 하는가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 &quot;앱을 재시작한다&quot;는 추상적인 기능만 알고 있었습니다. 멀티 프로세스를 생성한다는 구체적인 구현 방식은 몰랐습니다.  앞으로는 간단하게라도 라이브러리를 도입하기 전에 내부 구현을 살펴보는 습관을 만드려고 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;이 기능을 직접 구현하기 어려운가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ProcessPhoenix가 제공하는 기능은 안드로이드 네이티브 API로도 충분히 구현 가능했습니다. 20줄 정도의 코드로 같은 기능을 만들 수 있다면 라이브러리가 필요한지 고민해도 좋을 것 같아요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;유지보수 비용은 어떤가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 라이브러리는 사용하는 개발자가 통제할 수단이 한정적이거나 없습니다. AGP 9.0 업데이트를 하면서 Hilt가 호환성 문제를 겪었던 것처럼요. 하지만 직접 구현한 코드는 우리가 완전히 통제할 수 있습니다. 로그를 언제든 추가할수 있고 로직을 완전히 제어 할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;앞으로 확인해야 할 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지는 가설과 코드 분석에 기반한 개선이었습니다. 직접 확인했을때는 앱의 동작도 문제가 없었고 체감상 속도가 개선됐고 프레임 드랍이 사라졌지만 실제로 성능이 개선되었는지 확인하려면 데이터가 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;측정해야 할 지표&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;저사양 기기에서 재시작 시 멈춤 현상 발생 빈도&lt;/li&gt;
&lt;li&gt;시스템에 의한 강제 종료 발생 빈도&lt;/li&gt;
&lt;li&gt;재시작 완료까지 소요 시간&lt;/li&gt;
&lt;li&gt;메모리 사용량 변화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 데이터를 수집하면 ProcessPhoenix 제거가 실제로 효과가 있었는지 확인할 수 있습니다. 특히 2GB 이하 RAM 기기에서의 개선 정도가 궁금하네요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;편리하다&quot;는 이유만으로 라이브러리를 도입하면 그 비용을 놓치기 쉽습니다. 이번 작업을 통해 외부 의존성을 도입할 때 더 신중하게 생각하게 되었어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 라이브러리를 직접 구현하자는 이야기는 아닙니다. 복잡한 기능이나 검증이 필요한 영역에서는 라이브러리를 잘 활용하는것이 좋다고 생각해요. 다만 그 라이브러리가 무엇을 하는지 우리가 직접 만들 수 없는 것인지를 먼저 생각해보면 좋을 것 같습니다. 가끔은 라이브러리를 걷어내는 것도 좋은 리팩터링이라고 생각해요  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘도 긴 글 읽어주셔서 감사합니다. 같은 문제가 있었다면 도움이 되셨으면 좋겠어요!&lt;/p&gt;</description>
      <category>Android/Compose</category>
      <category>Android</category>
      <category>compose</category>
      <category>CompositionLocal</category>
      <category>ProcessPhoenix</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/32</guid>
      <comments>https://angrypodo.tistory.com/32#entry32comment</comments>
      <pubDate>Sun, 1 Feb 2026 02:44:37 +0900</pubDate>
    </item>
    <item>
      <title>[우아한테크코스] 8기 최종 코테 후기, 도움이 된다</title>
      <link>https://angrypodo.tistory.com/31</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요. 매우 뜬금없지만 우아한테크코스 8기 최종 코딩테스트를 보고 왔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리코스를 진행하는 건 이미 지난 글들을 보셨다면 알고 계셨을 것 같은데요. 오늘은 갑자기 1차 합격하고 최종 코테후기를 적어보겠습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;나야, 1차합격&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 프리코스가 종료된 후에는 다른 프로젝트를 하느라 그렇게 신경을 쓰지 못했습니다. 1차 발표 날이 언제지 하고 찾아봤을 때가 4일 전인가... 그랬었네요. 이번 8기 프리코스에서는 오픈 미션이라는 특수한 미션이 도입됐었어요. 해당 미션의 주제로는 프로젝트를 진행하고 공부를 하면서 불편했던 부분을 다른 개발자들이 편했으면 좋겠다는 마음에 덜컥 라이브러리를 개발하기도 하면서 지냈습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 발표 날 늦잠을 좀 자고 일어나서 메일을 기다렸습니다. 제목을 안 보고 살떨리게 오픈하고 싶었지만 미리보기에서 바로 알아버렸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;633&quot; data-origin-height=&quot;459&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kIA2G/dJMcaf6vKHa/Fvf4PyYw6SkRT4go3xKkf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kIA2G/dJMcaf6vKHa/Fvf4PyYw6SkRT4go3xKkf0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kIA2G/dJMcaf6vKHa/Fvf4PyYw6SkRT4go3xKkf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkIA2G%2FdJMcaf6vKHa%2FFvf4PyYw6SkRT4go3xKkf0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;633&quot; height=&quot;459&quot; data-origin-width=&quot;633&quot; data-origin-height=&quot;459&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기쁘면서도 되게 복잡했습니다. 이번에 당근 윈터테크 인턴십을 지원했었는데 최종에서 탈락하게 됐고, 그 뒤로 약간 번아웃 비슷하게 의욕이 없었습니다.. 첫 지원이기도 했고 준비도 많이 했고 기대도 많이 해서 그랬을까요? 이제 와서 보면 배부른 놈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그때문인지 메일을 받고도 &quot;코테를 잘 볼 수 있을까?&quot;라는 걱정이 더 많았던 것 같아요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;알고리즘 스트레스 많이 받는다. 그런 스트레스도 필요해&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 저는 알고리즘을 좋아하지 않습니다. 수학을 싫어하진 않지만 주입식 이해 못 해도 외우고 넘어가야 하는 교육이 정말 싫은 사람입니다. 그래서 개발도 저는 제가 재밌어야 합니다. 지금도 그렇고요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 하고 있는 프로젝트는 정말 재밌습니다. 안 풀리면 스트레스도 받고 답답한 것도 있지만 내가 설계해서 이뤄나가고 있는 걸 보면 보람차다는 생각이 드니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 저는 &lt;b&gt;문제를 해결하는 걸 좋아하지 문제를 푸는 걸 좋아하진 않습니다.&lt;/b&gt; 당근에서도 면접은 대답을 쭉쭉(내 기준) 했지만 코테를 정말 못 봤거든요ㅋㅋ 그렇다 보니 걱정이 앞섰던 것 같습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;우테코를 선택한 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 전공자이면서도 부족함을 느끼고 있었습니다. 대학에서 배우는 게 도움이 되는 것도 있고 아닌 것도 있었어요. 그리고 직접 부딪히면서 개발을 하다 보니 남들은 다 아는 무슨 아키텍처 무슨 지향 무슨 방법론 이런 것도 아예 모르고 헤딩하던 때도 있었습니다. 지금도 비슷하긴 합니다 &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 제가 납득이 되지 않는 건 이해가 잘 안 되는 성격입니다. 누군가 좋다고 말하면 왜 좋은지 납득하고 이해해야 합니다. 그렇지 않으면 와닿지 않아요. 되게 피곤한 성격이고 어떤 한 가지를 배울 때 시간이 많이 드는 스타일입니다. 하지만 그렇게 부딪히면 단단한 가치관이 항상 남아서 저는 이걸 장점으로 활용하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 저는 &lt;b&gt;더 깊게, 근거를 생각하고 '왜?'라는 질문을 던지고 그 답을 명확하게 아는 개발자&lt;/b&gt;가 되고 싶었습니다. 근본이 되는 지식을 파고 싶었고 그런 개발자가 어느 시기든 흔들리지 않을 거라고 저는 생각했기 때문이에요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 우테코에 지원했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리코스를 지원하면서도 굉장히 많은 인사이트를 얻었고 안드로이드 개발에만 갇혀있었던 저에게 많은 다양한 시선을 가르쳐줬습니다. 8기 크루로 합류하게 됐을 때 얻을 게 분명 많을 거라고 생각도 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코테 준비 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 어떻게 준비를 할까 이틀 정도 고민만 한 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 기수의 문제를 풀어볼지, 이번 미션을 다시 풀어볼지, 아니면 더 어려운 문제를 AI에게 부탁해서 풀어볼지, 코틀린 공부나 할지 등등 많이 고민을 했는데요. 결론은 이랬습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리코스 1, 2, 3주차 미션을 하면서 제가 얻었던, 깨우쳤던 것들을 더 단단하게 하자.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;여기서 설계 제일 잘하는 친구야&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때까지 저는 최근에 진행한 프로젝트를 제외하고 개발 초기에 기능을 개발할 때 먼저 코드부터 짰습니다. 지금 보면 처음 개발을 하거나 아직 미숙할 때는 그게 맞는 것 같습니다. 직접 해보고 불편함을 느끼고 왜 안 되는지 보고 겪어봐야 잘못된 걸 아니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 점점 머리에 지식이 차고, 신규 개발보다 관리와 확장을 생각하게 되니 &lt;b&gt;설계의 중요성&lt;/b&gt;을 체감하고 있었습니다. 그렇게 하이링구얼이라는 프로젝트에서 리드를 맡고 설계를 해보면서 '아, 이거 제로부터 시작하기 힘들다'를 많이 느꼈습니다. 전공자이긴 하지만 주로 CS나 네트워크 시스템같은 강의만 있었고 설계에 대한 강의는 들어본 적도 개설도 안 됐거든요ㅎㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저는 프리코스를 하면서 설계에 대해 감명 깊었고 많이 배웠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구체적인 대화가 된다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코테 전까지 설계를 연습하자고 생각하고 1, 2, 3주차 미션의 설계를 처음부터 다시 하는 연습을 했습니다. 그렇게 평소에도 한두 시간씩 생각도 해보고 직접 작성도 해보고요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리코스의 공통 피드백을 계속 읽었습니다. 읽으면서 이해가 되는 것도 있고 이해가 안 되는 것도 있었습니다. 그럴 때 AI에게 반대의 입장을 부여해서 토론도 해보면서 납득이 될 때까지 대화하고 그랬었네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면서 일급 객체를 사용해야 하는 이유나 객체를 객체답게 사용하는 법을 좀 어렴풋이 이해한 것 같아요. 이전까지 안드로이드 컴포즈를 쓰면서 컴포저블 함수가 객체처럼 느껴졌었는데 좀더 나은 개발이 될 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 여태까지는 절차지향적인 사고를 해왔던 걸 느꼈고, 좀 더 객체를 생각하도록 해봐야겠다고 연습도 했습니다. 아직까지도 객체지향은 어렵네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 사고하는 연습을 했습니다. 사실 코틀린 문법이나 API 사용 방법, 알고리즘은 검색하면 나오잖아요? 저는 그런 걸 암기하는 걸 싫어합니다. 쓰다 보면 외워지는 거고 필요할 때 찾아서 공부하면 된다고 생각하거든요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문법을 왜 그렇게 설계했는지 알고리즘이 왜 탄생했는지 그런 이유와 근거를 배우면 자연스럽게 암기가 된다고 생각합니다.&lt;/b&gt; 사실 이해를 하는 것이라고 하고 싶네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어... 아무튼 문제를 푸는 연습보다 &lt;b&gt;사고하는 연습&lt;/b&gt;을 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 쭉 연습을 하고 코테 전날에 문제를 딱 한 번만 풀었습니다. 3주차 로또 미션에서 살짝만 난이도를 올린 문제로 5시간 동안 설계/구현/테스트를 진행하고, AI에게 채점을 맡기고 해당 피드백을 통해서 가지고 있던 습관이나 생각을 다시 잡고 잠들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리코스를 하면서 느꼈던 건, &lt;b&gt;여태까지 감으로 맞추던 걸 생각으로 구체화하는 연습이 되었다&lt;/b&gt;는 점입니다. 결과적으로 맞는 선택이었어도 '왜?'인지를 알고 하는 것과 모르고 하는 것엔 큰 차이가 있다고 생각합니다. 그래서 여태까지 감이 좋아서 하던 행동들에 의미를 찾고 근거를 찾으면서 프리코스 기간이 연습이 되었고, 사고하는 연습을 조금만 하니 크게 걱정은 안 됐던 것 같아요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시험 당일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 테스트 장소는 우아한형제들의 작은집에서 진행됐는데요. 마침 제가 잠실에 거주 중이고 8호선으로 한 번에 갈 수 있어서 30분도 안 걸리는 거리였습니다(야호). 그래서 아침 일찍 일어나서 어제 봤던 피드백을 훑고 바로 출발했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에만 그런 건지는 모르겠습니다만, &lt;b&gt;검색, 준비한 문서, 기존 코드 참고가 가능&lt;/b&gt;했고 LLM AI를 이용한 방법만 금지였습니다. 그래서 템플릿을 준비할지 문서를 준비해 갈지 고민했지만, 저는 &lt;b&gt;온전히 나로서 시험을 보자&lt;/b&gt;는 생각으로 맥북만 챙겨서 털레털레 갔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1761&quot; data-origin-height=&quot;2666&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IYTOI/dJMcagj2Snm/bWxKvTgvxpusDlZhAtdK40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IYTOI/dJMcagj2Snm/bWxKvTgvxpusDlZhAtdK40/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IYTOI/dJMcagj2Snm/bWxKvTgvxpusDlZhAtdK40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIYTOI%2FdJMcagj2Snm%2FbWxKvTgvxpusDlZhAtdK40%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;606&quot; data-origin-width=&quot;1761&quot; data-origin-height=&quot;2666&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노션에 뭔가 준비하거나 그러지는 않았고, 그냥 평소보다 조금만 더 잘하자라는 마인드셋으로 테스트에 임했습니다. 시험장에 도착해서 매일 보던 GitHub 피드를 뒤적거리고 다른 사람 작업 구경하면서 시간을 기다렸고, 1시부터 바로 시작했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이번 문제는?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번은 다른 기수와는 조금 다른 성격의 문제였던 것 같습니다. &lt;b&gt;정말 본인이 생각해 오면서 개발을 했는지 묻는 것&lt;/b&gt; 같았어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 5시간 중에 &lt;b&gt;4시간이 코드를 작성하고 제출하는 시간&lt;/b&gt;이었고, 나머지 1시간은 소감문을 작성하는 시간이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 4시간 중&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;30분&lt;/b&gt;: 문제를 이해하고 구현을 설계&lt;/li&gt;
&lt;li&gt;&lt;b&gt;2시간&lt;/b&gt;: 구현&lt;/li&gt;
&lt;li&gt;&lt;b&gt;나머지&lt;/b&gt;: 검토 및 추가 과제였던 리팩토링&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 계획했어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 풀이 과정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설계 단계 (40분)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 쭉 읽으면서 README를 작성하다 보니 설계에 40분가량을 소모한 것 같습니다. 예상보다 시간을 더 사용했고 계획에 변경이 되어서 조금 걱정을 했으나, 그만큼 설계가 견고해졌다고 생각하고 바로 구현에 집중했어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 설계에 시간을 많이 사용한 이유 중 하나는 &lt;b&gt;일급 객체를 최대한 활용해 보려 한 점&lt;/b&gt;인 것 같아요. 원시 타입을 지양하고 도메인스러운 객체를 만들려고 하다 보니 약간 아리송한 생각은 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제껏 안드로이드 개발을 하면서 정말 도메인에 대한 진중한 고민을 해볼 기회가 없었기도 했고, 현대 실무가 아닌 수준에서 도메인을 고민할 만한 프로젝트가 흔하지 않다고 생각했었거든요. 그래서 더 일급 객체를 활용하려고 했고, 더 나아가서 &lt;b&gt;TDA(Tell, Don't Ask) 원칙&lt;/b&gt;을 최대한 지켜보려고 했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현 단계 (1시간)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설계한 대로 구현을 하고 테스트 코드를 중간중간 작성하면서 의도에 맞게 동작하는지 확인했습니다. 그렇게 집중해서 테스트 코드를 작성하고 주어진 예제 테스트를 모두 통과시키고 보니, 구현은 오히려 &lt;b&gt;1시간 만에 다 해버렸어요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;남은 시간이 두 시간이 조금 넘어서 '어라?' 싶었습니다. 이거 너무 쉽게 풀었나? 내가 이 정도면 다른 분들은 더 쉽게 했을 것 같은데 싶어서 오히려 불안했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드 리뷰, 리팩토링 (2시간+)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10분 정도는 제가 작성한 코드를 리뷰했는데요. 설계대로 구현했고 설계의 허점도 수정해 가면서 살아있는 문서도 만족했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 저는 이번 추가 도전으로 뭘 할까 고민을 했는데 &lt;b&gt;리팩토링&lt;/b&gt;을 하기로 했습니다. 어쨌든 제한된 시간 안에서 설계를 했고 실제로 코드를 작성하면서 설계를 수정한 부분도 있었어요. 때문에 완성도 높은 설계를 위해서 저는 리팩토링을 선택했고 좀 더 의존도를 낮추고 테스트하기 쉬운 구조로 만들기로 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문서에 리팩토링 섹션을 추가하고 리팩토링을 진행했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;제출&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 어찌 보면 순조롭게 구현을 마치고 &lt;b&gt;종료 5분 전에 제출을 완료&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;782&quot; data-origin-height=&quot;1080&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dqRklx/dJMcacopKb3/Xh9hvSIkZKh5tkQLQxIGSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dqRklx/dJMcacopKb3/Xh9hvSIkZKh5tkQLQxIGSK/img.png&quot; data-alt=&quot;(나름 고생했다ㅎ)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dqRklx/dJMcacopKb3/Xh9hvSIkZKh5tkQLQxIGSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdqRklx%2FdJMcacopKb3%2FXh9hvSIkZKh5tkQLQxIGSK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;691&quot; data-origin-width=&quot;782&quot; data-origin-height=&quot;1080&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;(나름 고생했다ㅎ)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;소감문 작성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현을 마치고 소감문을 작성했는데요. 담담하게 적어 내려갔습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;왜 우테코에 지원하게 됐고&lt;/li&gt;
&lt;li&gt;프리코스에서 어떤 걸 배웠고 어떤 성장을 했으며&lt;/li&gt;
&lt;li&gt;어떻게 습득을 했는지&lt;/li&gt;
&lt;li&gt;프리코스만으로도 정식적으로 꽤나 성장을 했는데, 본 코스에 들어간다면 얼마나 성장할지에 대한 기대&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 걸 써내려갔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그중에서도 기억에 남는 건&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;화려한 코드를 짜면서 잘하는 척해왔던 것 같다. 그러나 이제는 본질을 중요시하고 기본을 잃지 않는 개발자로 성장하고 싶다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 소감문을 20분 정도 써내려갔고 오타나 두서를 정리하고 소감문까지 제출했습니다. 그렇게 나오니 5시 반 정도였던 것 같아요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇게 최종 테스트는 종료가 되었고 집에 돌아갔습니다. 후련하면서도 약간은 불안했네요. 너무 술술 풀려서 제대로 한 게 맞나 세 번은 확인한 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프리코스 같이 달려오신 분들 모두 수고 많으셨고, 좋은 결과 있길 바랍니다ㅎㅎ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음은 합불 후기로 돌아오겠습니다.&lt;/p&gt;</description>
      <category>우아한테크코스/프리코스</category>
      <category>우아한테크코스</category>
      <category>우테코</category>
      <category>최종코테</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/31</guid>
      <comments>https://angrypodo.tistory.com/31#entry31comment</comments>
      <pubDate>Sat, 31 Jan 2026 01:27:28 +0900</pubDate>
    </item>
    <item>
      <title>BaseViewModel을 쓰지 않는 이유</title>
      <link>https://angrypodo.tistory.com/30</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;다른 프로젝트 코드를 볼 때마다 BaseActivity, BaseViewModel 같은 베이스 클래스를 어렵지 않게 볼 수 있습니다. 하지만 솔직히 저는 이런 구조를 이해하지 못했습니다. 항상 왜 이렇게 만들었을까, 어떤 점이 좋다는 걸까 하는 의문을 계속 가졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 쓰는 이유에 대해서 어느정도 짐작이 가긴합니다. 보통은 공통 로직을 한 곳에 모아두면 편하고 코드 중복을 줄이면서 일관된 구조를 유지하는 이점이 있습니다만 그 장점만으로는 베이스 클래스를 쓰지 않는 편이 낫다고 생각했어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 그런 기시감이 들었는지 계속 궁금했는데 최근에 본 영상에서 제가 막연하게 느꼈던 불편함에 대해서 구체화 한것 같았습니다. 그래서 그 내용을 바탕으로 제 생각을 정리해봤습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;클래스 네이밍으로는 아무것도 알 수 없음&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 한번 볼까요?&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class TodoListViewModel(
    // ...
) : BaseViewModel&amp;lt;TodoListState, TodoListAction, BaseEvent&amp;gt;() {
    
    override val initialState = TodoListState()
    
    override fun onAction(action: TodoListAction) {
        // ...
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드만 봤을 때 BaseViewModel이 정확히 무엇을 하는지 느껴지시나요? initialState와 onAction을 오버라이드한다는 건 보이지만 그 외에는 BaseViewModel 파일을 직접 열어봐야 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 클래스명은 그 클래스의 책임을 바로 알려줘야 합니다. TodoRepository라는 이름만 봐도 &quot;Todo 데이터를 관리하는 클래스구나&quot; 하고 이해할 수 있어요. 하지만 BaseViewModel은 &quot;뷰모델들의 부모 클래스&quot;라는 것 외에는 아무것도 알려주지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베이스 클래스가 이런 이름을 가질 수밖에 없는 이유는 명확한 책임이 하나가 아니기 때문입니다. 여러 유틸리티 함수를 넣다 보니 적절한 이름을 붙이는 것 자체가 불가능해진다고 생각해요. 300자짜리 클래스명을 만들 게 아닌이상...&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;숨겨진 의존성이 너무 많다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성은 보통 생성자에 명시적으로 넣습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;class TodoListViewModel(
    private val todoRepository: TodoRepository
) : ViewModel() {
    // todoRepository에 의존한다는 게 바로 보인다
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 코드를 작성하면 뷰모델이 무엇을 사용하는지 한눈에 알 수 있습니다..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 상속을 사용하면 달라지는데요. BaseViewModel을 상속받는 순간 그 안에 있는 모든 의존성을 함께 가져오게 되는데 이게 자식 클래스에서는 전혀 보이지 않습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;abstract class BaseViewModel&amp;lt;State, Action, Event&amp;gt; : ViewModel() {
    
    private val analyticsTracker = AnalyticsTracker() // 보이지 않는 의존성
    
    init {
        trackScreenView() // 모든 ViewModel 생성할 때마다 자동 실행
    }
    
    private fun trackScreenView() {
        analyticsTracker.track(&quot;screen_view&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 TodoListViewModel을 만들 때마다 자동으로 화면 조회 이벤트가 전송됩니다. 하지만 TodoListViewModel의 코드 어디에도 이런 동작에 대한 힌트가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작은 프로젝트에서는 괜찮을 수도 있습니다. 하지만 프로젝트가 커지고 팀원이 늘어나면 누군가 BaseViewModel을 수정하는 순간 수십 개 뷰모델이 영향받게 됩니다. 예상하지 못한 동작이 생겨나고 의도에 맞지 않는 동작을 유발할 가능성이 증가합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;유연하게 대응하기 어렵다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하다 보면 요구사항이 수도없이 계속 바뀝니다. 어떤 화면은 애널리틱스 추적이 필요 없을 수도 있고 어떤 뷰모델은 상태 관리가 필요 없을 수도 있습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;abstract class BaseViewModel&amp;lt;State, Action, Event&amp;gt; : ViewModel() {
    
    abstract val initialState: State
    abstract fun onAction(action: Action)
    
    protected val _state = MutableStateFlow(initialState)
    val state = _state.asStateFlow()
    
    protected val _events = Channel&amp;lt;Event&amp;gt;()
    val events = _events.receiveAsFlow()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 모든 뷰모델이 State, Action, Event를 가져야 한다고 강제합니다. 하지만 실제로는 정적인 화면이라 상태가 필요 없거나 이벤트 채널이 필요 없는 경우도 있습니다. 그래도 베이스 클래스를 상속받으면 억지로 만들어야 합니다. 그렇다고 상속받지 않으려니 직접 구현해야하는 문제와 컨벤션이 틀어진다는 문제도 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 큰 문제는 Kotlin에서는 클래스 하나만 상속받을 수 있다는 겁니다. 만약 외부 라이브러리가 제공하는 클래스를 상속받아야 한다면 BaseViewModel을 포기하거나 코드를 중복해서 작성해야 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;코드는 시간이 지나면 쌓인다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 간단합니다. BaseViewModel에 상태 관리 로직만 넣을 생각이었지만 시간이 지나면서 이런 식으로 변할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;모든 화면에서 로딩을 보여줘야 하니까 showLoading() 함수를 추가하자.&quot;&lt;br /&gt;&quot;에러 처리도 공통으로 하면 좋겠는데, handleError() 함수를 넣자.&quot;&lt;br /&gt;&quot;권한 체크도 여러 곳에서 하니까 여기 넣으면 편하겠다.&quot;&lt;br /&gt;&quot;로그아웃 처리도 공통화하자.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 BaseViewModel은 수백 줄짜리 거대한 클래스가 되고 누구도 완벽하게 이해하지 못하는 레거시가 됩니다. 수정하려니 어디에 영향을 미칠지 모르겠고 그래서 아무도 손대지 못하는 코드가 되는 거죠.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그럼 어떻게 하면 좋을까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 기능이 필요하다면 상속보다는 컴포지션을 사용하는 편이 좋습니다. 공통 로직은 유틸리티나 별도의 클래스로 분리한 뒤 필요한 지점에서만 주입해 사용하면 됩니다. 구글에서도 권장하는 설계 방식이고 흔히 말하는 &amp;ldquo;상속보다는 조합&amp;rdquo; 원칙에 해당합니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class AnalyticsTracker {
    fun trackScreenView(screenName: String) {
        // 애널리틱스 추적 로직
    }
}

class TodoListViewModel(
    private val analyticsTracker: AnalyticsTracker // 명시적인 의존성
) : ViewModel() {
    
    init {
        // 필요할 때만 호출
        analyticsTracker.trackScreenView(&quot;todo_list&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 작성하면 TodoListViewModel의 의존성이 명확하게 나타나죠? 애널리틱스 추적이 필요 없는 뷰모델에서는 주입하지 않으면 그만입니다. 반복적인 보일러플레이트가 있다면 확장 함수로 빼는 방법도 있습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun &amp;lt;T&amp;gt; ViewModel.collectAsState(
    flow: Flow&amp;lt;T&amp;gt;,
    initial: T
): StateFlow&amp;lt;T&amp;gt; {
    return flow.stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = initial
    )
}

// 사용
class TodoListViewModel : ViewModel() {
    val todos = todoRepository.getTodos()
        .collectAsState(initial = emptyList())
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정말로 아키텍처 가이드라인을 강제하려고 상속을 쓰고 싶다면 최소한 명확한 이름을 붙이는게 좋습니다. BaseViewModel 대신 MviViewModel 같은 이름을 쓰면 &quot;MVI 패턴을 강제하는 클래스구나&quot; 하고 바로 이해할 수 있을 것 같습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;abstract class MviViewModel&amp;lt;State, Action&amp;gt; {
    abstract val initialState: State
    abstract fun onAction(action: Action)
    
    // MVI 패턴 강제를 위한 최소한의 코드만
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 방식도 완벽하지는 않습니다. 정적인 화면처럼 상태가 필요 없는 경우에도 무조건 State를 정의해야만 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;정리해보면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 프로젝트를 진행하면서 베이스 클래스를 적극적으로 쓴 적이 아직 없습니다. 사용해야 하는 이유에 대해서, 왜 쓰는지 공감하지 못했기 때문이에요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 쓰는 이유는 생각해봤습니다. 공통 로직을 재사용하고 일관된 패턴을 강제하고 보일러플레이트를 줄이려는 의도, 하지만 그것만으로는 부족하다고 느꼈습니다. 오히려 베이스 클래스를 쓰지 않는 편이 훨씬 이점이 크다고 생각했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 그런 기시감이 들었는지 이제야 좀 알 것 같아요. 대신 공통 기능은 별도 클래스로 분리해서 의존성 주입으로 사용하고 확장 함수로 반복 코드를 줄이고 정말 상속이 필요하면 명확한 이름과 단일 책임을 지키는 게 낫다고 생각합니다. 실제로 하이링구얼이라는 프로젝트에서 커스텀 플러그인을 적용할때 조합을 사용했습니다. &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러분은 어떻게 생각하시나요? 혹시 다른 의견이 있다면 언제든 피드백 환영합니다.&lt;/p&gt;</description>
      <category>Android</category>
      <category>Android</category>
      <category>BaseViewModel</category>
      <category>composition</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/30</guid>
      <comments>https://angrypodo.tistory.com/30#entry30comment</comments>
      <pubDate>Fri, 30 Jan 2026 00:09:55 +0900</pubDate>
    </item>
    <item>
      <title>AGP 9.0 마이그레이션, 뭐가 달라졌는데요?</title>
      <link>https://angrypodo.tistory.com/29</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;AGP 9.0.0이 정식 릴리즈 되었습니다. 버저닝을 적용할때까지만해도 적당히 한두줄 수정하면 되겠지 하고 업데이트를 시작했는데요..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무수한 빨간줄의 요청으로 빌드 시스템을 다시 설계하면서 겪은 경험을 공유하고자 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;달라진 것은 생각보다 많았다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AGP 9.0은 AGP 8.0 이후 2년 만의 메이저 릴리즈입니다. 단순한 기능 추가가 아니라 지난 몇 년간 준비해온 구조적 변화를 본격적으로 적용한 버전이었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;새로운 DSL이 기본값이 되다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AGP 7.x와 8.x에서는 하위 호환성 유지를 위해 구형 DSL 타입(&lt;code&gt;BaseExtension&lt;/code&gt; 등)과 신형 공개 인터페이스를 동시에 지원했어요. 그런데 AGP 9.0부터는 새로운 DSL 인터페이스만 사용하며 내부 구현이 완전히 숨겨진 새로운 타입으로 변경되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 &lt;code&gt;android.newDsl=true&lt;/code&gt;가 기본값이 되면서 구형 DSL 인터페이스는 더 이상 사용할 수 없습니다. &lt;code&gt;android.newDsl=false&lt;/code&gt;로 임시 회피할 수 있지만 AGP 10.0(2026년 중반 예정)에서는 이마저도 불가능해집니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CommonExtension의 변화가 가져온 파장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 큰 영향을 미친 변화는 &lt;code&gt;CommonExtension&lt;/code&gt;의 제네릭 타입 파라미터 제거입니다. 공식 문서에 따르면 미래의 소스 레벨 호환성 문제를 방지하기 위한 변경이지만 그 여파는 작지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 와일드카드 타입으로 &lt;code&gt;CommonExtension&amp;lt;*, *, *, *, *, *&amp;gt;&lt;/code&gt;를 선언하면 &lt;code&gt;buildTypes&lt;/code&gt;, &lt;code&gt;defaultConfig&lt;/code&gt; 같은 블록에 접근할 수 있었어요. 하지만 AGP 9.0부터는 이런 블록 메서드들이 &lt;code&gt;ApplicationExtension&lt;/code&gt;, &lt;code&gt;LibraryExtension&lt;/code&gt;, &lt;code&gt;DynamicFeatureExtension&lt;/code&gt; 등 각 구체 타입으로 옮겨갔습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이런 변경을 단행했을까요? 정확한 이유는 공식 문서에 명시되어 있지 않지만 몇 가지 추측해볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;타입 안전성 강화&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Application&lt;/code&gt;과 &lt;code&gt;Library&lt;/code&gt;는 근본적으로 다른 산출물을 만듭니다. 전자는 APK를 생성하고 &lt;code&gt;applicationId&lt;/code&gt;를 가지지만 후자는 AAR을 생성하며 &lt;code&gt;applicationId&lt;/code&gt;가 없어요. 이런 차이를 제네릭으로 추상화하다 보면 컴파일 타임에 잡아낼 수 있는 오류가 런타임으로 미뤄질 위험이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;API 변경의 여파 최소화&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제네릭 타입 파라미터가 6개나 되는 인터페이스는 유지보수가 어렵습니다. 타입 파라미터를 완전히 제거함으로써 향후 API 변경의 여파를 최소화하려는 의도로 보입니다. &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;책임 분리의 명확화&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Application&lt;/code&gt;과 &lt;code&gt;Library&lt;/code&gt;가 각자의 설정 블록을 갖게 되면서 &quot;이 설정은 어디에 속하는가?&quot;가 더 명확해집니다. 이런 변경은 개발자들이 빌드 로직을 이해하고 작성하는 데 도움이 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;내부 API 접근 차단&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AGP 9.0은 또한 내부 클래스에 대한 접근을 완전히 차단했어요. 기존에 많은 서드파티 플러그인이 &lt;code&gt;BaseExtension&lt;/code&gt; 같은 내부 타입에 의존하고 있었는데 이제는 공개 인터페이스만 사용해야 합니다. 이 변화로 인해 Paparazzi, 일부 버전의 Firebase 플러그인 등이 호환성 문제를 겪었고 플러그인 생태계 전체가 업데이트되어야 했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;나도 문제다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hilingual 프로젝트는 Convention Plugin 패턴을 사용해 빌드 로직을 관리하고 있습니다. &lt;code&gt;build-logic&lt;/code&gt; 모듈에서 &lt;code&gt;Application&lt;/code&gt;과 &lt;code&gt;Library&lt;/code&gt; 모듈의 공통 설정을 다루는 방식인데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AGP 8.x에서는 이렇게 작성할 수 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 기존 코드 (AGP 8.x)
fun Project.configureBuildTypes(
    commonExtension: CommonExtension&amp;lt;*, *, *, *, *, *&amp;gt;,
) {
    commonExtension.apply {
        buildFeatures {
            buildConfig = true
        }

        buildTypes {
            debug {
                buildConfigField(&quot;String&quot;, &quot;BASE_URL&quot;, &quot;\&quot;https://dev.api.example.com\&quot;&quot;)
            }
            release {
                buildConfigField(&quot;String&quot;, &quot;BASE_URL&quot;, &quot;\&quot;https://api.example.com\&quot;&quot;)
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제네릭 타입 파라미터를 와일드카드로 처리하면서 &lt;code&gt;Application&lt;/code&gt;과 &lt;code&gt;Library&lt;/code&gt; 양쪽 모두에서 사용할 수 있는 공통 함수를 만들 수 있었어요. 깔끔하고 편리합니다만..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AGP 9.0으로 업데이트하자마자 빨간줄 천지가 되었습니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Unresolved reference: buildTypes&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;CommonExtension&lt;/code&gt;에서 &lt;code&gt;buildTypes&lt;/code&gt;에 접근할 수 없다는 에러입니다. 타입 파라미터가 사라지면서 이런 블록들이 각 구체 타입으로 이동했기 때문이에요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 아직 AGP 9.0을 지원하지 않는 서드파티 플러그인들도 있습니다. 대표적으로 안드로이드 생태계의 의존성 주입인 &lt;code&gt;dagger-hilt&lt;/code&gt;가 있어요. 2.58 릴리즈에서도 AGP 9를 지원하지 않았는데요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;865&quot; data-origin-height=&quot;151&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8Jo1H/dJMcahpFyQ0/hSSS56SaANdIo45vdO4xNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8Jo1H/dJMcahpFyQ0/hSSS56SaANdIo45vdO4xNk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8Jo1H/dJMcahpFyQ0/hSSS56SaANdIo45vdO4xNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8Jo1H%2FdJMcahpFyQ0%2FhSSS56SaANdIo45vdO4xNk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;865&quot; height=&quot;151&quot; data-origin-width=&quot;865&quot; data-origin-height=&quot;151&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 작성중인 시간 1/21(수) 18:33 기준으로 8시간 전에 따끈한 업데이트가 나왔습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;870&quot; data-origin-height=&quot;204&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AOyX3/dJMcafrP9mv/BTt5h9gjhT7jOfa8xyedNK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AOyX3/dJMcafrP9mv/BTt5h9gjhT7jOfa8xyedNK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AOyX3/dJMcafrP9mv/BTt5h9gjhT7jOfa8xyedNK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAOyX3%2FdJMcafrP9mv%2FBTt5h9gjhT7jOfa8xyedNK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;870&quot; height=&quot;204&quot; data-origin-width=&quot;870&quot; data-origin-height=&quot;204&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 AGP 업그레이드는 단순히 우리 코드만 고치는 게 아니라 생태계 전체가 준비될 때까지 기다려야 하는 일이기도 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결 방법 찾기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 타입 분기로 접근하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 직관적인 방법은 &lt;code&gt;CommonExtension&lt;/code&gt;을 구체적인 타입으로 분기 처리하는 것이었습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun Project.configureBuildTypes(commonExtension: CommonExtension) {
    // 공통 로직을 함수로 분리
    fun configureBuildTypeFields(
        buildConfigField: (String, String, String) -&amp;gt; Unit, 
        isDebug: Boolean
    ) {
        val baseUrl = if (isDebug) {
            &quot;\&quot;https://dev.api.example.com\&quot;&quot;
        } else {
            &quot;\&quot;https://api.example.com\&quot;&quot;
        }
        buildConfigField(&quot;String&quot;, &quot;BASE_URL&quot;, baseUrl)
        buildConfigField(&quot;Boolean&quot;, &quot;DEBUG_MODE&quot;, isDebug.toString())
    }

    commonExtension.apply {
        when (this) {
            is ApplicationExtension -&amp;gt; {
                buildFeatures { 
                    buildConfig = true 
                }
                buildTypes {
                    getByName(&quot;debug&quot;) { 
                        configureBuildTypeFields(::buildConfigField, true) 
                    }
                    getByName(&quot;release&quot;) { 
                        configureBuildTypeFields(::buildConfigField, false) 
                    }
                }
            }
            is LibraryExtension -&amp;gt; {
                buildFeatures { 
                    buildConfig = true 
                }
                buildTypes {
                    getByName(&quot;debug&quot;) { 
                        configureBuildTypeFields(::buildConfigField, true) 
                    }
                    getByName(&quot;release&quot;) { 
                        configureBuildTypeFields(::buildConfigField, false) 
                    }
                }
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드가 길어 보일 수 있지만 이 방식에는 몇 가지 장점이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;타입 안전성 확보&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴파일러가 타입을 명확히 인지합니다. &lt;code&gt;when&lt;/code&gt; 표현식 내부에서 스마트 캐스트가 일어나기 때문에 IDE의 자동완성도 정확하게 동작해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;명시적인 구분&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Application&lt;/code&gt;과 &lt;code&gt;Library&lt;/code&gt;의 차이를 명시적으로 구분할 수 있습니다. 예를 들어 &lt;code&gt;applicationId&lt;/code&gt;는 &lt;code&gt;ApplicationExtension&lt;/code&gt;에만 존재하는 속성이에요. 이런 차이를 코드로 명확하게 표현할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;재사용 가능한 로직&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 로직은 내부 함수로 추출할 수 있어요. &lt;code&gt;configureBuildTypeFields&lt;/code&gt;처럼 실제 값을 설정하는 로직은 재사용하면서 구조적인 설정은 각 타입별로 처리하는 방식입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Variant API 활용하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;buildConfigField&lt;/code&gt; 같은 값 주입 작업은 &lt;code&gt;androidComponents&lt;/code&gt;의 &lt;code&gt;onVariants&lt;/code&gt; API로도 처리할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;val androidComponents = extensions.getByType(AndroidComponentsExtension::class.java)

androidComponents.onVariants { variant -&amp;gt;
    val baseUrl = if (variant.buildType == &quot;debug&quot;) {
        &quot;https://dev.api.example.com&quot;
    } else {
        &quot;https://api.example.com&quot;
    }

    variant.buildConfigFields?.put(
        &quot;BASE_URL&quot;,
        BuildConfigField(&quot;String&quot;, &quot;\&quot;$baseUrl\&quot;&quot;, &quot;API Base URL&quot;)
    )

    variant.buildConfigFields?.put(
        &quot;DEBUG_MODE&quot;,
        BuildConfigField(&quot;Boolean&quot;, (variant.buildType == &quot;debug&quot;).toString(), &quot;Debug Mode&quot;)
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 분명 깔끔합니다. 타입 분기 없이 variant 레벨에서 처리할 수 있으니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 문제가 있었습니다. &lt;code&gt;buildFeatures { buildConfig = true }&lt;/code&gt;와 같은 구조적 설정은 여전히 DSL 단계에서 처리해야 합니다. 결국 &lt;code&gt;when&lt;/code&gt; 분기를 완전히 없앨 수는 없었어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;code&gt;buildConfigFields&lt;/code&gt;가 nullable하기 때문에 null 체크를 계속 해야 했습니다.&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;val buildConfigFields: MapProperty&amp;lt;String, BuildConfigField&amp;lt;out Serializable&amp;gt;&amp;gt;?&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;뭘 우선시해야 할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 가지 방식 사이에서 고민했습니다. Variant API가 더 모던하고 세련되어 보이긴 했지만 Hilingual 프로젝트에서는 타입 분기 방식을 선택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 간단합니다. 일관성입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;configureBuildTypes&lt;/code&gt; 함수 하나에서 DSL 설정과 값 주입을 모두 처리하는 것이 코드를 읽는 사람 입장에서 더 명확하다고 생각했어요. &quot;빌드 타입 설정은 여기서 한다&quot;는 것을 한눈에 알 수 있으니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 프로젝트의 성격에 따라 다른 선택을 할 수도 있을 것입니다. 중요한 것은 어떤 방식을 선택하든 그 이유를 명확히 아는 것이라고 생각합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;android.newDsl=false로 미룰 수도 있긴 해요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 서드파티 플러그인 때문에 당장 AGP 9.0으로 넘어가기 어렵다면 &lt;code&gt;gradle.properties&lt;/code&gt;에 다음을 추가할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;android.newDsl=false
android.enableLegacyVariantApi=true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 구형 DSL과 Variant API를 계속 사용할 수 있습니다. 하지만 이것은 어디까지나 임시방편입니다. AGP 10.0(2026년 중반 예정)에서는 이 옵션도 제거될 예정이니 지금부터 차근차근 마이그레이션을 준비하는 것이 좋아요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배운 것들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AGP 9.0을 적용하는 건 단순히 빌드가 성공하게 만드는 작업이 아니었습니다. 안드로이드 빌드 시스템이 어떤 방향을 추구하고 있는지, 그리고 우리의 빌드 로직이 어떤 원칙 위에 서 있어야 하는지를 다시 생각하게 만든 계기였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CommonExtension은 만능 키가 아니다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CommonExtension에 의존하던 방식은 편리했지만 타입 안전성을 희생한 것이었어요. AGP 9.0의 변화는 이런 약점을 드러냈습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Application과 Library는 다르다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 모듈 타입은 근본적으로 다른 산출물을 만들고 다른 설정을 필요로 합니다. 이 차이를 인정하고 명시적으로 처리하는 것이 더 건강한 빌드 로직입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;빌드 로직도 코드다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 작성할 때 타입 안전성, 가독성, 유지보수성을 고민하듯이 빌드 로직을 작성할 때도 같은 고민이 필요해요. 빌드 스크립트가 복잡해질수록 이런 원칙들의 중요성은 더욱 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;변화의 방향을 이해하자&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AGP 7.x부터 새로운 DSL과 Variant API가 준비되기 시작했고, AGP 8.x에서 이 인터페이스들이 안정화되었으며, AGP 9.0에서 마침내 구형 API가 제거되었습니다. 도구가 변화하는 방향을 미리 파악하고 준비한다면 메이저 업데이트가 두려운 일이 아니라 자연스러운 과정이 될 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;생태계와 함께 움직이기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인 프로젝트라면 곧바로 AGP 9.0을 적용할 수 있겠지만 실제 프로덕션 환경에서는 서드파티 플러그인의 호환성도 고려해야 합니다. Firebase, Hilt, KSP 등 주요 플러그인들이 AGP 9.0을 지원하는지 확인하고, 필수가 아니라면 생태계가 안정될 때를 기다리는 것도 좋은 선택입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AGP 9.0 마이그레이션을 고민하고 계신가요? 이 글이 AGP 9.0을 도입하려는 분들께 작은 도움이 되었기를 바랍니다. 여러분만의 빌드 로직 개선 경험도 공유해 주시면 좋겠습니다. 긴 글 읽어 주셔서 감사합니다.&lt;/p&gt;</description>
      <category>Android</category>
      <category>AGP</category>
      <category>Android</category>
      <category>build-logic</category>
      <category>Gradle</category>
      <category>plugin</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/29</guid>
      <comments>https://angrypodo.tistory.com/29#entry29comment</comments>
      <pubDate>Wed, 21 Jan 2026 19:26:27 +0900</pubDate>
    </item>
    <item>
      <title>HttpLoggingInterceptor JSON 파싱 최적화로 95% 성능 개선하기</title>
      <link>https://angrypodo.tistory.com/28</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안드로이드 개발을 하면서 로그캣을 통한 네트워크 로깅은 필수입니다. 하지만 JSON을 있는 그대로 출력하면 가독성이 매우 좋지 않은데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 프로젝트 설계 당시 네트워크 통신 로그를 가독성 좋게 파악하고 싶어서 &lt;code&gt;HttpLoggingInterceptor&lt;/code&gt;에 JSON Pretty Print 기능을 추가했습니다. 개발 생산성은 확실히 좋아졌지만 문득 '이거 성능상 문제 없을까'라는 의문이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 작은 의문에서 시작해 성능을 95% 개선하기까지의 과정을 공유합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 작성한 로깅 코드는 이렇습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// NetworkModule.kt
HttpLoggingInterceptor { message -&amp;gt;
    val log = runCatching {
        val jsonElement = json.decodeFromString(JsonElement.serializer(), message)
        json.encodeToString(JsonElement.serializer(), jsonElement)
    }.getOrElse { message }

    Timber.tag(&quot;okhttp&quot;).d(log)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얼핏 보면 괜찮아 보입니다. JSON이면 예쁘게 출력하고 아니면 원본을 출력하니까요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;HttpLoggingInterceptor&lt;/code&gt;가 전달하는 &lt;code&gt;message&lt;/code&gt;에는 다음과 같은 것들이 포함됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JSON 응답 본문 (우리가 원하는 것)&lt;/li&gt;
&lt;li&gt;HTTP 헤더 (&lt;code&gt;Content-Type: application/json&lt;/code&gt;) ✖️&lt;/li&gt;
&lt;li&gt;HTML 페이지 (&lt;code&gt;&amp;lt;!DOCTYPE html&amp;gt;...&lt;/code&gt;) ✖️&lt;/li&gt;
&lt;li&gt;일반 텍스트 (&lt;code&gt;Connection established&lt;/code&gt;) ✖️&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 메시지에 대해 JSON 파싱을 시도하면 JSON이 아닌 경우 매번 예외가 발생하고 &lt;code&gt;catch&lt;/code&gt;로 빠집니다. HTTP 헤더만 해도 요청/응답마다 수십 줄씩 찍히는데 이게 누적되면 상당한 오버헤드가 발생합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 크기 측정하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추측하지 말고 측정하라는 원칙에 따라 벤치마크 코드를 작성했습니다.&lt;/p&gt;
&lt;pre class=&quot;pony&quot;&gt;&lt;code&gt;// 2,000회 반복 테스트
@Test
fun `최적화 전 성능 측정`() {
    val iterations = 2000

    // 케이스 1: 실제 JSON
    val validJson = &quot;&quot;&quot;{&quot;userId&quot;: 123, &quot;name&quot;: &quot;홍길동&quot;}&quot;&quot;&quot;

    // 케이스 2: HTTP 헤더 (가장 빈번)
    val header = &quot;Content-Type: application/json; charset=utf-8&quot;

    // 케이스 3: HTML 응답
    val html = &quot;&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html&amp;gt;...&amp;lt;/html&amp;gt;&quot;

    measureTimeMillis { /* 테스트 */ }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;측정 결과&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;========== BENCHMARK RESULTS (Before Optimization) ==========
Iterations: 2000
Valid JSON Time: 84ms
Plain Text (Header) Time: 11ms
HTML Content Time: 4ms
=============================================================&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JSON이 아닌 문자열을 파싱하려다 실패하는 비용이 케이스당 4~11ms&lt;/li&gt;
&lt;li&gt;HTTP 헤더는 모든 요청/응답마다 수십 줄씩 발생&lt;/li&gt;
&lt;li&gt;100줄이면 1초 가까운 딜레이 발생 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개선해봅시다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON 파싱은 비용이 높은 작업입니다. 파싱할 가치가 있는지 먼저 확인하면 불필요한 연산을 줄일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON은 항상 &lt;code&gt;{&lt;/code&gt; 또는 &lt;code&gt;[&lt;/code&gt;로 시작합니다. 이 간단한 규칙을 활용해서 사전 검사를 추가했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private fun createSmartLogger(json: Json) = HttpLoggingInterceptor.Logger { message -&amp;gt;
    val trimmed = message.trim()

    // JSON일 가능성이 있는 경우만 파싱 시도
    val isPotentialJson = trimmed.startsWith(&quot;{&quot;) || trimmed.startsWith(&quot;[&quot;)

    val log = if (isPotentialJson) {
        runCatching {
            val jsonElement = json.decodeFromString(JsonElement.serializer(), trimmed)
            json.encodeToString(JsonElement.serializer(), jsonElement)
        }.getOrElse { message }
    } else {
        message // JSON 아니면 그냥 원본 출력
    }

    Timber.tag(&quot;okhttp&quot;).d(log)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;startsWith(&quot;{&quot;)&lt;/code&gt; 또는 &lt;code&gt;startsWith(&quot;[&quot;)&lt;/code&gt;를 추가해 JSON 가능성을 먼저 체크하도록 했습니다. HTTP 헤더나 HTML 같은 불필요한 정보들은 파싱을 시도하지 않습니다. 최종적으로 JSON이 아니라면 원본을 출력합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;개선 효과 검증&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일한 조건으로 다시 테스트했습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;========== BENCHMARK RESULTS (After Optimization) ==========
Iterations: 2000
Valid JSON Time: 82ms
Plain Text (Header) Time: 0ms
HTML Content Time: 0ms
=============================================================&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 기대 이상이었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JSON 처리 속도는 거의 동일&lt;/li&gt;
&lt;li&gt;&lt;code&gt;startsWith()&lt;/code&gt; 검사가 성능에 큰 영향을 주지 않음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Header 처리: 100% 개선&lt;/b&gt; (11ms &amp;rarr; 0ms)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;HTML 처리: 100% 개선&lt;/b&gt; (4ms &amp;rarr; 0ms)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정밀 검증&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우연이 아닌지 확인하기 위해&amp;nbsp; 더 강한 테스트 조건과 엣지 케이스를 추가했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;반복 횟수: 50,000회&lt;/li&gt;
&lt;li&gt;JVM Warm-up 적용&lt;/li&gt;
&lt;li&gt;GC 고려&lt;/li&gt;
&lt;li&gt;Malformed JSON 케이스 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Test
fun `정밀 벤치마크 - 50000회 반복`() {
    // Warm-up
    repeat(1000) { /* ... */ }

    System.gc() // GC 실행
    Thread.sleep(100)

    // 실제 측정
    val iterations = 50000
    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 결과&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;----------------------------------------------------------------------------------
Case            | Before (ms)  | After (ms)   | Improvement (%)
----------------------------------------------------------------------------------
Plain Text      | 147          | 7            | 95%
Valid JSON      | 99           | 94           | 5%
Malformed JSON  | 180          | 174          | 3%
-----------------------------------------------------------------------------------&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Plain Text 처리에서 95%의 성능 향상&lt;/li&gt;
&lt;li&gt;JSON 파싱에는 영향 없음 (오히려 미세하게 개선)&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;try-catch는 예외 처리용이지 흐름 제어용이 아니다&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 예외를 흐름 제어에 사용
runCatching {
    parseJson(unknownString) // 매번 실패할 수 있음
}.getOrElse { defaultValue }

// 사전 검사로 예외 회피
if (isValidFormat(unknownString)) {
    parseJson(unknownString)
} else {
    defaultValue
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이게 느릴 것 같은데라는 추측보다 얼마나 느린지 측정해보자가 더 중요합니다. 그리고 단순한 &lt;code&gt;startsWith()&lt;/code&gt; 검사 하나로 95%의 성능 개선을 달성했습니다. 선입견을 가지고 매번 검사하는게 리소스가 더 클거라고 생각했었는데 한방 먹었네요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 로그나 편하게 보자는 단순한 생각이었습니다. 하지만 작은 의문을 놓치지 않고 측정하고 개선하면서 성능 최적화의 본질을 배울 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러분도 코드를 작성하면서 이게 괜찮을까 싶은 부분이 있다면 추측하지 말고 직접 측정해보는 것을 추천합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고 자료&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/square/okhttp/tree/master/okhttp-logging-interceptor&quot;&gt;OkHttp - HttpLoggingInterceptor 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/json.md&quot;&gt;Kotlinx Serialization - JSON 공식 가이드&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.android.com/studio/profile/network-profiler&quot;&gt;Android Developers - 네트워크 프로파일링&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Android</category>
      <category>Android</category>
      <category>JSON</category>
      <category>log</category>
      <category>LogCat</category>
      <category>okhttp</category>
      <category>Timber</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/28</guid>
      <comments>https://angrypodo.tistory.com/28#entry28comment</comments>
      <pubDate>Wed, 21 Jan 2026 02:58:26 +0900</pubDate>
    </item>
    <item>
      <title>DisposableEffect대신 LifecycleEffect</title>
      <link>https://angrypodo.tistory.com/27</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하면서 Compose의 생명주기를 다루는 방식에 대해 고민하게 되는 건 당연한 것 같습니다. 특히 안드로이드의 생명주기와 컴포저블의 생명주기가 만나는 지점에서 어떤 선택을 해야 하는지는 항상 고민이 되는데요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 &lt;code&gt;presentation:home&lt;/code&gt; 모듈을 리팩터링하면서 이 문제를 깊이 있게 들여다볼 기회가 있었습니다. 그 과정에서 배운 것들을 공유하고자 합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱이 백그라운드에서 다시 활성화될 때마다 알림 권한 상태를 확인해야 하는 요구사항이 있었어요. 자연스럽게 &lt;code&gt;DisposableEffect&lt;/code&gt;를 사용했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Composable
fun HomeRoute(viewModel: HomeViewModel) {
    val lifecycleOwner = LocalLifecycleOwner.current

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event -&amp;gt;
            if (event == Lifecycle.Event.ON_RESUME) {
                viewModel.handleNotificationPermission()
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 잘 동작했어요. 하지만 뭔가 마음에 들지 않았습니다. 그래서 좀더 불만을 구체화 해봤어요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;코드의 의도를 파악하기 위해 구현 세부사항을 먼저 읽어야 함&lt;/li&gt;
&lt;li&gt;옵저버를 등록하고 해제하는 보일러플레이트가 반복&lt;/li&gt;
&lt;li&gt;&lt;code&gt;removeObserver&lt;/code&gt;를 빼먹으면 메모리 누수 가능성이 존재&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 ON_RESUME일 때 권한을 체크한다는 간단한 의도를 표현하기에 코드가 너무 장황했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;더 나은 방법 찾기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 나은 방법이 있지 않을까 하는 생각에 Compose의 공식 문서를 다시 살펴봤어요. 그러다 &lt;code&gt;lifecycle-runtime-compose:2.7.0&lt;/code&gt;에 추가된 새로운 API들을 발견했습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun HomeRoute(viewModel: HomeViewModel) {
    LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
        viewModel.handleNotificationPermission()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 동작을 하는 코드인데 훨씬 읽기 수월하면서 옵저버 관리 로직이 사라지고 무엇을 하려는지가 명확히 드러났습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 단순히 코드가 짧아졌다는 것보다 더 중요한 변화가 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;명령형에서 선언형으로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 &lt;code&gt;DisposableEffect&lt;/code&gt; 코드는 명령형으로 작성됐습니다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;&quot;옵저버를 만들어라&quot;
&quot;생명주기에 등록해라&quot;
&quot;이벤트가 ON_RESUME이면 함수를 실행해라&quot;
&quot;정리할 때는 옵저버를 제거해라&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 &lt;code&gt;LifecycleEventEffect&lt;/code&gt;는 선언형으로 작성됩니다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;&quot;ON_RESUME 이벤트가 발생하면 이 작업을 수행한다&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 차이가 왜 중요할까요? Compose는 선언형 UI 프레임워크예요. 어떻게보다 무엇을에 집중하는 것이 Compose의 철학이라고 생각합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;내부 구조 살펴보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;LifecycleEventEffect&lt;/code&gt;가 어떻게 동작하는지 궁금해서 소스 코드를 열어봤어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// 간단하게 가져온 버전입니다
@Composable
fun LifecycleEventEffect(
    event: Lifecycle.Event,
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onEvent: () -&amp;gt; Unit
) {
    val currentOnEvent by rememberUpdatedState(onEvent)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, e -&amp;gt;
            if (e == event) {
                currentOnEvent()
            }
        }
        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;놀랍게도 내부적으로는 &lt;code&gt;DisposableEffect&lt;/code&gt;를 사용하고 있었어요. 하지만 몇 가지 중요한 개선이 있었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;rememberUpdatedState의 역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;rememberUpdatedState&lt;/code&gt;를 사용해서 &lt;code&gt;onEvent&lt;/code&gt; 람다를 관리해요. 이게 왜 필요할까요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 &lt;code&gt;onEvent&lt;/code&gt; 내부에서 상태 변수를 참조하고 있다면 상태가 바뀔 때마다 Effect가 재실행돼야 할까요? 아니에요. Effect의 생명주기는 유지하면서 람다만 최신 버전으로 교체하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;rememberUpdatedState&lt;/code&gt;가 바로 이 역할을 합니다. 불필요한 재실행을 방지하면서도 최신 상태를 참조할 수 있게 해줘요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;code&gt;removeObserver&lt;/code&gt; 로직이 내부에 캡슐화되어 있어요. 개발자가 실수로 빼먹을 가능성이 원천적으로 차단됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다른 API들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;LifecycleEventEffect&lt;/code&gt; 외에도 &lt;code&gt;LifecycleStartEffect&lt;/code&gt;와 &lt;code&gt;LifecycleResumeEffect&lt;/code&gt;라는 API가 있었어요. 조금 다른 상황에서 유용합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;LifecycleStartEffect&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun LocationTracker(locationManager: LocationManager) {
    LifecycleStartEffect(locationManager) {
        locationManager.startTracking()
        onStopOrDispose {
            locationManager.stopTracking()
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ON_START&lt;/code&gt;부터 &lt;code&gt;ON_STOP&lt;/code&gt;까지 유지되어야 하는 작업에 사용해요. 화면이 완전히 보이지 않아도 앱이 활성화된 상태라면 계속 실행되어야 하는 경우입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;LifecycleResumeEffect&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun VideoPlayer(player: Player) {
    LifecycleResumeEffect(player) {
        player.play()
        onPauseOrDispose {
            player.pause()
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ON_RESUME&lt;/code&gt;부터 &lt;code&gt;ON_PAUSE&lt;/code&gt;까지 유지되어야 하는 작업에 사용합니다. 사용자와 직접 상호작용할 때만 실행되어야 하는 경우예요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 API 모두 정리 로직이 두 가지 경우에 실행되는 것을 발견했어요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;안드로이드 생명주기가 종료될 때: &lt;code&gt;ON_STOP&lt;/code&gt; 또는 &lt;code&gt;ON_PAUSE&lt;/code&gt; 이벤트&lt;/li&gt;
&lt;li&gt;컴포지션이 종료될 때: 컴포저블이 제거될 때&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 곳에만 정리 로직을 작성하면 두 가지 상황을 모두 처리할 수 있어요. 이것도 실수를 방지하는 설계라는 게 느껴졌습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;언제 무엇을 사용할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 API를 보면서 언제 뭘 써야 하지라는 고민이 생겼어요. 제가 정리한 기준은 이렇습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;LifecycleEventEffect&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단발성 작업이 필요할 때 사용해요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로깅 이벤트 전송&lt;/li&gt;
&lt;li&gt;데이터 새로고침 트리거&lt;/li&gt;
&lt;li&gt;권한 재요청&lt;/li&gt;
&lt;li&gt;분석 이벤트 기록&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리 로직이 필요 없는 작업이라면 이 API가 적합합니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
    analytics.logScreenView(&quot;home&quot;)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;LifecycleStartEffect&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면이 보이지 않아도 계속되어야 하는 작업에 사용해요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GPS 위치 추적&lt;/li&gt;
&lt;li&gt;센서 데이터 수집&lt;/li&gt;
&lt;li&gt;백그라운드 데이터 동기화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱이 활성화된 동안에는 계속 실행되어야 하지만 앱이 백그라운드로 가면 중단되어야 하는 경우예요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;LifecycleResumeEffect&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자와 직접 상호작용할 때만 실행되어야 하는 작업에 사용합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동영상 재생&lt;/li&gt;
&lt;li&gt;카메라 프리뷰&lt;/li&gt;
&lt;li&gt;복잡한 애니메이션&lt;/li&gt;
&lt;li&gt;음성 녹음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면이 조금이라도 가려지면 즉시 중단되어야 하는 경우예요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;적용할만한 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카메라 프리뷰를 구현하면서 &lt;code&gt;LifecycleResumeEffect&lt;/code&gt;를 적용해봤어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun CameraPreview(
    camera: Camera,
    modifier: Modifier = Modifier
) {
    LifecycleResumeEffect(camera) {
        camera.startPreview()
        onPauseOrDispose {
            camera.stopPreview()
        }
    }

    AndroidView(
        factory = { context -&amp;gt;
            PreviewView(context).apply {
                implementationMode = PreviewView.ImplementationMode.COMPATIBLE
            }
        },
        modifier = modifier
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화면이 일시정지되거나 컴포저블이 제거되면 자동으로 카메라 프리뷰가 중단되는 코드입니다. 두 가지 경우를 하나의 블록으로 처리할 수 있어서 확실하게 쓰기 편하고 읽기 편했어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;DisposableEffect&lt;/code&gt;는 범용적이지만 저수준 API입니다. 특정 사용 사례에 맞는 고수준 API를 사용하면 코드의 의도가 명확해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose는 선언형 도구이기 때문에 어떻게보다 무엇을에 집중하는 코드가 Compose의 철학과 잘 맞는다고 생각해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새로운 API들이 계속 추가되고 있어요. 정기적으로 공식 문서와 릴리스 노트를 확인하는 습관이 필요하다는 걸 느꼈습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 쓰면서 정답을 제시하려는 건 아니에요. 다만 제가 고민했던 과정과 그 과정에서 배운 것들을 공유하고 싶었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러분의 프로젝트에는 어떤 방식이 적합할까요? 상황에 따라 다를 수 있어요. 중요한 건 왜 이 방식을 선택했는지 설명할 수 있는 것이라고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트에 &lt;code&gt;DisposableEffect&lt;/code&gt;로 생명주기를 관리하는 코드가 있다면 한번 다시 살펴보는 걸 추천드립니다 &lt;/p&gt;</description>
      <category>Android/Compose</category>
      <category>Android</category>
      <category>compose</category>
      <category>DisposableEffect</category>
      <category>effect</category>
      <category>LifecycleEventEffect</category>
      <category>LifecycleResumeEffect</category>
      <category>LifecycleStartEffect</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/27</guid>
      <comments>https://angrypodo.tistory.com/27#entry27comment</comments>
      <pubDate>Mon, 19 Jan 2026 02:21:41 +0900</pubDate>
    </item>
    <item>
      <title>Android CI 빌드 속도 1분대로 줄여보기</title>
      <link>https://angrypodo.tistory.com/26</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;하이링구얼 프로젝트는 규모가 커짐에 따라 오타 수정 같은 경미한 변경에도 빌드 시간이 &lt;b&gt;평균 10분~14분&lt;/b&gt; 소요되어 개발 효율이 저하되는 문제가 발생했어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 Gradle 설정과 GitHub Actions 워크플로우를 최적화하여 &lt;b&gt;빌드 시간을 1분대로 단축&lt;/b&gt;한 과정을 공유해요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 병목 원인 분석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 로그를 분석한 결과, 성능 저하의 주원인은 두 가지였어요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Gradle 설정 미흡:&lt;/b&gt; 병렬 빌드나 캐싱 같은 핵심 성능 옵션이 꺼져 있어 시스템 자원을 효율적으로 쓰지 못하고 있었어요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CI 캐싱 전략 부재:&lt;/b&gt; 기존 actions/cache는 Gradle의 복잡한 의존성 구조를 섬세하게 다루지 못해, 라이브러리 하나만 바뀌어도 캐시가 깨져(Miss) 매번 새로 다운로드해야 했어요.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 해결 방법&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1. Gradle 빌드 환경 튜닝&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에 주석 처리되어 있거나 비활성화되어 있던 성능 옵션을 켰어요. 이 설정만으로 로컬과 CI 환경 모두에서 빌드 속도를 높일 수 있어요.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# 독립적인 모듈을 병렬로 빌드하여 CPU 활용도를 높여요
org.gradle.parallel=true

# 빌드 결과물(Task Output)을 캐시하여 재사용해요
org.gradle.caching=true

# 필요한 모듈만 구성(Configuration)하여 초기화 시간을 단축해요
org.gradle.configureondemand=true
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2. GitHub Actions 개선하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 캐시 키를 관리해야 했던 actions/cache를 제거하고, Gradle 공식 액션인 setup-gradle로 교체했어요. 또한, PR 빌드에서도 캐시를 활용할 수 있도록 기준캐시를 적용했어요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;트리거 추가:&lt;/b&gt; develop 브랜치에 코드가 푸시될 때도 CI를 실행하여 PR이 참고할 수 있는 최신 캐시를 미리 생성하도록 했어요.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;액션 교체:&lt;/b&gt; gradle/actions/setup-gradle을 사용하여 의존성과 빌드 캐시를 자동으로 관리하도록 했어요.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;http&quot;&gt;&lt;code&gt;name: Hilingual PR CI

on:
  pull_request:
    branches: [ develop, main ]
  # [핵심] develop 브랜치가 업데이트될 때 실행하여 '기준 캐시'를 갱신해요
  push:
    branches: [ develop ]

jobs:
  lint:
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      # [변경] actions/cache 대신 setup-gradle 사용
      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v4
      # ... (이하 생략)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 개선 결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최적화 적용 후 성능변화가 유의미 했는지를 봐야겠죠?&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1. 캐시 히트 유무에 따른 차이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;develop 브랜치에 기준 캐시가 없을 때와 생성된 직후의 빌드 시간을 비교했어요. setup-gradle이 의존성과 빌드 결과를 효과적으로 복원하면서 시간이 약&lt;b&gt;90% 단축&lt;/b&gt;되었어요.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;구분&lt;/td&gt;
&lt;td&gt;상태&lt;/td&gt;
&lt;td&gt;소요 시간&lt;/td&gt;
&lt;td&gt;분석&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Attempt 1&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Cold Start&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;13분 14초&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;기준 캐시가 없어 모든 라이브러리를 새로 다운로드함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Attempt 2&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Hot Start&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;1분 27초&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;기준 캐시 생성 직후 재실행하여 캐시 히트 성공&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2. 기존 방식 vs 개선된 방식 비교&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확히 동일한 작업을 수행하는 두 개의 PR을 비교하여 설정 최적화 효과를 검증했어요.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구분&lt;br /&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;PR 내용&lt;/td&gt;
&lt;td&gt;&lt;b&gt;소요 시간&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;환경설정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;개선 전&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;#908 (Re-run)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;12분 23초&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;actions/cache 사용, 병렬 빌드 OFF&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;개선 후&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;#917&lt;/td&gt;
&lt;td&gt;&lt;b&gt;1분 24초&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;setup-gradle 사용, 병렬 빌드 ON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;88% 단축&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;약 10.8배 속도 향상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단순 라이브러리 업데이트 검증에 12분을 쓸 필요가 없어짐&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3. QA 배포 속도의 낙수 효과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;develop 브랜치의 캐시를 공유하는 구조 덕분에 PR뿐만 아니라 APK 배포 시간 또한 &lt;b&gt;16분대에서 2분대로 단축&lt;/b&gt;되었어요.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;버전(태그)&lt;b&gt;&lt;br /&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;소요 시간&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;단축률&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;v2.1.0&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;16분 2초&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;캐시 미적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;v2.1.1&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;2분 41초&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;83%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;develop 브랜치 캐시 활용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 CI 속도 개선 작업은 Gradle의 동작 원리와 GitHub Actions의 캐싱을 이해하고 조율한 결과예요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 팀 전체의 &lt;b&gt;개발 생산성이 획기적으로 향상&lt;/b&gt;되었으며 더 빠른 주기로 코드를 통합하고 검증할 수 있는 환경이 마련되었어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빠른 작업이 필요하거나 CI 검증에 시간을 많이 뺏긴다고 체감하신 분들에게 도움이 되었으면 합니다  &lt;/p&gt;</description>
      <category>Android</category>
      <category>actions</category>
      <category>Android</category>
      <category>cache</category>
      <category>CD</category>
      <category>CI</category>
      <category>CI/CD</category>
      <category>Github</category>
      <category>Gradle</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/26</guid>
      <comments>https://angrypodo.tistory.com/26#entry26comment</comments>
      <pubDate>Tue, 6 Jan 2026 23:28:49 +0900</pubDate>
    </item>
    <item>
      <title>OkHttp Authenticator에서 runBlocking 제거하기</title>
      <link>https://angrypodo.tistory.com/25</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안드로이드 개발에서 멀티플랫폼을 사용하지 않는 이상 대부분의 네트워크 통신은 Retrofit을 기반으로 합니다. Retrofit은 내부적으로 OkHttp 위에서 동작하고 토큰 기반 인증 로직을 구현하려면 결국 OkHttp의 특성을 직접 다루게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 리프레시 토큰을 이용한 토큰 재발급 과정은 단순히 새 토큰을 받아오는 것이 아니라 여러 요청이 동시에 들어오는 상황에서의 동시성 문제까지 함께 고민해야 해서 생각보다 까다롭습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 &lt;code&gt;OkHttp + Retrofit + Coroutines&lt;/code&gt; 조합을 사용하면서 &lt;code&gt;Authenticator&lt;/code&gt; 안에 &lt;code&gt;runBlocking&lt;/code&gt;을 섞어 쓰던 구조를 어떻게 리팩토링했는지를 정리한 기록입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 코루틴을 공부하다가 문득 동기와 비동기의 경계를 runBlocking으로 이어 붙이는 구조가 정말 안전한지 의문이 들었어요. 아직까지 프로덕션 앱에서 문제가 보고된 적은 없지만 OkHttp가 사용하는 스레드를 직접 블로킹하는 방식은 언젠가 이슈를 만들 수 있다는 불안감이 계속 남아 있었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;요약&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OkHttp &lt;code&gt;Authenticator&lt;/code&gt; 안에서 &lt;code&gt;runBlocking { suspend API 호출 }&lt;/code&gt;을 사용하면 Dispatcher 스레드를 점유한 채 같은 Dispatcher 기반 비동기 요청을 다시 보내는 구조가 만들어질 수 있습니다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maxRequestsPerHost&lt;/code&gt; 제한과 겹치면 토큰 재발급 요청이 영원히 실행되지 않는 데드락 시나리오가 생길 수 있습니다&lt;/li&gt;
&lt;li&gt;다행히 우리 프로젝트는 이미 토큰 갱신용 클라이언트를 분리해서 데드락 위험은 없었습니다&lt;/li&gt;
&lt;li&gt;하지만 비효율을 개선하고 구조적 안전성을 확보하기 위해 다음과 같이 리팩토링했습니다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토큰 재발급을 동기 &lt;code&gt;Call.execute()&lt;/code&gt; 기반 API로 분리&lt;/li&gt;
&lt;li&gt;리프레시 전용 &lt;code&gt;OkHttpClient&lt;/code&gt;를 분리해 메인 요청 Dispatcher와 분리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;synchronized&lt;/code&gt;로 동시 갱신 제어&lt;/li&gt;
&lt;li&gt;토큰은 메모리 캐시를 즉시 갱신하고 영속 저장은 코루틴으로 비동기 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Authenticator 안의 runBlocking&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하이링구얼의 네트워크 스택은 대략 이렇게 구성되어 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Android + Kotlin&lt;/li&gt;
&lt;li&gt;Retrofit (코루틴 어댑터 사용, &lt;code&gt;suspend&lt;/code&gt; API)&lt;/li&gt;
&lt;li&gt;OkHttp (공용 &lt;code&gt;OkHttpClient&lt;/code&gt; 하나를 여러 API에서 공유)&lt;/li&gt;
&lt;li&gt;토큰 기반 인증 + 자동 재발급&lt;/li&gt;
&lt;li&gt;401 응답이 오면 OkHttp &lt;code&gt;Authenticator&lt;/code&gt;에서 토큰을 재발급한 뒤 요청을 재시도&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;5113&quot; data-origin-height=&quot;1124&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rezL0/dJMcabbzv0A/q5dhZ9R91pSqtkpJ2ETP10/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rezL0/dJMcabbzv0A/q5dhZ9R91pSqtkpJ2ETP10/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rezL0/dJMcabbzv0A/q5dhZ9R91pSqtkpJ2ETP10/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrezL0%2FdJMcabbzv0A%2Fq5dhZ9R91pSqtkpJ2ETP10%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;154&quot; data-origin-width=&quot;5113&quot; data-origin-height=&quot;1124&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 &lt;code&gt;Authenticator&lt;/code&gt;의 구현 방식이었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기존 Authenticator 구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시 &lt;code&gt;Authenticator&lt;/code&gt; 코드는 대략 다음과 같았어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Singleton
class TokenAuthenticator @Inject constructor(
    private val tokenManager: TokenManager,
    private val tokenRefreshService: TokenRefreshService,
    private val appRestarter: AppRestarter
) : Authenticator {

    private val mutex = Mutex()

    override fun authenticate(route: Route?, response: Response): Request? {
        return runBlocking {
            handleAuthentication(response)
        }
    }

    private suspend fun handleAuthentication(response: Response): Request? = mutex.withLock {
        // ... 토큰 갱신 로직 ...
        val result = tokenRefreshService.refreshToken(refreshToken) // suspend 함수
        // ...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 작성했던 이유는 토큰 재발급 과정에서 사용하는 Service 함수와 로컬에서 토큰을 읽고 쓰는 함수가 모두 &lt;code&gt;suspend&lt;/code&gt; 형태였기 때문입니다. 401을 감지한 뒤 토큰을 로컬에서 읽고 재발급 API를 호출한 뒤 다시 저장하는 일련의 과정은 비동기 처리가 기본이라 동기적인 &lt;code&gt;authenticate()&lt;/code&gt; 안에서는 이 간극을 채우기 위해 &lt;code&gt;runBlocking&lt;/code&gt;을 사용했어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 핵심은 두 가지입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;authenticate&lt;/code&gt;는 동기 함수&lt;/li&gt;
&lt;li&gt;그 안에서 &lt;code&gt;runBlocking { suspend fun refreshToken(...) }&lt;/code&gt;을 호출하고 있다는 점&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;runBlocking&lt;/code&gt;은 새 코루틴을 실행하되 현재 스레드를 블로킹하는 함수입니다. 당시에는 OkHttp가 내부적으로 별도 스레드풀에서 돌아가니까 여기서 스레드를 잠시 막아도 메인 스레드를 막지 않는다고 생각했어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제로 OkHttp Dispatcher가 스레드를 어떻게 관리하는지 분석해보면 이 판단이 틀렸다는 걸 알 수 있었습니다. 단순히 별도 스레드에서 돌기 때문에 안전하다는 보장은 없었고 구조적으로 데드락이 발생할 수 있는 형태였어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;OkHttp Dispatcher와 호출 모델&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데드락 가능성을 판단하려면 OkHttp의 요청 실행 모델을 먼저 명확히 이해해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;execute vs enqueue&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OkHttp는 요청을 크게 두 가지 방식으로 실행합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;동기 호출 (execute)&lt;/b&gt;: 호출한 스레드에서 네트워크 작업을 직접 수행&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비동기 호출 (enqueue)&lt;/b&gt;: 현재 스레드에서 큐에 등록한 뒤 즉시 반환하고 실제 네트워크 처리는 &lt;code&gt;Dispatcher&lt;/code&gt;의 스레드풀에서 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요약하면 아래와 같습니다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;execute()  : 호출한 스레드가 직접 네트워크 처리
enqueue()  : Dispatcher 스레드가 네트워크 처리&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Dispatcher의 동시성 제한&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OkHttp의 &lt;code&gt;Dispatcher&lt;/code&gt;는 비동기 호출을 두 개의 큐로 관리합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;runningAsyncCalls&lt;/code&gt;: 현재 실행 중인 비동기 호출들&lt;/li&gt;
&lt;li&gt;&lt;code&gt;readyAsyncCalls&lt;/code&gt;: 동시성 제한 때문에 아직 실행되지 못하고 대기 중인 호출들&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 두 가지 숫자로 동시 실행 개수를 제한합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;maxRequests&lt;/code&gt;: 전체 비동기 요청 동시 실행 수 (기본 64개)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maxRequestsPerHost&lt;/code&gt;: 호스트당 비동기 요청 동시 실행 수 (기본 5개)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름을 단순화해서 그리면 다음과 같아요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3377&quot; data-origin-height=&quot;1831&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ebsV7a/dJMcahXaxrO/1duAYK2NNZZ8HMeUz7KOA0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ebsV7a/dJMcahXaxrO/1duAYK2NNZZ8HMeUz7KOA0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ebsV7a/dJMcahXaxrO/1duAYK2NNZZ8HMeUz7KOA0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FebsV7a%2FdJMcahXaxrO%2F1duAYK2NNZZ8HMeUz7KOA0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;380&quot; data-origin-width=&quot;3377&quot; data-origin-height=&quot;1831&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은 동시 실행 개수 제한에 걸리면 새로운 비동기 호출은 readyAsyncCalls에만 쌓이고 실행 슬롯이 비워질 때까지 시작조차 하지 못한다는 점입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;데드락 시나리오 분석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 기존 구현과 OkHttp의 동작이 어떻게 겹치는지 그리고 실제 상황은 어땠는지 살펴보겠습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이론적인 데드락 시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 우리가 하나의 OkHttp 클라이언트를 공유해서 쓰고 있었다면 다음과 같은 시나리오에서 데드락이 발생했을 거예요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;동일 호스트로 비동기 API를 5개 동시에 호출합니다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;maxRequestsPerHost = 5&lt;/code&gt;이므로 이 5개 요청은 모두 실행 중&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;5개 모두 401 Unauthorized를 받습니다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OkHttp는 각 응답에 대해 &lt;code&gt;Authenticator.authenticate()&lt;/code&gt;를 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;각 &lt;code&gt;authenticate()&lt;/code&gt;에서 &lt;code&gt;runBlocking { refreshToken(...) }&lt;/code&gt;이 실행됩니다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 시점에서 OkHttp Dispatcher의 5개 스레드가 모두 runBlocking 안에서 멈춰 있는 상태가 됩니다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;runBlocking&lt;/code&gt;은 어떤 디스패처를 쓰든 호출한 스레드를 끝까지 붙잡습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;refreshToken()&lt;/code&gt;은 Retrofit의 &lt;code&gt;suspend&lt;/code&gt; API이고 내부적으로 다시 같은 &lt;code&gt;OkHttpClient&lt;/code&gt;에 비동기 요청을 보냅니다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이게 6번째 요청입니다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Dispatcher 입장에서 보면
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 호스트에는 이미 5개 요청이 실행 중이네?&lt;/li&gt;
&lt;li&gt;&lt;code&gt;maxRequestsPerHost&lt;/code&gt;를 넘으니까 6번째 요청은 &lt;code&gt;readyAsyncCalls&lt;/code&gt;에 넣고 대기시키자&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2572&quot; data-origin-height=&quot;2004&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvjVn4/dJMcaiBID5w/fbPHe3zTX6GEz1nkADkSq0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvjVn4/dJMcaiBID5w/fbPHe3zTX6GEz1nkADkSq0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvjVn4/dJMcaiBID5w/fbPHe3zTX6GEz1nkADkSq0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbvjVn4%2FdJMcaiBID5w%2FfbPHe3zTX6GEz1nkADkSq0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;545&quot; data-origin-width=&quot;2572&quot; data-origin-height=&quot;2004&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 서로가 서로를 기다리는 구조가 만들어집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;원래 5개의 요청은 토큰 재발급 요청이 끝나야 &lt;code&gt;authenticate()&lt;/code&gt;를 빠져나오고&lt;/li&gt;
&lt;li&gt;토큰 재발급 요청은 Dispatcher의 슬롯이 비어야 실행됩니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 둘 중 어느 쪽도 먼저 일어나지 않기 때문에 데드락이 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 괜찮았을까&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이미 프로젝트 내에서는 interceptor 분리를 위해 토큰 갱신용 클라이언트를 이미 분리해서 사용하고 있었어요. 즉 6번째 요청은 꽉 찬 메인 클라이언트의 Dispatcher가 아니라 텅 빈 리프레시 클라이언트의 Dispatcher로 들어갔기 때문에 데드락은 발생하지 않는 구조였습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그럼에도 리팩토링한 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데드락 위험이 없는데도 굳이 리팩토링을 진행한 이유는 &lt;code&gt;runBlocking&lt;/code&gt;의 구조적 비효율성 때문입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;불필요한 스레드 점유&lt;/b&gt;: &lt;code&gt;runBlocking&lt;/code&gt;은 호출한 스레드를 아무 일도 못하게 붙잡아 둡니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;불필요한 컨텍스트 스위칭&lt;/b&gt;: 스레드를 잡아둔 채로 비동기 작업을 위해 또 다른 스레드로 이동했다가 돌아오는 비용이 발생합니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;잠재적 위험&lt;/b&gt;: 만약 나중에 누군가 실수로 클라이언트를 합치게 된다면 즉시 데드락이 터지게 됩니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 더 효율적이고 안전한 구조로 개선하기로 했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;실패한 접근들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 리팩토링 과정에서 몇 가지 방법을 시도해 봤어요. 결론적으로 모두 구조적 문제를 근본적으로 해결하지는 못했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;runBlocking(Dispatchers.IO)로 바꾸기&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;override fun authenticate(route: Route?, response: Response): Request? {
    return runBlocking(Dispatchers.IO) {
        val newToken = tokenRefreshService.refreshToken(...)
        // ...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가정은 굉장히 단순했습니다. Dispatchers.IO를 쓰면 OkHttp 스레드를 점유하지 않는 거 아닐까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;code&gt;runBlocking&lt;/code&gt;의 블로킹 대상은 항상 자신을 호출한 스레드입니다. 컨텍스트를 IO로 바꾼다고 해서 호출 스레드가 해방되는 것이 아니라 내가 지금 서 있는 스레드를 끝까지 붙잡고 있는 구조는 그대로였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 OkHttp Dispatcher 스레드는 여전히 &lt;code&gt;runBlocking&lt;/code&gt; 안에서 멈춰 있고 토큰 재발급 비동기 요청은 여전히 같은 Dispatcher의 빈 슬롯을 기다리는 상태가 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Call.suspendExecute() 헬퍼 함수 만들기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;enqueue&lt;/code&gt; 대신 &lt;code&gt;execute&lt;/code&gt;를 쓰면 어떨까 하는 생각으로 이런 헬퍼를 시도해봤어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;suspend fun &amp;lt;T&amp;gt; Call&amp;lt;T&amp;gt;.suspendExecute(): Response&amp;lt;T&amp;gt; =
    withContext(Dispatchers.IO) {
        execute()
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수만 보면 괜찮아 보이지만 문제는 &lt;code&gt;Authenticator&lt;/code&gt;가 여전히 동기 함수라는 점이에요. 여기서 이 &lt;code&gt;suspend&lt;/code&gt; 함수를 호출하려면 또다시 &lt;code&gt;runBlocking&lt;/code&gt;이 필요합니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;override fun authenticate(...): Request? {
    return runBlocking {
        reissueService.reissueToken(refreshToken).suspendExecute()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 동기 컨텍스트 + runBlocking + 내부에서 네트워크 조합으로 돌아오게 되고 &lt;code&gt;Dispatcher&lt;/code&gt;와의 충돌 가능성은 그대로 남습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Interceptor로 로직을 옮기기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;401 응답 처리를 &lt;code&gt;Authenticator&lt;/code&gt;가 아닌 &lt;code&gt;Interceptor&lt;/code&gt;에서 처리하는 방식도 고려했어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class AuthInterceptor(
    private val tokenRefreshService: TokenRefreshService,
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val response = chain.proceed(request)

        if (response.code == 401) {
            runBlocking {
                val newToken = tokenRefreshService.refreshToken(...)
                // ...
            }
            // 새 토큰으로 재시도...
        }

        return response
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Interceptor 역시 OkHttp 내부 스레드에서 동기적으로 실행되는 구조라서 결국 같은 문제를 다른 위치로만 옮기는 셈이 됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Authenticator&lt;/code&gt;에서 &lt;code&gt;runBlocking&lt;/code&gt;을 돌리든&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Interceptor&lt;/code&gt;에서 &lt;code&gt;runBlocking&lt;/code&gt;을 돌리든&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Dispatcher 스레드를 막아둔 채 다시 같은 Dispatcher에 네트워크 요청을 보내는 구조 자체가 변하지 않습니다. 네트워크 계층에서 도메인/데이터 계층을 직접 참조하게 되어 의존성이 꼬이는 Side Effect도 존재했어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;동기/비동기 경계 분리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 해결 방향은 돌고 돌아 정공법으로 돌아왔습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동기 컨텍스트 (Authenticator, OkHttp 콜백)에서는 순수 동기 코드만 사용한다&lt;/li&gt;
&lt;li&gt;비동기 컨텍스트 (UI, ViewModel, Repository)에서는 &lt;code&gt;suspend&lt;/code&gt; 코루틴 기반 코드만 사용한다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 아래 네 가지를 적용했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;토큰 재발급 API를 동기 &lt;code&gt;Call.execute()&lt;/code&gt; 기반 인터페이스로 분리&lt;/li&gt;
&lt;li&gt;토큰 재발급용 전용 &lt;code&gt;OkHttpClient&lt;/code&gt; 분리&lt;/li&gt;
&lt;li&gt;동시 토큰 갱신 제어를 &lt;code&gt;synchronized&lt;/code&gt; 블록으로 처리&lt;/li&gt;
&lt;li&gt;토큰은 메모리 캐시를 즉시 갱신하고 영속 저장은 코루틴으로 비동기 처리&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1837&quot; data-origin-height=&quot;1680&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tH8d7/dJMcagcS59q/gPssVDXhnnRg7DDbLGGXKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tH8d7/dJMcagcS59q/gPssVDXhnnRg7DDbLGGXKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tH8d7/dJMcagcS59q/gPssVDXhnnRg7DDbLGGXKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtH8d7%2FdJMcagcS59q%2FgPssVDXhnnRg7DDbLGGXKK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;457&quot; data-origin-width=&quot;1837&quot; data-origin-height=&quot;1680&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 실제 프로젝트 코드 기준으로 어떻게 바꿨는지 단계별로 정리합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Retrofit 토큰 재발급 API: 동기 Call 기반으로 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 &lt;code&gt;suspend&lt;/code&gt; 기반이었던 토큰 재발급 API를 동기 &lt;code&gt;Call&amp;lt;T&amp;gt;&lt;/code&gt; 기반 인터페이스로 나눴습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// data/auth/service/ReissueService.kt
interface ReissueService {
    @POST(&quot;api/v1/users/reissue&quot;)
    fun reissueToken(
        @Header(AUTHORIZATION) refreshToken: String
    ): Call&amp;lt;BaseResponse&amp;lt;ReissueTokenResponseDto&amp;gt;&amp;gt; // suspend 제거, Call 반환
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이 API를 감싸는 서비스 레이어에서 동기 호출을 수행합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// data/auth/service/TokenRefreshServiceImpl.kt
internal class TokenRefreshServiceImpl @Inject constructor(
    private val reissueService: ReissueService,
    private val tokenManager: TokenManager,
    @ApplicationScope private val appScope: CoroutineScope,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : TokenRefreshService {

    override fun refreshToken(refreshToken: String): Result&amp;lt;Pair&amp;lt;String, String&amp;gt;&amp;gt; = runCatching {
        synchronized(this) {
            // ... 중복 갱신 체크 로직 ...

            // 1. 동기 네트워크 호출 (Call.execute)
            val response = reissueService.reissueToken(&quot;$BEARER $refreshToken&quot;).execute()
            val data = response.body()?.data ?: throw Exception(&quot;...&quot;)

            // 2. 캐시 즉시 업데이트 (동기)
            tokenManager.updateTokensInCache(data.accessToken, data.refreshToken)

            // 3. 영속 저장은 비동기 (Fire-and-forget)
            appScope.launch(ioDispatcher) {
                try {
                    tokenManager.saveTokens(data.accessToken, data.refreshToken)
                } catch (e: Throwable) {
                    Timber.e(e, &quot;Failed to save tokens to DataStore&quot;)
                }
            }

            Pair(data.accessToken, data.refreshToken)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;execute()&lt;/code&gt;는 호출한 스레드에서 블로킹 방식으로 동작합니다. 비동기 Dispatcher의 슬롯을 사용하지 않기 때문에 &lt;code&gt;maxRequestsPerHost&lt;/code&gt;와 직접적인 충돌 없이 동작합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;토큰 재발급 전용 OkHttpClient 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 재발급 요청이 메인 API 요청과 동일한 Dispatcher 제한을 공유하지 않도록 전용 &lt;code&gt;OkHttpClient&lt;/code&gt;를 분리해서 사용했습니다. 코드 상에서는 &lt;code&gt;@RefreshClient&lt;/code&gt; 같은 &lt;code&gt;Qualifier&lt;/code&gt;를 두고 별도의 OkHttp 인스턴스를 주입받도록 구성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 아이디어는 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;메인 API &amp;rarr; &lt;code&gt;MainOkHttpClient&lt;/code&gt; (공용, &lt;code&gt;Authenticator&lt;/code&gt; 부착)&lt;/li&gt;
&lt;li&gt;토큰 재발급 &amp;rarr; &lt;code&gt;RefreshOkHttpClient&lt;/code&gt; (전용, &lt;code&gt;Authenticator&lt;/code&gt; 비부착)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 토큰 재발급 요청은 메인 요청의 동시성 제한 상황과 독립적으로 실행할 수 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;메모리 캐시 + 비동기 영속화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 관리 책임은 &lt;code&gt;TokenManagerImpl&lt;/code&gt;이 가지고 있습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// core/localstorage/TokenManagerImpl.kt
@Singleton
class TokenManagerImpl @Inject constructor(
    private val dataStore: DataStore&amp;lt;UserPreferences&amp;gt;,
    @ApplicationScope private val externalScope: CoroutineScope
) : TokenManager {

    @Volatile private var cachedAccessToken: String? = null
    @Volatile private var cachedRefreshToken: String? = null

    init {
        // 앱 시작 시 비동기 초기화
        externalScope.launch {
            val preferences = dataStore.data.first()
            cachedAccessToken = preferences.token
            cachedRefreshToken = preferences.refreshToken
        }
    }

    // 동기 함수
    override fun getAccessToken(): String? = cachedAccessToken

    // ... updateTokensInCache, saveTokens 등 구현 ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서의 포인트는 동기 컨텍스트에서는 &lt;code&gt;cachedAccessToken&lt;/code&gt; / &lt;code&gt;cachedRefreshToken&lt;/code&gt;만 읽습니다. 디스크에 쓰는 작업은 &lt;code&gt;suspend&lt;/code&gt; 함수로 제공하고 호출 측에서 코루틴 컨텍스트를 정하도록 했습니다. &lt;code&gt;init&lt;/code&gt; 블록에서 미리 캐시를 올려두기 때문에 Authenticator에서 바로 사용이 가능합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;runBlocking 제거 + synchronized&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 &lt;code&gt;Authenticator&lt;/code&gt;는 순수 동기 코드로 정리했습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// core/network/auth/TokenAuthenticator.kt
@Singleton
class TokenAuthenticator @Inject constructor(
    private val tokenManager: TokenManager,
    private val tokenRefreshService: TokenRefreshService,
    // ...
) : Authenticator {

    override fun authenticate(route: Route?, response: Response): Request? {
        synchronized(this) {
            // ...
            val refreshToken = tokenManager.getRefreshToken() ?: return handleRefreshFailure()

            // 동기 재발급 요청 실행 (내부에서 execute 호출)
            val result = tokenRefreshService.refreshToken(refreshToken)

            return if (result.isSuccess) {
                val (newAccessToken, _) = result.getOrThrow()
                response.request.newBuilder()
                    .header(AUTHORIZATION, &quot;$BEARER $newAccessToken&quot;)
                    .build()
            } else {
                handleRefreshFailure()
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서의 흐름은 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;synchronized(this)&lt;/code&gt;로 Authenticator 전체를 직렬화합니다&lt;/li&gt;
&lt;li&gt;요청에 포함된 토큰과 현재 캐시된 토큰을 비교해서 이미 다른 스레드가 토큰을 갱신했다면 기존 요청만 새 토큰으로 교체해서 재시도합니다&lt;/li&gt;
&lt;li&gt;정말 갱신이 필요한 경우에만 &lt;code&gt;tokenRefreshService.refreshToken&lt;/code&gt;을 호출합니다&lt;/li&gt;
&lt;li&gt;실패한 경우에는 별도의 실패 처리 로직으로 넘깁니다&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;새 구조의 실행 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩토링 이후 토큰 만료 상황에서 호출 흐름은 다음과 같이 정리할 수 있어요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3567&quot; data-origin-height=&quot;2400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sg9gB/dJMcafLOeZX/SHJZuiQ83pfeI65Uu9kkY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sg9gB/dJMcafLOeZX/SHJZuiQ83pfeI65Uu9kkY0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sg9gB/dJMcafLOeZX/SHJZuiQ83pfeI65Uu9kkY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fsg9gB%2FdJMcafLOeZX%2FSHJZuiQ83pfeI65Uu9kkY0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;700&quot; height=&quot;471&quot; data-origin-width=&quot;3567&quot; data-origin-height=&quot;2400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래의 변화를 중점적으로 보면 좋습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;토큰 재발급 호출은 &lt;code&gt;RefreshOkHttpClient&lt;/code&gt;를 사용합니다&lt;/li&gt;
&lt;li&gt;위 클라이언트는 메인 클라이언트와 서로 다른 Dispatcher를 사용하므로 메인 API 요청이 &lt;code&gt;maxRequestsPerHost&lt;/code&gt;로 꽉 차 있어도 토큰 재발급 호출은 독립적으로 실행할 수 있습니다&lt;/li&gt;
&lt;li&gt;Authenticator 내부에는 &lt;code&gt;runBlocking&lt;/code&gt;이나 &lt;code&gt;suspend&lt;/code&gt;가 없습니다&lt;/li&gt;
&lt;li&gt;동기 코드만 있고 동기 코드가 다시 비동기 Dispatcher에 의존하는 구조를 제거했습니다&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 리팩토링에서 정리할 수 있었던 포인트를 간단히 다시 적어보면 다음과 같습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;라이브러리의 동시성/스레드 모델을 이해하는 것이 중요합니다&lt;/li&gt;
&lt;li&gt;OkHttp의 &lt;code&gt;Dispatcher&lt;/code&gt;는 비동기 요청의 동시 실행 개수를 제한하고 이 제약과 &lt;code&gt;runBlocking&lt;/code&gt;을 섞었을 때 예상치 못한 데드락이 생길 수 있습니다&lt;/li&gt;
&lt;li&gt;동기 컨텍스트에서 코루틴을 강제로 돌리는 패턴은 신중해야 합니다&lt;/li&gt;
&lt;li&gt;특히 이미 제한된 스레드풀 위에서 동작하는 콜백 안에서는 &lt;code&gt;runBlocking&lt;/code&gt; 하나가 전체 호출 흐름을 막는 원인이 될 수 있습니다&lt;/li&gt;
&lt;li&gt;동기/비동기 경계를 명확히 나누면 설계가 단순해집니다
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Authenticator 같은 동기 API는 동기 네트워크 호출 + &lt;code&gt;synchronized&lt;/code&gt; 만 사용&lt;/li&gt;
&lt;li&gt;UI/UseCase/Repository 쪽은 &lt;code&gt;suspend&lt;/code&gt;/코루틴만 사용하도록 분리&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체적으로 보면 동기 컨텍스트에서는 동기 코드만, 비동기 컨텍스트에서는 코루틴만이라는 단순한 원칙을 지키는 것만으로도 데드락 가능성을 크게 줄일 수 있었습니다. 긴글 읽어주셔서 감사합니다!&lt;/p&gt;</description>
      <category>Android</category>
      <category>Android</category>
      <category>Authenticator</category>
      <category>coroutine</category>
      <category>Deadlock</category>
      <category>jwt</category>
      <category>mutex</category>
      <category>okhttp</category>
      <category>Retrofit</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/25</guid>
      <comments>https://angrypodo.tistory.com/25#entry25comment</comments>
      <pubDate>Thu, 27 Nov 2025 11:37:51 +0900</pubDate>
    </item>
    <item>
      <title>Android Credential Manager에서 Google 로그인 UI가 표시되지 않는 문제 해결하기</title>
      <link>https://angrypodo.tistory.com/23</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로덕션 환경에서 Google 로그인을 사용하던 중 특정 사용자 기기에서 로그인 버튼을 눌러도 아무 UI가 보이지 않는 문제가 제보됐습니다. 앱은 멈추지 않았고 크래시도 발생하지 않았으며 에러 로그도 남지 않았어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 QA 환경에서는 재현되지 않았고 개발자 기기에서도 동일한 문제가 나타나지 않았다는 점 입니다. 소수의 기기에서만 발생했고 안드로이드 유저중 제보를 받은 건 단 2건이었어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글은 원인을 찾기 위해 Credential Manager의 내부 동작 방식을 여러모로 분석하며 겪은 과정을 정리한 기록입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 정의&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스에서 Google 로그인을 호출하는 코드는 다음과 같은 형태였어요.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;class GoogleLoginManager @Inject constructor() {
    fun getGoogleIdToken(context: Context): String {
        var idToken = &quot;&quot;
        val clientId = if (BuildConfig.DEBUG) BuildConfig.DEV_SERVER_CLIENT_ID else BuildConfig.PROD_SERVER_CLIENT_ID
        val credentialManager = CredentialManager.create(context)

        val googleIdOption = GetGoogleIdOption.Builder()
            .setServerClientId(clientId)
            .setFilterByAuthorizedAccounts(false)
            .setAutoSelectEnabled(false)
            .build()

        val credentialRequest = GetCredentialRequest.Builder()
            .addCredentialOption(googleIdOption)
            .build()

        suspendRunCatching {
            credentialManager.getCredential(credentialRequest, context)
        }

        return idToken
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QA를 진행하고 프로덕션 이후에서는 정상적으로 Bottom Sheet 형태의 Google 계정 선택 UI가 표시됐습니다. 하지만 일부 사용자 기기에서는 &lt;code&gt;getCredential()&lt;/code&gt; 호출 이후 UI가 전혀 표시되지 않았어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;UI가 표시되지 않는 이유 파악하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Credential Manager는 앱이 직접 UI를 구성하지 않습니다. 앱은 설정한 옵션을 Bundle 형태로 Google Play services(GMS)로 전달하고 UI는 GMS 내부에서 생성되는데요. 때문에 UI가 표시되지 않는 원인으로 다음 가능성을 고려했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Bundle 크기가 비정상적으로 커져서 GMS가 처리하지 못한 경우&lt;/li&gt;
&lt;li&gt;GMS 내부에서 예외가 발생했지만 앱으로 전달되지 않은 경우&lt;/li&gt;
&lt;li&gt;UI 구성이 특정 환경에서만 실패한 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;내부 동작 흐름 분석&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Credential Manager와 Google Play services 간의 동작 흐름은 아래와 같아요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Untitled-2025-11-14-1944.png&quot; data-origin-width=&quot;2634&quot; data-origin-height=&quot;2400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8Calz/dJMcahCNKY5/q6hVWRxvXpMW1Xw1WJ0qAK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8Calz/dJMcahCNKY5/q6hVWRxvXpMW1Xw1WJ0qAK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8Calz/dJMcahCNKY5/q6hVWRxvXpMW1Xw1WJ0qAK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8Calz%2FdJMcahCNKY5%2Fq6hVWRxvXpMW1Xw1WJ0qAK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2634&quot; height=&quot;2400&quot; data-filename=&quot;Untitled-2025-11-14-1944.png&quot; data-origin-width=&quot;2634&quot; data-origin-height=&quot;2400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI가 표시되지 않는 현상은 UI 생성 실패 과정에서 발생하고 있었어요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제가 특정 옵션에서만 발생하는 이유?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;GetGoogleIdOption의 구조적 특징&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Google 로그인을 위해 사용할 수 있는 옵션은 두 가지예요.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;옵션&lt;/th&gt;
&lt;th&gt;목적&lt;/th&gt;
&lt;th&gt;UI 형태&lt;/th&gt;
&lt;th&gt;특징&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GetGoogleIdOption&lt;/td&gt;
&lt;td&gt;통합 로그인 제공&lt;/td&gt;
&lt;td&gt;Bottom Sheet&lt;/td&gt;
&lt;td&gt;기기 내 Google 계정 정보를 번들로 전달&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GetSignInWithGoogleOption&lt;/td&gt;
&lt;td&gt;Google 로그인 버튼 전용&lt;/td&gt;
&lt;td&gt;단독 Dialog&lt;/td&gt;
&lt;td&gt;구조가 단순하고 안정적&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 구현에서는 GetGoogleIdOption을 사용했어요. 이 경우 기기 내 Google 계정을 모두 포함한 Bundle이 GMS로 전달되기 때문에 환경에 따라 데이터 크기가 크게 달라질 수 있었어요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;내부적으로 발생하는 TransactionTooLargeException&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Credential Manager 트러블슈팅 문서에서는 Android 14 이상 환경에서 다음 문제가 발생할 수 있다고 안내하고 있어요.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GetGoogleIdOption 사용&lt;/li&gt;
&lt;li&gt;기기에 Google 계정이 여러 개 등록되어 있는 경우&lt;/li&gt;
&lt;li&gt;Google Play services 내부에서 &lt;code&gt;android.os.TransactionTooLargeException&lt;/code&gt; 발생 가능&lt;/li&gt;
&lt;li&gt;UI가 표시되지 않음&lt;/li&gt;
&lt;li&gt;예외가 앱으로 전달되지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 이해하려면 Android Binder 구조를 살펴볼 필요가 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Binder 트랜잭션과 1MB 제한&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안드로이드의 IPC 메커니즘인 Binder는 프로세스당 약 1MB의 고정 버퍼를 사용합니다. 이 버퍼는 프로세스 내 모든 요청이 공유하기 때문에 번들 크기가 커지면 트랜잭션이 실패할 수 있어요. 이해를 돕기위해 Credential Manager의 처리 흐름을 Binder 트랜잭션 기준으로 정리하면 다음과 같아요.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;2.png&quot; data-origin-width=&quot;2973&quot; data-origin-height=&quot;1617&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bI4Spy/dJMcaioaSmA/7kTLAJaAIRxAwTPO65Pn41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bI4Spy/dJMcaioaSmA/7kTLAJaAIRxAwTPO65Pn41/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bI4Spy/dJMcaioaSmA/7kTLAJaAIRxAwTPO65Pn41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbI4Spy%2FdJMcaioaSmA%2F7kTLAJaAIRxAwTPO65Pn41%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2973&quot; height=&quot;1617&quot; data-filename=&quot;2.png&quot; data-origin-width=&quot;2973&quot; data-origin-height=&quot;1617&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 흐름에서 중요한 점은 아래의 3가지입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Bundle 직렬화 단계에서 기기 내 Google 계정이 많을수록 데이터 크기가 커짐&lt;/li&gt;
&lt;li&gt;1MB 제한을 초과하면 Binder가 트랜잭션을 중단&lt;/li&gt;
&lt;li&gt;Google Play services 내부에서 예외가 발생하지만 앱으로 전달되지 않기 때문에 앱은 단순히 아무 UI도 표시되지 않는 상태로 남음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조 때문에 특정 기기에서만 문제가 나타났어요. 다계정을 사용하는 Android 14 기기에서 GMS 업데이트가 적용되지 않은 경우입니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제는 수정되었지만 완전히 사라진 것은 아님&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 트러블슈팅 문서 기준으로 Google Play services v24.40.14 이상에서 Bundle 크기와 관련된 문제가 개선되었다고 안내하고 있어요. 하지만 사용자 기기에서 Google Play services가 항상 최신 버전이라는 보장은 없습니다. 이번 사용자 제보도 발생했으니까요.. 때문에 사용자의 업데이트되지 않은 기기에서는 동일한 문제가 여전히 발생할 수 있습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;GetSignInWithGoogleOption 사용하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 정리해보니 Google 로그인 버튼 플로우에서는 GetSignInWithGoogleOption을 사용하는 게 목적에 맞다고 결론을 도출했어요. 옵션 구조가 단순하고 계정 정보를 번들로 전달하지 않기 때문에 Binder 제한에 걸릴 가능성이 없습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;수정된 코드&lt;/h3&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class GoogleLoginManager @Inject constructor() {
    fun getGoogleIdToken(context: Context): String {
        var idToken = &quot;&quot;
        val clientId = if (BuildConfig.DEBUG) BuildConfig.DEV_SERVER_CLIENT_ID else BuildConfig.PROD_SERVER_CLIENT_ID
        val credentialManager = CredentialManager.create(context)

        val option = GetSignInWithGoogleOption.Builder(clientId).build()

        val credentialRequest = GetCredentialRequest.Builder()
            .addCredentialOption(option)
            .build()

        suspendRunCatching {
            credentialManager.getCredential(credentialRequest, context)
        }

        return idToken
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드를 적용한 이후 제보된 기기에서 동일한 문제 환경에서도 Google 로그인 UI가 안정적으로 표시됐어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 해당 API는 Modal형식의 UI를 제공해요. 때문에 통합 로그인 UI를 하나의 Bottom Sheet에서 제공해야 하는 경우라면 여전히 GetGoogleIdOption을 사용해야 합니다. 이 경우에는 Google Play services가 최신 버전이 아닌 기기에서 동일한 문제가 발생할 가능성을 고려해서 Fallback 처리를 추가하는걸 추천드려요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 문제는 UI가 표시되지 않는다는 단순한 증상으로 시작했지만 원인을 찾기 위해 Credential Manager 구조와 Android Binder 트랜잭션 동작까지 확인해야 했어요. 결과적으로 다음 사실들을 확인할 수 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GetGoogleIdOption은 구조적으로 Bundle 크기가 커질 수 있어 환경에 따라 &lt;code&gt;TransactionTooLargeException&lt;/code&gt;이 발생한다.&lt;/li&gt;
&lt;li&gt;Google Play services 최신 버전에서 개선되었지만 사용자 업데이트 여부에 따라 동일 문제가 남아있다.&lt;/li&gt;
&lt;li&gt;버튼 기반 Google 로그인의 경우 GetSignInWithGoogleOption이 의도에 맞다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글이 Credential Manager 기반 Google 로그인을 도입할 때 발생할 수 있는 문제를 이해하는 데 도움이 되었으면 좋겠습니다.  &lt;/p&gt;</description>
      <category>Android</category>
      <category>Android</category>
      <category>binder</category>
      <category>credential</category>
      <category>Credential API</category>
      <category>google signin</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/23</guid>
      <comments>https://angrypodo.tistory.com/23#entry23comment</comments>
      <pubDate>Tue, 18 Nov 2025 13:14:49 +0900</pubDate>
    </item>
    <item>
      <title>Kotlin 클래스 12개, 언제 뭘 써야 할까?</title>
      <link>https://angrypodo.tistory.com/22</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Kotlin은 다양한 프로그래밍 시나리오에 맞춰 세분화된 클래스 유형을 제공해요. 우테코 프리코스를 진행한지 이제 4주차에 접어 들었는데, value class에 대한 인사이트를 얻어서 여태까지 안드로이드 개발을 하면서 잘 사용하지 않았던 클래스에도 관심을 가지고자 각 클래스가 어떤 목적으로 설계됐는지, 언제 사용해야 하는지 코드와 함께 정리했어요  &lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 일반 클래스 (Class)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;객체의 상태(State)와 기능(Function)을 정의하는 가장 표준적인 설계도예요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성자를 통해 의존성을 주입받거나, 변경 가능한 내부 상태를 가져야 할 때, 또는 데이터 처리 로직을 포함해야 할 때 사용해요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class SessionStorage(private val filePath: String) {
    private var accessCount: Int = 0

    fun save(data: String) {
        println(&quot;Saving to $filePath...&quot;)
        accessCount++
    }
}

val storage1 = SessionStorage(&quot;session.txt&quot;)
val storage2 = SessionStorage(&quot;session.txt&quot;)

println(storage1 == storage2) // false
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 특징&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 인스턴스가 고유해요. 기본적으로 참조 비교(Reference Comparison)를 하기 때문에, 내부 프로퍼티 값이 모두 같아도 &lt;code&gt;==&lt;/code&gt; 연산자는 &lt;code&gt;false&lt;/code&gt;를 반환해요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 데이터 클래스 (Data Class)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;데이터 보관(Holding)에만 집중하는 클래스예요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스나 API 응답을 담을 DTO(Data Transfer Object)가 필요하거나, 복잡한 로직 없이 순수하게 값만 묶어서 사용할 때 적합해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;data&lt;/code&gt; 키워드를 붙이면 컴파일러가 유용한 함수들을 자동으로 생성해줘요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;data class Person(
    val name: String,
    val email: String,
    val birthDate: String
)

val person1 = Person(&quot;Alice&quot;, &quot;alice@example.com&quot;, &quot;2000-01-01&quot;)
val person2 = Person(&quot;Alice&quot;, &quot;alice@example.com&quot;, &quot;2000-01-01&quot;)

// 1. 내용 비교 (Content Comparison)
println(person1 == person2) // true

// 2. 가독성 좋은 toString()
println(person1) // Person(name=Alice, email=alice@example.com, ...)

// 3. 일부만 변경한 복사본 생성
val person3 = person1.copy(name = &quot;Bob&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;자동 생성되는 함수들&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;equals()&lt;/code&gt;: 내용 비교를 수행해요. 주 생성자의 프로퍼티 값이 모두 같으면 &lt;code&gt;true&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hashCode()&lt;/code&gt;: &lt;code&gt;equals()&lt;/code&gt;와 일관성을 유지하도록 해시 코드 생성&lt;/li&gt;
&lt;li&gt;&lt;code&gt;toString()&lt;/code&gt;: 사람이 읽기 편한 형태로 출력&lt;/li&gt;
&lt;li&gt;&lt;code&gt;copy()&lt;/code&gt;: 불변성을 유지하며 일부 값만 변경한 새 복사본 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Class vs Data Class&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Class&lt;/b&gt;: &quot;이 두 객체는 같은 객체인가?&quot; (참조 비교)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Data Class&lt;/b&gt;: &quot;이 두 객체는 같은 값을 가졌는가?&quot; (내용 비교)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 객체 (Object)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;애플리케이션 전역에서 단 하나만 존재하는 싱글턴(Singleton)이에요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성자를 가질 수 없으며, 프로그램이 실행되는 동안 유일한 객체임이 보장돼요. 여러 곳에서 공유해야 하는 유틸리티 함수나 상수를 모아두거나, 상태가 필요 없는 헬퍼 클래스가 필요할 때 사용해요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;object DateUtil {
    fun format(date: Long): String {
        return date.toString()
    }
}

// 생성자 호출 없이 바로 접근
val formattedDate = DateUtil.format(System.currentTimeMillis())
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 데이터 객체 (Data Object)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;출력(&lt;code&gt;toString&lt;/code&gt;)이 깔끔한 싱글턴이에요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Object&lt;/code&gt;의 싱글턴 특징과 &lt;code&gt;Data Class&lt;/code&gt;의 &lt;code&gt;toString()&lt;/code&gt; 편의성을 결합한 형태예요. 로그 출력 등에서 메모리 주소 대신 깔끔한 이름을 보고 싶을 때 사용해요. 특히 Sealed Class 계층 구조에서 특정 상태를 나타내는 싱글턴 객체로 사용할 때 유용해요.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;object NormalObject
data object MyDataObject

println(NormalObject)   // NormalObject@1f32e5
println(MyDataObject)   // MyDataObject
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 열거형 클래스 (Enum Class)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;미리 정의된, 고정된 상수 값들의 집합을 나타내요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 상태 코드, 요일, 방향처럼 명확하게 제한된 수의 고정된 값 중 하나를 표현할 때 사용해요. &lt;code&gt;String&lt;/code&gt;이나 &lt;code&gt;Int&lt;/code&gt; 대신 타입 안전성(Type-Safe)을 보장하는 상수가 필요할 때 적합해요.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;enum class HttpStatus(val code: Int) {
    OK(200),
    NOT_FOUND(404),
    BAD_REQUEST(400);

    fun toResponseString(): String {
        return &quot;Error $code: $name&quot;
    }
}

val status = HttpStatus.NOT_FOUND
println(status.code) // 404
println(status.toResponseString()) // Error 404: NOT_FOUND
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 특징&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 값(&lt;code&gt;OK&lt;/code&gt;, &lt;code&gt;NOT_FOUND&lt;/code&gt; 등)은 해당 Enum 클래스의 고유한 상수 인스턴스&lt;/li&gt;
&lt;li&gt;생성자를 통해 각 상수에 추가 데이터 바인딩 가능&lt;/li&gt;
&lt;li&gt;&lt;code&gt;when&lt;/code&gt; 표현식과 함께 사용 시 컴파일러가 모든 경우를 처리했는지 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 실드 클래스 (Sealed Class)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;제한된 타입 계층을 만드는, Enum의 '슈퍼 버전'이에요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Enum&lt;/code&gt;처럼 미리 정의된 타입만 가질 수 있도록 상속을 제한하지만, 하위 클래스들이 서로 다른 데이터와 상태를 가진 '인스턴스'가 될 수 있다는 결정적인 차이가 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네트워크 요청 결과처럼 &quot;성공(데이터 포함)&quot;, &quot;실패(에러 메시지 포함)&quot;, &quot;로딩 중&quot;처럼 같은 부모 타입이지만 각 케이스별로 다른 데이터를 가져야 할 때 사용해요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;sealed class NetworkResult {
    data class Success(val data: String) : NetworkResult()
    data class Error(val throwable: Throwable) : NetworkResult()
    data object Empty : NetworkResult()
}

val successResult = NetworkResult.Success(&quot;Response data...&quot;)
val errorResult = NetworkResult.Error(Exception(&quot;Timeout!&quot;))

fun handleResult(result: NetworkResult) {
    when (result) {
        is NetworkResult.Success -&amp;gt; println(&quot;Success: ${result.data}&quot;)
        is NetworkResult.Error -&amp;gt; println(&quot;Error: ${result.throwable.message}&quot;)
        is NetworkResult.Empty -&amp;gt; println(&quot;Empty response&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Sealed Class를 상속하는 모든 자식 클래스는 같은 모듈(주로 같은 파일) 내에 정의되어야 해요.&lt;/b&gt; &lt;code&gt;when&lt;/code&gt;과 함께 사용 시, 컴파일러가 모든 하위 타입을 처리했는지 검사해줘요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Enum Class vs Sealed Class&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Enum Class&lt;/b&gt;: 모든 값이 단일 상수 인스턴스 (&lt;code&gt;HttpStatus.OK&lt;/code&gt;는 세상에 단 하나)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Sealed Class&lt;/b&gt;: 하위 타입이 서로 다른 데이터를 가진 여러 인스턴스를 만들 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 추상 클래스 (Abstract Class)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;공통된 기능을 정의하되, 그 자체로는 인스턴스화될 수 없는 '미완성 설계도'예요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 하위 클래스에서 공유해야 할 공통 기능과, 하위 클래스가 반드시 구현해야 할 '추상' 기능을 함께 정의하는 부모 클래스예요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;모든 센서는 &lt;code&gt;name&lt;/code&gt;이 있고 &lt;code&gt;startListening()&lt;/code&gt; 기능이 있어야 한다&quot;처럼, 공통된 규약(Contract)을 강제하고 싶을 때 사용해요. Interface와 달리, 하위 클래스들이 공유할 상태(프로퍼티)나 구현된 공통 함수를 제공할 수 있어요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;abstract class Sensor(val name: String) {
    // 추상 함수 - 자식 클래스가 반드시 구현
    abstract fun startListening()

    // 일반 함수 - 자식 클래스가 공통으로 사용
    fun stopListening() {
        println(&quot;$name sensor stopped.&quot;)
    }
}

class HeartRateSensor : Sensor(&quot;HeartRate&quot;) {
    override fun startListening() {
        println(&quot;HeartRate sensor started...&quot;)
    }
}

// val sensor = Sensor(&quot;Generic&quot;) // 에러! 추상 클래스는 인스턴스화 불가
val heartSensor = HeartRateSensor() // OK
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 특징&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;abstract&lt;/code&gt; 클래스는 직접 인스턴스를 만들 수 없음&lt;/li&gt;
&lt;li&gt;&lt;code&gt;abstract&lt;/code&gt;로 선언된 함수나 프로퍼티는 하위 클래스에서 반드시 재정의(override)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 오픈 클래스 (Open Class)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;상속을 허용하도록 '열어둔' 클래스예요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kotlin의 모든 클래스는 기본적으로 &lt;code&gt;final&lt;/code&gt; (상속 불가)이에요. &lt;code&gt;open&lt;/code&gt; 키워드는 다른 클래스가 이 클래스를 상속할 수 있도록 명시적으로 허용하는 키워드예요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Abstract 클래스가 아닌, 그 자체로 인스턴스화가 가능하면서 동시에 상속도 허용하고 싶을 때 사용해요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;open class HeartRateSensor(val name: String) {
    open fun startListening() {
        println(&quot;$name sensor started.&quot;)
    }
}

class CustomHeartRateSensor : HeartRateSensor(&quot;CustomSensor&quot;) {
    override fun startListening() {
        println(&quot;Custom logic added!&quot;)
        super.startListening()
    }
}

val basicSensor = HeartRateSensor(&quot;Basic&quot;) // 부모 자체로 인스턴스화 가능
val customSensor = CustomHeartRateSensor()
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하위 클래스에서 재정의(Override)를 허용할 함수나 프로퍼티에도 개별적으로 &lt;code&gt;open&lt;/code&gt; 키워드를 붙여야 해요.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. 익명 클래스 (Anonymous Class)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이름 없이, 그 자리에서 즉시 구현해 사용하는 '일회용 클래스'예요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인터페이스나 추상 클래스를 구현해야 할 때, 별도의 클래스 파일을 만들지 않고 코드 내에서 즉석으로(On-the-fly) 구현체를 만드는 방법이에요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;딱 한 번만 사용될 간단한 인터페이스 구현체가 필요할 때 유용해요. &lt;code&gt;object : Type { ... }&lt;/code&gt; 구문을 사용하며, 이름이 없으므로 재사용이 불가능해요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;abstract class Sensor(val name: String) {
    abstract fun startListening()
}

fun setupSensor() {
    val specialSensor: Sensor = object : Sensor(&quot;Special&quot;) {
        override fun startListening() {
            println(&quot;Special sensor logic...&quot;)
        }
    }

    specialSensor.startListening()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 밸류 클래스 (Value Class)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;타입 안전성(Type-Safety)은 높이고, 런타임 오버헤드는 없애는 클래스예요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;String&lt;/code&gt;이나 &lt;code&gt;Int&lt;/code&gt; 같은 단 하나의 값을 감싸서(Wrapping) 새로운 타입을 만들어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;String&lt;/code&gt; 타입의 &quot;이메일&quot;과 &quot;사용자 이름&quot;을 함수 파라미터에서 구분하고 싶을 때, 또는 유효성 검사 로직을 타입 자체에 포함시키고 싶을 때 사용해요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@JvmInline
value class Email(private val value: String) {
    init {
        if (!value.contains(&quot;@&quot;)) {
            throw IllegalArgumentException(&quot;Invalid email format&quot;)
        }
    }

    fun getDomain(): String = value.substringAfter(&quot;@&quot;)
}

fun sendEmail(email: Email) {
    println(&quot;Sending email to ${email.getDomain()}&quot;)
}

// sendEmail(Email(&quot;test@example.com&quot;)) // OK
// sendEmail(&quot;test@example.com&quot;) // 에러! String 타입은 전달 불가
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 특징&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;컴파일 시점: &lt;code&gt;Email&lt;/code&gt;과 &lt;code&gt;String&lt;/code&gt;을 엄격히 구분 (타입 안전성)&lt;/li&gt;
&lt;li&gt;런타임: &lt;code&gt;Email&lt;/code&gt; 객체가 사라지고 &lt;code&gt;String&lt;/code&gt; 값으로 인라인(Inlined)되어 성능 저하 없음&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@JvmInline&lt;/code&gt; 어노테이션 필요&lt;/li&gt;
&lt;li&gt;단 하나의 프로퍼티만 가질 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 어노테이션 클래스 (Annotation Class)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;코드에 '메타데이터'를 붙이는 표식이에요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스, 함수, 변수 등에 부가적인 정보(Metadata)를 제공하기 위해 사용하는 특별한 클래스예요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 자체의 동작에는 영향을 미치지 않지만, &lt;code&gt;@Deprecated&lt;/code&gt;처럼 컴파일러에게 경고를 주거나, &lt;code&gt;@Inject&lt;/code&gt;처럼 프레임워크(예: Dagger, Hilt)가 이 표식을 보고 특정 작업을 수행하도록 해요.&lt;/p&gt;
&lt;pre class=&quot;delphi&quot;&gt;&lt;code&gt;@Target(AnnotationTarget.CLASS, AnnotationTarget.CONSTRUCTOR)
annotation class CustomDeprecated(val reason: String)

@CustomDeprecated(&quot;Use NewClass&quot;)
class OldClass
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;annotation class&lt;/code&gt; 키워드로 선언하며, &lt;code&gt;@Target&lt;/code&gt;을 통해 어노테이션이 적용될 위치(클래스, 함수 등)를 지정할 수 있어요.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. 이너 클래스 (Inner Class)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;바깥 클래스의 속성에 접근할 수 있는 내부 클래스예요.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클래스 내부에 &lt;code&gt;inner&lt;/code&gt; 키워드로 선언되며, 바깥쪽(Outer) 클래스의 인스턴스에 대한 참조를 유지해요. 이 특징 덕분에 바깥쪽 클래스의 &lt;code&gt;private&lt;/code&gt; 멤버에도 접근할 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;논리적으로 특정 클래스에 강하게 종속되어, 해당 클래스의 상태(프로퍼티)를 직접 참조해야 하는 헬퍼 클래스를 만들 때 사용해요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;class SessionStorage(private val file: String) {
    private val storageName = &quot;MainStorage&quot;

    // 중첩 클래스 (Nested Class) - inner 없음
    class NestedHelper {
        // fun checkFile() = println(file) // 에러! 'file'에 접근 불가
    }

    // 이너 클래스 (Inner Class)
    inner class InnerFileHelper {
        fun checkFileIfExists() {
            // 바깥쪽의 'file'과 'storageName'에 접근 가능
            println(&quot;Checking $file in $storageName...&quot;)
        }
    }

    fun performCheck() {
        val helper = InnerFileHelper()
        helper.checkFileIfExists()
    }
}

val storage = SessionStorage(&quot;my_data.txt&quot;)
storage.performCheck() // &quot;Checking my_data.txt in MainStorage...&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;code&gt;inner&lt;/code&gt;가 없는 중첩 클래스(Nested Class)는 바깥 클래스의 인스턴스에 접근할 수 없어요.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;클래스 유형 요약&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;클래스 유형&lt;/th&gt;
&lt;th&gt;핵심 목적&lt;/th&gt;
&lt;th&gt;인스턴스화&lt;/th&gt;
&lt;th&gt;비교 방식&lt;/th&gt;
&lt;th&gt;상속&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Class&lt;/td&gt;
&lt;td&gt;상태와 기능을 가진 표준 객체&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;참조&lt;/td&gt;
&lt;td&gt;&lt;code&gt;open&lt;/code&gt; 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data Class&lt;/td&gt;
&lt;td&gt;데이터 보관 (값 객체)&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;내용&lt;/td&gt;
&lt;td&gt;&lt;code&gt;open&lt;/code&gt; 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object&lt;/td&gt;
&lt;td&gt;싱글턴 (유일한 인스턴스)&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;참조&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data Object&lt;/td&gt;
&lt;td&gt;&lt;code&gt;toString&lt;/code&gt;이 깔끔한 싱글턴&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;참조&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enum Class&lt;/td&gt;
&lt;td&gt;고정된 상수 값 집합&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;td&gt;참조&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sealed Class&lt;/td&gt;
&lt;td&gt;제한된 타입 계층&lt;/td&gt;
&lt;td&gt;불가 (자식만)&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;자식만&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Abstract Class&lt;/td&gt;
&lt;td&gt;공통 규약/기능 정의&lt;/td&gt;
&lt;td&gt;불가 (자식만)&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;강제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open Class&lt;/td&gt;
&lt;td&gt;상속을 허용하는 클래스&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;참조&lt;/td&gt;
&lt;td&gt;허용됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anonymous&lt;/td&gt;
&lt;td&gt;일회용 즉석 구현체&lt;/td&gt;
&lt;td&gt;즉시&lt;/td&gt;
&lt;td&gt;참조&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value Class&lt;/td&gt;
&lt;td&gt;타입 안전성 + 성능&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;내용&lt;/td&gt;
&lt;td&gt;불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Annotation&lt;/td&gt;
&lt;td&gt;메타데이터 표식&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inner Class&lt;/td&gt;
&lt;td&gt;외부 클래스 참조 내부 클래스&lt;/td&gt;
&lt;td&gt;외부 필요&lt;/td&gt;
&lt;td&gt;참조&lt;/td&gt;
&lt;td&gt;&lt;code&gt;open&lt;/code&gt; 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;더 알아보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본 아티클은 Kotlin 클래스의 유형(Type)에 초점을 맞췄어요. 클래스를 더욱 강력하게 활용하려면 다음 주제들을 함께 학습하는 걸 권장해요  &lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;인터페이스 (Interfaces)&lt;/b&gt;: 여러 클래스가 공통으로 구현해야 하는 행동(Behavior)을 정의&lt;/li&gt;
&lt;li&gt;&lt;b&gt;위임 (Delegation)&lt;/b&gt;: 상속 대신 객체 조합을 사용하여 코드 재사용 (&lt;code&gt;by&lt;/code&gt; 키워드)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;제네릭 (Generics)&lt;/b&gt;: &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt;처럼 다양한 타입을 처리할 수 있도록 유연성 부여&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>우아한테크코스/프리코스</category>
      <category>class</category>
      <category>enum</category>
      <category>Kotlin</category>
      <category>Sealed</category>
      <category>value class</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/22</guid>
      <comments>https://angrypodo.tistory.com/22#entry22comment</comments>
      <pubDate>Tue, 4 Nov 2025 11:19:56 +0900</pubDate>
    </item>
    <item>
      <title>data class랑 class랑 뭐가 그렇게 다른데?</title>
      <link>https://angrypodo.tistory.com/21</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;안녕하세요! 최근 우테코 프리코스 과정을 진행하면서 굉장히 많은걸 배우고 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 2주차 자동차 경주 미션을 진행하면서 다른 분들의 코드를 리뷰할 기회가 있었는데, 한 가지 흥미로운 점을 발견했어요. 바로 핵심 도메인 객체인 &lt;code&gt;Car&lt;/code&gt;를 설계하는 방식이 크게 두 가지로 나눠지는 점이에요.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;data class&lt;/code&gt;를 사용해 불변(immutable) 객체로 구현한 방식&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;일반 &lt;code&gt;class&lt;/code&gt;에서 내부 상태를 직접 변경하는 가변(mutable) 객체로 구현한 방식 (public &lt;code&gt;var&lt;/code&gt; 또는 백킹 프로퍼티 활용)&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 테스트 용이성과 코드의 안정성을 높이기 위해 &lt;code&gt;data class&lt;/code&gt;를 선택했는데요, 이 주제로 동료들과 토론을 나누다 보니 문득 이런 궁금증이 생겼어요.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;그래서, 정말 성능 차이가 얼마나 날까?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마침 이번 미션의 핵심 목표 중 하나가 '테스트 도구를 사용하는 방법'을 배우는 것이었기에, 이 궁금증을 직접 벤치마크 테스트를 통해 풀어보기로 했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;두 방식은 이렇게 달랐어요&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성능을 비교하기 위해, 자동차의 위치(&lt;code&gt;position&lt;/code&gt;)를 업데이트하는 두 가지 방식의 &lt;code&gt;Car&lt;/code&gt; 객체를 준비했어요.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 불변 객체&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;data class&lt;/code&gt;로 &lt;code&gt;Car&lt;/code&gt;를 정의하고, &lt;code&gt;move()&lt;/code&gt;가 호출될 때마다 &lt;code&gt;position&lt;/code&gt;이 1 증가한 새로운 &lt;code&gt;Car&lt;/code&gt; 객체를 &lt;code&gt;copy()&lt;/code&gt;를 통해 반환하는 방식이에요.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;private data class ImmutableCar(val name: String, val position: Int = 0) {
    fun move(): ImmutableCar = this.copy(position = position + 1)
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 가변 객체&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 &lt;code&gt;class&lt;/code&gt;로 &lt;code&gt;Car&lt;/code&gt;를 정의하고, &lt;code&gt;position&lt;/code&gt;을 &lt;code&gt;var&lt;/code&gt; 프로퍼티로 가져요. &lt;code&gt;move()&lt;/code&gt; 메소드는 내부 &lt;code&gt;position&lt;/code&gt;의 값을 직접 1 증가시키는 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;private class MutableCar(val name: String, var position: Int = 0) {
    fun move() {
        this.position += 1
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그래서 직접 테스트 해보자&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 방식의 성능 차이를 명확히 확인하기 위해, &lt;b&gt;10,000대의 자동차가 10,000번 움직이는 상황&lt;/b&gt;을 시뮬레이션했어요. 즉, 총 1억 번의 &lt;code&gt;move&lt;/code&gt; 연산이 발생하는 시나리오입니다  &lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;불변 객체 테스트:&lt;/b&gt; 매 라운드마다 &lt;code&gt;map&lt;/code&gt; 연산을 통해 새로운 자동차 리스트를 생성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;가변 객체 테스트:&lt;/b&gt; &lt;code&gt;forEach&lt;/code&gt;로 각 자동차 객체의 &lt;code&gt;move()&lt;/code&gt; 메소드를 호출하여 내부 상태를 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 벤치마크에 사용된 전체 테스트 코드예요.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.system.measureTimeMillis

class PerformanceTest {

    private val carCount = 10000
    private val roundCount = 10000

    // 1. 불변 객체 (data class)
    private data class ImmutableCar(val name: String, val position: Int = 0) {
        fun move(): ImmutableCar = this.copy(position = position + 1)
    }

    // 2. 가변 객체 (class)
    private class MutableCar(val name: String, var position: Int = 0) {
        fun move() {
            this.position += 1
        }
    }

    @Test
    @DisplayName(&quot;data class와 class의 객체 업데이트 성능 비교&quot;)
    fun `comparePerformance_ImmutableVsMutable`() {
        // 불변 객체 테스트
        var immutableCars = (1..carCount).map { ImmutableCar(&quot;car$it&quot;) }
        val immutableTime = measureTimeMillis {
            repeat(roundCount) {
                immutableCars = immutableCars.map { it.move() }
            }
        }
        println(&quot;--- 성능 테스트 결과 ---&quot;)
        println(&quot;Immutable (data class): $immutableTime ms&quot;)

        // 가변 객체 테스트
        val mutableCars = (1..carCount).map { MutableCar(&quot;car$it&quot;) }
        val mutableTime = measureTimeMillis {
            repeat(roundCount) {
                mutableCars.forEach { it.move() }
            }
        }
        println(&quot;Mutable (class): $mutableTime ms&quot;)
        println(&quot;----------------------&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트 결과는?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 예상대로였지만, 수치는 생각보다 더 극적이었어요. 가변 객체를 사용한 방식이 훨씬 빨랐습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Immutable (&lt;code&gt;data class&lt;/code&gt;): 662 ms&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Mutable (&lt;code&gt;class&lt;/code&gt;): 274 ms&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부 상태를 직접 변경하는 방식이 새로운 객체를 계속 생성하는 방식보다 약 &lt;b&gt;2.4배&lt;/b&gt; 더 빠른 성능을 보여주었어요. 이 결과는 객체 생성 및 가비지 컬렉션(GC) 오버헤드가 실제로 성능에 유의미한 영향을 미친다는 것을 명확하게 보여줘요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그렇다면 &lt;code&gt;data class&lt;/code&gt;는 항상 나쁜 선택일까요?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 그렇지 않다고 생각해요. 이 벤치마크는 성능이라는 극단적 환경에서 검증한 내용이기 때문인데요!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 오히려 불변성이 주는 이점에 관점을 맞췄어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 객체의 상태가 변하지 않으므로, 여러 곳에서 객체를 참조하더라도 Side Effect 걱정 없이 안전하게 사용할 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 변경이 항상 새로운 객체 생성을 통해 이루어지므로, 데이터의 흐름을 추적하기 쉽고 버그가 발생했을 때 원인을 찾기 용이해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 테스트 시 객체의 내부 상태를 확인할 필요 없이, &quot;입력에 따라 기대하는 출력이 나왔는가&quot;만 검증하면 되므로 테스트 코드가 매우 명확하고 간결해져요.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정답은 없지만, 현명한 선택은 있어요&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;섣부른 최적화는 모든 악의 근원이다.&quot; - 도널드 커누스&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;class&lt;/code&gt;가 더 빠르다는 결과가 나왔지만, 이번 자동차 경주 미션의 요구사항(테스트 용이성, 안정성)과 규모를 고려했을 때 저는 다시 설계를 하더라도 &lt;code&gt;data class&lt;/code&gt;를 사용할거에요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 애플리케이션에서는 &lt;code&gt;data class&lt;/code&gt;가 제공하는 안정성과 테스트 용이성의 이점이 약간의 성능 비용을 충분히 상쇄하고도 남는다고 생각해요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면, 게임 엔진, 대용량 데이터 실시간 처리 등 성능이 극도로 중요한 일부 로직에서는 객체 생성 오버헤드를 줄이기 위해 가변 &lt;code&gt;class&lt;/code&gt;를 사용하는 것이 합리적인 선택일 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 정답은 없다고 생각해요  주어진 문제의 성격과 요구사항, 시스템의 제약 조건을 종합적으로 고려하여 상황에 맞는 최적의 도구를 선택하는 것&lt;b&gt;.&lt;/b&gt; 그것이 바로 좋은 설계면서 생각하는 개발자가 되는 능력이라고 생각합니다  &lt;/p&gt;</description>
      <category>우아한테크코스/프리코스</category>
      <category>backing property</category>
      <category>class</category>
      <category>data class</category>
      <category>우테코</category>
      <category>테스트</category>
      <author>아키001</author>
      <guid isPermaLink="true">https://angrypodo.tistory.com/21</guid>
      <comments>https://angrypodo.tistory.com/21#entry21comment</comments>
      <pubDate>Fri, 31 Oct 2025 17:38:16 +0900</pubDate>
    </item>
  </channel>
</rss>