<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>훌륭한 개발자가 되기 위하여</title>
    <link>https://jay20033.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sat, 11 Jul 2026 06:57:07 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>jay20033</managingEditor>
    <item>
      <title>클린아키텍처에서의 예외 처리하기</title>
      <link>https://jay20033.tistory.com/79</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;예외 처리(exception handling)란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프로그램 실행 중에 발생하는 비정상적이거나 예외적인 상황에 대응하는 과정&lt;/b&gt;이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예외는 네트워크 오류, 서버 오류, 데이터 파싱 실패 등 &lt;b&gt;개발자가 완전히 통제할 수 없는 상황&lt;/b&gt;에서 주로 발생한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;[예외란?] &lt;a href=&quot;https://jay20033.tistory.com/68&quot;&gt;https://jay20033.tistory.com/68&amp;nbsp;&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&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;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt; 기존 방식의 문제점&lt;/h4&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;발생한 Exception을 그대로 UI에 노출&lt;/b&gt;하는 방식으로 처리하고 있었다.&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;is DataResource.Error -&amp;gt; {
    _uiState.update { state -&amp;gt; state.copy(isLoading = false) }
    _uiEvent.emit(
        UIEvent.ShowSnackBar(
            resource.throwable.message ?: &quot;운동 상세 정보 불러오기 실패&quot;
        )
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt; 개선 방향 : Exception Handler 도입&lt;/h4&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;현재 아키텍처 구조 :&amp;nbsp;&lt;b&gt;Presentation(ViewModel) ➡️ Domain ⬅️ Data ⬅️ Remote, Local&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;Remote, Local 모듈에서 &lt;b&gt;기술적인 예외&lt;/b&gt; 발생&lt;/li&gt;
&lt;li&gt;Data 모듈에서 이를 &lt;b&gt;Custom Exception&lt;/b&gt;으로 매핑&lt;/li&gt;
&lt;li&gt;Domain 모듈에서 비즈니스 관점의 에러만 전달&lt;/li&gt;
&lt;li&gt;Presentation 모듈에서 사용자 메시지로 변환&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Custom Exception 정의&lt;/h4&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;sealed class DomainException : Exception() {
    object NoInternet : DomainException()
    object Timeout : DomainException()
    object ServiceUnavailable : DomainException()
    ...
    data class Unknown(val origin: Throwable) : DomainException()
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;sealed class RemoteException : Exception() {
    object NoInternetConnection : RemoteException()
    object Timeout : RemoteException()
    ...
    data class HttpError(val code: Int, override val message: String) : RemoteException()
    data class Unknown(val origin: Throwable) : RemoteException()
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;sealed class LocalException : Exception() {
    data class DBError(val origin: Throwable) : LocalException()
    object TransactionFailed : LocalException()
    object DiskFull : LocalException()
    ...
    data class Unknown(val origin: Throwable) : LocalException()
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Exception Handler(Mapper)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Remote &lt;b&gt;➡️&lt;/b&gt; Data&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;class RemoteExceptionMapper @Inject constructor() {
    fun map(e: Throwable): RemoteException {
        return when (e) {
            is HttpException -&amp;gt; RemoteException.HttpError(e.code(), e.message())
            is UnknownHostException, is IOException -&amp;gt; RemoteException.NoInternetConnection
            ...
            is FirebaseFirestoreException -&amp;gt; mapFirestore(e)
            else -&amp;gt; RemoteException.Unknown(e)
        }
    }

    private fun mapFirestore(e: FirebaseFirestoreException): RemoteException.FirebaseFirestore {
        return when (e.code) {
            FirebaseFirestoreException.Code.UNAVAILABLE -&amp;gt; RemoteException.FirebaseFirestore.Offline
            FirebaseFirestoreException.Code.PERMISSION_DENIED -&amp;gt; RemoteException.FirebaseFirestore.PermissionDenied
            ...
            else -&amp;gt; RemoteException.FirebaseFirestore.Unknown(e)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Local ➡️ Data&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;class LocalExceptionMapper @Inject constructor() {
    fun map(e: Throwable): LocalException {
        return when (e) {
            is SQLiteFullException -&amp;gt; LocalException.DiskFull
            is SQLiteConstraintException, is SQLiteException -&amp;gt; LocalException.DBError(e)
            ...
            else -&amp;gt; LocalException.Unknown(e)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Data ➡️ Domain&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;class DataExceptionMapper @Inject constructor() {

    fun map(e: Throwable): DomainException {
        return when (e) {
            is RemoteException -&amp;gt; mapRemote(e)
            is LocalException -&amp;gt; mapLocal(e)
            else -&amp;gt; DomainException.Unknown(e)
        }
    }

    private fun mapRemote(e: RemoteException): DomainException {
        return when (e) {
            is RemoteException.HttpError -&amp;gt; handleHttpException(e.code)
            is RemoteException.NoInternetConnection -&amp;gt; DomainException.NoInternet
            ...
            is RemoteException.Unknown -&amp;gt; DomainException.Unknown(e.origin)
        }
    }

    private fun mapLocal(e: LocalException): DomainException {
        return when (e) {
            is LocalException.EmptyResult -&amp;gt; DomainException.NotFound
            is LocalException.DBError -&amp;gt; DomainException.DBError
            ...
            is LocalException.Unknown -&amp;gt; DomainException.Unknown(e.origin)
        }
    }

    private fun handleHttpException(code: Int): DomainException {
        return when (code) {
            400 -&amp;gt; DomainException.BadRequest
            401 -&amp;gt; DomainException.Unauthenticated
            403 -&amp;gt; DomainException.Forbidden
            404 -&amp;gt; DomainException.NotFound
            ...
            else -&amp;gt; DomainException.Unknown(
                IllegalStateException(&quot;Unknown HTTP code: $code&quot;)
            )
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;UI 계층에서의 처리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Presentation 계층(ViewModel, UI)은 &lt;b&gt;Exception을 직접 분기하거나 해석하지 않는다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI의 역할은 &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 data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;예외 변환 흐름&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Throwable ➡️ DomainException ➡️ UiError ➡️ UiText ➡️ UI&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;DomainException ➡️ UiError&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;fun DomainException.toUIError(): UiError = when (this) {
    // Network
    is DomainException.NoInternet -&amp;gt; UiError.Network.NoInternet
    is DomainException.Timeout -&amp;gt; UiError.Network.Timeout
    is DomainException.ServiceUnavailable -&amp;gt; UiError.Network.ServiceUnavailable
    ...

    // Local
    is DomainException.DBError -&amp;gt; UiError.Local.DBError
    is DomainException.DiskFull -&amp;gt; UiError.Local.DiskFull
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; &amp;nbsp;&lt;/b&gt;이 변환을 통해 UI는 기술적인 예외나 비즈니스 규칙을 알 필요가 없어진다.&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;UiError ➡️ UiText&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;fun UiError.asUIText(): UiText {
    return when (this) {
        UiError.Network.NoInternet -&amp;gt;
            UiText.StringResource(R.string.error_no_internet)

        UiError.Network.Timeout -&amp;gt;
            UiText.StringResource(R.string.error_timeout)

        UiError.Network.ServiceUnavailable -&amp;gt;
            UiText.StringResource(R.string.error_service_unavailable)
       ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ViewModel에서 사용하기&lt;/h4&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;is DataResource.Error -&amp;gt; {
    _uiState.update { it.copy(isLoading = false) }
    _uiEvent.emit(UIEvent.ShowSnackBar(resource.throwable.toUIText()))
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  ViewModel은 예외의 종류를 판단하지 않고 단순히 UiText를 UI 이벤트로 전달한다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;최종적으로 얻은 이점&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 개선의 핵심은 &lt;b&gt;&quot;예외를 그대로 UI까지 전달하지 않는다는 것&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;계층 간 책임이 명확해지고 UI가 기술적인 세부 구현으로부터 완전히 분리되었다.&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;a href=&quot;https://proandroiddev.com/android-error-handling-in-clean-architecture-844a7fc0dc03&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://proandroiddev.com/android-error-handling-in-clean-architecture-844a7fc0dc03&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://rkdrkd-history.tistory.com/55&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://rkdrkd-history.tistory.com/55&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://medium.com/dmsfordsm/clean-architecture-errorhandling-in-android-48eaef35010c?&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://medium.com/dmsfordsm/clean-architecture-errorhandling-in-android-48eaef35010c?&lt;/a&gt;&lt;/p&gt;</description>
      <category>안드로이드</category>
      <author>jay20033</author>
      <guid isPermaLink="true">https://jay20033.tistory.com/79</guid>
      <comments>https://jay20033.tistory.com/79#entry79comment</comments>
      <pubDate>Fri, 28 Nov 2025 17:13:45 +0900</pubDate>
    </item>
    <item>
      <title>Jetpack Compose로 ViewModel 공유하기</title>
      <link>https://jay20033.tistory.com/78</link>
      <description>&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Jetpack Compose에서 ViewModel&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Compose에서는 Composable 자체가 ViewModelStoreOwner가 아니므로 ViewModel의 범위를 Composable로 지정할 수 없고 ViewModel의 생명주기를 맞출 수 없다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;왜냐하면, ViewModelStoreOwner은 ComponentActivity, Fragment, NavBackStackEntry 만의 SubClass이기 때문이다.&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;ViewModelStoreOwner란?&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ViewModelStoreOwner&lt;/b&gt;는 ViewModel을 저장하고 관리하는 컨테이너 역할을 하는 객체이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ViewModelStoreOwner가 configuration change로 인해 삭제되고 재생성되었을때 ViewModelStore의 이전 owner를 알수 있어야 한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;만약 재생성이 안되는 경우, ViewModelStore에 clear 매서드를 호출하여 ViewModel이 더 이상 필요하지 않다고 알려야한다.(메모리에서 해제)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Navigation에서 ViewModelStoreOwner&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Navigation Component에서 각 Navigation Destination은 NavBackStackEntry로 관리됨&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;NavBackStackEntry마다 ViewModelStoreOwner를 가지므로 ViewModel의 생명주기를 독립적으로 관리할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Compose의 viewModel()&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1. &lt;b&gt;LocalViewModelStoreOwner.current&lt;/b&gt;를 조회하여 현재 Composable이 속해 있는 &lt;b&gt;ViewModelStoreOwner&lt;/b&gt;(보통 NavBackStackEntry)를 가져온다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2. &lt;b&gt;ViewModelStoreOwner.get()&lt;/b&gt;을 호울하여 해당 BackStackEntry가 보유한 ViewModelStore에서 &lt;b&gt;ViewModel&lt;/b&gt;을 조회하거나 없으면 Factory를 통해 새로 생성한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Key : 동일 타입 ViewModel을 여러 개 만들어야 할 때 구분자 역할&lt;/li&gt;
&lt;li&gt;Factory : HiltViewModelFactory / DefaultFactory 등 실제 생성 책임&lt;/li&gt;
&lt;li&gt;Extras : SavedStateHandle, default arguments 등 추가 정보 포함&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
public fun &amp;lt;VM : ViewModel&amp;gt; viewModel(
    modelClass: KClass&amp;lt;VM&amp;gt;,
    viewModelStoreOwner: ViewModelStoreOwner =
        checkNotNull(LocalViewModelStoreOwner.current) {
            &quot;No ViewModelStoreOwner was provided via LocalViewModelStoreOwner&quot;
        },
    key: String? = null,
    factory: ViewModelProvider.Factory? = null,
    extras: CreationExtras =
        if (viewModelStoreOwner is HasDefaultViewModelProviderFactory) {
            viewModelStoreOwner.defaultViewModelCreationExtras
        } else {
            CreationExtras.Empty
        }
): VM = viewModelStoreOwner.get(modelClass, key, factory, extras)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;SharedViewModel 적용 전&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Select화면에서 선택한 운동 정보를 &lt;b&gt;Safe Args&lt;/b&gt;를 이용해 전달하여 Record화면에서 출력하는 형태&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Select 화면&lt;/h4&gt;
&lt;pre id=&quot;code_1764353846439&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Composable 
fun  SelectScreen ( 
	onExerciseRecordClick: (String, List&amp;lt;ExerciseModel&amp;gt;) -&amp;gt; Unit, 
	viewModel: SelectViewModel = hiltViewModel()
 ) { 
 	// ...
 }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1764353977816&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@HiltViewModel
class SelectViewModel @Inject constructor(
	// ...
    ): ViewModel() {
    // ...
 }&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Record 화면&lt;/h4&gt;
&lt;pre id=&quot;code_1764354007191&quot; class=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable 
fun  RecordScreen ( 
	onBackClick: () -&amp;gt; Unit, 
	viewModel: RecordViewModel = hiltViewModel()
 ) { 
 	// ...
 }&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1764354043293&quot; class=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@HiltViewModel
class RecordViewModel @Inject constructor(
	// ...
    ): ViewModel() {
    // ...
 }&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Navigation 설정&lt;/h4&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;composable&amp;lt;CalendarRoute.Select&amp;gt; {
    SelectExerciseRoute(
        onBackClick = onBackClick,
        onExerciseRecordClick = { date, exerciseIdList -&amp;gt;
            onExerciseRecordClick(
                date,
                exerciseIdList
            )
        }
    )
}

composable&amp;lt;CalendarRoute.Record&amp;gt;(
    typeMap = mapOf(typeOf&amp;lt;List&amp;lt;ExerciseNavArg&amp;gt;&amp;gt;() to ExerciseNavArgListType)
)
{
    ExerciseRecordRoute(
        ...
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;※ 기능 추가로 인해 발생한 문제&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;`&lt;b&gt;운동 추가하기&lt;/b&gt;` 기능을 도입하면서 다음 시나리오가 발생한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Record -&amp;gt; Select -&amp;gt; Record&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&amp;nbsp;사용자는 Record 화면에서 새로운 운동을 추가하기 위해 Select 화면으로 이동&lt;/li&gt;
&lt;li&gt;Select 화면에서 추가할 운동 선택&lt;/li&gt;
&lt;li&gt;다시 Record로 돌아옴&lt;/li&gt;
&lt;li&gt;기존 운동 리스트 + 새로 추가한 운동 리스트가 합쳐져야 함&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 때, 각각 독립적인 SelectViewModel / RecordViewModel 인스턴스가 생성됨&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;RecordViewModel의 init 블록이 다시 실행되면서 기존 운동 리스트가 날아감&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;  화면 전환 시에도 기존 운동 정보를 유지해야하기에 이때 필요한 것이 공유하는 ViewModel &lt;b&gt;[SharedViewModel]&lt;/b&gt;이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Compose Navigation에서 ViewModel 공유하기&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상위 NavBackStackEntry를 이용해 여러 Composable 목적지에서 뷰모델을 공유할 수 있음&lt;/li&gt;
&lt;li&gt;공유할 ViewModel은 상위 NavBackStackEntry가 아직 백스택에 있다면, 계속 메모리에 살아있기 때문이다.&lt;/li&gt;
&lt;li&gt;Jetpack Compose에서는 NavBackStackEntry를 활용해 네비게이션 스택이 ViewModel의 상태를 인지하도록 설정하여 불필요한 재생성을 방지할 수 있다.&lt;/li&gt;
&lt;li&gt;상위 NavBackStackEntry 를 구하고, 그 NavBackStackEntry 의 뷰모델을 가져와서 각 컴포저블 목적지에 전달한다&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;background-color: #fbfcfd; color: #24292e; text-align: left;&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
inline fun &amp;lt;reified T : ViewModel&amp;gt; NavBackStackEntry.sharedViewModel(navController: NavController): T {
    val navGraphRoute = destination.parent?.route ?: return viewModel()
    val parentEntry = remember(this) {
        navController.getBackStackEntry(navGraphRoute)
    }
    return viewModel(parentEntry)
}

composable&amp;lt;CalendarRoute.Select&amp;gt; { backStackEntry -&amp;gt;
    val sharedViewModel = backStackEntry.sharedViewModel&amp;lt;CalendarSharedViewModel&amp;gt;(navController)
    SelectExerciseRoute(
        viewModel = sharedViewModel,
        ...
    )
}

composable&amp;lt;CalendarRoute.Record&amp;gt; { backStackEntry -&amp;gt;
    val sharedViewModel = backStackEntry.sharedViewModel&amp;lt;CalendarSharedViewModel&amp;gt;(navController)
    ExerciseRecordRoute(
        viewModel = sharedViewModel,
        ...
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;HiltViewModel을 사용하는 경우&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상위 NavBackStackEntry에 접근한 뒤, hiltViewModel의 파라미터에 넣어주면 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;composable&amp;lt;CalendarRoute.Select&amp;gt; { backStackEntry -&amp;gt;
    val parentEntry = remember(backStackEntry) {
        navController.getBackStackEntry(MainRoute.Calendar)
    }
    val sharedViewModel = hiltViewModel&amp;lt;CalendarSharedViewModel&amp;gt;(parentEntry)
    SelectExerciseRoute(
        viewModel = sharedViewModel,
        ...
    )
}

composable&amp;lt;CalendarRoute.Record&amp;gt; { backStackEntry -&amp;gt;
    val parentEntry = remember(backStackEntry) {
        navController.getBackStackEntry(MainRoute.Calendar)
    }
    val sharedViewModel = hiltViewModel&amp;lt;CalendarSharedViewModel&amp;gt;(parentEntry)
    ExerciseRecordRoute(
        viewModel = sharedViewModel,
        ...
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;

            &lt;figure class=&quot;unsupported component-kakaotv&quot; contenteditable=&quot;false&quot; style=&quot;background:#000;margin:16px 0;min-height:72px;padding:10px 16px;display:flex;align-items:center;justify-content:center;text-align:center;box-sizing:border-box;width:100%;max-width:100%;&quot;&gt;
                &lt;p contenteditable=&quot;false&quot; style=&quot;margin:0;color:#8a8a8a;font-size:13px;line-height:1.6;user-select:none;pointer-events:none;&quot;&gt;동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.&lt;/p&gt;
            &lt;/figure&gt;
        
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고 자료&lt;/h4&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;a href=&quot;https://developer.android.com/topic/libraries/architecture/viewmodel&quot;&gt;https://developer.android.com/topic/libraries/architecture/viewmodel&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://medium.com/@mousaieparniyan/shared-viewmodel-in-jetpack-compose-1328f5c895c5&quot;&gt;https://medium.com/@mousaieparniyan/shared-viewmodel-in-jetpack-compose-1328f5c895c5&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@kej_ad/Android-Compsoe-Jetpack-Navigation-Nested-Graph%EC%99%80-Shared-ViewModel&quot;&gt;https://velog.io/@kej_ad/Android-Compsoe-Jetpack-Navigation-Nested-Graph%EC%99%80-Shared-ViewModel&lt;/a&gt;&lt;/p&gt;</description>
      <category>안드로이드</category>
      <author>jay20033</author>
      <guid isPermaLink="true">https://jay20033.tistory.com/78</guid>
      <comments>https://jay20033.tistory.com/78#entry78comment</comments>
      <pubDate>Thu, 13 Nov 2025 17:35:43 +0900</pubDate>
    </item>
    <item>
      <title>Jetpack Compose로 Custom Calendar 구현하기</title>
      <link>https://jay20033.tistory.com/76</link>
      <description>&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트에서 운동 기록을 날짜 단위로 보여주는 캘린더 UI가 필요했다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;Jetpack Compose Material3에서 기본으로 제공하는 캘린더 관련 컴포넌트는 &lt;b&gt;DatePicker&lt;/b&gt;, &lt;b&gt;TimePicker&lt;/b&gt; 두 가지가 있다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이 컴포넌트들은 단순한 날짜 입력에는 적합하지만 월 단위 캘린더 UI나 날짜별 상태 표시(운동 기록 여부)를 표현하기에는 한계가 있었다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;추가로 다른 사람들이 만들어놓은 외부 라이브러리가 있었지만 내가 원하는 형태로 커스텀하기에는 제한적인 부분이 있어 Custom Calendar를 직접 구현하기로 결정했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1384&quot; data-origin-height=&quot;1407&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c4OX6X/dJMcadAGUn7/1auKfpBXk4Fz2MU3wXV8kk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c4OX6X/dJMcadAGUn7/1auKfpBXk4Fz2MU3wXV8kk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c4OX6X/dJMcadAGUn7/1auKfpBXk4Fz2MU3wXV8kk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc4OX6X%2FdJMcadAGUn7%2F1auKfpBXk4Fz2MU3wXV8kk%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;394&quot; height=&quot;401&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1384&quot; data-origin-height=&quot;1407&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;요구사항 정리&lt;/h4&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;li&gt;날짜마다 운동 기록 유무 상태 표시&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;전체적인 구조&lt;/h4&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;b&gt;HorizontalPager&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;월별 날짜를 그리드 형태로 보여주는 영역&lt;/li&gt;
&lt;li&gt;날짜 하나하나를 담당하는 &lt;b&gt;Day&lt;/b&gt; Composable&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CalendarConfig : 캘린더 설정 분리&lt;/h4&gt;
&lt;pre id=&quot;code_1768296806227&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;data class CalendarConfig(
    val yearRange: IntRange = 2000..2100,
    val locale: Locale = Locale.getDefault(),
    val startOfWeek: DayOfWeek = DayOfWeek.MONDAY
)&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;yearRange&lt;/b&gt; : 캘린더에서 허용할 연도 범위&lt;/li&gt;
&lt;li&gt;&lt;b&gt;locale&lt;/b&gt; : 국가별 Locale&lt;/li&gt;
&lt;li&gt;&lt;b&gt;startOfWeek&lt;/b&gt; : 주(Week)의 시작 요일&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CalendarHeader : 연도와 월 표시&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Text만 사용한 단순한 &lt;b&gt;Composable&lt;/b&gt;이다.&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun CalendarHeader(
    modifier: Modifier = Modifier,
    text: String,
) {
   	...
        Text(text = text)
   	...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CalendarMonthItem : 월 단위 캘린더 구성&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;LazyVerticalGrid&lt;/b&gt;를 사용해 날짜를 7열 그리드 형태로 배치했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 달의 날짜를 기준으로 Grid의 남는 공간만큼 이전 달과 다음 달의 날짜를 계산해 채우는 방식으로 구현하였다.&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun CalendarMonthItem(
    modifier: Modifier = Modifier,
    currentDate: LocalDate,
    selectedDate: LocalDate,
    config: CalendarConfig = CalendarConfig(),
    calendarStatus: List&amp;lt;CalendarDayStatusModel&amp;gt;,
    onSelectedDate: (LocalDate) -&amp;gt; Unit
) {
    val prevMonth = currentDate.minusMonths(1)
    val nextMonth = currentDate.plusMonths(1)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;시작 요일 계산&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1768299728463&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val firstDayOfWeek = remember(currentDate, config.startOfWeek) {
    val dayValue = currentDate.dayOfWeek.value
    val startValue = config.startOfWeek.value
    ((dayValue - startValue + 7) % 7) + 1
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;startOfWeek&lt;/b&gt; 설정을 기준으로 해당 월의 시작 요일 위치를 계산해 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;날짜 리스트 구성&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1768299774052&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val prevMonthDays = prevMonth.lengthOfMonth()
val leadingDays = (1..firstDayOfWeek).map { day -&amp;gt;
    prevMonth.withDayOfMonth(prevMonthDays - firstDayOfWeek + day)
}
val currentMonthDays = (1..currentDate.lengthOfMonth()).map { day -&amp;gt;
    currentDate.withDayOfMonth(day)
}
val remainingCells = (leadingDays.size + currentMonthDays.size) % 7
val remainingDays = if (remainingCells == 0) emptyList() else {
    (1..(7 - remainingCells)).map { day -&amp;gt;
        nextMonth.withDayOfMonth(day)
    }
}
val calendarDays = leadingDays + currentMonthDays + remainingDays&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;전체 Grid 개수에서 현재 달 날짜를 제외한 만큼 이전 달, 다음 달 날짜로 채운다.&lt;/li&gt;
&lt;li&gt;항상 7의 배수 형태가 되도록 구성한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;날짜 출력&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;LazyVerticalGrid(
    columns = GridCells.Fixed(7)
) {
    items(calendarDays) { date -&amp;gt;
    	...
        CalendarDay(
            ...
            date = date,
            ...
        ) {
            onSelectedDate(date)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CalendarDay : Grid에 출력되는 날짜&lt;/h4&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun CalendarDay(
    modifier: Modifier = Modifier,
    date: LocalDate,
    isToday: Boolean,
    isSelected: Boolean,
    isCurrentMonth: Boolean,
    isCompleted: Boolean,
    hasEvent: Boolean,
    onSelectedDate: (LocalDate) -&amp;gt; Unit
) {
    ...
    Text(text = text)
    ...
  }&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;date&lt;/b&gt; : 표시할 날짜&lt;/li&gt;
&lt;li&gt;&lt;b&gt;isToday&lt;/b&gt; : 오늘 날짜 여부&lt;/li&gt;
&lt;li&gt;&lt;b&gt;isSelected&lt;/b&gt; : 선택된 날짜 여부&lt;/li&gt;
&lt;li&gt;&lt;b&gt;isCurrentMonth&lt;/b&gt; : 현재 달 날짜인지 여부(이전/다음 달 날짜는 흐리게 표시)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;isCompleted&lt;/b&gt; : 운동 완료 여부&lt;/li&gt;
&lt;li&gt;&lt;b&gt;hasEvent&lt;/b&gt; : 운동 기록 존재 여부&lt;/li&gt;
&lt;li&gt;&lt;b&gt;onSelectedDate&lt;/b&gt; : 날짜 선택 이벤트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;조건에 따라 Text 색상, 배경, 테두리 등을 변경해주면 되기 때문에 특별히 복잡한 로직은 없다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Horizontal Pager로 월 단위 이동 구현&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 구성한 UI를 &lt;b&gt;HorizontalPager&lt;/b&gt;에 올려 월 단위로 스와이프할 수 있도록 구현한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MuscleMemoryCalendar(
    modifier: Modifier = Modifier,
    currentDate: LocalDate = LocalDate.now(),
    config: CalendarConfig = CalendarConfig(),
    calendarStatus: List&amp;lt;CalendarDayStatusModel&amp;gt;,
    onSelectedDate: (LocalDate) -&amp;gt; Unit,
    onMonthChange: (String, String) -&amp;gt; Unit
) {&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Pager 설정&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1768305093811&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val initialPage = (currentDate.year - config.yearRange.first) * 12 + currentDate.monthValue - 1
val pageCount = (config.yearRange.last - config.yearRange.first + 1) * 12
val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { pageCount })&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;1페이지 = 1개월&lt;/li&gt;
&lt;li&gt;연도 범위를 기준으로 페이지 인덱스를 계산한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;페이지 변경 시 현재 월(Month) 업데이트&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;LaunchedEffect(pagerState.currentPage) {
    val addMonth = (pagerState.currentPage - currentPage).toLong()
    currentMonth = currentMonth.plusMonths(addMonth)
    currentPage = pagerState.currentPage
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;월 변경 시 날짜 범위 계산&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;LaunchedEffect(currentMonth) {
    val firstDayOfMonth = currentMonth.atDay(1)
    val lastDayOfMonth = currentMonth.atEndOfMonth()

    val calendarStart = firstDayOfMonth.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY))
    val calendarEnd = lastDayOfMonth.with(TemporalAdjusters.nextOrSame(DayOfWeek.SATURDAY))

    val startDate = calendarStart.toString()
    val endDate = calendarEnd.toString()

    onMonthChange(startDate, endDate)
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;월(Month)이 변경되면 캘린더에 실제로 표시되는 날짜 범위를 계산해 외부로 전달한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;Column(modifier = modifier) {
        val headerText = currentMonth.dateFormat(&quot;yyyy년 M월&quot;)
        CalendarHeader(text = headerText)
        HorizontalPager(
            state = pagerState
        ) {
            ...
            CalendarMonthItem(currentDate = date)
            ...       
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;</description>
      <category>안드로이드</category>
      <author>jay20033</author>
      <guid isPermaLink="true">https://jay20033.tistory.com/76</guid>
      <comments>https://jay20033.tistory.com/76#entry76comment</comments>
      <pubDate>Sun, 26 Oct 2025 23:16:56 +0900</pubDate>
    </item>
    <item>
      <title>Paging 검색 기능에 Debounce를 적용하여 성능 개선하기</title>
      <link>https://jay20033.tistory.com/71</link>
      <description>&lt;p data-ke-size=&quot;size18&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;대용량 운동 데이터를 서버 API를 통해 받아와 사용자에게 보여주는 기능을 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 운동 데이터를 &lt;b&gt;Paging&lt;/b&gt; 방식으로 받아온 뒤, Jetpack Compose의 LazyColumn에 &lt;b&gt;PagingData&lt;/b&gt;를 바인딩하여 출력하도록 구성하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768126967686&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val pagingExercises: Flow&amp;lt;PagingData&amp;lt;ExerciseModel&amp;gt;&amp;gt; =
        getPagedExercisesUseCase(10).map { pagingData -&amp;gt; pagingData.map { it.toPresentation() } }
            .cachedIn(viewModelScope)&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;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;검색 &amp;amp; 필터링 기능 추가&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검색 기능을 위해 다음과 같이 UI를 구현했다.&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;SearchBar&lt;/b&gt;를 통해 사용자의 검색어(&lt;b&gt;query&lt;/b&gt;)를 문자열로 입력받음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Chip UI&lt;/b&gt;를 통해 신체 부위(&lt;b&gt;bodyPart&lt;/b&gt;)를 선택&lt;/li&gt;
&lt;li&gt;각 신체 부위는 &lt;b&gt;enum class&lt;/b&gt;로 정의하여 클릭 시 상태가 업데이트되도록 두현&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후, 검색어와 신체 부위 값을 API에 전달하여 서버에서 필터링된 운동 데이터를 &lt;b&gt;Paging&lt;/b&gt;으로 받아오도록 설계했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt; 문제점 발견&lt;/h4&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;SearchBar&lt;/b&gt;에 글자를 입력할 때마다 API 호출이 발생한다는 점이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; 빠르게 입력할 경우 짧은 시간에 여러 번의 네트워크 요청이 발생하여 &lt;b&gt;네트워크 낭비 + 성능 저하&lt;/b&gt;가 발생하였다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Debounce란?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 알게 된 개념이 &lt;b&gt;Debounce&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;720&quot; data-origin-height=&quot;270&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bv9R67/dJMcadOcOJj/Exgx0RpiZh0WGn6HlyqfkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bv9R67/dJMcadOcOJj/Exgx0RpiZh0WGn6HlyqfkK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bv9R67/dJMcadOcOJj/Exgx0RpiZh0WGn6HlyqfkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbv9R67%2FdJMcadOcOJj%2FExgx0RpiZh0WGn6HlyqfkK%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;720&quot; height=&quot;270&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;270&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;❓Debounce&lt;/b&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;/li&gt;
&lt;li&gt;최종적으로 일정 시간 동안 이벤트가 발생하지 않았을 때 한 번만 실행된다.&lt;/li&gt;
&lt;li&gt;즉, 이벤트를 그룹핑하여 일정 시간동안 이벤트가 추가로 갱신되지 않으면 서버에 전송한다.&lt;/li&gt;
&lt;li&gt;Ex) 검색 입력 시 실시간 자동완성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Debounce의 내부 구조&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@FlowPreview
public fun &amp;lt;T&amp;gt; Flow&amp;lt;T&amp;gt;.debounce(timeoutMillis: Long): Flow&amp;lt;T&amp;gt; {
    require(timeoutMillis &amp;gt;= 0L) { &quot;Debounce timeout should not be negative&quot; }
    if (timeoutMillis == 0L) return this
    return debounceInternal { timeoutMillis }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;timeoutMillis&lt;/b&gt;가 음수인지 먼저 검사 (&lt;b&gt;유효성 검사&lt;/b&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;timeoutMillis&lt;/b&gt;가 0이면 debounce 의미가 없으므로 그대로 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768225534114&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private fun &amp;lt;T&amp;gt; Flow&amp;lt;T&amp;gt;.debounceInternal(timeoutMillisSelector: (T) -&amp;gt; Long): Flow&amp;lt;T&amp;gt; =
    scopedFlow { downstream -&amp;gt;
        // Produce the values using the default (rendezvous) channel
        val values = produce {
            collect { value -&amp;gt; send(value ?: NULL) }
        }
        // Now consume the values
        var lastValue: Any? = null
        while (lastValue !== DONE) {
            var timeoutMillis = 0L // will be always computed when lastValue != null
            // Compute timeout for this value
            if (lastValue != null) {
                timeoutMillis = timeoutMillisSelector(NULL.unbox(lastValue))
                require(timeoutMillis &amp;gt;= 0L) { &quot;Debounce timeout should not be negative&quot; }
                if (timeoutMillis == 0L) {
                    downstream.emit(NULL.unbox(lastValue))
                    lastValue = null // Consume the value
                }
            }
            // assert invariant: lastValue != null implies timeoutMillis &amp;gt; 0
            assert { lastValue == null || timeoutMillis &amp;gt; 0 }
            // wait for the next value with timeout
            select&amp;lt;Unit&amp;gt; {
                // Set timeout when lastValue exists and is not consumed yet
                if (lastValue != null) {
                    onTimeout(timeoutMillis) {
                        downstream.emit(NULL.unbox(lastValue))
                        lastValue = null // Consume the value
                    }
                }
                values.onReceiveCatching { value -&amp;gt;
                    value
                        .onSuccess { lastValue = it }
                        .onFailure {
                            it?.let { throw it }
                            // If closed normally, emit the latest value
                            if (lastValue != null) downstream.emit(NULL.unbox(lastValue))
                            lastValue = DONE
                        }
                }
            }
        }
    }&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;produce : Flow ➡️ Channel로 변환&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Channel로 변환하는 이유 :&amp;nbsp; select는 Channel 이벤트만 대기 가능하고 flow는 &lt;b&gt;select&lt;/b&gt; 대상이 될 수 없기 때문이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;lastValue&lt;/b&gt; : 아직 emit되지 않은 가장 최신 값&lt;/li&gt;
&lt;li&gt;timeout이 0이면 debounce 비활성화 ➡️ 즉시 emit&lt;/li&gt;
&lt;li&gt;&lt;b&gt;onTimeout&lt;/b&gt; : 지정한 시간 동안 아무 이벤트도 발생하지 않으면 내부 블록 실행&lt;/li&gt;
&lt;li&gt;&lt;b&gt;onReceiveCatching&lt;/b&gt; : 새로운 값이 들어오면 이전 값은 버리고 &lt;b&gt;lastValue&lt;/b&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;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&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;663&quot; data-origin-height=&quot;585&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cANJlb/dJMcahiPyDz/meChUsWawjQy0990Iw1NRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cANJlb/dJMcahiPyDz/meChUsWawjQy0990Iw1NRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cANJlb/dJMcahiPyDz/meChUsWawjQy0990Iw1NRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcANJlb%2FdJMcahiPyDz%2FmeChUsWawjQy0990Iw1NRK%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;663&quot; height=&quot;585&quot; data-origin-width=&quot;663&quot; data-origin-height=&quot;585&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;개선된 구조&lt;/h4&gt;
&lt;pre id=&quot;code_1768206427395&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private fun getPagedExercises(): Flow&amp;lt;PagingData&amp;lt;ExerciseModel&amp;gt;&amp;gt; {
    return _directoryUIState
        .map { it.query to it.bodyPart?.apiValue }
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { (query, bodyPart) -&amp;gt;
            if (bodyPart == BodyPartModel.BOOKMARK.apiValue) {
                _directoryUIState
                    .map { state -&amp;gt;
                        PagingData.from(
                            state.bookmarkExercises
                                .filter { it.name.contains(query ?: &quot;&quot;, ignoreCase = true) }
                        )
                    }
            } else {
                getPagedExercisesUseCase(30, query, bodyPart)
                    .map { pagingData -&amp;gt;
                        pagingData.map { it.toPresentation() }
                    }
            }
        }
        .cachedIn(viewModelScope)
}&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;distinctUntilChanged()&lt;/b&gt;는 이전 값과 동일한 값인 들어오면 Flow를 emit 하지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;flapMapLatest&lt;/b&gt;는 새로운 값이 들어오면 이전 작업을 즉시 취소하고 최신 작업만 유지한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Throttle&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;293&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HFidK/dJMcad1KN6Y/Q6BnzxnxWCBLMPnqDAcOa1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HFidK/dJMcad1KN6Y/Q6BnzxnxWCBLMPnqDAcOa1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HFidK/dJMcad1KN6Y/Q6BnzxnxWCBLMPnqDAcOa1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHFidK%2FdJMcad1KN6Y%2FQ6BnzxnxWCBLMPnqDAcOa1%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;720&quot; height=&quot;293&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;293&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;❓Throttle이란?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; &amp;nbsp;&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;Ex) 버튼 중복 클릭 방지&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Sample&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;293&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Emeid/dJMcahQF3cJ/JHrdebW2t9y5FC0bOevjK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Emeid/dJMcahQF3cJ/JHrdebW2t9y5FC0bOevjK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Emeid/dJMcahQF3cJ/JHrdebW2t9y5FC0bOevjK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEmeid%2FdJMcahQF3cJ%2FJHrdebW2t9y5FC0bOevjK1%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;720&quot; height=&quot;293&quot; data-origin-width=&quot;720&quot; data-origin-height=&quot;293&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;❓Sample이란?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  throttle 기법 중 &lt;b&gt;throttleLast&lt;/b&gt;이며 일정하게 도는 타이머가 종료될 때 발생하는 이벤트 중 마지막 이벤트 처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Sample의 내부 구조&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@FlowPreview
public fun &amp;lt;T&amp;gt; Flow&amp;lt;T&amp;gt;.sample(periodMillis: Long): Flow&amp;lt;T&amp;gt; {
    require(periodMillis &amp;gt; 0) { &quot;Sample period should be positive&quot; }
    return scopedFlow { downstream -&amp;gt;
        val values = produce(capacity = Channel.CONFLATED) {
            collect { value -&amp;gt; send(value ?: NULL) }
        }
        var lastValue: Any? = null
        val ticker = fixedPeriodTicker(periodMillis)
        while (lastValue !== DONE) {
            select&amp;lt;Unit&amp;gt; {
                values.onReceiveCatching { result -&amp;gt;
                    result
                        .onSuccess { lastValue = it }
                        .onFailure {
                            it?.let { throw it }
                            ticker.cancel(ChildCancelledException())
                            lastValue = DONE
                        }
                }

                // todo: shall be start sampling only when an element arrives or sample aways as here?
                ticker.onReceive {
                    val value = lastValue ?: return@onReceive
                    lastValue = null // Consume the value
                    downstream.emit(NULL.unbox(value))
                }
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;662&quot; data-origin-height=&quot;310&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYE9cy/dJMcahwmTut/SLCIzgkHffqWVxTvQ8el50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYE9cy/dJMcahwmTut/SLCIzgkHffqWVxTvQ8el50/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYE9cy/dJMcahwmTut/SLCIzgkHffqWVxTvQ8el50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYE9cy%2FdJMcahwmTut%2FSLCIzgkHffqWVxTvQ8el50%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;662&quot; height=&quot;310&quot; data-origin-width=&quot;662&quot; data-origin-height=&quot;310&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;</description>
      <category>안드로이드</category>
      <author>jay20033</author>
      <guid isPermaLink="true">https://jay20033.tistory.com/71</guid>
      <comments>https://jay20033.tistory.com/71#entry71comment</comments>
      <pubDate>Thu, 2 Oct 2025 14:06:55 +0900</pubDate>
    </item>
    <item>
      <title>Jetpack Compose에서 Paging3로 대용량 데이터 처리하기</title>
      <link>https://jay20033.tistory.com/70</link>
      <description>&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Paging을 적용 하게 된 계기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 구현 중 API를 통해 대규모 운동 데이터를 가져와야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순히 API 호출 후 모든 데이터를 한 번에 불러오는 방식을 사용했지만, 다음과 같은 문제가 발생했다.&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;b&gt;리소스 낭비&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;스크롤 시 사용하지 않는 데이터도 계속 유지됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 Jetpack Paging3 라이브러리를 도입했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Paging 라이브러리를 사용하면 &lt;b&gt;로컬 DB 또는 네트워크로부터 대규모 데이터를 &quot;페이지 단위&quot;로 로드&lt;/b&gt;하고 UI에 표시할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Paging3는 &lt;b&gt;Google 권장 아키텍처(MVVM)&lt;/b&gt;에 맞게 설계되어 있어 &lt;b&gt;Repository - ViewModel - UI&lt;/b&gt; 레이어가 명확하게 분리된다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Paging란 무엇인가?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Paging는 Android Jetpack의 일부로, 대규모 데이터 세트를 점진적으로 로드하고 표시하도록 도와주는 라이브러리이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;  핵심 아이디어&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&quot;사용자가 실제로 필요로 하는 데이터만, 필요한 시점에 로드하자&quot;&lt;/span&gt;&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Paging의 장점&lt;/b&gt;&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;메모리 사용 최적화
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Paging된 데이터는 필요한 만큼만 메모리에 유지&lt;/li&gt;
&lt;li&gt;스크롤에서 벗어난 데이터는 자동으로 정리됨&lt;/li&gt;
&lt;/ul&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;/li&gt;
&lt;li&gt;네트워크 대역폭과 시스템 리소스를 효율적으로 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;자동 데이터 로딩&lt;br /&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;/ul&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;Kotlin Coroutine / Flow&lt;/li&gt;
&lt;li&gt;LiveData&lt;/li&gt;
&lt;li&gt;RxJava&lt;/li&gt;
&lt;/ul&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;새로고침 (Refresh)&lt;/li&gt;
&lt;li&gt;재시도 (Retry)&lt;/li&gt;
&lt;li&gt;로딩 상태 관리 (LoadState)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Paging 전체 구조&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;paging3-library-architecture.svg&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;73&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QT6tv/dJMcabbUGBt/hofNcHyaU355PBze3DAkW1/tfile.svg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QT6tv/dJMcabbUGBt/hofNcHyaU355PBze3DAkW1/tfile.svg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QT6tv/dJMcabbUGBt/hofNcHyaU355PBze3DAkW1/tfile.svg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQT6tv%2FdJMcabbUGBt%2FhofNcHyaU355PBze3DAkW1%2Ftfile.svg&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;551&quot; height=&quot;134&quot; data-filename=&quot;paging3-library-architecture.svg&quot; data-origin-width=&quot;300&quot; data-origin-height=&quot;73&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Repository 레이어&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;  &lt;b&gt;PagingSource&lt;/b&gt; : 데이터 소스에서 페이지 단위로 데이터를 불러오는 역할을 한다.&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PagingSource는 데이터를 어디서, 어떻게 가져올 것인가만 책임진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순수한 데이터 로더이며, 언제 load()를 호출할지는 Pager와 UI 레이어가 결정한다.&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;네트워크 API 호출&lt;/li&gt;
&lt;li&gt;로컬 DB 조회 (Room DB)&lt;/li&gt;
&lt;li&gt;다음 페이지 키 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;class ExercisePagingSource(
    private val exerciseRemoteDataSource: ExerciseRemoteDataSource,
    private val name: String?,
    private val bodyPart: String?
) : PagingSource&amp;lt;String, ExerciseEntity&amp;gt;() {
    
    override suspend fun load(params: LoadParams&amp;lt;String&amp;gt;): LoadResult&amp;lt;String, ExerciseEntity&amp;gt; {
        return try {
            val cursor = params.key
            val result = exerciseRemoteDataSource.getExercises(cursor, params.loadSize, name, bodyPart)

            LoadResult.Page(
                data = result.data,
                prevKey = if (result.hasPreviousPage) result.prevCursor else null,
                nextKey = if (result.hasNextPage) result.nextCursor else null
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState&amp;lt;String, ExerciseEntity&amp;gt;): String? {
        return state.anchorPosition?.let { anchorPosition -&amp;gt;
            state.closestPageToPosition(anchorPosition)?.prevKey
                ?: state.closestPageToPosition(anchorPosition)?.nextKey
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;load() 함수&amp;nbsp;&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;b&gt;PagingSource&amp;lt;Key, Value&amp;gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Key는 데이터를 로드하는 데 사용되는 식별자를 정의&lt;/li&gt;
&lt;li&gt;Value는 데이터 자체의 유형&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ex) 필자의 경우, String 형태의 Cursor를 Retrofit에 전달하여 네트워크에서 Exercise 객체의 페이지를 로드하고 있다.&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;LoadParams&lt;/b&gt; : 실행할 로드 작업에 관한 정보가 포함되어 있다.
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;b&gt;Key&lt;/b&gt; : 현재 페이지 키 (null이면 첫 페이지)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;loadSize&lt;/b&gt; : 요청할 데이터 개수&lt;/li&gt;
&lt;li&gt;... 등이 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LoadResult&lt;/b&gt; : 로드 작업의 결과
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;b&gt;LoadResult.Page&lt;/b&gt; : 정상 로드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LoadResult.Error&lt;/b&gt; : 에러 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;getRefreshKey()&lt;/b&gt; : 새로고침 시 기준이 되는 Key를 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ex) : 화면 회전이나 refresh가 발생했을 때, 현재 사용자가 보고 있던 위치(&lt;b&gt;anchorPosition&lt;/b&gt;)을 기준으로 가장 가까운 페이지의 key를 반환하여 비슷한 위치부터 다시 로드하도록 돕는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt; &amp;nbsp; &lt;b&gt;RemoteMediator : 네트워크 + 로컬 DB 캐시를 함께 사용하는 경우에 사용된다. (선택사항)&lt;/b&gt;&lt;/h4&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;Room DB에 저장&lt;/li&gt;
&lt;li&gt;PagingSource는 DB만 바라보도록 설계&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RemoteMediator 사용시 효과적인 경우&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;네트워크 데이터를 로컬 DB에 캐싱해야 할 때&lt;/li&gt;
&lt;li&gt;앱 재실행 시 이전 데이터를 유지해야 할 때&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;ViewModel 레이어&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;  PagingConfig : 페이징 동작 방식을 설정하는 구성 객체&lt;/b&gt;&lt;/h4&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;override fun getPagedExercises(
    pageSize: Int,
    name: String?,
    bodyPart: String?
): Flow&amp;lt;PagingData&amp;lt;Exercise&amp;gt;&amp;gt; {
    return Pager(
        config = PagingConfig(
            pageSize = pageSize,
            enablePlaceholders = false
        ),
        pagingSourceFactory = {
            ExercisePagingSource(
                exerciseRemoteDataSource,
                name,
                bodyPart
            )
        }
    ).flow.map { pagingData -&amp;gt; pagingData.map { it.toDomain() } }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;pageSize&lt;/b&gt; : 한 페이지 당 로드 개수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;initialLoadSize&lt;/b&gt; : 최초 로드 개수 (Default 값은 &lt;b&gt;pageSize * 3&lt;/b&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;546&quot; data-origin-height=&quot;69&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bI6GYT/dJMcagYzFGz/690AYrw4KrL8y0I6PmOV70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bI6GYT/dJMcagYzFGz/690AYrw4KrL8y0I6PmOV70/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bI6GYT/dJMcagYzFGz/690AYrw4KrL8y0I6PmOV70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbI6GYT%2FdJMcagYzFGz%2F690AYrw4KrL8y0I6PmOV70%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;546&quot; height=&quot;69&quot; data-origin-width=&quot;546&quot; data-origin-height=&quot;69&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;583&quot; data-origin-height=&quot;176&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/drnKvZ/dJMcacu7KKX/iEUWRsu3etJgNshDGwDw70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/drnKvZ/dJMcacu7KKX/iEUWRsu3etJgNshDGwDw70/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/drnKvZ/dJMcacu7KKX/iEUWRsu3etJgNshDGwDw70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdrnKvZ%2FdJMcacu7KKX%2FiEUWRsu3etJgNshDGwDw70%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;544&quot; height=&quot;164&quot; data-origin-width=&quot;583&quot; data-origin-height=&quot;176&quot;/&gt;&lt;/span&gt;&lt;/figure&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;prefetchDistance&lt;/b&gt; : 미리 로드할 거리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;enablePlaceholders&lt;/b&gt; : 빈 아이템 표시 여부&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;  Pager : PagingSource와 PagingConfig를 연결하여 Flow&amp;lt;PagingData&amp;lt;T&amp;gt;&amp;gt;를 생성한다.&lt;/b&gt;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;  PagingData&lt;/b&gt;&lt;/h4&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;b&gt;불변(Immutable) 객체&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;UI에 전달되는 단위&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private fun getPagedExercises(): Flow&amp;lt;PagingData&amp;lt;ExerciseModel&amp;gt;&amp;gt; {
    return _directoryUIState
        .map { it.query to it.bodyPart?.apiValue }.debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { (query, bodyPart) -&amp;gt;
            if (bodyPart == BodyPartModel.BOOKMARK.apiValue) {
                _directoryUIState
                    .map { state -&amp;gt;
                        PagingData.from(
                            state.bookmarkExercises
                                .filter { it.name.contains(query ?: &quot;&quot;, ignoreCase = true) }
                        )
                    }
            } else {
                getPagedExercisesUseCase(10, query, bodyPart)
                    .map { pagingData -&amp;gt; pagingData.map { it.toPresentation() } }
            }
        }
        .cachedIn(viewModelScope)
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;검색어 변경 시 이전 Paging 요청을 취소하기 위해 &lt;b&gt;flatMapLatest&lt;/b&gt;를 사용했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;debounce&lt;/b&gt;를 적용하여 사용자의 빠른 입력으로 인한 불필요한 네트워크 요청을 방지하였다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;cachedIn(viewModelScope)&lt;/b&gt;을 적용하여 &lt;b&gt;화면 회전(Configuration Change)&lt;/b&gt;시에도 이미 로드된 데이터를 &lt;b&gt;재사용&lt;/b&gt;하도록 하였다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;UI 레이어 (Compose)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;LazyPagingItems&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose에서는 PagingDataAdapter 대신 &lt;b&gt;collectAsLazyPagingItems()&lt;/b&gt;를 사용한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;//ViewModel
val pagingExercises: Flow&amp;lt;PagingData&amp;lt;ExerciseModel&amp;gt;&amp;gt; = getPagedExercises()&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;// ui
val exercises = viewModel.pagingExercises.collectAsLazyPagingItems()

LaunchedEffect(exercises.loadState) {
        val errorState = exercises.loadState.refresh as? LoadState.Error
        errorState?.let {
            viewModel.onPagingError(it.error)
        }
    }
    
...

@Composable
fun DirectoryScreen(
    state: DirectoryUIState,
    exercises: LazyPagingItems&amp;lt;ExerciseModel&amp;gt;,
    ...
    snackBarHostState: SnackbarHostState
) {
    Scaffold(snackbarHost = { SnackbarHost(snackBarHostState) }) { _ -&amp;gt;
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            ...
            LazyColumn(
                modifier = Modifier.fillMaxWidth(),
                verticalArrangement = Arrangement.spacedBy(10.dp),
                contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp)
            ) {
                items(
                    exercises.itemCount,
                    key = { index -&amp;gt; exercises[index]?.id ?: index }
                )
                { index -&amp;gt;
                    val exercise = exercises[index]
                    val isBookmarked = state.bookmarkExercises.any { it.id == exercise?.id }
                    if (exercise != null) {
                        DirectoryItem(
                            exercise = exercise,
                            isBookmarked = isBookmarked,
                            onItemClick = { onDirectoryItemClick(exercise.id, isBookmarked) },
                            onBookmarkClick = { toggleBookmark(exercise, isBookmarked) })
                        Spacer(modifier = Modifier.height(2.dp))
                        if (index &amp;lt; exercises.itemCount - 1) {
                            HorizontalDivider(
                                modifier = Modifier.fillParentMaxWidth(),
                                color = MaterialTheme.colorScheme.onSurface,
                                thickness = 1.dp
                            )
                        }
                    }
                }
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Paging 동작 흐름&lt;/b&gt;&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;UI에서 colletAsLazyPagingItems() 호출&lt;/li&gt;
&lt;li&gt;Pager가 PagingSource 생성&lt;/li&gt;
&lt;li&gt;PagingSource.load() 호출&lt;/li&gt;
&lt;li&gt;LoadResult.Page 반환&lt;/li&gt;
&lt;li&gt;PagingData 생성&lt;/li&gt;
&lt;li&gt;UI는 필요한 만큼만 데이터 소비&lt;/li&gt;
&lt;li&gt;스크롤시 append 로드 자동 호출&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고자료&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.android.com/topic/libraries/architecture/paging/v3-overview?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.android.com/topic/libraries/architecture/paging/v3-overview?hl=ko&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.android.com/codelabs/android-paging?hl=ko#19&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.android.com/codelabs/android-paging?hl=ko#19&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev-inventory.com/18&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dev-inventory.com/18&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>안드로이드</category>
      <author>jay20033</author>
      <guid isPermaLink="true">https://jay20033.tistory.com/70</guid>
      <comments>https://jay20033.tistory.com/70#entry70comment</comments>
      <pubDate>Thu, 18 Sep 2025 21:10:59 +0900</pubDate>
    </item>
    <item>
      <title>Kotlin에서의 Exception와 Error 개념 정리</title>
      <link>https://jay20033.tistory.com/67</link>
      <description>&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;프로그램 오류&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;프로그램 실행 중 어떤 원인에 의해 오작동하거나 비정상적으로 종료되는 경우가 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이러한 결과를 초래하는 원인을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;프로그렘 오류&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;또는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;에러&lt;/b&gt;라고 한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;에러는 발생시점에 따라 3가지로 나뉜다.&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;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;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;런타임 에러&lt;/b&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;주로 예외(Exception)나 에러(Error) 형태로 나타남&lt;/li&gt;
&lt;li&gt;컴파일은 성공하지만 실행 중 비정상 종료될 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;논리적 에러&lt;/b&gt;&lt;br /&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;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;예외 클래스의 계층 구조&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Kotlin에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Throwable을 최상위 클래스&lt;/b&gt;로 하며, Throwable은 Error와 Exception으로 나뉩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Java와&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;가장 큰 차이점&lt;/span&gt;은 Kotlin은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;Checked Exception이 없다는 점&lt;/span&gt;이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 예외에 대한 처리를 컴파일 시점에 강제하지 않기 때문에 함수 시그니처가 깔끔해지고 함수형 스타일, 코루틴과 잘 어울린다.   try-catch가 람다 내부로 침투❌&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;685&quot; data-origin-height=&quot;440&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/V41mx/dJMcaihCbzR/PkJV0APIGbtvjLzKyh7qQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/V41mx/dJMcaihCbzR/PkJV0APIGbtvjLzKyh7qQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/V41mx/dJMcaihCbzR/PkJV0APIGbtvjLzKyh7qQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FV41mx%2FdJMcaihCbzR%2FPkJV0APIGbtvjLzKyh7qQ0%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;536&quot; height=&quot;344&quot; data-origin-width=&quot;685&quot; data-origin-height=&quot;440&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Throwable&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;:&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;모든 오류의 최상위 타입&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Exception :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;프로그래머의 실수나 외부 상황으로 발생하는 복구 가능한 예외(런타임 예외)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ex) RuntimeException : NullPointerException, IllegalArgumentException&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;IOException : 입출력 관련 오류&lt;/li&gt;
&lt;li&gt;NumberFormatExcxeption: 숫자 변환 오류&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Error :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;시스템 문제 등 심각하고 복구 불가능한 상황&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ex) OutOfMemoryError(메모리 부족), StackOverflowError&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;  Kotlin은 JVM 위에서 동작하므로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Java의 예외 계층 구조를 그대로 사용&lt;/b&gt;한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;  예외(Exception)는 사람이 대비할 수 있는 영역이므로 반드시 처리해야 한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Kotlin과 Java의 차이점 : Checked Exception의 부재&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Kotlin에는 Checked Exception 개념이 없다.&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;throws 선언이 필수가 아님&lt;/li&gt;
&lt;li&gt;try-catch를 강제하지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이로 인해, 함수형 프로그래밍 스타일에 적합하며 코루틴과 잘 어울림.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;Checked Exception이란(Java 기준)&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;주로 외부 요인(IO, 네트워크, DB 등)으로 발생함&amp;nbsp;&lt;/li&gt;
&lt;li&gt;트랜잭션 도중 예외가 발생하면 롤백하지 않음&lt;/li&gt;
&lt;li&gt;즉, 명시적인 예외 처리를 강제하기 때문에 Checked Exception이라하며, 반드시 try-catch로 예외를 잡거나 throw로 호출한 메소드에게 예외를 던져야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;void read() throws IOException {
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;예외 처리하기(try - catch 문)&lt;/h4&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;try {
    // 예외가 발생할 수 있는 코드
} catch (처리할 예외 타입 e) {
    // try 블록 안에서 예외가 발생했을 때 예외를 처리하는 코드
} finally {
    // 예외 처리와 관계 없이 항상 실행되는 코드
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;정리&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예외와 에러는 클래스이며, Throwable을 최상위로 한다.&lt;/li&gt;
&lt;li&gt;Kotlin은 Checked Exception을 제거하여 더 유연한 예외 처리 모델을 제공한다.&lt;/li&gt;
&lt;li&gt;예외(Exception)는 반드시 처리해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;참고자료&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-throwable/&quot;&gt;https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-throwable/&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kotlinworld.com/516&quot;&gt;https://kotlinworld.com/516&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://jtm0609.tistory.com/250&quot;&gt;https://jtm0609.tistory.com/250&lt;/a&gt;&lt;/p&gt;</description>
      <category>Kotlin</category>
      <author>jay20033</author>
      <guid isPermaLink="true">https://jay20033.tistory.com/67</guid>
      <comments>https://jay20033.tistory.com/67#entry67comment</comments>
      <pubDate>Sun, 17 Aug 2025 14:07:29 +0900</pubDate>
    </item>
    <item>
      <title>[JetpackCompose] Compose Side Effect</title>
      <link>https://jay20033.tistory.com/66</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;부수 효과(&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Side Effect&lt;/span&gt;)란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose에서 부수 효과란 &lt;u&gt;&lt;b&gt;&quot;함수의 범위 밖에서 발생하는 앱 상태에 관한 변경사항&quot;&lt;/b&gt;&lt;/u&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;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;LaunchedEffect : 컴포저블 범위 내에서 suspend 함수 실행&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LaunchedEffect가 컴포지션을 시작하면 매개변수로 전달된 코드 블록으로 코루틴이 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LaunchedEffect가 컴포지션을 종료하면 코루틴이 취소된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;&lt;b&gt;키가 변경되면 기존 코루틴이 취소되고 새 코루틴에서 새 Suspend 함수가 실행된다.&lt;/b&gt;&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포저블이 컴포지션에 진입할 때 수행해야 하는 &lt;span style=&quot;background-color: #99cefa;&quot;&gt;&lt;b&gt;데이터 로딩, 애니메이션 시작, 이벤트 수신과 같은 작업에 유용하다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;android_compose_side_effects_launchedeffect-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_side_effects_launchedeffect-SideEffectsSnippets&quot; data-region-tag=&quot;android_compose_side_effects_launchedeffect&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/sideeffects/SideEffectsSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_side_effects_launchedeffect&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;// Allow the pulse rate to be configured, so it can be sped up if the user is running
// out of time
var pulseRateMs by remember { mutableLongStateOf(3000L) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { // Restart the effect when the pulse rate changes
    while (isActive) {
        delay(pulseRateMs) // Pulse the alpha every pulseRateMs to alert the user
        alpha.animateTo(0f)
        alpha.animateTo(1f)
    }
}
// 키가 상수인 경우 한 번만 실행됨
LaunchedEffect(Unit) { or LaunchedEffect(true)
    viewModel.loadData()
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;DisposableEffect : 메모리 해지 등이 필요한 Side Effect 실행에 적합&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;DisposableEffect 컴포저블의 Lifecycle에 따라 &lt;u&gt;&lt;b&gt;리소스 해지 및 실행 중인 테스트를 정리하기 위해 사용&lt;/b&gt;&lt;/u&gt;된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;DisposableEffect는 리소스를 해제하기 위해 DisposableEffectScope를 제공하고 해당 스코프 안에서 &lt;span style=&quot;background-color: #99cefa;&quot;&gt;&lt;b&gt;onDispose 람다를 통해 해지 작업을 수행한다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;android_compose_side_effects_disposableeffect-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_side_effects_disposableeffect-SideEffectsSnippets&quot; data-region-tag=&quot;android_compose_side_effects_disposableeffect&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/sideeffects/SideEffectsSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_side_effects_disposableeffect&quot;&gt;&lt;code&gt;@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -&amp;gt; Unit, // Send the 'started' analytics event
    onStop: () -&amp;gt; Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event -&amp;gt;
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;예를 들어, 생명 주기 이벤트를 기반으로 애널리틱스 관련 이벤트를 전송해야 하는 경우 LifecycleObserver를 사용하여 구현할 수 있다. 이 때 DisposableEffect를 사용하여 컴포저블이 컴포지션에 진입할 때 관찰자를 등록하고 떠날 때 자동으로 등록 취소할 수 있다. 이로 인해 &lt;u&gt;&lt;b&gt;리소스의 적절한 정리를 보장하고 메모리 누수를 방지할 수 있다.&lt;/b&gt;&lt;/u&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;SideEffect : Compose의 상태를 non-Compose 코드로 실행하기&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;SideEffect는 매 recomposition 직후에 적용해야 하는 작업을 실행하는 데 사용된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ViewModel이나 외부 라이브러리의 UI 상태 업데이트와 같이 &lt;b&gt;컴포지션의 일부가 아닌 외부 시스템과 Compose 상태를 동기화하는데 적합하다.&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;b&gt;매 recomposition 후 실행이 보장된다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;non-compose 컴포넌트와의 상태 동기화에 유용하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; 주의사항 : &lt;b&gt;비동기 X, 즉시 실행만 보장&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;android_compose_side_effects_sideeffect-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_side_effects_sideeffect-SideEffectsSnippets&quot; data-region-tag=&quot;android_compose_side_effects_sideeffect&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/sideeffects/SideEffectsSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_side_effects_sideeffect&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // 매 recomposition 후 실행 &amp;rarr; 항상 최신 userType 반영
    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty(&quot;userType&quot;, user.userType)
    }
    return analytics
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;애널리틱스 라이브러리를 사용하면 커스텀 메타데이터를 이벤트에 연결하여 인구를 분류할 수 있는데 현재 사용자의 사용자 유형을 애널리틱스 서버로 전달되도록 하여 애플리케이션의 데이터와 동기화 상태를 유지할 수 있도록 할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;rememberCoroutineScope : 컴포지션 함수 내에서 코루틴 실행&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;rememberCoroutineScope는 컴포저블이 &lt;span style=&quot;background-color: #99cefa;&quot;&gt;&lt;b&gt;컴포지션을 벗어날 때 활성중인 코루틴 스코프를 자동으로 취소&lt;/b&gt;&lt;/span&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부적으로 컴포저블 함수의 내부 생명주기를 인지하게 작동하고 있으므로 컴포저블에서 코루틴을 알어서 런칭 및 취소한다.&lt;/p&gt;
&lt;pre id=&quot;android_compose_side_effects_remembercoroutinescope-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_side_effects_remembercoroutinescope-SideEffectsSnippets&quot; data-region-tag=&quot;android_compose_side_effects_remembercoroutinescope&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/sideeffects/SideEffectsSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_side_effects_remembercoroutinescope&quot;&gt;&lt;code&gt;@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding -&amp;gt;
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        snackbarHostState.showSnackbar(&quot;Something happened!&quot;)
                    }
                }
            ) {
                Text(&quot;Press me&quot;)
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&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;b&gt;컴포지션 인식 : &lt;/b&gt;rememberCoroutineScope에 의해 생성된 코루틴 스코프는 컴포지션으로 범위가 지정된다. (컴포지션에서 제거될 때 코루틴도 취소된다.)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상태 관리&lt;/b&gt; : remember Api가 값을 메모리에 저장하고 recomposition이 발생했을때 저장된 메모리에서 값을 꺼내와 복원한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;메모리 누수 방지&lt;/b&gt; : GlobalScope를 사용하거나 코루틴 스코프를 사용하여 수동으로 관리하지 않고, 더 이상 사용되지 않을 때 알아서 리소스가 정리된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  컴포지션 생명주기에 연결된 &lt;b&gt;가벼운 UI 작업은 rememberCoroutineScope를 사용&lt;/b&gt;하고 &lt;b&gt;장기적으로 실행되어야 하는 경우 viewModelScope나 lifecycleScope과 같이 더 넓은 코루틴 스코프를 사용해야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또, rememberCoroutineScope는 기본적으로 메인 스레드를 사용하기 때문에 &lt;u&gt;&lt;b&gt;네트워크 요청이나 DB 쿼리와 같은 비즈니스 로직을 하는 경우에는 사용을 피해야한다.&lt;/b&gt;&lt;/u&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;rememberUpdatedState : 값이 변경되는 경우 최신 상태 유지하기&lt;/b&gt;&lt;/h4&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;rememberUpdatedState는 상태의 가장 최근 값을 기억하고 상태가 변경될 때마다 업데이트하고, 컴포저블 또는 람다 내에서 값을 읽고 가장 최신 상태를 반영하는 State&amp;lt;T&amp;gt; 객체를 반환한다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;android_compose_side_effects_rememberupdatedstate-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_side_effects_rememberupdatedstate-SideEffectsSnippets&quot; data-region-tag=&quot;android_compose_side_effects_rememberupdatedstate&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/sideeffects/SideEffectsSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_side_effects_rememberupdatedstate&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun LandingScreen(onTimeout: () -&amp;gt; Unit) {

    // 최신 콜백 저장
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout() // 변경된 콜백도 정확히 호출
    }

    /* Landing screen content */
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드는 앱에 시간이 지나면 사라지는 LandingScreen이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LandingScreen이 recomposition되어도 일정 시간 동안 대기하고 시간이 경과되었음을 알리는 효과는 다시 시작해서는 안된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;onTimeout 람다에 LandingScreen이 재구성된 최신 값이 항상 포함하도록 하려면 rememberUpdatedState로 onTimeout을 래핑해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 rememberUpdatedState를 사용하지 않는다면 delay가 실행되는 동안 컴포지션이 실행될 당시의 구현체 값은 캡쳐되었기 때문에, onTimeout 콜백의 구현체 자체가 변경되면 초기에 딜레이가 발생하기 이전의 콜백이 호출될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;&lt;b&gt;즉, rememberUpdatedState는 onTimeout 콜백을 항상 최신 상태로 업데이트하고, 딜레이가 완료되었을 때 가장 최신 함수가 호출되도록 보장한다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;produceState : non Compose 상태를 Compose 상태로 변환하기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;suspend 함수 결과를 State로 반환&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun &amp;lt;T : Any?&amp;gt; produceState(
    initialValue: T, // 상태 초기값
    vararg keys: Any?, // 코루틴 재시작을 위한 키 값
    producer: suspend ProduceStateScope&amp;lt;T&amp;gt;.() -&amp;gt; Unit // 상태 값 업데이트하는 suspend 람다
): State&amp;lt;T&amp;gt; // State 객체 반환&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;android_compose_side_effects_producestate-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_side_effects_producestate-SideEffectsSnippets&quot; data-region-tag=&quot;android_compose_side_effects_producestate&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/sideeffects/SideEffectsSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_side_effects_producestate&quot;&gt;&lt;code&gt;@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State&amp;lt;Result&amp;lt;Image&amp;gt;&amp;gt; {
    // Creates a State&amp;lt;T&amp;gt; with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState&amp;lt;Result&amp;lt;Image&amp;gt;&amp;gt;(initialValue = Result.Loading, url, imageRepository) {
        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드로 설명하면 &lt;b&gt;초기 값은 Result.loading이고 url, imageRepository 값 중 하나라도 변경되면 기존에 돌아가던 생산자 코루틴은 취소되고 다시 시작된다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생산자 코루틴은 아래 람다 함수로 imageRepository로 부터 image를 로딩하고 null 이 아닌 경우 Success로 넘기는 구조이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;produceState 장점&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;b&gt;선언적(Declarative)&lt;/b&gt;: 비동기 작업을 실행하고 그 결과를 Compose의 상태로 만들기 위해 깔끔하고 Compose의 선언적 방식을 있는 그대로 사용한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;컴포지션 생명주기 인지(Composition‑aware)&lt;/b&gt;: 컴포저블이 컴포지션을 떠날 때 코루틴 &lt;br /&gt;을&amp;nbsp;자동으로&amp;nbsp;취소하여&amp;nbsp;리소스&amp;nbsp;누수&amp;nbsp;위험을&amp;nbsp;방지한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유연성(Flexible)&lt;/b&gt;: 외부 suspend 함수와도 잘 작동하고 의존성(키)이 변경될 때 &lt;br /&gt;코루틴 작업을 다시 시작할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;derivedStateOf : 하나 이상의 상태 객체에서 파생된 값을 계산하여 다른 상태로 변환&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;&lt;b&gt;Flow의 distinctUntilChanged() 연산자와 유사하게 작동한다.&lt;/b&gt;&lt;/span&gt;&lt;b&gt; &lt;/b&gt;&lt;b&gt;(&lt;/b&gt;&lt;b&gt;계산 값 변경 시만 recompose)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태가 자주 업데이트되더라도 계산된 값 자체가 변경될 때만 recomposition을 트리거하여 recomposition을 최적화한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로 인해 빈번한 상태 업데이트가 있는 시나리오에서 성능을 개선하고 &lt;b&gt;불필요한 recomposition을 방지하는데 유용&lt;/b&gt;하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;약간의 오버헤드를 동반하기 때문에 recomposition 방지가 중요한 상황에서만 신중하게 사용해야 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;derivedStateOf 작동 방식&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;derivedStateOf의 람다 매개변수 내에서 사용된 모든 상태들의 변화를 관찰&lt;/li&gt;
&lt;li&gt;관찰된 상태 중 하나가 변경되면 새 값을 계산&lt;/li&gt;
&lt;li&gt;새로 계산된 값이 이전 값과 다른 경우에만 recomposition을 트리거&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;올바른 예시&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;android_compose_side_effects_derivedstateof-code-sample&quot; class=&quot;reasonml&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_side_effects_derivedstateof-SideEffectsSnippets&quot; data-region-tag=&quot;android_compose_side_effects_derivedstateof&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/sideeffects/SideEffectsSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_side_effects_derivedstateof&quot;&gt;&lt;code&gt;@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List&amp;lt;Message&amp;gt;) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex &amp;gt; 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크롤 하면 firestVisibleItemIndex 값이 0,1,2,3,4 등으로 바뀌는데 0보다 큰경우에만 recomposition이 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 때 showButton 상태가 변경될 때만 AnimatedVisibility가 reocmpose된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;잘못된 예시&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;android_compose_side_effects_derivedstateof_wrong-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_side_effects_derivedstateof_wrong-SideEffectsSnippets&quot; data-region-tag=&quot;android_compose_side_effects_derivedstateof_wrong&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/sideeffects/SideEffectsSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_side_effects_derivedstateof_wrong&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf(&quot;&quot;) }
var lastName by remember { mutableStateOf(&quot;&quot;) }

// recomposition을 자주 유발하지 않기 때문에 derivedStateOf로 래핑하면 오히려 오버헤드가 추가됨.
val fullNameBad by remember { derivedStateOf { &quot;$firstName $lastName&quot; } } // This is bad!!!
val fullNameCorrect = &quot;$firstName $lastName&quot; // This is correct&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;snapshotFlow : Compose의 상태를 Flow로 변환&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SnapshotFlow는 Compose의 &lt;u&gt;&lt;b&gt;상&lt;/b&gt;&lt;b&gt;태 변경을 수신하고 이 값을 Flow로 방출하여 상태 업데이트를 관리&lt;/b&gt;&lt;/u&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;snapshotFlow의&amp;nbsp;주요&amp;nbsp;특징&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;nbsp;상태 관찰(State Observation): Snapshot 시스템을 사용하여 Compose의 상태 변화 관찰&lt;/li&gt;
&lt;li&gt;스레드&amp;nbsp;안전성(Thread&amp;nbsp;Safety):&amp;nbsp;상태&amp;nbsp;읽기&amp;nbsp;및&amp;nbsp;쓰기가&amp;nbsp;Compose의&amp;nbsp;스냅샷&amp;nbsp;스코프&amp;nbsp;내에서 &lt;br /&gt;발생하도록 보장하여 경쟁 조건(race condition) 방지&lt;/li&gt;
&lt;li&gt;&amp;nbsp;유휴&amp;nbsp;건너뛰기(Idle&amp;nbsp;Skipping):&amp;nbsp;상태&amp;nbsp;값이&amp;nbsp;변경될&amp;nbsp;때만&amp;nbsp;Flow&amp;nbsp;값으로의&amp;nbsp;방출이&amp;nbsp;발생하고 &lt;br /&gt;recomposition이 발생하지 않는 중에는 업데이트를 건너뛰도록 보장&lt;/li&gt;
&lt;li&gt;취소&amp;nbsp;인지(Cancelation‑Aware):&amp;nbsp;Flow를&amp;nbsp;수집하는&amp;nbsp;코루틴이&amp;nbsp;취소될&amp;nbsp;때&amp;nbsp;구독&amp;nbsp;또한&amp;nbsp;자동 &lt;br /&gt;으로&amp;nbsp;취소하여,&amp;nbsp;컴포지션의&amp;nbsp;생명주기를&amp;nbsp;인지하는(composition‑aware)&amp;nbsp;형태로&amp;nbsp;동작하여 &lt;br /&gt;부적절한 메모리 누수 방지&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) { // listState가 변경될 때마다 LaunchedEffect 실행
    snapshotFlow { listState.firstVisibleItemIndex } // 첫번째 아이템 인덱스 관찰
        .map { index -&amp;gt; index &amp;gt; 0 } // 스크롤 했는지 확인
        .distinctUntilChanged() // 값이 실제로 변경될 때만 방출
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}&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;b&gt;snapshotFlow 내부 작동 방식&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;람다 매개변수로 제공된 Compose의 상태를 Snapshot 관찰자 내에서 지속적으로 관찰&lt;/li&gt;
&lt;li&gt;람다 매개변수에 상태 변수가 들어오는 순간 Compose는 해당 상태에 대해서 종속성 등록&lt;/li&gt;
&lt;li&gt;상태가&amp;nbsp;변경되면&amp;nbsp;Compose는&amp;nbsp;snapshotFlow에&amp;nbsp;업데이트&amp;nbsp;사항을&amp;nbsp;알리고&amp;nbsp;새&amp;nbsp;값을&amp;nbsp;방출하도록 &lt;br /&gt;트리거&lt;/li&gt;
&lt;/ol&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/side-effects&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.android.com/develop/ui/compose/side-effects&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1773229923994&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Compose의 부수 효과 &amp;nbsp;|&amp;nbsp; Jetpack Compose &amp;nbsp;|&amp;nbsp; Android Developers&quot; data-og-description=&quot;이 문서에서는 다양한 효과 API를 사용하여 Jetpack Compose 애플리케이션에서 부수 효과를 안전하게 관리하고 실행하여 예측 가능한 동작과 적절한 수명 주기 관리를 보장하는 방법을 설명합니다.&quot; data-og-host=&quot;developer.android.com&quot; data-og-source-url=&quot;https://developer.android.com/develop/ui/compose/side-effects&quot; data-og-url=&quot;https://developer.android.com/develop/ui/compose/side-effects?hl=ko&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bHymJS/dJMb87NUKoy/kbVwA4LISXqAPQuG9Oi7UK/img.png?width=1201&amp;amp;height=676&amp;amp;face=0_0_1201_676&quot;&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/side-effects&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.android.com/develop/ui/compose/side-effects&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bHymJS/dJMb87NUKoy/kbVwA4LISXqAPQuG9Oi7UK/img.png?width=1201&amp;amp;height=676&amp;amp;face=0_0_1201_676');&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;Compose의 부수 효과 &amp;nbsp;|&amp;nbsp; Jetpack Compose &amp;nbsp;|&amp;nbsp; Android Developers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이 문서에서는 다양한 효과 API를 사용하여 Jetpack Compose 애플리케이션에서 부수 효과를 안전하게 관리하고 실행하여 예측 가능한 동작과 적절한 수명 주기 관리를 보장하는 방법을 설명합니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.android.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary?_gl=1*14sw6sn*_up*MQ..*_ga*MTI4ODg3MjM3Ni4xNzczMjIyMTg2*_ga_6HH9YJMN9M*czE3NzMyMjIxODYkbzEkZzAkdDE3NzMyMjIxODYkajYwJGwwJGgxNjMzMjc0MTU.#rememberCoroutineScope(kotlin.Function0)&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary?_gl=1*14sw6sn*_up*MQ..*_ga*MTI4ODg3MjM3Ni4xNzczMjIyMTg2*_ga_6HH9YJMN9M*czE3NzMyMjIxODYkbzEkZzAkdDE3NzMyMjIxODYkajYwJGwwJGgxNjMzMjc0MTU.#rememberCoroutineScope(kotlin.Function0)&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1773230398934&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;androidx.compose.runtime &amp;nbsp;|&amp;nbsp; API reference &amp;nbsp;|&amp;nbsp; Android Developers&quot; data-og-description=&quot;androidx.appsearch.builtintypes.properties&quot; data-og-host=&quot;developer.android.com&quot; data-og-source-url=&quot;https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary?_gl=1*14sw6sn*_up*MQ..*_ga*MTI4ODg3MjM3Ni4xNzczMjIyMTg2*_ga_6HH9YJMN9M*czE3NzMyMjIxODYkbzEkZzAkdDE3NzMyMjIxODYkajYwJGwwJGgxNjMzMjc0MTU.#rememberCoroutineScope(kotlin.Function0)&quot; data-og-url=&quot;https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary?_gl=1*14sw6sn*_up*MQ..*_ga*MTI4ODg3MjM3Ni4xNzczMjIyMTg2*_ga_6HH9YJMN9M*czE3NzMyMjIxODYkbzEkZzAkdDE3NzMyMjIxODYkajYwJGwwJGgxNjMzMjc0MTU.#rememberCoroutineScope(kotlin.Function0)&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary?_gl=1*14sw6sn*_up*MQ..*_ga*MTI4ODg3MjM3Ni4xNzczMjIyMTg2*_ga_6HH9YJMN9M*czE3NzMyMjIxODYkbzEkZzAkdDE3NzMyMjIxODYkajYwJGwwJGgxNjMzMjc0MTU.#rememberCoroutineScope(kotlin.Function0)&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;androidx.compose.runtime &amp;nbsp;|&amp;nbsp; API reference &amp;nbsp;|&amp;nbsp; Android Developers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;androidx.appsearch.builtintypes.properties&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.android.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://proandroiddev.com/understanding-the-internals-of-side-effect-handlers-in-jetpack-compose-d55fbf914fde&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://proandroiddev.com/understanding-the-internals-of-side-effect-handlers-in-jetpack-compose-d55fbf914fde&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1773229920285&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;Understanding the Internals of Side-Effect Handlers in Jetpack Compose&quot; data-og-description=&quot;The Jetpack Compose ecosystem has grown exponentially in recent years, and it&amp;rsquo;s now widely adopted for building production-level UIs in&amp;hellip;&quot; data-og-host=&quot;proandroiddev.com&quot; data-og-source-url=&quot;https://proandroiddev.com/understanding-the-internals-of-side-effect-handlers-in-jetpack-compose-d55fbf914fde&quot; data-og-url=&quot;https://proandroiddev.com/understanding-the-internals-of-side-effect-handlers-in-jetpack-compose-d55fbf914fde&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/9uUp2/dJMb85vNg6I/zeqhOG84Z8Z4LCd8lnmhUK/img.jpg?width=1200&amp;amp;height=800&amp;amp;face=0_0_1200_800&quot;&gt;&lt;a href=&quot;https://proandroiddev.com/understanding-the-internals-of-side-effect-handlers-in-jetpack-compose-d55fbf914fde&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://proandroiddev.com/understanding-the-internals-of-side-effect-handlers-in-jetpack-compose-d55fbf914fde&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/9uUp2/dJMb85vNg6I/zeqhOG84Z8Z4LCd8lnmhUK/img.jpg?width=1200&amp;amp;height=800&amp;amp;face=0_0_1200_800');&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;Understanding the Internals of Side-Effect Handlers in Jetpack Compose&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The Jetpack Compose ecosystem has grown exponentially in recent years, and it&amp;rsquo;s now widely adopted for building production-level UIs in&amp;hellip;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;proandroiddev.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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;</description>
      <category>안드로이드</category>
      <author>jay20033</author>
      <guid isPermaLink="true">https://jay20033.tistory.com/66</guid>
      <comments>https://jay20033.tistory.com/66#entry66comment</comments>
      <pubDate>Thu, 14 Aug 2025 21:57:25 +0900</pubDate>
    </item>
    <item>
      <title>[Jetpack Compose] State Hoisting이란?</title>
      <link>https://jay20033.tistory.com/64</link>
      <description>&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;State Hoisting이란?&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202124; text-align: start;&quot;&gt;Compose에서 상태 호이스팅은&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #99cefa; text-align: start;&quot;&gt;&lt;b&gt;&lt;u&gt;&quot;컴포저블이 직접 상태를 가지지 않고, 호출자가 상태를 소유하도록 상태를 끌어올리는 패턴&quot;&lt;/u&gt;&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #202124; text-align: start;&quot;&gt;을 말한다. &lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;일반적으로&amp;nbsp;&lt;/span&gt;&lt;b&gt;value: T&lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;와&amp;nbsp;&lt;/span&gt;&lt;b&gt;onValueChange: (T) -&amp;gt; Unit&lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;두 파라미터로 상태를 표현하고, &lt;span style=&quot;background-color: #99cefa;&quot;&gt;&lt;b&gt;실제 상태는 부모 컴포저블이나 ViewModel이 가진다.&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202124; text-align: start;&quot;&gt;상태 호이스팅은 근본적으로 단방향 데이터 흐름 원칙을 따른다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #202124; text-align: start;&quot;&gt;즉, 상태는 위에서 아래로 내려가고, 이벤트는 아래에서 위로 올라가면서 &lt;span style=&quot;background-color: #ffffff; color: #202124; text-align: start;&quot;&gt;UI를 더 쉽게 관리하고 확장 가능하게 만든다.&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; 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;958&quot; data-origin-height=&quot;1078&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nweZc/dJMcahcxXgU/8mxjYgNHcN2UQPEna9VhK1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nweZc/dJMcahcxXgU/8mxjYgNHcN2UQPEna9VhK1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nweZc/dJMcahcxXgU/8mxjYgNHcN2UQPEna9VhK1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnweZc%2FdJMcahcxXgU%2F8mxjYgNHcN2UQPEna9VhK1%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;208&quot; height=&quot;234&quot; data-origin-width=&quot;958&quot; data-origin-height=&quot;1078&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #202124; text-align: start;&quot;&gt;상태 호이스팅 특성&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상태(State)는 부모 컴포저블에서 관리한다.&lt;/li&gt;
&lt;li&gt;이벤트(Event) 또는 트리거(onClick, onValueChange)는 자식에서 발생하고, 콜백을 통해 부모로 전달되어 상태를 업데이트한다.&lt;/li&gt;
&lt;li&gt;Compose에서 상태 호이스팅의 전형적인 형태
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;&lt;b&gt;value: T&lt;/b&gt; - 표시할 상태값&lt;/li&gt;
&lt;li&gt;&lt;b&gt;onValuleChange: (T) -&amp;gt; Unit&lt;/b&gt; - 상태 변경을 부모에 요청하는 콜백&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Stateless vs StateFul&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Stateless 예제 (상태 호이스팅 적용)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;android_compose_state_hoisting-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_state_hoisting-StateOverviewSnippets&quot; data-region-tag=&quot;android_compose_state_hoisting&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/state/StateOverviewSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_state_hoisting&quot;&gt;&lt;code&gt;@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf(&quot;&quot;) }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -&amp;gt; Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = &quot;Hello, $name&quot;,
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text(&quot;Name&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;span style=&quot;text-align: start;&quot;&gt;위 코드에서는&amp;nbsp;&lt;/span&gt;&lt;b&gt;name&lt;span style=&quot;text-align: start;&quot;&gt;과&amp;nbsp;&lt;/span&gt;onNameChange&lt;span style=&quot;text-align: start;&quot;&gt;를&amp;nbsp;&lt;/span&gt;HelloContent&lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;b&gt;의 매개변수로 전달&lt;/b&gt;하고, 상태는 상위 컴포저블인&amp;nbsp;&lt;/span&gt;HelloScreen&lt;span style=&quot;text-align: start;&quot;&gt;에서 관리한다. 이로 인해&amp;nbsp;&lt;/span&gt;HelloContent&lt;span style=&quot;text-align: start;&quot;&gt;는 &lt;b&gt;어떤 상태도 관리하지 않는&amp;nbsp;&lt;/b&gt;&lt;/span&gt;&lt;b&gt;Stateless&lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;컴포저블이 되며, &lt;u&gt;&lt;b&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;여러 화면에서 동일한 UI를 재사용하고 테스트하기가 훨씬 쉬워진다.&lt;/span&gt;&lt;/b&gt;&lt;/u&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;상태 저장 위치를 &lt;u&gt;&lt;b&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;ViewModel, 상위 화면, 다른 State holder 등으로 옮기더라도&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/u&gt;&lt;/span&gt;&lt;u&gt;&lt;b&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;HelloContent&lt;span style=&quot;text-align: start;&quot;&gt;의 구현은 바꿀 필요가 없다.&lt;/span&gt; &lt;/span&gt;&lt;/b&gt;&lt;/u&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;StateFul 예제 (내부에서 상태 관리)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;android_compose_state_hoisting-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_state_hoisting-StateOverviewSnippets&quot; data-region-tag=&quot;android_compose_state_hoisting&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/state/StateOverviewSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_state_hoisting&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
fun HelloScreen() {
    HelloContent()
}

@Composable
fun HelloContent() {
    val (name, onNameChange) = remember { mutableStateOf(&quot;&quot;) }
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = &quot;Hello, $name&quot;,
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text(&quot;Name&quot;) })
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 코드에서는 HelloContent가 내부적으로 상태를 직접 관리한다. 호출자는 별다른 매개변수를 넘기지 않아 편하지만, HelloContent의 상태를 외부에서 관찰, 제어하거나 다른 컴포저블과 공유하기가 어렵기 때문에 &lt;u&gt;&lt;b&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;유연성이 감소할 수 밖에 없다.&lt;/span&gt;&lt;/b&gt;&lt;/u&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;HelloContent가 &lt;u&gt;&lt;b&gt;&lt;span style=&quot;background-color: #99cefa;&quot;&gt;내부적으로 상태를 관리하므로 외부에서 동작을 제어하거나 커스텀하기가 더 어려워지기 때문에 재사용하기가 더 어려워질 수 있다.&lt;/span&gt;&lt;/b&gt;&lt;/u&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;상태&amp;nbsp;호이스팅의&amp;nbsp;장점&lt;/b&gt;&lt;/h4&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;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태와 이벤트 콜백을 분리하면, 특정 화면이나 ViewModel 구현에 묶이지 않고 재사용 가능한 UI 컴포넌트를 만들 수 있다.&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;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Stateless 컴포저블은 &quot;입력 -&amp;gt; 출력&quot;만 보면 되기 때문에, 다양한 상태값을 주입해서 시나리오 테스트를 작성하기 쉽다.&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;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 관리와 비즈니스 로직은 ViewModel, State Holder에서 담당하고 컴포저블은 UI렌더링에만 집중할 수 있다.&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;/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;b&gt;향상된 상태 관리&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 호이스팅을 사용하면 ViewModel 또는 부모 컴포저블과 같은 상위 수준 컨테이너에서 상태를 중앙 집중화할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;항상 State Hoisting을 해야 할까?&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt; Android 공식 문서에서도 &amp;ldquo;다른 컴포저블이 제어할 필요가 없는, 단순한 UI 로컬 상태&amp;rdquo;는 컴포저블 내부에 둘 수 있다고 설명한다. &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;android_compose_state_hoisting_no_hoisting-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_state_hoisting_no_hoisting-StateHoistingSnippets&quot; data-region-tag=&quot;android_compose_state_hoisting_no_hoisting&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/state/StateHoistingSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_state_hoisting_no_hoisting&quot;&gt;&lt;code&gt;@Composable
fun ChatBubble(
    message: Message
) {
    var showDetails by rememberSaveable { mutableStateOf(false) } // Define the UI element expanded state

    Text(
        text = AnnotatedString(message.content),
        modifier = Modifier.clickable {
            showDetails = !showDetails // Apply UI logic
        }
    )

    if (showDetails) {
        Text(message.timestamp)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;이 예제에서&amp;nbsp;&lt;/span&gt;showDetails&lt;span style=&quot;text-align: start;&quot;&gt;는 버블 안에서만 읽고 수정되고, UI 로직도 클릭 시 펼치기/접기 정도로 &lt;b&gt;단순하다&lt;/b&gt;. 다른 컴포저블이 이 상태를 &lt;u&gt;&lt;b&gt;제어하거나 공유할 필요가 없기 때문에, 굳이 상태를 끌어올려도 얻을 수 있는 이점이 크지 않다.&lt;/b&gt;&lt;/u&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;즉, 상태와 상태에 적용하는 로직이 단순하고 UI 계층 구조의 다른 부분에서 상태가 필요하지 않은 경우에 유용한 방식이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;참고자료&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/state?hl=ko&amp;amp;_gl=1*1ftizxu*_up*MQ..*_ga*NjE3OTY2NTQxLjE3NzMyMDcyNDI.*_ga_6HH9YJMN9M*czE3NzMyMDcyNDEkbzEkZzAkdDE3NzMyMDcyNDEkajYwJGwwJGg5MjgxODk3Ng..&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.android.com/develop/ui/compose/state?hl=ko&amp;amp;_gl=1*1ftizxu*_up*MQ..*_ga*NjE3OTY2NTQxLjE3NzMyMDcyNDI.*_ga_6HH9YJMN9M*czE3NzMyMDcyNDEkbzEkZzAkdDE3NzMyMDcyNDEkajYwJGwwJGg5MjgxODk3Ng..&lt;/a&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/state?hl=ko&amp;amp;_gl=1*1708zfs*_up*MQ..*_ga*NjE3OTY2NTQxLjE3NzMyMDcyNDI.*_ga_6HH9YJMN9M*czE3NzMyMDcyNDEkbzEkZzAkdDE3NzMyMDcyNDEkajYwJGwwJGg5MjgxODk3Ng..&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;zEkZzAkdDE3NzMyMDcyNDEkajYwJGwwJGg5MjgxODk3Ng..&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1773222045012&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;상태 및 Jetpack Compose &amp;nbsp;|&amp;nbsp; Android Developers&quot; data-og-description=&quot;이 문서에서는 Android 앱의 상태, remember 및 mutableStateOf와 같은 API를 사용하여 Jetpack Compose에서 상태를 관리하는 방법, 상태 호이스팅이 컴포저블 재사용성을 개선하는 방법, rememberSaveable을 사용하&quot; data-og-host=&quot;developer.android.com&quot; data-og-source-url=&quot;https://developer.android.com/develop/ui/compose/state?hl=ko&amp;amp;_gl=1*1708zfs*_up*MQ..*_ga*NjE3OTY2NTQxLjE3NzMyMDcyNDI.*_ga_6HH9YJMN9M*czE3NzMyMDcyNDEkbzEkZzAkdDE3NzMyMDcyNDEkajYwJGwwJGg5MjgxODk3Ng..&quot; data-og-url=&quot;https://developer.android.com/develop/ui/compose/state?hl=ko&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/buBAuG/dJMb8Rj0gi5/ELsqHZHSyYk5emarCTzzK1/img.png?width=1201&amp;amp;height=676&amp;amp;face=0_0_1201_676,https://scrap.kakaocdn.net/dn/fvj0J/dJMb8XR3YI9/E0zXrKjYGkmkdzkq2JqCHk/img.png?width=958&amp;amp;height=1078&amp;amp;face=0_0_958_1078&quot;&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/state?hl=ko&amp;amp;_gl=1*1708zfs*_up*MQ..*_ga*NjE3OTY2NTQxLjE3NzMyMDcyNDI.*_ga_6HH9YJMN9M*czE3NzMyMDcyNDEkbzEkZzAkdDE3NzMyMDcyNDEkajYwJGwwJGg5MjgxODk3Ng..&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.android.com/develop/ui/compose/state?hl=ko&amp;amp;_gl=1*1708zfs*_up*MQ..*_ga*NjE3OTY2NTQxLjE3NzMyMDcyNDI.*_ga_6HH9YJMN9M*czE3NzMyMDcyNDEkbzEkZzAkdDE3NzMyMDcyNDEkajYwJGwwJGg5MjgxODk3Ng..&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/buBAuG/dJMb8Rj0gi5/ELsqHZHSyYk5emarCTzzK1/img.png?width=1201&amp;amp;height=676&amp;amp;face=0_0_1201_676,https://scrap.kakaocdn.net/dn/fvj0J/dJMb8XR3YI9/E0zXrKjYGkmkdzkq2JqCHk/img.png?width=958&amp;amp;height=1078&amp;amp;face=0_0_958_1078');&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;상태 및 Jetpack Compose &amp;nbsp;|&amp;nbsp; Android Developers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이 문서에서는 Android 앱의 상태, remember 및 mutableStateOf와 같은 API를 사용하여 Jetpack Compose에서 상태를 관리하는 방법, 상태 호이스팅이 컴포저블 재사용성을 개선하는 방법, rememberSaveable을 사용하&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.android.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/state-hoisting?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.android.com/develop/ui/compose/state-hoisting?hl=ko&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1773151836963&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;상태를 호이스팅할 대상 위치 &amp;nbsp;|&amp;nbsp; Jetpack Compose &amp;nbsp;|&amp;nbsp; Android Developers&quot; data-og-description=&quot;이 문서에서는 Jetpack Compose 애플리케이션에서 UI 상태를 호이스팅하기 위한 권장사항을 설명하고, 상태 관리가 UI 로직과 비즈니스 로직 중 어느 쪽에서 주도되는지 시나리오를 자세히 설명합니&quot; data-og-host=&quot;developer.android.com&quot; data-og-source-url=&quot;https://developer.android.com/develop/ui/compose/state-hoisting?hl=ko&quot; data-og-url=&quot;https://developer.android.com/develop/ui/compose/state-hoisting?hl=ko&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bI3uu1/dJMb86OZ4dn/0l1wDjkq9uqsuWsKnWEoyK/img.png?width=1201&amp;amp;height=676&amp;amp;face=0_0_1201_676,https://scrap.kakaocdn.net/dn/bzbXUP/dJMb9jOldg7/xkoM13hK2BxgOTIS7ZSZbK/img.png?width=3400&amp;amp;height=1726&amp;amp;face=0_0_3400_1726,https://scrap.kakaocdn.net/dn/bZfDjg/dJMb9bv0yEb/setU0uVisoAaBNkFFiBRbk/img.png?width=1388&amp;amp;height=942&amp;amp;face=0_0_1388_942&quot;&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/state-hoisting?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.android.com/develop/ui/compose/state-hoisting?hl=ko&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bI3uu1/dJMb86OZ4dn/0l1wDjkq9uqsuWsKnWEoyK/img.png?width=1201&amp;amp;height=676&amp;amp;face=0_0_1201_676,https://scrap.kakaocdn.net/dn/bzbXUP/dJMb9jOldg7/xkoM13hK2BxgOTIS7ZSZbK/img.png?width=3400&amp;amp;height=1726&amp;amp;face=0_0_3400_1726,https://scrap.kakaocdn.net/dn/bZfDjg/dJMb9bv0yEb/setU0uVisoAaBNkFFiBRbk/img.png?width=1388&amp;amp;height=942&amp;amp;face=0_0_1388_942');&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;상태를 호이스팅할 대상 위치 &amp;nbsp;|&amp;nbsp; Jetpack Compose &amp;nbsp;|&amp;nbsp; Android Developers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이 문서에서는 Jetpack Compose 애플리케이션에서 UI 상태를 호이스팅하기 위한 권장사항을 설명하고, 상태 관리가 UI 로직과 비즈니스 로직 중 어느 쪽에서 주도되는지 시나리오를 자세히 설명합니&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.android.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>안드로이드</category>
      <author>jay20033</author>
      <guid isPermaLink="true">https://jay20033.tistory.com/64</guid>
      <comments>https://jay20033.tistory.com/64#entry64comment</comments>
      <pubDate>Sat, 9 Aug 2025 22:39:10 +0900</pubDate>
    </item>
    <item>
      <title>[Jetpack Compose] State와 Remember란?</title>
      <link>https://jay20033.tistory.com/62</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;state란?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UI에서 동적으로 반영되는 데이터, 변경될 수 있는 값이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose에서 State는 값이 바뀔 때 해당 값을 읽은 컴포저블의 recomposition을 예약하는 관찰 가능한 값이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;네트워크 연결을 설정할 수 없을 때 표시되는 Snackbar 메시지 노출&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;Composable은 선언적이므로 UI 업데이트는 컴포저블이 변경된 매개변수를 통해 호출될 때만 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;초기 Composition&lt;/b&gt; : 처음 컴포저블을 실행하여 생성된 컴포지션&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Recomposition&lt;/b&gt; : 데이터가 변경될 때 (상태 변경 시) 발생되며, 관련 컴포저블을 업데이트하여 새로운 상태를 반영한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compose Runtime은 &lt;span style=&quot;background-color: #ffc9af;&quot;&gt;상태 변경 사항을 자동으로 추적&lt;/span&gt;하고, 안드로이드 View 시스템에서 UI를 호출하기 위해 사용하는 &lt;span style=&quot;background-color: #ffc9af;&quot;&gt;&lt;b&gt;View.invalidate()&lt;/b&gt;&lt;/span&gt; 메서드와 유사한 동작을 개발자 대신하여 UI를 업데이트한다.&lt;/p&gt;
&lt;pre id=&quot;android_compose_state_remember-code-sample&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f9fa; color: #37474f; text-align: left;&quot; data-copy-event-label=&quot;android_compose_state_remember-StateOverviewSnippets&quot; data-region-tag=&quot;android_compose_state_remember&quot; data-github-path=&quot;android/snippets/compose/snippets/src/main/java/com/example/compose/snippets/state/StateOverviewSnippets.kt&quot; data-code-snippet=&quot;true&quot; data-scope=&quot;android_compose_state_remember&quot;&gt;&lt;code&gt;@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by remember { mutableStateOf(&quot;&quot;) }
        if (name.isNotEmpty()) {
            Text(
                text = &quot;Hello, $name!&quot;,
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text(&quot;Name&quot;) }
        )
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;remember&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 컴포지션 발생시 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;메모리에 객체를 저장하고 recomposition이 발생하면 저장된 값을 반환한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt; 구성 변경 시에는 저장된 값 날라감.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;rememberSaveable&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;recomposition 뿐만 아니라, &lt;span style=&quot;background-color: #f6e199;&quot;&gt;화면 회전과 같은 구성 변경 시에도 Bundle에 저장할 수 있는 값을 자동으로 저장한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; &lt;span style=&quot;text-align: start;&quot;&gt;remember 위에서 동작하며, SavedStateRegistry에 값을 저장/복원해&amp;nbsp;&lt;/span&gt;&lt;b&gt;구성 변경 + 프로세스 재생성&lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;이후에도 값을 살려준다.&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;mutablestateof&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태 값이 변경될 때 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;recomposition을 트리거하는 관찰 가능한 상태 객체를 생성&lt;/span&gt;한다.&lt;/p&gt;
&lt;pre id=&quot;code_1773032696680&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val mutableState = remember { mutableStateOf(default) }
var value by remember { mutableStateOf(default) }
val (value, setValue) = remember { mutableStateOf(default) }&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;일반적으로 기능만 놓고 봤을때 rememberSaveable이 구성 변경시에도 저장된 데이터가 유지되기에 rememberSaveable만 사용하면 되지 않을까라는 생각을 하였는데, &lt;span style=&quot;background-color: #f6e199;&quot;&gt;내부 구현체를 보면 rememberSaveable은 remember보다 다양한 내부 처리 및 오버헤드가 발생하기 때문에 rememberSaveable을 무턱대고 사용하게 되면 오히려 성능이 떨어질 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;사용 시기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;remember&lt;/b&gt; : 애니메이션이나 임시적인 UI 상태와 같이 유지될 필요가 없는 &lt;span style=&quot;background-color: #99cefa;&quot;&gt;일시적인 상태&lt;/span&gt;가 필요한 경우&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;rememberSaveable&lt;/b&gt; : 사용자 입력, 선택 상태와 같이 &lt;span style=&quot;background-color: #99cefa;&quot;&gt;구성 변경 시에도 유지되어야 하는 상태&lt;/span&gt;가 필요한 경우&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;내부 구조&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;remember&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
public inline fun &amp;lt;T&amp;gt; remember(crossinline calculation: @DisallowComposableCalls () -&amp;gt; T): T =
    currentComposer.cache(false, calculation)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 Composer 인스턴스에서 &lt;b&gt;cache 함수를 호출&lt;/b&gt;한다.&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@ComposeCompilerApi
public inline fun &amp;lt;T&amp;gt; Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -&amp;gt; T): T {
    @Suppress(&quot;UNCHECKED_CAST&quot;)
    return rememberedValue().let {
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cache 내부 코드를 보면 처음에 값이 유효하지 않거나 초기화되 지 않았는지 확인한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;초기화 되지 않은 경우 block 함수가 실행되어 값을 계산하고 컴포지션 데이터에 값을 저장하고 반환&lt;/span&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;rememberSaveable&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Composable
public fun &amp;lt;T : Any&amp;gt; rememberSaveable(
    vararg inputs: Any?,
    saver: Saver&amp;lt;T, out Any&amp;gt; = autoSaver(),
    key: String? = null,
    init: () -&amp;gt; T,
): T {
    val compositeKey = currentCompositeKeyHashCode
    // key is the one provided by the user or the one generated by the compose runtime
    val finalKey =
        if (!key.isNullOrEmpty()) {
            key
        } else {
            compositeKey.toString(MaxSupportedRadix)
        }
    @Suppress(&quot;UNCHECKED_CAST&quot;) (saver as Saver&amp;lt;T, Any&amp;gt;)

    val registry = LocalSaveableStateRegistry.current

    val holder = remember {
        // value is restored using the registry or created via [init] lambda
        val restored = registry?.consumeRestored(finalKey)?.let { saver.restore(it) }
        val finalValue = restored ?: init()
        SaveableHolder(saver, registry, finalKey, finalValue, inputs)
    }

    val value = holder.getValueIfInputsDidntChange(inputs) ?: init()
    SideEffect { holder.update(saver, registry, finalKey, value, inputs) }

    return value
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;키 생성&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;val finalKey =
    if (!key.isNullOrEmpty()) {
        key
    } else {
        compositeKey.toString(MaxSupportedRadix)
    }&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;public val currentCompositeKeyHashCode: CompositeKeyHashCode
    @Composable
    @ExplicitGroupsComposable
    @OptIn(InternalComposeApi::class)
    get() = currentComposer.compositeKeyHashCode&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;key 매개변수를 통해 사용자 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;커스텀 키를 생성&lt;/span&gt;할 수 있으며, 따로 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;지정하지 않은 경우 현재 컴포지션 해시를 통해 키가 자동으로 생성&lt;/span&gt;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;상태 복원&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;val registry = LocalSaveableStateRegistry.current

val holder = remember {
    // value is restored using the registry or created via [init] lambda
    val restored = registry?.consumeRestored(finalKey)?.let { saver.restore(it) }&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;LocalSaveableStateRegistry&lt;/b&gt;가 이전에 저장된 값을 검색하고 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;저장된 값이 존재하면 Saver를 사용하여 복원&lt;/span&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;기본값 초기화&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;val finalValue = restored ?: init()&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복원된 값이 없으면 init 람다가 실행되어 기본값이 초기화된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;SaveableHolder&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;SaveableHolder(saver, registry, finalKey, finalValue, inputs)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태, saver, 레지스트리 및 입력을 관리하기 위해 SaveableHolder가 생성된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;입력 변경 처리&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;val value = holder.getValueIfInputsDidntChange(inputs) ?: init()&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;fun getValueIfInputsDidntChange(inputs: Array&amp;lt;out Any?&amp;gt;): T? {
    return if (inputs.contentEquals(this.inputs)) {
        value
    } else {
        null
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&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;size18&quot;&gt;&lt;b&gt;Side Effect&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #263238; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;SideEffect { holder.update(saver, registry, finalKey, value, inputs) }&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Recomposition이 발생하면 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;업데이트된 상태가 Registry에 저장된다.&lt;/span&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;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/state?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.android.com/develop/ui/compose/state?hl=ko&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://medium.com/@appdevinsights/jetpack-compose-remember-vs-remembersaveable-39cb4fb6b9b9&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://medium.com/@appdevinsights/jetpack-compose-remember-vs-remembersaveable-39cb4fb6b9b9&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@jayjazy/Android-Compose%EC%9D%98-%EC%83%81%ED%83%9C-feat.-remember-rememberSavable&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@jayjazy/Android-Compose%EC%9D%98-%EC%83%81%ED%83%9C-feat.-remember-rememberSavable&lt;/a&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;</description>
      <category>안드로이드</category>
      <author>jay20033</author>
      <guid isPermaLink="true">https://jay20033.tistory.com/62</guid>
      <comments>https://jay20033.tistory.com/62#entry62comment</comments>
      <pubDate>Wed, 6 Aug 2025 14:33:46 +0900</pubDate>
    </item>
    <item>
      <title>Jetpack Compose의 안정성(Stability)</title>
      <link>https://jay20033.tistory.com/55</link>
      <description>&lt;h2 style=&quot;text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;안정성(Stability)란?&lt;/h2&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;클래스나 타입이 동일한 매개변수 입력값에 대해 일관된 결과를 생성하도록 보장하는 속성이다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;  안정된 클래스나 함수는 recomposition이 여러 번 발생해도 의도와 다르게 동작이 변경되지 않도록 보장한다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;❓ Compose에서 안정성이 중요한 이유&lt;/h4&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;composable 컴파일러는 컴포저블 함수의 매개변수를 검사하고 &lt;b&gt;stable&lt;/b&gt; 또는 &lt;b&gt;unstable&lt;/b&gt;로 분류한다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이 정보는 Compose Runtime이 매개변수의 입력값 변경에 따라 컴포저블 함수를 다시 랜더링할지 여부를 결정하는데 사용되므로 &lt;b&gt;효율적으로 recomposition을 관리하여 앱 성능 향상에 기여를 한다.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-end=&quot;1518&quot; data-start=&quot;1499&quot; data-ke-size=&quot;size18&quot;&gt;✅ Stable 파라미터일 때 특징&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1702&quot; data-start=&quot;1524&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1612&quot; data-start=&quot;1524&quot;&gt;&lt;b&gt;참조가 같으면 변경되지 않았다고 판단&lt;/b&gt;&lt;br /&gt;즉, stable 객체가 들어오면 &lt;b&gt;equals 체크를 생략&lt;/b&gt;하고 &lt;b&gt;참조 비교(pointer compare)&lt;/b&gt;로 충분.&lt;/li&gt;
&lt;li data-end=&quot;1702&quot; data-start=&quot;1614&quot;&gt;만약 stable 객체가 내부적으로 값 변화 가능하면&lt;br /&gt;&amp;rarr; Compose runtime이 자체 추적(State object)을 이용해 변화 감지.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  Stable 객체는 &quot;값이 바뀌었는지 Compose가 신뢰하고 판단할 수 있는 타입&quot;이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size18&quot;&gt;✔️ Stable로 간주되는 대표적인 경우&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;String을 포함한 &lt;b&gt;원시 타입이 읽기 전용인 val&lt;/b&gt;로 정의된 경우
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Int, Float, Boolean, String 등&lt;/li&gt;
&lt;/ul&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;ex) (Int) -&amp;gt; String 과 같은 함수 타입&lt;/li&gt;
&lt;li&gt;외부 상태를 캡처하지 않는 람다는 동작이 예측 가능하므로 Stable&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1766508509854&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;val lambdaFunc: (Int) -&amp;gt; String&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;클래스, val 이거나 immutable하고 stable 한 public 속성을 가진 데이터 클래스&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1766508529317&quot; class=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;data class User(
    val id: Int,
    val name: String
)&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;@Stable&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@Immutable&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size18&quot;&gt;✔️ Unstable 간주되는 대표적인 경우&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인터페이스 및 추상 클래스
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;List, Map, Any와 같은 인터페이스는 컴파일 시점에 구현체의 종류를 보장할 수 없기 때문&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;가변적인 프로퍼티를 가진 클래스
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;하나 이상의 가변적이거나 본질적으로 불안정하다고 간주되는 타입의 public 프로퍼티를 단 하나라도 포함하는 클래스&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #2b2b2b; color: #a9b7c6;&quot;&gt;
&lt;pre class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;data class UnstableUser(
    val id: Int,
    var name: String
)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;  Composable 함수 추론하기&lt;/h4&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;성능을 최적화하기 위해 컴파일러는 컴포저블 함수를 &lt;b&gt;Restartable, Skippable, Moveable, Replaceable&lt;/b&gt;과 같은 유형으로 분류한다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Restartable&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;recomposition 프로세스의 기본 단위 ➡️ 매개변수 입력값 or 상태가 변경되면 compose 런타임은 UI를 업데이트 하기 위해 recomposition을 위해 함수를 재호출함&lt;/li&gt;
&lt;li&gt;대부분의 컴포저블 함수는 기본적으로 Restartable로 간주&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Skippable&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;스마트 recomposition에 의해 활성화된 특정 조건 하에서 recomposition 스킵할 수 있음 ➡️성능 향상에 중요&lt;/li&gt;
&lt;li&gt;컴포저블 함수는 restartable이면서 동시에 skippable일 수 있음 ➡️ 필요할 때 recomposition 수행할 수 있지만 특정 조건이 허용되면 건너뛸 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;Smart Recomposition이란?&lt;/h4&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;컴파일러에서 제공하는 안정성 정보를 기반으로 불필요한 업데이트를 선택적으로 건너뛰어 UI 성능과 응답성을 최적화하는 매커니즘이다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; 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;새로운 매개변수 입력값이 컴포저블 함수에 전달될 때마다 Compose는 클래스의 &lt;b&gt;equals() 매서드를 이용하여 이전 값과 비교&lt;/b&gt;한다.&lt;/li&gt;
&lt;li&gt;Stable 매개변수
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;입력값 동일 ➡️ equals()가 true 반환 ➡️ &lt;b&gt;recomposition Skip&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;입력값 바뀜 ➡️ equals()가 false 반환 ➡️ &lt;b&gt;recomposition 트리거함&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Unstable 매개변수
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;Compose는 입력값 변경 여부에 관계없이 &lt;b&gt;항상 recomposition 트리거함&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;안정성 어노테이션&lt;/h4&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;@Immutable&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;클래스 내의 모든 속성이 컴파일러에 의해 변경 불가능한 것으로 처리되도록 보장한다.&lt;/p&gt;
&lt;pre id=&quot;code_1766509094093&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Immutable
data class User(
    val id: Int,
    val items: List&amp;lt;String&amp;gt;
)&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;  List는 기본적으로 Unstable이지만 &lt;b&gt;@Immutable&lt;/b&gt; 때문에 명시적으로 stable 유형으로 만들 수 있음&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; 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 data-end=&quot;292&quot; data-start=&quot;278&quot;&gt;모든 필드가 &lt;b&gt;val&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;314&quot; data-start=&quot;293&quot;&gt;필드 타입도 모두 &lt;b&gt;immutable&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;@Stable&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;속성이 immutable 하거나 recomposition에 영향을 미치지 않는 제어된 가변성을 가진 클래스에 사용&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;  읽기 전용보다는 강력하지만 &lt;b&gt;@Immutable&lt;/b&gt; 어노테이션 보다는 덜 엄격함&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;  클래스 자체는 stable로 간주되지만 해당 속성은 여전히 변경될 수 있음&lt;/p&gt;
&lt;pre id=&quot;code_1766509243439&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Stable
class PlayerState {
    var position by mutableFloatStateOf(0f)
    var isPlaying by mutableStateOf(false)
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;@Immutable ⚔️ @Stable&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 101px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style14&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.4497%; height: 21px;&quot;&gt;구분&lt;/td&gt;
&lt;td style=&quot;width: 40.0773%; height: 21px;&quot;&gt;&lt;b&gt;@Immutable&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.4729%; height: 21px;&quot;&gt;&lt;b&gt;@Stable&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.4497%; height: 21px;&quot;&gt;불변성&lt;/td&gt;
&lt;td style=&quot;width: 40.0773%; height: 21px;&quot;&gt;완전 불변&lt;/td&gt;
&lt;td style=&quot;width: 41.4729%; height: 21px;&quot;&gt;제어된 가변성&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.4497%; height: 21px;&quot;&gt;프로퍼티&lt;/td&gt;
&lt;td style=&quot;width: 40.0773%; height: 21px;&quot;&gt;모두 Immutable&lt;/td&gt;
&lt;td style=&quot;width: 41.4729%; height: 21px;&quot;&gt;Mutable 허용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.4497%; height: 21px;&quot;&gt;사용 사례&lt;/td&gt;
&lt;td style=&quot;width: 40.0773%; height: 21px;&quot;&gt;DTO, Model&lt;/td&gt;
&lt;td style=&quot;width: 41.4729%; height: 21px;&quot;&gt;UI State&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.4497%; height: 17px;&quot;&gt;Recomposition&lt;/td&gt;
&lt;td style=&quot;width: 40.0773%; height: 17px;&quot;&gt;완전히 Skip 가능&lt;/td&gt;
&lt;td style=&quot;width: 41.4729%; height: 17px;&quot;&gt;상태 변경시 트리거&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;참고자료&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.android.com/develop/ui/compose/performance/stability?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.android.com/develop/ui/compose/performance/stability?hl=ko&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@skydoves/compose-stability&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@skydoves/compose-stability&lt;/a&gt;&lt;/p&gt;</description>
      <category>안드로이드</category>
      <author>jay20033</author>
      <guid isPermaLink="true">https://jay20033.tistory.com/55</guid>
      <comments>https://jay20033.tistory.com/55#entry55comment</comments>
      <pubDate>Sun, 27 Jul 2025 23:54:03 +0900</pubDate>
    </item>
  </channel>
</rss>