Jetpack Compose vs SwiftUI: 선언형 UI 프레임워크 심층 비교

모바일

Jetpack ComposeSwiftUI선언형 UIAndroidiOS

이 글은 누구를 위한 것인가

  • 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 경험 우수)

두 프레임워크 모두 배울 가치가 있다. 하나를 잘 알면 다른 하나를 배우는 데 큰 도움이 된다.