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

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

이 글의 핵심

SwiftUI에 대한 실전 가이드입니다. State·Binding·ObservableObject, MVVM, List·내비게이션, URLSession과 Combine, 프리뷰를 예제와 함께 설명합니다.

들어가며

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


1. SwiftUI 기본

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 예시: 카운터는 뷰만의 로컬 상태입니다.

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        VStack {
            Text("\(count)")
            Button("증가") { count += 1 }
        }
    }
}

Binding 예시: 자식이 부모 상태를 직접 수정해야 할 때 Binding으로 넘깁니다.

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 예시: 화면 밖에서도 유지되는 비즈니스 상태·비동기 로딩에 적합합니다.

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에 필요한 상태·액션·비동기 호출
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와 조합해 행을 그립니다.

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:) 조합을 쓸 수 있습니다.

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

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

URLSessiondata task를 Combine의 Future 또는 dataTaskPublisher로 감싸 ViewModel에서 구독합니다.

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+): 여러 기기·다크 모드를 한 파일에서 확인합니다.
#Preview("기본") {
    ItemListView()
}

#Preview("다크") {
    ItemListView()
        .preferredColorScheme(.dark)
}
  • PreviewProvider: 구버전 호환 시 static var previews: some View에 동일하게 구성합니다.
  • 프리뷰 전용 데이터: UserSettings에 mock을 넣거나, #if DEBUG에서만 쓰는 샘플 ViewModel을 두어 실제 API 없이 UI를 검증합니다.
  • 라이브 프리뷰가 느리면 해당 뷰만 분리해 의존성을 줄이면 빨라집니다.

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

ViewModel 하나에 목록 로딩과 선택 상태를 묶는 패턴입니다.

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 반복 검증

다음 단계

  • Swift async/await
  • Swift Combine
  • Swift 클래스와 구조체

관련 글

  • Swift 시작하기 | iOS 개발 공식 언어 완벽 입문
  • Swift 변수와 타입 | 옵셔널, 타입 추론
  • Swift 함수 | 클로저, 고차 함수