선언형 UI가 부수 효과를 다루는 법 — Compose Effect API

지훈·2026년 4월 22일

1편에서 "Composable은 순수 함수여야 한다"고 했습니다.
2편에서 "Snapshot System이 상태 변화를 추적한다"고 했습니다.
3편에서 "State Hoisting으로 진정한 순수 함수를 만든다"고 했습니다.
4편(개정)에서 "@Stable은 성능 도구가 아니라 컴파일러와의 계약이다"고 했습니다.

그런데 여기에는 미뤄둔 숙제가 하나 있습니다.

실제 앱에는 순수할 수 없는 것들이 있지 않나?
네트워크 호출, 타이머, 이벤트 구독, 로그 전송, 아날리틱스…

1편에서 "Composable은 순수 함수"라고 선언했지만, 실제 앱을 만들려면 이런 부수 효과들을 어딘가에서 실행해야 합니다. 그럼 Compose는 이것을 어디서, 어떻게 처리하나요?

답은 Effect API입니다. LaunchedEffect, DisposableEffect, SideEffect 같은 것들이죠.

이 글은 Effect API를 사용법으로 설명하지 않습니다. 대신 "왜 여러 개로 분리돼 있는가", "이 네 개를 관통하는 공통 본성이 무엇인가", 그리고 그 설계가 1편의 순수 함수 원칙과 어떻게 맞물려 시리즈 전체를 완성하는가를 다룹니다.


흔한 오해

Effect API 관련 설명을 보면 이런 프레이밍이 많습니다.

"LaunchedEffect는 Composable 안에서 코루틴을 돌리는 도구다."

"DisposableEffect는 리소스 정리가 필요할 때 쓰는 도구다."

"SideEffect는 매 recomposition마다 실행되는 훅이다."

틀린 말이 아닙니다. 사용 결과만 보면 그렇게 동작하는 것도 맞습니다.

그런데 이 프레이밍엔 한 가지 문제가 있습니다. 각 API가 "코드 실행 장소"로만 보이고, 네 개를 관통하는 공통 본성이 드러나지 않습니다. 그 공통 본성은 — 뒤에서 보겠지만 — Compose 세계와 외부 세계 사이의 동기화입니다 (React가 useEffect를 보는 관점과 같은 각도).

이 글은 Effect API의 이 공통 본성과, 그 설계가 1편의 "순수 함수" 원칙과 어떻게 맞물려 시리즈 전체를 완성하는지를 다룹니다.


이 글의 독자

  1. 시리즈 1~4편을 읽은 독자

    • "Composable은 순수 함수다"라는 1편의 선언이 실제 앱에서 어떻게 가능한지 궁금한 분
    • 순수 영역과 부수 효과 영역이 Compose에서 어떻게 분리되는지 알고 싶은 분
  2. Effect API를 "비동기/리소스 도구 세트"로만 이해하는 개발자

    • LaunchedEffect / DisposableEffect / SideEffect를 각각 쓸 줄은 알지만
    • 왜 여러 개로 분리됐는지, key가 왜 필수인지 근본 원리가 궁금한 분
  3. React useEffect 경험자

    • 하나의 API로 부수 효과를 처리하던 경험이 있지만
    • Compose는 왜 여러 개로 나눴는지 설계 철학이 궁금한 분

부수 효과는 순수 함수의 반대편이다

시작하기 전에, 부수 효과가 왜 문제인지부터 짚습니다.

Composable의 딜레마

1편에서 Composable은 순수 함수여야 한다고 했습니다. 순수 함수는:

  • 같은 입력 → 같은 출력
  • 외부 상태를 바꾸지 않음
  • 외부 세계와 소통하지 않음

그런데 앱을 만들려면 외부 세계와 소통해야 합니다. 문제가 되는 코드를 먼저 보겠습니다.

@Composable
fun UserProfile(userId: String) {
    var user by remember { mutableStateOf<User?>(null) }

    // 이렇게 쓰면 안 됨
    coroutineScope.launch { user = fetchedUser(userId) }

    user?.let { Text(it.name) }
}

