[2026] SwiftUI 입문 | 선언적 UI, 상태, MVVM

[2026] SwiftUI 입문 | 선언적 UI, 상태, MVVM

이 글의 핵심

SwiftUI 입문: 선언적 UI, 상태, MVVM. SwiftUI 기본·State, Binding, ObservableObject 차이.

들어가며

SwiftUI는 뷰를 상태의 함수처럼 기술합니다. @State·@Binding 등 상태 바인딩으로 바뀐 값에 맞춰 다시 그릴 범위를 좁힐 수 있습니다.

실무에서 마주한 현실

개발을 배울 때는 모든 게 깔끔하고 이론적입니다. 하지만 실무는 다릅니다. 레거시 코드와 씨름하고, 급한 일정에 쫓기고, 예상치 못한 버그와 마주합니다. 이 글에서 다루는 내용도 처음엔 이론으로 배웠지만, 실제 프로젝트에 적용하면서 “아, 이래서 이렇게 설계하는구나” 하고 깨달은 것들입니다. 특히 기억에 남는 건 첫 프로젝트에서 겪은 시행착오입니다. 책에서 배운 대로 했는데 왜 안 되는지 몰라 며칠을 헤맸죠. 결국 선배 개발자의 코드 리뷰를 통해 문제를 발견했고, 그 과정에서 많은 걸 배웠습니다. 이 글에서는 이론뿐 아니라 실전에서 마주칠 수 있는 함정들과 해결 방법을 함께 다루겠습니다.

1. SwiftUI 기본

아래 코드는 swift를 사용한 구현 예제입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

import SwiftUI
struct HelloView: View {
    var body: some View {
        Text("Hello, SwiftUI!")
            .font(.title)
    }
}

View 프로토콜의 body한 화면의 설명이며, @ViewBuilder로 자식 뷰를 조합합니다.

2. State, Binding, ObservableObject 차이

래퍼용도소유
@State내부의 값 타입 상태 (Int, String, struct 등)SwiftUI가 저장소 소유
@Binding부모의 @State 등을 읽고 쓰기로 넘길 때원본은 부모가 소유
@StateObject뷰가 처음 생성하는 ObservableObject 수명 관리뷰가 소유(생성 시 한 번)
@ObservedObject주입ObservableObject (부모·환경에서 전달)외부에서 수명 결정
@EnvironmentObject상위에서 .environmentObject로 내려준 공유 객체앱/모듈 전역에 가깝게 공유
@State 예시: 카운터는 뷰만의 로컬 상태입니다.
아래 코드는 swift를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
struct CounterView: View {
    @State private var count = 0
    var body: some View {
        VStack {
            Text("\(count)")
            Button("증가") { count += 1 }
        }
    }
}

Binding 예시: 자식이 부모 상태를 직접 수정해야 할 때 Binding으로 넘깁니다. 다음은 swift를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

struct EditorView: View {
    @Binding var text: String
    var body: some View {
        TextField("입력", text: $text)
    }
}
struct ParentView: View {
    @State private var name = ""
    var body: some View {
        EditorView(text: $name)
    }
}

ObservableObject 예시: 화면 밖에서도 유지되는 비즈니스 상태·비동기 로딩에 적합합니다. 아래 코드는 swift를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

final class UserSettings: ObservableObject {
    @Published var username: String = ""
}
struct SettingsView: View {
    @StateObject private var settings = UserSettings()
    var body: some View {
        TextField("이름", text: $settings.username)
    }
}

@Published가 바뀌면 objectWillChange가 알리고, 해당 객체를 구독하는 뷰가 갱신됩니다.

3. 실전 앱 구조 (MVVM)

  • Model: 순수 데이터·도메인 규칙 (보통 struct 또는 서비스)
  • View: SwiftUI View, 표현만 담당
  • ViewModel: ObservableObject, UI에 필요한 상태·액션·비동기 호출 다음은 swift를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.
// 타입 정의
struct Item: Identifiable {
    let id: UUID
    var title: String
}
final class ItemListViewModel: ObservableObject {
    @Published private(set) var items: [Item] = []
    func add(_ title: String) {
        items.append(Item(id: UUID(), title: title))
    }
}
struct ItemListView: View {
    @StateObject private var viewModel = ItemListViewModel()
    var body: some View {
        List(viewModel.items) { item in
            Text(item.title)
        }
        .toolbar {
            Button("추가") { viewModel.add("새 항목") }
        }
    }
}

