많은 유명한 개발자들이 하는 말처럼, 저도 항상 이런 말을 했습니다.:
"기술은 그냥 배우는 게 아니라, 그 기술의 철학과 패러다임을 알아야 한다고 생각해요. 그래야 코더가 아니라 개발자고, AI가 대체 못하는 사람이 되지 않을까요?"
어느날 사내에서 제가 진행한 객체지향 프로그래밍 관련 세미나가 끝나고 나서 동료가 질문을 던졌습니다.
"객체지향 프로그래밍 패러다임도 언젠가 바뀔 수 있겠죠?"
"그럼요~ 함수형 프로그래밍 패러다임도 굉장히 많이 쓰이잖아요, Compose 도 함수형 프로그래밍 패러다임에서 태어난 기술인 걸요~"
"오 그러면 나중에 Compose 와 함수형 프로그래밍 패러다임 세미나도 해주시나요?"
"어...... 😅"
순수 함수? 참조 투명성? 알고는 있었습니다. 하지만 Compose와 어떻게 연결되는지 '제대로' 설명하지 못했습니다. 평소 제가 하던 말에 제가 찔렸습니다.
이 글은 다음과 같은 분들을 위해 작성되었습니다:
remember, LaunchedEffect 같은 개별 API 사용법은 알지만, "왜 이렇게 설계되었는가?"에 대한 깊은 이해를 원하는 분Jetpack Compose는 선언형 UI 프레임워크입니다.
선언형 UI의 핵심은 "상태가 변경되면 UI를 다시 그린다"는 단순한 원칙입니다.
그런데 이 단순한 원칙이 안전하게 작동하려면, UI를 그리는 함수가 순수 함수여야 합니다.
왜일까요?
Compose 는 언제든지 Recomposition을 실행할 수 있기 때문입니다.
같은 상태로 UI를 10번 그리든, 100번 그리든 결과가 달라지면 안 됩니다. 순수 함수만이 이 안전성을 보장할 수 있습니다.
"같은 입력에 대해 항상 같은 출력을 반환한다"
수학 함수를 떠올려 봅시다. f(x) = 2x라는 함수가 있을 때, f(5)는 항상 10입니다. 1초 후에 호출하든, 다른 곳에서 호출하든 결과는 같습니다.
Composable 함수도 이렇게 동작해야 합니다.
// ✅ 순수 함수: 같은 name이면 항상 같은 결과
@Composable
fun PureGreeting(name: String) {
Text(text = "Hello, $name!")
}
// ❌ 비순수 함수: 호출할 때마다 다른 결과
@Composable
fun ImpureGreeting(name: String) {
val timestamp = System.currentTimeMillis()
Text(text = "Hello, $name! (at $timestamp)")
}
ImpureGreeting은 같은 name을 전달해도 시간이 지나면 다른 텍스트를 표시합니다. 이는 참조 투명성을 위배합니다.
"외부 상태를 변경하지 않는다"
순수 함수는 자신의 스코프 밖의 무언가를 변경하지 않습니다. 전역 변수를 수정하거나, 파일에 쓰거나, 네트워크 요청을 보내는 것은 모두 부수효과입니다.
var globalCounter = 0 // 전역 변수
// ❌ 부수효과: 전역 변수 변경
@Composable
fun BadCounter() {
Button(onClick = { globalCounter++ }) {
Text("Count: $globalCounter")
}
}
// ⚠️ Compose-safe: Compose가 관리하는 상태 사용 (엄밀히는 순수 아님)
@Composable
fun GoodCounter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Count: $count")
}
}
BadCounter는 클릭할 때마다 전역 변수를 변경합니다. 이는 부수효과입니다.
반면, GoodCounter는 remember로 Composable 내부에서 상태를 관리합니다.
"잠깐, GoodCounter도 count++로 상태를 변경하는데, 이게 순수한가요?"
좋은 질문입니다. 엄밀히 말하면 GoodCounter는 순수 함수가 아닙니다.
이 글에서는 이런 패턴을 "Compose-safe"라고 부르겠습니다:
Compose-safe
- 엄밀히 순수 함수는 아니지만
- Compose가 상태를 추적하고 관리하기 때문에
- Recomposition에 안전한 패턴
주의: "Compose-safe"는 공식 용어가 아닙니다. 순수 함수와 구분하기 위해 이 글에서 사용하는 표현입니다.
remember { mutableStateOf(0) }로 생성된 상태를 onClick에서 변경하기 때문입니다.
다만, Compose가 이 상태를 추적하고 관리하기 때문에 Recomposition에는 안전합니다.
Compose-safe ≠ 순수 함수
GoodCounter는 Compose가 안전하게 관리하는 패턴진정한 순수 함수: State Hoisting
공식 문서에서 권장하는 패턴을 보면:
"Instead, trigger side-effects from callbacks such as
onClickthat always execute on the UI thread."
공식 문서의 예시는 State Hoisting 패턴입니다:
// 공식 문서의 권장 패턴 (State Hoisting)
@Composable
fun SharedPrefsToggle(
text: String,
value: Boolean,
onValueChanged: (Boolean) -> Unit // 상태 변경을 위로 전달
) {
Row {
Text(text)
Checkbox(checked = value, onCheckedChange = onValueChanged)
}
}
이 패턴을 카운터에 적용하면:
// State Hoisting 적용
@Composable
fun Counter(
count: Int,
onCountChange: (Int) -> Unit
) {
Button(onClick = { onCountChange(count + 1) }) {
Text("Count: $count")
}
}
// 사용하는 쪽
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Counter(
count = count,
onCountChange = { count = it }
)
}
이 Counter 함수를 분석해보면:
Composition 시점:
count: Int → 불변 값을 받음onCountChange → 콜백 함수를 받음Event 시점:
onClick → onCountChange(count + 1) 호출| 버전 | Compose-safe | 순수 함수 | 공식 문서 권장 |
|---|---|---|---|
BadCounter | ❌ 위험 | ❌ | ❌ |
GoodCounter | ✅ 안전 | ❌ | △ 허용 |
Counter (hoisted) | ✅ 안전 | ✅ | ✅ 권장 |
핵심 정리:
GoodCounter는 Compose-safe하지만 순수 함수는 아님Counter가 진정한 순수 함수State Hoisting은 단순히 "상태를 위로 올리는" 패턴이 아닙니다.
Composable 함수를 진정한 순수 함수로 만드는 함수형 프로그래밍 패턴입니다.
"그러면 모든 Composable을 완전히 순수하게 만들어야 하나요?"
아닙니다. 상태는 어딘가에 존재할 수밖에 없습니다.
// Screen Composable - 상태 관리 담당
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Counter(
count = count,
onCountChange = { count = it } // 여기서 상태 변경
)
}
CounterScreen은 상태를 더 이상 위로 올릴 곳이 없습니다.
누군가는 상태를 "소유"해야 하고, 그 역할을 Screen Composable이 담당합니다.
State Hoisting의 진짜 목표:
| Composable 유형 | 역할 | 순수성 |
|---|---|---|
| Screen/Container | 상태 관리, 이벤트 위임 | Compose-safe (순수 아님) |
| Presentational | UI 렌더링, 이벤트 전달 | 진정한 순수 함수 |
목표는 "모든 것을 순수하게"가 아니라,
"순수한 것과 상태 관리하는 것을 명확히 분리" 하는 것입니다.
물론 상태를 더 이상 올릴 곳이 없을 때까지 끌어올려야 할 필요는 없습니다.
관련해서는 State Hoisting 관련 공식 문서를 보시는 것을 추천드립니다.
순수 함수는 입력과 출력만 검증하면 됩니다.
전역 상태나 부수효과를 신경 쓸 필요가 없습니다.
@Test
fun 같은_입력에_항상_같은_출력() {
composeTestRule.setContent {
PureGreeting("Alice")
}
composeTestRule.onNodeWithTag("greeting")
.assertTextEquals("Hello, Alice!")
}
코드를 읽을 때 함수의 입력만 보면 출력을 예측할 수 있습니다.
전역 변수나 숨겨진 의존성을 찾을 필요가 없습니다.
Compose는 언제든지 Recomposition을 실행할 수 있습니다.
순수 함수이기 때문에 이것이 안전합니다.
@Composable
fun DynamicUI() {
var trigger by remember { mutableStateOf(0) }
// 순수 함수이므로 여러 번 Recomposition해도 안전
Text("Hello, World!")
Button(onClick = { trigger++ }) {
Text("Trigger Recomposition")
}
}
이 섹션은 순수 함수 개념의 이해를 돕기 위한 비교입니다.
React, Vue, SwiftUI, Haskell 개념적 유사성을 이해하는 참고 자료로 활용하세요.
React, Vue, SwiftUI, Compose는 모두 "순수 함수로 UI를 선언한다"는 공통 철학을 공유합니다.
// React: 함수형 컴포넌트
function PureGreeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
// 최적화: React.memo로 수동 메모이제이션
export default React.memo(PureGreeting);
특징:
React.memo()(컴포즈의 지능적 리컴포지션하는 것과 유사)로 수동 메모이제이션(Memoization) 필요.(React 19 에서 플러그인 설정으로 컴파일러가 자동으로 메모이제이션 처리)useMemo, useCallback(컴포즈의 remember과 유사)으로 세밀한 최적화 가능Virtual DOM: 실제 DOM의 가벼운 복사본. 변경 사항을 먼저 Virtual DOM에 적용(가상으로 렌더링)한 후, 실제 DOM과 비교(diffing)하여 최소한의 업데이트만 수행합니다.
<!-- Vue 3: Composition API -->
<script setup>
import { ref, computed } from 'vue'
// 반응형 상태 (Compose의 mutableStateOf와 유사)
const name = ref('World')
// computed: 의존성 기반 자동 캐싱 (Compose의 derivedStateOf와 유사)
const greeting = computed(() => `Hello, ${name.value}!`)
</script>
<template>
<h1>{{ greeting }}</h1>
</template>
특징:
ref/reactive(컴포즈의 mutableStateOf와 유사)로 반응형 상태 관리 (Proxy 기반)computed(컴포즈의 derivedStateOf와 유사)는 순수 함수처럼 동작하며 자동 캐싱watch/watchEffect(컴포즈의LauncedEffect와 유사)로 부수효과 관리Proxy: JavaScript ES6 기능. 객체의 읽기/쓰기를 가로채서 Vue가 어떤 데이터가 변경되었는지 자동 추적
userProxy.name = "Alice" → Proxy 감지 → Vue가 반응성 처리 ↑ 여기서 가로챔
// SwiftUI: View Protocol
struct PureGreeting: View {
let name: String
var body: some View {
Text("Hello, \(name)!")
}
}
특징:
struct로 불변성 보장 (Swift 언어 특성), View 프로토콜이 순수 함수 강제(컴포즈의 Composable 과 유사)@State, @Binding)로 상태 관리(각각 컴포즈의 remember, 데이터 바인딩과 유사)-- Haskell: 타입 시스템이 순수성 보장
pureGreeting :: String -> String
pureGreeting name = "Hello, " ++ name ++ "!"
특징:
Compose와의 관계:
@Composable = UI 도메인의 순수 함수 선언Haskell (1990)
↓ 순수 함수 철학
Elm Architecture (2012 - 2015)
↓ 단방향 데이터 흐름
React (2013) → Vue (2014) → SwiftUI (2019), Jetpack Compose (2021)
연도는 정식 출시 기준이며, 발표 시점과 다를 수 있습니다.
공통 DNA:
핵심:
순수 함수형 UI는 산업 표준을 향해 달려가고 있습니다. React (2013), SwiftUI (2019), Compose (2021) 모두 이 철학을 공유하며, 각 플랫폼에 최적화된 방식으로 구현했습니다.
Compose는 Kotlin 컴파일러와의 긴밀한 통합으로 이 철학을 가장 자동화하고 안전하게 구현한 프레임워크입니다.
공식 문서
함수형 프로그래밍
비교 프레임워크