반응형

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와 높은 유지보수성을 확보할 수 있다.
반응형
Posted by 까칠코더
,