미리 만든 ViewModel을 뷰에 주입할 때는 @ObservedObject var viewModel: ItemListViewModel을 쓰고, 상위에서 ItemListView(viewModel: vm)처럼 넘깁니다. 뷰 안에서 매번 StateObject로 새로 만들면 탭 전환 시 상태가 초기화될 수 있어 주의합니다.

4. List와 NavigationView / NavigationStack

List: Identifiable 모델 배열 또는 ForEach와 조합해 행을 그립니다. 다음은 swift를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 타입 정의
struct City: Identifiable {
    let id = UUID()
    let name: String
}
struct CityListView: View {
    let cities = [City(name: "서울"), City(name: "부산")]
    var body: some View {
        NavigationStack {
            List(cities) { city in
                NavigationLink(value: city) {
                    Text(city.name)
                }
            }
            .navigationTitle("도시")
            .navigationDestination(for: City.self) { city in
                Text("\(city.name) 상세")
            }
        }
    }
}

iOS 16 미만이나 간단한 스택만 필요하면 NavigationViewNavigationLink(destination:) 조합을 쓸 수 있습니다. 아래 코드는 swift를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

NavigationView {
    List(0..<10, id: \.self) { i in
        NavigationLink("항목 \(i)", destination: Text("상세 \(i)"))
    }
    .navigationTitle("목록")
}

5. 네트워크 통신 (URLSession + Combine)

URLSessiondata task를 Combine의 Future 또는 dataTaskPublisher로 감싸 ViewModel에서 구독합니다. 다음은 swift를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 에러 처리를 통해 안정성을 확보합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 필요한 모듈 import
import Combine
import Foundation
final class PostViewModel: ObservableObject {
    @Published var titles: [String] = []
    @Published var errorMessage: String?
    private var cancellables = Set<AnyCancellable>()
    func loadPosts() {
        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else { return }
        URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: [PostDTO].self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    if case .failure(let err) = completion {
                        self?.errorMessage = err.localizedDescription
                    }
                },
                receiveValue: { [weak self] posts in
                    self?.titles = posts.map(\.title)
                }
            )
            .store(in: &cancellables)
    }
}
struct PostDTO: Decodable {
    let title: String
}

[weak self]ViewModel ↔ 클로저 순환 참조를 끊는 것이 안전합니다. async/await를 쓰면 swift-series-08-async의 패턴과 조합할 수 있습니다.

6. 프리뷰 활용법

  • #Preview(Xcode 15+): 여러 기기·다크 모드를 한 파일에서 확인합니다. 아래 코드는 swift를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.
#Preview("기본") {
    ItemListView()
}
#Preview("다크") {
    ItemListView()
        .preferredColorScheme(.dark)
}
  • PreviewProvider: 구버전 호환 시 static var previews: some View에 동일하게 구성합니다.
  • 프리뷰 전용 데이터: UserSettings에 mock을 넣거나, #if DEBUG에서만 쓰는 샘플 ViewModel을 두어 실제 API 없이 UI를 검증합니다.
  • 라이브 프리뷰가 느리면 해당 뷰만 분리해 의존성을 줄이면 빨라집니다.

7. 실전 예제: 목록 + 상세 + 로딩

ViewModel 하나에 목록 로딩과 선택 상태를 묶는 패턴입니다. 다음은 swift를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

final class CityExplorerViewModel: ObservableObject {
    @Published var cities: [City] = []
    @Published var isLoading = false
    func refresh() {
        isLoading = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
            self?.cities = [City(name: "서울"), City(name: "부산")]
            self?.isLoading = false
        }
    }
}
struct CityExplorerView: View {
    @StateObject private var vm = CityExplorerViewModel()
    var body: some View {
        NavigationStack {
            Group {
                if vm.isLoading {
                    ProgressView()
                } else {
                    List(vm.cities) { city in
                        NavigationLink(value: city) { Text(city.name) }
                    }
                }
            }
            .navigationTitle("도시")
            .navigationDestination(for: City.self) { city in
                Text(city.name)
            }
            .onAppear { vm.refresh() }
        }
    }
}

정리

핵심 요약

  1. @State: 뷰 로컬 값 타입 상태
  2. Binding: 부모 상태를 자식이 수정할 때
  3. ObservableObject + @Published: 공유·지속 상태, MVVM의 ViewModel에 적합
  4. MVVM: View는 얇게, 상태와 비동기는 ViewModel
  5. List + NavigationStack: 목록과 스택 내비게이션
  6. URLSession + Combine: 비동기 스트림으로 API 연동
  7. 프리뷰: 시나리오별로 쪼개서 UI 반복 검증

다음 단계


관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3