반응형

SwiftUI Study – 상태 변경을 한 곳으로 모으는 패턴

 

1. 왜 중요한가 (문제 배경)

SwiftUI는 “상태가 변하면 View를 다시 그린다”는 매우 단순한 규칙으로 움직입니다.

그런데 하나의 상태를 여러 계층에서 동시에 변경하기 시작하면 다음과 같은 문제가 생깁니다.

  • 버튼 한 번 눌렀을 뿐인데 상태가 두 번 토글됨
  • 시트가 잠깐 떴다가 바로 사라지는 깜빡임
  • 네비게이션이 두 번 push 되거나 pop 순서가 꼬임
  • 동일 API가 중복 호출됨

문제의 본질은 간단합니다.

같은 상태를 여러 곳에서 제각각 바꾸고 있다.

그래서 실무에서는 “이 상태를 실제로 바꾸는 곳은 단 하나” 라는 원칙을 세우는 것이 중요합니다.

 

2. 잘못된 패턴 예시

❌ 예시 1: Button과 onChange에서 같은 상태를 모두 변경하는 경우

struct WrongView: View {
    @State private var isOn = false

    var body: some View {
        VStack {
            Toggle("스위치", isOn: $isOn)

            Button("토글 버튼") {
                isOn.toggle()                       // ① 버튼에서 상태 변경
            }
        }
        .onChange(of: isOn) { newValue in
            if newValue {
                isOn.toggle()                       // ② onChange에서 다시 상태 변경
            }
        }
    }
}

문제점

  • isOn이 true로 바뀌는 순간 onChange가 다시 토글을 호출
  • 최종 상태는 다시 false가 되어, 사용자는 “버튼이 안 먹는다”고 느끼게 됨
  • 어디에서 상태가 바뀌는지 추적이 어려워 디버깅 난이도가 높아짐

❌ 예시 2: View와 ViewModel이 동시에 같은 상태를 건드리는 경우

final class CounterViewModel: ObservableObject {
    @Published var count = 0

    func increase() {
        count += 1
    }
}

struct WrongCounterView: View {
    @StateObject private var viewModel = CounterViewModel()

    var body: some View {
        Button("증가: \(viewModel.count)") {
            viewModel.increase()        // ① ViewModel에서 증가
            viewModel.count += 1        // ② View에서 또 증가
        }
    }
}

문제점

  • 버튼 한 번에 count가 2씩 증가
  • 나중에 로직을 수정하면 어디를 고쳐야 할지 모호해짐
  • View와 ViewModel 간 역할 경계가 무너짐

 

3. 올바른 패턴 예시

✅ 예시 1: 상태 변경 진입점을 하나로 모으기

struct SingleSourceView: View {
    @State private var isOn = false

    var body: some View {
        VStack {
            Toggle("스위치", isOn: $isOn)

            Button("토글 버튼") {
                toggle()                 // 상태 변경은 이 함수 한 곳에서만
            }
        }
    }

    private func toggle() {
        isOn.toggle()
        // 부수 효과가 있다면 모두 여기에서 처리
    }
}

특징

  • isOn을 변경하는 코드는 toggle() 안에만 존재
  • 나중에 로직이 복잡해져도 이 함수 하나만 보면 됨

✅ 예시 2: 상태 변경은 ViewModel 메서드로만, View에서는 메서드만 호출

final class CounterViewModel: ObservableObject {
    @Published private(set) var count = 0

    func increase() {
        count += 1
        // 로깅, 분석, 추가 로직 등도 여기서 함께 수행
    }
}

struct CorrectCounterView: View {
    @StateObject private var viewModel = CounterViewModel()

    var body: some View {
        Button("증가: \(viewModel.count)") {
            viewModel.increase()        // View는 오직 메서드 호출만
        }
    }
}

특징

  • count 변경은 ViewModel 내부에서만 발생
  • View는 “어떤 UI에서 어떤 액션을 발생시켰는지”만 표현
  • 테스트할 때도 ViewModel만 떼어 검증 가능

✅ 예시 3: onChange에서는 상태를 “관찰”만 하고, 다시 변경하지 않기

struct OnChangeView: View {
    @State private var text = ""

    var body: some View {
        TextField("검색어 입력", text: $text)
            .onChange(of: text) { newValue in
                // ✅ 상태 변경 대신, 부수 효과만 수행
                print("검색어 변경: \(newValue)")
                // 예: 디바운스 후 검색 API 호출 등의 트리거
            }
    }
}

원칙

  • onChange에서는 이미 변경된 상태를 보고 부수 효과를 수행
  • 그 상태를 다시 변경하지 않는다

 

4. 실전 적용 팁

✔ 팁 1 – “이 상태를 실제로 바꾸는 함수는 어디인가?”를 항상 의식하기

  • 하나의 상태마다 “실제 쓰기(write)”가 발생하는 진입점이 몇 개인지 점검
  • 2곳 이상이면 구조를 재설계할 신호로 보아야 함

✔ 팁 2 – View는 가능하면 상태를 직접 건드리지 말고, 메서드를 호출하는 쪽으로

  • 특히 비즈니스 로직이 함께 얽힌 상태는 ViewModel/Reducer에 모으는 것이 좋음

✔ 팁 3 – onChange / onAppear / task 내부에서 다시 동일 상태를 바꾸지 말기

  • 대부분의 “깜빡임, 두 번 실행” 문제는 여기서 발생
  • 이곳에서는 부수 효과(side effect)만 실행하는 것을 원칙으로 삼기

✔ 팁 4 – TCA나 Redux 스타일 아키텍처의 핵심도 결국 “단일 상태 변경 경로”

  • Action → Reducer → State 변경 흐름처럼
    모든 변경이 한 함수(또는 한 레이어)를 거치게 만드는 것이 가장 안전한 구조

 

5. 정리

  • 하나의 상태를 여러 계층에서 동시에 변경하면 UI 버그, 중복 실행, 디버깅 지옥으로 이어진다.
  • 상태 변경은 “단일 진입점(single source of mutation)”을 갖도록 설계하는 것이 중요하다.
  • View는 가급적 상태 변경 메서드를 호출만 하고, 실제 변경은 ViewModel/Reducer에서 처리한다.
  • onChange, onAppear 등에서는 변경된 상태를 관찰만 하고 다시 변경하지 않는다.

이 원칙을 프로젝트 전반에 적용해두면,
SwiftUI 특유의 “왜 두 번 실행되지?” “왜 깜빡이지?” 같은 문제를 크게 줄일 수 있습니다.

반응형
Posted by 까칠코더
,