이 코드의 문제는 명확합니다. UserProfile이 recomposition마다 다시 실행되면 매번 새 네트워크 요청이 나갑니다. Composable은 "자주, 예측 불가능한 타이밍에" 재실행되도록 설계됐기 때문에, 부수 효과를 Composable 본문에 직접 두는 것은 금지입니다.

그럼 어디에 둬야 하나요?

Composable은 순수 함수

1편에서 이렇게 말했습니다.

"Composable은 순수 함수여야 한다."

이 원칙을 지키려면 부수 효과를 별도의 공간에 격리해야 합니다. 이 격리 공간이 바로 Effect API입니다. 순수 영역(Composable 본문)과 부수 영역(Effect 블록) 사이의 경계죠. 다음 섹션부터 이 경계 안에서 각 API가 무엇을 선언하는지 봅니다.


Effect API는 부수 효과의 '종류와 정리 계약'을 '선언'한다

각 Effect API가 무엇을 하는지가 아니라 무엇을 선언하는지로 설명합니다. 모든 Effect API는 수명을 Composition에 맡긴다는 공통점을 가집니다. 각각이 타입으로 선언하는 건 그 안에서 "어떤 종류의 부수 효과이며, 어떻게 정리하고, 누가 시작하는가" 입니다.

LaunchedEffect: Composition이 소유하는 코루틴

@Composable
fun UserProfile(userId: String) {
    var user by remember { mutableStateOf<User?>(null) }

    LaunchedEffect(userId) {
        user = fetchedUser(userId)  // userId가 바뀔 때만 재호출
    }

    user?.let { Text(it.name) }
}

LaunchedEffect의 본질은 "이 코루틴의 수명은 Composition이 소유한다" 는 선언입니다.

  • Composable이 Composition에 진입하면 코루틴 시작
  • Composable이 Composition에서 이탈하면 코루틴 자동 취소
  • key가 바뀌면 이전 코루틴을 취소하고 새 코루틴 시작

개발자는 "언제 시작해서 언제 취소할지"를 직접 관리하지 않습니다. LaunchedEffect를 선택하는 순간, 그 관리 책임이 Composition으로 넘어갑니다.

DisposableEffect: 획득과 해제가 쌍을 이루는 리소스

@Composable
fun LocationTracker(onLocationChanged: (Location) -> Unit) {
    val context = LocalContext.current

    DisposableEffect(Unit) {
        val listener = LocationListener { onLocationChanged(it) }
        context.registerLocationListener(listener)

        onDispose {
            context.unregisterLocationListener(listener)
        }
    }
}

DisposableEffect의 본질은 "이 리소스는 획득/해제 쌍으로 관리된다" 는 선언입니다.

  • 진입 시점(새로운 key 첫 composition에만 실행): 리스너 등록, 구독 시작, 리소스 획득
  • 이탈 시점 (또는 key 변경 시점): 리스너 해제, 구독 취소, 리소스 해제
  • onDispose가 필수 — 짝이 없는 획득은 메모리 누수

DisposableEffect은 코루틴이 아닙니다. 하지만 LaunchedEffect와의 본질적 차이는 그게 아니라 해제 로직이 구조적으로 강제된다는 점입니다. 컴파일러가 onDispose 블록을 요구하므로, 획득과 해제의 짝을 빠뜨릴 수 없습니다.

SideEffect: 매 Composition의 동기화 지점

@Composable
fun Analytics(user: User) {
    SideEffect {
        Firebase.setUserId(user.id)
    }
}

SideEffect의 본질은 "성공한 Composition마다 Compose 상태를 외부 시스템에 반영한다" 는 선언입니다. 방향은 단방향 — Compose → 외부.

  • Composition이 성공적으로 커밋될 때마다 실행
  • key 없음 — 매번 실행이 본질
  • 용도: Compose에서 관리하는 값을 non-Compose API(Firebase SDK, Window title, 로깅 등)에 밀어넣기

Analytics(user)가 "user 바뀔 때만 Firebase에 전송"되는 것처럼 보이는 건 SideEffect가 변경을 감지해서가 아니라, Composition Skipping이 안 바뀐 호출을 걸러주기 때문입니다.

rememberCoroutineScope: Composable이 주는 scope, 개발자가 부르는 launch

