이 글은 누구를 위한 것인가
- iOS와 Android를 모두 개발하며 두 프레임워크를 비교하고 싶은 개발자
- Compose나 SwiftUI 중 하나를 새로 배우려는 모바일 엔지니어
- 두 프레임워크의 설계 철학 차이를 이해하고 싶은 팀 리더
들어가며
Jetpack Compose(2021 안정화)와 SwiftUI(2019 출시)는 각 플랫폼의 선언형 UI 표준이 되었다. 두 프레임워크 모두 React의 영향을 받았지만 설계 철학과 구현 방식에서 중요한 차이가 있다.
두 프레임워크를 동시에 다루는 개발자라면, 각각의 패러다임을 이해하면 서로를 배우는 데 큰 도움이 된다. 이 글에서는 같은 UI를 두 프레임워크로 구현해 비교한다.
이 글은 bluefoxdev.kr의 모바일 UI 프레임워크 비교 를 참고하고, 실전 코드 비교 관점에서 확장하여 작성했습니다.
1. 핵심 설계 철학 차이
[Jetpack Compose]
- 함수형 컴포저블 (Composable Function)
- 상태가 변하면 해당 컴포저블만 재구성 (Recomposition)
- 명시적 상태 호이스팅 권장
- 단방향 데이터 흐름 (Unidirectional Data Flow)
[SwiftUI]
- 구조체 기반 뷰 (Value Type)
- 상태 변화 시 body 재계산 (Diffing)
- 프로퍼티 래퍼로 상태 관리 (@State, @Binding, @ObservedObject)
- Swift 타입 시스템과 긴밀한 통합
2. 상태 관리 비교
2.1 지역 상태
// Compose: remember + mutableStateOf
@Composable
fun CounterScreen() {
var count by remember { mutableStateOf(0) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("카운트: $count", style = MaterialTheme.typography.headlineMedium)
Row {
Button(onClick = { count-- }) { Text("-") }
Spacer(modifier = Modifier.width(16.dp))
Button(onClick = { count++ }) { Text("+") }
}
}
}
// SwiftUI: @State
struct CounterScreen: View {
@State private var count = 0
var body: some View {
VStack {
Text("카운트: \(count)")
.font(.headline)
HStack {
Button("-") { count -= 1 }
Spacer().frame(width: 16)
Button("+") { count += 1 }
}
}
}
}
2.2 상태 공유 (부모 → 자식)
// Compose: 상태 호이스팅 + 람다 콜백
@Composable
fun ParentScreen() {
var text by remember { mutableStateOf("") }
// 상태를 부모가 소유, 자식에 전달
SearchBar(
query = text,
onQueryChange = { text = it },
onSearch = { performSearch(it) }
)
}
@Composable
fun SearchBar(
query: String,
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit
) {
TextField(
value = query,
onValueChange = onQueryChange,
trailingIcon = {
IconButton(onClick = { onSearch(query) }) {
Icon(Icons.Default.Search, null)
}
}
)
}
// SwiftUI: @Binding
struct ParentScreen: View {
@State private var text = ""
var body: some View {
SearchBar(query: $text) { performSearch($0) }
}
}
struct SearchBar: View {
@Binding var query: String
var onSearch: (String) -> Void
var body: some View {
HStack {
TextField("검색", text: $query)
Button(action: { onSearch(query) }) {
Image(systemName: "magnifyingglass")
}
}
}
}
2.3 ViewModel 연동
// Compose + ViewModel (StateFlow)
class ProductViewModel : ViewModel() {
private val _products = MutableStateFlow<List<Product>>(emptyList())
val products: StateFlow<List<Product>> = _products.asStateFlow()
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun loadProducts() {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
_products.value = repository.getProducts()
_uiState.value = UiState.Success
} catch (e: Exception) {
_uiState.value = UiState.Error(e.message ?: "Unknown error")
}
}
}
}
@Composable
fun ProductListScreen(viewModel: ProductViewModel = viewModel()) {
val products by viewModel.products.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (uiState) {
is UiState.Loading -> CircularProgressIndicator()
is UiState.Error -> ErrorView((uiState as UiState.Error).message)
is UiState.Success -> ProductList(products = products)
}
}
// SwiftUI + ViewModel (@MainActor)
@MainActor
class ProductViewModel: ObservableObject {
@Published var products: [Product] = []
@Published var uiState: UiState = .loading
func loadProducts() async {
uiState = .loading
do {
products = try await repository.getProducts()
uiState = .success
} catch {
uiState = .error(error.localizedDescription)
}
}
}
struct ProductListScreen: View {
@StateObject private var viewModel = ProductViewModel()
var body: some View {
Group {
switch viewModel.uiState {
case .loading:
ProgressView()
case .error(let message):
ErrorView(message: message)
case .success:
ProductList(products: viewModel.products)
}
}
.task { await viewModel.loadProducts() }
}
}
3. 커스텀 레이아웃
// Compose: Layout Composable
@Composable
fun FlowLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) }
var xPosition = 0
var yPosition = 0
var rowHeight = 0
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach { placeable ->
if (xPosition + placeable.width > constraints.maxWidth) {
xPosition = 0
yPosition += rowHeight
rowHeight = 0
}
placeable.placeRelative(xPosition, yPosition)
xPosition += placeable.width
rowHeight = maxOf(rowHeight, placeable.height)
}
}
}
}
// SwiftUI: Layout Protocol (iOS 16+)
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var height: CGFloat = 0
var row: (y: CGFloat, maxHeight: CGFloat) = (0, 0)
var x: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if x + size.width > maxWidth && x > 0 {
height = row.y + row.maxHeight + spacing
x = 0
row = (height, 0)
}
x += size.width + spacing
row.maxHeight = max(row.maxHeight, size.height)
}
return CGSize(width: maxWidth, height: row.y + row.maxHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for view in subviews {
let size = view.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX && x > bounds.minX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
view.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
4. 성능 특성 비교
[Recomposition vs body 재계산]
Compose:
- 상태 변경 시 영향받는 Composable만 재구성
- remember로 계산 결과 캐시
- LazyColumn/LazyRow: 보이는 항목만 구성
SwiftUI:
- 상태 변경 시 body 전체 재계산 (구조체 재생성)
- 내부적으로 diffing으로 실제 변경 최소화
- 과도한 @StateObject 사용 시 성능 영향
공통 주의사항:
- 비싼 계산은 뷰 body 밖으로
- 목록에서는 각 항목에 stable ID 제공
- 이미지 로딩은 비동기로
5. 테스트 비교
// Compose 테스트
@RunWith(AndroidJUnit4::class)
class CounterScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun counterIncrement() {
composeTestRule.setContent { CounterScreen() }
composeTestRule.onNodeWithText("0").assertIsDisplayed()
composeTestRule.onNodeWithText("+").performClick()
composeTestRule.onNodeWithText("1").assertIsDisplayed()
}
}
// SwiftUI 테스트 (XCTest)
import XCTest
import ViewInspector
class CounterScreenTests: XCTestCase {
func testCounterIncrement() throws {
let view = CounterScreen()
let exp = view.inspection.inspect { view in
try view.find(button: "+").tap()
XCTAssertEqual(try view.find(text: "카운트: 1").string(), "카운트: 1")
}
ViewHosting.host(view: view)
wait(for: [exp], timeout: 1)
}
}
마무리: 상황별 선택
[Compose vs SwiftUI 강점 정리]
Compose가 더 나은 곳:
✅ 복잡한 커스텀 레이아웃 (Layout API가 더 유연)
✅ 상태 관리 (recomposition scope가 더 명확)
✅ 애니메이션 (Compose Animation API가 풍부)
✅ 이전 View 시스템과 혼합 사용 (interop 쉬움)
SwiftUI가 더 나은 곳:
✅ 애플 플랫폼 통합 (watchOS, macOS, visionOS 공유)
✅ Swift 언어와의 통합 (타입 시스템, async/await)
✅ 기본 컴포넌트 (TextField, Sheet 등이 더 성숙)
✅ 빠른 Preview (Xcode Preview 경험 우수)
두 프레임워크 모두 배울 가치가 있다. 하나를 잘 알면 다른 하나를 배우는 데 큰 도움이 된다.