본문으로 건너뛰기 SwiftUI 입문 | 선언적 UI, 상태, MVVM

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

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

이 글의 핵심

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

시리즈 안내

#09 | 📋 전체 목차 | 이전: #08 비동기 · 다음: #10 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
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 반복 검증

다음 단계


관련 글


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. SwiftUI 입문: 선언적 UI, 상태, MVVM. SwiftUI 기본·State, Binding, ObservableObject 차이로 흐름을 잡고 원리·코드·실무 적용을 한글로 정리합니다. Start now. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Swift, SwiftUI, iOS, MVVM, Combine 등으로 검색하시면 이 글이 도움이 됩니다.