@Composable
fun SaveButton(data: Data) {
    val scope = rememberCoroutineScope()

    Button(onClick = {
        scope.launch { saveToServer(data) }
    }) {
        Text("저장")
    }
}

이름을 그대로 읽으면 됩니다. remember + CoroutineScope — "CoroutineScope 하나를 recomposition 건너 유지해주세요." 그 이상도 이하도 아닙니다. Kotlin의 표준 CoroutineScope를 Compose 수명주기에 얹은 얇은 래퍼입니다.

  • Composable 진입 시 scope 생성, recomposition 건너도 같은 인스턴스 유지
  • Composable이 Composition에서 이탈하면 scope.cancel() 자동 호출 → 그 scope로 시작된 모든 코루틴 취소

여기까지만 보면 LaunchedEffect와 뭐가 다른지 흐릿합니다. 결정적 차이는 launch를 누가 호출하는가 입니다.

  • LaunchedEffect { ... }: Compose 런타임이 코루틴을 launch합니다. 개발자는 본문(suspend 블록)만 작성.
  • rememberCoroutineScope().launch { ... }: Compose는 scope만 준비해두고, 개발자가 직접 .launch 호출. 호출 시점은 사용자 이벤트 같은 임의의 시점.

왜 개발자가 직접 launch하는 API가 따로 필요할까요? Composable 본문과 이벤트 핸들러가 다른 실행 맥락이기 때문입니다. Composable 함수 자체는 suspend로 선언돼 있지 않아 suspend 함수 직접 호출이 불가능하고, onClick 같은 이벤트 핸들러도 일반 콜백이라 역시 suspend 직접 호출이 불가능합니다. 이벤트에서 네트워크 호출 같은 비동기 작업을 하려면 코루틴이 필요한데, 매번 새 scope를 만들자니 Composable 이탈 시 정리가 안 되고, 전역 scope를 쓰자니 누수가 납니다. rememberCoroutineScope는 이 정확한 공백을 메꿉니다 — "이벤트에서 suspend를 부를 때 쓰되, 수명은 Composable이 관리해주는 scope".

주의할 점 하나: scope의 수명은 이벤트가 아니라 Composable이 소유합니다. 이벤트 핸들러가 리턴된 뒤에도 그 안에서 launch된 코루틴은 계속 돌아가고, Composable이 dispose될 때 일괄 취소됩니다.

네 가지 API 비교: 수명 주체와 관리 대상

API용도 (언제 쓰나)수명 주체관리 대상시작정리
LaunchedEffectComposable 수명 내에서 돌아가야 하는 비동기 작업 — 데이터 fetch, Flow 수집, 타이머 등Composition코루틴진입 시 · key 변경 시Composition 이탈 · key 변경 시 자동 cancel
DisposableEffect획득/해제 짝이 있는 리소스 — 시스템 리스너 등록·해제, 브로드캐스트 수신자, 옵저버 구독 등Composition획득/해제 쌍 리소스진입 시 · key 변경 시Composition 이탈 · key 변경 시 onDispose 호출 (명시적)
SideEffectCompose 상태를 외부 non-Compose 시스템에 반영 — Firebase SDK, Window title, 로깅 등Composition 커밋외부 상태 동기화 액션매 성공 커밋 시정리 없음 (매번 실행이 본질)
rememberCoroutineScope버튼 클릭 같은 이벤트 핸들러에서 suspend 함수를 호출해야 하고, 그 코루틴 수명은 Composable이 관리해야 할 때CompositionCoroutineScope생성은 진입 시 · .launch 호출은 개발자Composition 이탈 시 scope 취소 → 자식 코루틴 일괄 취소

표에서 드러나듯 "수명 주체"는 거의 모두 Composition으로 같습니다. 이것이 Effect API가 순수 영역과 부수 영역 사이의 경계 역할을 하는 이유입니다 — 수명을 Composable의 생애에 묶어 부수 효과를 안전한 공간에 가둡니다. 각 API가 타입으로 선언하는 것은 "무엇을 관리하는가(관리 대상)", "어떻게 정리하는가(정리 방식)", "누가 시작하는가(시작 주도권)" 입니다. 개발자가 API를 선택하는 순간, 이 세 가지 계약이 Compose 런타임과 맺어집니다.

