SwiftUI Study – 로딩·성공·에러·빈 상태를 일관되게 표현하는 State 머신 기반 View 설계 패턴
Dev Study/SwiftUI 2025. 12. 11. 11:11반응형
SwiftUI Study – 로딩·성공·에러·빈 상태를 일관되게 표현하는 State 머신 기반 View 설계 패턴
1. 왜 중요한가 (문제 배경)
대부분의 네트워크 기반 화면은 다음과 같은 공통 흐름을 가진다.
- 최초 진입 → 로딩 중
- 성공 → 데이터 표시
- 실패 → 에러 메시지 + 재시도 버튼
- 데이터는 성공이지만 비어 있음 → 빈 상태(Empty State) UI
실무에서는 이 네 가지 상태를 깔끔하게 처리하지 못해 다음과 같은 문제가 자주 발생한다.
- isLoading, isError, isEmpty 같은 Bool 플래그가 난무
- 에러가 발생했는데도 이전 데이터가 화면에 남아 있는 상태
- 빈 데이터임에도 로딩 스피너만 계속 도는 UI
- 재시도 시 상태 전환이 꼬여 이상한 화면이 잠깐씩 보임
핵심 원인은 “상태를 하나의 단일 State로 모델링하지 않고,
여러 Bool과 옵셔널로 흩뿌려 관리하기 때문”이다.
이 문제를 해결하려면 화면을 명확한 상태(State Machine) 로 설계해야 한다.
2. 잘못된 패턴 예시
❌ 예시 1: Bool 플래그 여러 개로 상태 관리
struct WrongView: View {
@State private var isLoading = false
@State private var isError = false
@State private var errorMessage: String?
@State private var items: [Item] = []
var body: some View {
VStack {
if isLoading {
ProgressView()
} else {
List(items) { item in
Text(item.title)
}
}
if isError {
Text(errorMessage ?? "에러 발생")
.foregroundColor(.red)
}
}
.task {
await load()
}
}
func load() async {
isLoading = true
isError = false
do {
items = try await api.fetchItems()
isLoading = false
if items.isEmpty {
// ❌ isEmpty 상태 따로 없음 → UI 애매해짐
}
} catch {
isLoading = false
isError = true
errorMessage = error.localizedDescription
}
}
}
문제점
- 로딩/성공/에러/빈 상태가 Bool로 흩어져 있음
- isLoading == false && isError == true && items.isEmpty == false같은 조합이 생기면 UI가 애매해짐
- 상태 조합이 많아질수록 버그 가능성이 급증
❌ 예시 2: 이전 데이터와 새 에러 상태가 섞여 있는 화면
do {
items = try await api.fetchItems()
} catch {
isError = true // ❌ 이전 items는 그대로 남아 있음
}
문제점
- 네트워크 에러인데 이전 데이터가 화면에 남아 있어
“지금이 성공 상태인지 에러 상태인지” 명확하지 않다. - 사용자는 “에러 텍스트 + 오래된 데이터”를 동시에 보게 됨
3. 올바른 패턴 예시
✅ 예시 1: 상태를 하나의 enum으로 강제 모델링
enum LoadableState<Value> {
case idle
case loading
case loaded(Value)
case empty
case failed(Error)
}
ViewModel:
@MainActor
final class ItemsViewModel: ObservableObject {
@Published var state: LoadableState<[Item]> = .idle
func load() async {
state = .loading
do {
let result = try await api.fetchItems()
if result.isEmpty {
state = .empty
} else {
state = .loaded(result)
}
} catch {
state = .failed(error)
}
}
func reload() async {
await load()
}
}
장점
- 항상 상태는 이 다섯 가지 중 하나
- “로딩이면서 에러” 같은 애매한 조합이 원천적으로 불가능
- UI는 상태에 따라 분기만 하면 됨 → 가독성 상승
✅ 예시 2: View에서 상태별 UI를 명확하게 분리
struct ItemsScreen: View {
@StateObject private var vm = ItemsViewModel()
var body: some View {
content
.task {
await vm.load()
}
}
@ViewBuilder
private var content: some View {
switch vm.state {
case .idle, .loading:
ProgressView("불러오는 중…")
case .loaded(let items):
List(items) { item in
Text(item.title)
}
case .empty:
VStack(spacing: 12) {
Text("표시할 항목이 없습니다.")
.font(.headline)
Button("새로고침") {
Task { await vm.reload() }
}
}
case .failed(let error):
VStack(spacing: 12) {
Text("데이터를 불러오는 중 문제가 발생했습니다.")
.font(.headline)
Text(error.localizedDescription)
.font(.footnote)
.foregroundColor(.secondary)
Button("다시 시도") {
Task { await vm.reload() }
}
}
}
}
}
장점
- 상태별 UI를 한눈에 파악 가능
- 새로운 상태(예: 권한 부족, 네트워크 끊김 등)를 추가해도 enum에만 추가하면 됨
- 재사용 가능한 패턴으로 확장 가능
✅ 예시 3: 제너릭 LoadableView로 패턴 재사용
struct LoadableView<Value, Content: View>: View {
let state: LoadableState<Value>
let content: (Value) -> Content
let reload: (() -> Void)?
init(state: LoadableState<Value>,
reload: (() -> Void)? = nil,
@ViewBuilder content: @escaping (Value) -> Content) {
self.state = state
self.content = content
self.reload = reload
}
var body: some View {
switch state {
case .idle, .loading:
ProgressView()
case .loaded(let value):
content(value)
case .empty:
VStack(spacing: 8) {
Text("데이터가 없습니다.")
if let reload {
Button("새로고침", action: reload)
}
}
case .failed(let error):
VStack(spacing: 8) {
Text("문제가 발생했습니다.")
Text(error.localizedDescription)
.font(.footnote)
.foregroundColor(.secondary)
if let reload {
Button("다시 시도", action: reload)
}
}
}
}
}
사용:
LoadableView(state: vm.state, reload: {
Task { await vm.reload() }
}) { items in
List(items) { item in
Text(item.title)
}
}
장점
- 로딩/에러/빈 상태 UI를 프로젝트 전역에서 재사용 가능
- 화면마다 “로딩 처리” 코드를 다시 작성할 필요 없음
✅ 예시 4: 대시보드 등 부분 화면에도 동일 패턴 적용
struct DashboardView: View {
@StateObject private var vm = DashboardViewModel()
var body: some View {
VStack {
LoadableView(state: vm.notificationsState) { notifications in
NotificationListView(notifications: notifications)
}
LoadableView(state: vm.statsState) { stats in
StatsView(stats: stats)
}
}
.task { await vm.loadAll() }
}
}
장점
- 하나의 화면 안에서 각 섹션별로 독립된 상태 머신 운영
- 일부 섹션만 실패해도 전체 화면을 망가뜨리지 않음
4. 실전 적용 팁
✔ 팁 1 – Bool 플래그 2개 이상이면 enum으로 합칠 수 있는지 먼저 의심해라
isLoading + isError + isEmpty 조합은 버그의 시작이다.
✔ 팁 2 – “이 화면은 지금 어떤 상태인가?”를 한 줄로 설명할 수 있어야 한다
그 한 줄을 enum 케이스 이름으로 쓰면 된다.
✔ 팁 3 – 에러 상태에서 이전 데이터를 유지할지 정책을 먼저 정하라
- 최신 상태만 보여줄지
- 마지막 성공 데이터를 유지하고 에러를 별도로 표시할지
정책을 정한 뒤 enum 설계를 하면 일관성이 생긴다.
✔ 팁 4 – LoadableState 제너릭을 공용 유틸로 빼두면 좋다
List, Grid, Detail 등 다양한 화면에서 공유 가능.
✔ 팁 5 – 상태 전환 다이어그램을 종이에 그려보면 누락 케이스를 쉽게 찾을 수 있다
idle → loading → loaded/empty/failed 흐름이 모두 연결되는지 확인.
5. 정리
- 로딩/성공/에러/빈 상태를 Bool과 옵셔널로 관리하면
조합 폭발과 함께 UI 버그가 필연적으로 발생한다. - enum 기반 State 머신으로 화면 상태를 모델링하면
코드가 “지금 이 화면은 어떤 상태인가?”를 정확하게 설명한다. - 제너릭 LoadableView 패턴까지 도입하면
프로젝트 전반에서 일관된 UX와 높은 유지보수성을 확보할 수 있다.
반응형

