SwiftUI 입문 | 선언적 UI, 상태, MVVM
이 글의 핵심
SwiftUI에서는 상태를 어디에 두느냐가 화면 동작을 결정합니다. 뷰 안에서 ViewModel을 @ObservedObject로 직접 생성하면 부모가 다시 그려질 때마다 객체가 새로 만들어져 상태가 초기화되므로, @StateObject와 @ObservedObject를 나누는 기준과 ViewModel을 주입하는 방법을 먼저 정리하고 실제 앱 구조로 이어갑니다.
시리즈 안내
#09 | 📋 전체 목차 | 이전: #08 비동기 · 다음: #10 Combine
들어가며
SwiftUI는 뷰를 상태의 함수처럼 기술합니다. UIKit에서는 데이터가 바뀌면 label.text = ...처럼 화면을 직접 고쳐야 했고, 이 갱신 코드를 하나라도 빠뜨리면 화면과 데이터가 어긋나는 버그가 생겼습니다. SwiftUI에서는 “이 상태일 때 화면은 이렇게 생겼다”만 적어 두면, 상태가 바뀔 때 프레임워크가 body를 다시 계산하고 이전 결과와 비교해 바뀐 부분만 실제 화면에 반영합니다. @State·@Binding 같은 프로퍼티 래퍼는 SwiftUI에게 “이 값이 바뀌면 이 뷰를 다시 그려라”라고 알려 주는 표시이고, 어떤 래퍼를 쓰느냐가 곧 그 상태의 소유자와 수명을 정합니다.
SwiftUI를 처음 쓰면서 제가 가장 헷갈렸던 부분도 뷰 코드가 아니라 이 “상태를 누가 소유하는가”였습니다. 레이아웃은 몇 번 따라 하면 익숙해지지만, 상태 래퍼를 잘못 고르면 화면이 갱신되지 않거나 입력한 값이 이유 없이 사라지는 버그가 생기고, 원인이 코드 한 줄의 래퍼 선택이라 찾기 어렵습니다. 이 글은 그 기준을 중심으로 설명합니다.
SwiftUI의 선언형 뷰 기본
import SwiftUI
struct HelloView: View {
var body: some View {
Text("Hello, SwiftUI!")
.font(.title)
}
}
View 프로토콜의 body는 한 화면의 설명이며, @ViewBuilder로 자식 뷰를 조합합니다. SwiftUI의 뷰가 클래스가 아니라 struct인 이유는 뷰가 “화면 그 자체”가 아니라 가볍게 만들고 버리는 설명서이기 때문입니다. 상태가 바뀔 때마다 body가 다시 호출되고 뷰 구조체도 새로 만들어지므로, 뷰의 init이나 body 안에서 네트워크 요청이나 무거운 계산을 하면 예상보다 훨씬 자주 실행됩니다. .font(.title) 같은 수정자(modifier)도 기존 뷰를 바꾸는 것이 아니라 새 뷰로 감싸는 것이라서, .padding().background(.blue)와 .background(.blue).padding()은 결과가 다릅니다.
@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가 알리고, 해당 객체를 구독하는 뷰가 갱신됩니다. 여기서 알아 둘 점은 ObservableObject의 알림이 객체 단위라는 것입니다. username 하나만 바뀌어도 이 객체를 관찰하는 모든 뷰의 body가 다시 계산되므로, 큰 ViewModel 하나를 여러 화면이 공유하면 불필요한 재계산이 늘어납니다. 또 @Published 값은 메인 스레드에서 바꿔야 하며, 백그라운드 스레드에서 바꾸면 Xcode가 “Publishing changes from background threads is not allowed” 경고를 냅니다.
@State에 private를 붙이는 것도 관례가 아니라 이유가 있습니다. @State의 초기값은 뷰가 화면에 처음 나타날 때 한 번만 쓰이고, 그 뒤에는 SwiftUI가 저장한 값이 우선합니다. 그래서 부모가 CounterView(count: 5)처럼 값을 넘겨도 이미 표시된 뷰에는 반영되지 않는데, 이를 막기 위해 외부에서 초기화하지 못하게 private으로 두는 것입니다. 부모의 값을 반영해야 한다면 @State가 아니라 @Binding이나 일반 let 프로퍼티로 받아야 합니다.
iOS 17 이상을 대상으로 한다면 ObservableObject 대신 @Observable 매크로(Observation 프레임워크)를 쓸 수 있습니다. @Observable 클래스는 @Published 없이도 프로퍼티 변경을 추적하고, 뷰가 body에서 실제로 읽은 프로퍼티가 바뀔 때만 다시 그리므로 위의 객체 단위 갱신 문제가 줄어듭니다. 이 경우 소유하는 쪽은 @StateObject 대신 @State, 주입받는 쪽은 래퍼 없이 일반 프로퍼티로 선언합니다. 이 글의 예제는 iOS 16 이하와의 호환을 위해 ObservableObject로 작성했습니다.
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)처럼 넘깁니다. 가장 흔한 실수는 반대로 @ObservedObject var viewModel = ItemListViewModel()처럼 뷰 안에서 생성하면서 @ObservedObject로 선언하는 것입니다. 앞에서 말했듯 부모가 다시 그려질 때마다 이 뷰 구조체가 새로 만들어지고, @ObservedObject는 수명을 관리하지 않으므로 ViewModel도 매번 새로 생성됩니다. 결과적으로 목록에 추가한 항목이나 입력 중이던 값이 부모의 사소한 상태 변화에 이유 없이 사라집니다. @StateObject는 SwiftUI가 뷰의 정체성(identity)이 유지되는 동안 객체를 한 번만 만들어 보관하므로 이 문제가 없습니다.
private(set)으로 items의 쓰기를 막은 것도 MVVM에서 의미가 있습니다. 뷰는 items를 읽기만 하고 변경은 반드시 add(_:) 같은 ViewModel의 메서드를 거치게 되므로, 검증이나 저장 같은 로직을 한곳에 모을 수 있습니다. 다만 SwiftUI는 @State와 @Binding만으로도 상당히 많은 화면을 처리할 수 있어서, 단순한 화면에까지 ViewModel을 만들면 오히려 코드가 늘어납니다. 비동기 로딩, 여러 화면이 공유하는 상태, 테스트가 필요한 로직이 생길 때 ViewModel로 분리하는 정도가 실용적인 기준입니다.
List와 NavigationView / NavigationStack
List: Identifiable 모델 배열 또는 ForEach와 조합해 행을 그립니다.
// 타입 정의
struct City: Identifiable, Hashable { // NavigationLink(value:)는 Hashable 필요
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) 상세")
}
}
}
}
NavigationStack의 핵심은 목적지를 값으로 표현한다는 점입니다. NavigationLink(value: city)는 “어떤 데이터로 이동할지”만 정하고, 그 데이터를 어떤 화면으로 보여 줄지는 .navigationDestination(for: City.self)가 한곳에서 정합니다. 이 방식이 필요한 이유는 기존 NavigationLink(destination:)가 목록의 모든 행마다 상세 뷰를 미리 만들어 두는 구조라 비효율적이었고, 푸시 알림이나 딥 링크로 특정 화면까지 코드로 이동하기가 어려웠기 때문입니다. NavigationStack(path: $path)에 배열을 바인딩하면 path.append(city)로 화면을 쌓거나 path.removeAll()로 루트로 돌아가는 프로그래매틱 내비게이션이 가능합니다. 값으로 쓰는 타입은 Hashable이어야 하므로, 위 예제의 City에 Hashable을 붙이지 않으면 NavigationLink(value:)에서 컴파일 에러가 납니다. 또 .navigationDestination을 List 안의 각 행이 아니라 스택 안쪽 컨테이너에 한 번만 붙여야 하며, List나 LazyVStack 같은 지연 컨테이너 안에 두면 Xcode 콘솔에 navigationDestination 관련 경고가 찍히면서 이동이 무시될 수 있습니다.
iOS 16 미만이나 간단한 스택만 필요하면 NavigationView와 NavigationLink(destination:) 조합을 쓸 수 있습니다.
NavigationView {
List(0..<10, id: \.self) { i in
NavigationLink("항목 \(i)", destination: Text("상세 \(i)"))
}
.navigationTitle("목록")
}
URLSession + Combine으로 네트워크 통신
URLSession의 data 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 ↔ 클로저 순환 참조를 끊는 것이 안전합니다. 파이프라인에서 .receive(on: DispatchQueue.main)이 빠지면 URLSession의 콜백 스레드(백그라운드)에서 @Published 값을 바꾸게 되어 앞에서 설명한 백그라운드 스레드 경고가 나고, 화면 갱신이 늦거나 불규칙해집니다. .store(in: &cancellables)도 필수입니다. sink가 반환하는 AnyCancellable을 어디에도 저장하지 않으면 즉시 해제되면서 구독이 취소되어, 요청을 보냈는데 결과가 아무것도 오지 않는 것처럼 보입니다. 에러 처리에서는 decode 단계의 실패가 DecodingError로 오는데, localizedDescription은 “The data couldn’t be read because it isn’t in the correct format.” 정도만 알려 주므로 디버깅할 때는 에러 객체 전체를 출력해 어떤 키가 문제인지 확인하는 편이 빠릅니다.
새로 작성하는 코드라면 async/await가 더 읽기 쉽습니다. func loadPosts() async에서 try await URLSession.shared.data(from: url)로 받고, ViewModel에 @MainActor를 붙이면 스레드 전환을 따로 신경 쓰지 않아도 됩니다. 뷰에서는 .task { await vm.loadPosts() }로 호출하면 뷰가 사라질 때 작업이 자동으로 취소됩니다. 자세한 패턴은 Swift async/await 글에 정리했습니다.
Xcode 프리뷰 활용법
#Preview(Xcode 15+): 여러 기기·다크 모드를 한 파일에서 확인합니다.
#Preview("기본") {
ItemListView()
}
#Preview("다크") {
ItemListView()
.preferredColorScheme(.dark)
}
- PreviewProvider: 구버전 호환 시
static var previews: some View에 동일하게 구성합니다. - 프리뷰 전용 데이터:
UserSettings에 mock을 넣거나,#if DEBUG에서만 쓰는 샘플 ViewModel을 두어 실제 API 없이 UI를 검증합니다. - 라이브 프리뷰가 느리면 해당 뷰만 분리해 의존성을 줄이면 빨라집니다.
예제: 목록 + 상세 + 로딩
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() }
}
}
}
asyncAfter는 실제 네트워크 요청 대신 0.5초 지연으로 로딩 상태를 흉내 낸 것입니다. 이 예제에서 주의할 부분은 .onAppear입니다. onAppear는 뷰가 처음 나타날 때만이 아니라 상세 화면에서 뒤로 돌아올 때도 다시 호출되므로, 목록이 매번 로딩 화면으로 바뀌며 새로 불러와집니다. 한 번만 불러오고 싶다면 cities가 비어 있을 때만 refresh()를 호출하거나, .task를 쓰고 ViewModel에서 이미 로드했는지 확인하는 방식이 좋습니다. 또 로딩 중에 List 전체를 ProgressView로 바꾸면 당겨서 새로고침(.refreshable) 같은 동작과 어울리지 않으므로, 이미 데이터가 있을 때는 목록을 유지한 채 위에 작은 인디케이터만 띄우는 편이 사용자 경험이 낫습니다.
SwiftUI 요약
- @State: 뷰 로컬 값 타입 상태
- Binding: 부모 상태를 자식이 수정할 때
ObservableObject+@Published: 공유·지속 상태, MVVM의 ViewModel에 적합- MVVM: View는 얇게, 상태와 비동기는 ViewModel
List+NavigationStack: 목록과 스택 내비게이션URLSession+ Combine: 비동기 스트림으로 API 연동- 프리뷰: 시나리오별로 쪼개서 UI 반복 검증
다음 단계
같이 보면 좋은 글
- Swift 시작하기: iOS 개발 언어의 특징과 Xcode 환경 설정
- Swift 변수와 타입 | 옵셔널, 타입 추론
- Swift 함수 | 클로저, 고차 함수
- Swift 프로토콜과 확장 | Protocol, Extension
자주 묻는 질문 (FAQ)
Q. @StateObject와 @ObservedObject는 어떤 기준으로 나눠 쓰나요?
A. 뷰가 ObservableObject를 직접 생성하고 그 수명을 소유해야 하면 @StateObject를 씁니다. 부모나 환경에서 이미 만들어진 객체를 주입받는다면 @ObservedObject가 맞으며, 이때 수명은 외부에서 결정됩니다. 뷰 안에서 새로 만든 객체를 @ObservedObject로 선언하면 뷰가 다시 생성될 때마다 객체도 새로 만들어져 상태가 초기화될 수 있습니다.