이 4개 외에 produceState, snapshotFlow, rememberUpdatedState, derivedStateOf 같은 Effect 관련 API가 더 있습니다. 이 글은 핵심 4개에 집중하며, 나머지는 공식 Side-effects 문서를 참고하세요.


네 개를 관통하는 관점 — 동기화

앞 섹션에서 각 Effect API를 "종류와 정리 계약"으로 분류했습니다 — 이건 생명주기 관점(무엇의 수명을 어떻게 관리하나)에서 본 것입니다. 이번에는 같은 4개를 동기화 관점(무엇과 무엇을 맞물리나)에서 봅시다. 두 관점은 충돌이 아니라 같은 설계를 보는 두 각도 — 시간 축과 관계 축일 뿐입니다.

React가 먼저 정리한 프레이밍

React 공식 문서는 useEffect를 이렇게 정의합니다.[^1]

"useEffect is a React Hook that lets you synchronize a component with an external system."
"Effects are an escape hatch from the React paradigm."
"Try to write every Effect as an independent process and think about a single setup/cleanup cycle at a time."[^4]

즉 React 팀은 Effect를 "코드 실행 장소"가 아니라 "컴포넌트와 외부 시스템 사이의 동기화 관계" 로 봅니다.

이 시각을 Compose에 적용하면

Compose의 4개 API가 하나의 문장 구조로 통합됩니다.

API무엇과 무엇의 동기화인가
LaunchedEffect코루틴 실행을 Composition 수명과 동기화
DisposableEffect외부 리소스 획득 상태를 Composition 수명과 동기화
SideEffectCompose 상태를 외부 시스템에 단방향 push 동기화
rememberCoroutineScopeCoroutineScope 수명을 Composition 수명과 동기화 (launch 시점은 이벤트가 결정)

그럼 key는 뭔가

동기화 시각에서 key"이 동기화가 의존하는 입력" 입니다. 입력이 바뀌면 기존 동기화는 새 입력에 대해 더 이상 유효하지 않으므로, Compose 런타임이 기존 연결을 해제하고 새 입력으로 다시 맺습니다.

"key가 바뀌면 재실행된다"는 틀린 말이 아닙니다. 다만 "무엇의 재실행인지" 가 이제 명확합니다 — 새 입력에 대한 동기화 재수립이죠. 취소는 부산물이 아니라 이 재수립 과정의 명시적 단계입니다.

자주 마주치는 경우들이 이 시각에서 일관되게 설명됩니다.

  • LaunchedEffect(Unit)이 한 번만 실행되는 이유: Unit은 항상 같으므로 동기화의 입력이 변하지 않음 → 재수립 없음
  • 리스트 item에 id를 key로 주는 이유: 각 item은 독립된 동기화이므로 서로 다른 입력(id)으로 구분
  • 의존성이 바뀌면 이전 코루틴이 취소되는 이유: 기존 동기화 해제 → 새 입력으로 다시 맺음

key 타입에 대한 주의: 4편 Stability의 연장

key 비교는 Any.equals() 호출입니다. 즉 4편에서 다룬 Stability 계약이 key 타입에도 필수입니다.

// OK — String은 Stable, equals 신뢰 가능
LaunchedEffect(userId) { ... }

// 위험 — List<T>는 Unstable, equals 결과가 시점에 따라 흔들릴 수 있음
LaunchedEffect(itemList) { ... }

// OK — ImmutableList는 Stable
LaunchedEffect(itemList.toImmutableList()) { ... }

Stable 타입은 Composable 파라미터뿐 아니라 Effect key에도 신뢰의 전제입니다. 거짓 Stability는 Recomposition 버그를 만들고, 불안정한 key는 동기화 판단을 흔들어 Effect를 오동작시킵니다.


React useEffect와 비교: 뭉치기 vs 분리

같은 문제(부수 효과를 순수 영역 밖으로 격리)에 대한 다른 설계 철학을 봅니다.

React: 하나의 API로 모든 부수 효과를 처리

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);

    useEffect(() => {
        let cancelled = false;
        fetchUser(userId).then(data => {
            if (!cancelled) setUser(data);
        });
        return () => { cancelled = true; };  // cleanup
    }, [userId]);  // dependency array

    return user && <div>{user.name}</div>;
}

