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 미만이나 간단한 스택만 필요하면 NavigationView와 NavigationLink(destination:) 조합을 쓸 수 있습니다.
NavigationView {
List(0..<10, id: \.self) { i in
NavigationLink("항목 \(i)", destination: Text("상세 \(i)"))
}
.navigationTitle("목록")
}
5. 네트워크 통신 (URLSession + Combine)
URLSession의 data 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() }
}
}
}
정리
핵심 요약
@State: 뷰 로컬 값 타입 상태Binding: 부모 상태를 자식이 수정할 때ObservableObject+@Published: 공유·지속 상태, MVVM의 ViewModel에 적합- MVVM: View는 얇게, 상태와 비동기는 ViewModel
List+NavigationStack: 목록과 스택 내비게이션URLSession+ Combine: 비동기 스트림으로 API 연동- 프리뷰: 시나리오별로 쪼개서 UI 반복 검증
다음 단계
- Swift async/await
- Swift Combine
- Swift 클래스와 구조체
관련 글
- Swift 시작하기 | iOS 개발 공식 언어 완벽 입문
- Swift 변수와 타입 | 옵셔널, 타입 추론
- Swift 함수 | 클로저, 고차 함수