React는 useEffect 하나로 앞서 본 Compose 4개 API 역할을 전부 소화합니다[^1]. 대신 용도마다 호출 패턴이 달라집니다.

  • Compose의 LaunchedEffect (비동기 작업) → React에선 useEffect 안에 promise 체인 + 취소 플래그 수동 관리
  • DisposableEffect (획득/해제 짝) → React에선 return () => { ... }로 cleanup 함수 반환
  • SideEffect (매 커밋 실행) → React에선 의존성 배열(두 번째 인자) 생략
  • LaunchedEffect(Unit) (마운트 시 1회) → React에선 의존성 배열을 빈 []

같은 일을 "API 분리" 로 표현하느냐 "호출 관용구" 로 표현하느냐의 차이입니다. 이 차이가 아래 "장점/단점"으로 이어집니다.

장점: API가 하나라 학습 부담이 낮고, 패턴이 유연합니다.

단점: 코드만 보고 "이 Effect는 어떤 종류의 부수 효과인가"를 판별하기 어렵습니다. 정리 로직이 있는지, 의존성이 올바른지, 매번 실행되는지는 개발자가 매번 신경 써야 합니다. 잘못된 의존성 배열은 런타임까지 가야 발견되는 흔한 버그 원인입니다.

SwiftUI: modifier 수준의 분리

struct UserProfile: View {
    let userId: String
    @State var user: User?

    var body: some View {
        Group {
            if let user { Text(user.name) }
        }
        .task(id: userId) {
            user = await fetchUser(userId)
        }
    }
}

SwiftUI는 .task, .onAppear, .onDisappear, .onChange 등으로 부분적 분리를 제공합니다[^2]. Compose처럼 완전 분리는 아니지만 "어떤 생명주기인지"를 modifier 선택으로 드러냅니다. .task(id:)id는 Compose의 key와 거의 같은 개념입니다.

Vue: watchEffect + 수명주기 훅 혼합

<script setup>
import { ref, watchEffect } from 'vue'

const props = defineProps(['userId'])
const user = ref(null)

// 의존성 자동 추적 + onCleanup 콜백으로 취소 처리
watchEffect(async (onCleanup) => {
    const controller = new AbortController()
    onCleanup(() => controller.abort())

    user.value = await fetchUser(props.userId, { signal: controller.signal })
})
</script>

Vue는 React와 Compose 사이 어딘가입니다[^3]. watchEffect는 의존성을 자동 추적, onMounted/onUnmounted명시적 수명주기 — 개발자가 상황에 맞게 섞어 씁니다.

설계 철학의 차이

부수 효과 처리 방식생명주기 표현의존성 표현
Compose여러 API로 분리 (LaunchedEffect, DisposableEffect, SideEffect, …)API 선택으로 명시key 파라미터
React하나의 useEffect의존성 배열 + 정리 함수의존성 배열
SwiftUImodifier 분리 (.task, .onAppear, .onChange, …)modifier 선택으로 명시.task(id:)
VuewatchEffect + 수명주기 훅혼합자동 추적 또는 명시

차이는 "부수 효과의 종류를 언어/API로 구분하느냐, 개발자 패턴으로 구분하느냐" 입니다.

  • Compose/SwiftUI: "이 Effect는 이런 종류다"를 API 이름 자체가 선언
  • React: 개발자가 의존성 배열과 정리 패턴을 올바르게 작성해서 의도를 표현
  • Vue: 경우에 따라 둘 다

모두 같은 문제(부수 효과를 순수 영역 밖으로 격리)를 풀지만, 해법의 추상화 층위가 다릅니다. Compose는 타입으로 구분, React는 패턴으로 구분.


종합: 함수형 UI의 완성

이제 시리즈 전체를 되짚어봅니다.

  • 1편: Composable은 순수 함수여야 한다.
  • 2편: Snapshot System이 상태 변화를 추적한다.
  • 3편: State Hoisting으로 진정한 순수 함수를 만든다.
  • 4편(개정): @Stable은 성능 도구가 아니라 "이 타입은 순수하다"는 컴파일러와의 계약이다.
  • 5편 (이 글): 순수할 수 없는 것은 Effect API로 경계를 그어 격리한다.

함수형 프로그래밍의 핵심 원리 중 하나는 "순수 영역과 부수 영역을 경계 명확히 분리하라" 입니다. Haskell의 IO 모나드, Elm의 Command/Subscription 모델 — 모두 같은 원리입니다. 순수한 것은 순수하게 유지하고, 부수 효과는 별도 공간에서 타입으로 관리하는 것.

Compose는 이 원리를 UI 프레임워크에 적용한 결과입니다.

  • 순수 영역: Composable 함수 본문 — 컴파일러가 Stability 계약을 근거로 자동 최적화 (1~4편 다룬 내용)
  • 경계: Effect API — 부수 효과의 종류와 정리 계약을 타입으로 선언 (이 글)
  • 부수 영역: Effect API 안쪽 — 네트워크, 리스너, 타이머, 외부 시스템 동기화

LaunchedEffectkey는 이 경계를 지탱하는 장치입니다. 개발자가 동기화의 입력을 선언하면, Compose 런타임이 그 입력에 맞춰 동기화를 유지·재수립·해제합니다. key를 "재실행 트리거"로만 이해하면 이 동기화 구조 전체가 안 보입니다.


정리

결국 Compose의 설계 철학은 두 축으로 완성됩니다.

첫 번째 축 — 순수 영역의 자동 최적화. val을 쓰고 순수 함수를 지키면, 컴파일러가 Stability 계약(4편)을 근거로 불필요한 재실행을 자동으로 건너뜁니다. 성능은 이 계약의 부산물입니다.

두 번째 축 — 부수 영역의 명시적 격리. 순수할 수 없는 것들은 Effect API 안으로 들어갑니다. 개발자는 API 선택만으로 "이 부수 효과는 어떤 종류이며 어떻게 정리할 것인가"를 선언하고, Compose 런타임이 그 계약대로 수명을 관리합니다.

이 두 축의 연결점이 동기화입니다. Effect API는 Compose 세계와 외부 세계 사이의 동기화 기본 장치이고, key는 그 동기화가 의존하는 입력입니다. 순수 영역의 자동 최적화와 부수 영역의 명시적 동기화 — 이 둘이 맞물려 함수형 UI가 완성됩니다.

물론 이것이 Compose의 방식이 "더 좋다"는 뜻은 아닙니다. React의 통합된 useEffect, SwiftUI의 modifier 분리, Vue의 혼합 방식 모두 각자의 설계 철학이 반영된 trade-off입니다. 중요한 것은 각 프레임워크가 같은 문제 — 부수 효과를 순수 영역 밖으로 격리하기 — 를 풀고 있다는 것, 그리고 핵심 원리(순수 영역 분리)를 이해하면 어떤 프레임워크든 빠르게 적응할 수 있다는 것입니다.

Effect API를 "비동기 작업 실행기" 정도로만 이해하면 순수 함수 원칙의 나머지 절반을 놓치는 것입니다. Composable은 Effect API가 받쳐줄 때만 진짜로 순수합니다 — 그렇지 않으면 그건 순수함수인 척하는 버그 제조기입니다.


이 글은 Compose Effect API와 함수형 프로그래밍의 경계 설계를 공부하면서 정리한 내용입니다. 다른 프레임워크에 대한 비교는 공식 문서를 참고했지만 부정확한 부분이 있을 수 있습니다. 틀린 내용이나 보완할 점이 있다면 댓글로 알려주시면 감사하겠습니다.


시리즈

참고 자료

공식 문서

프레임워크 비교


[^1]: React - useEffect — "useEffect is a React Hook that lets you synchronize a component with an external system."

[^2]: SwiftUI - task(priority:_:) — 뷰의 수명주기에 묶인 비동기 task를 시작하는 modifier.

[^3]: Vue - watchEffect — 실행 즉시 반응형 의존성을 자동 추적하는 함수.

[^4]: React - You Might Not Need an Effect — "Effects are an escape hatch from the React paradigm." 및 "Try to write every Effect as an independent process and think about a single setup/cleanup cycle at a time."

profile
안드로이드 개발 공부

0개의 댓글