LODY/정리

SwiftUI 기본 / 데이터 흐름의 두 원칙

데이터 흐름의 두 원칙

🟣 속성 래퍼가 왜 이렇게 많나

선언형 감각을 잡고 나면 곧바로 막히는 지점이 있다. 상태를 담는 방법이 하나가 아니라는 것이다. @State, @Binding, @ObservedObject, @StateObject, @EnvironmentObject가 줄줄이 나오는데, 처음에는 무엇을 언제 써야 하는지 감이 안 온다. 아무거나 골라 쓰다가 화면이 갱신되지 않거나, 반대로 값이 자꾸 초기화되는 문제를 겪는다.

이 혼란은 원칙 두 개로 정리된다. 첫째, 모든 상태에는 진짜 주인이 하나 있어야 한다. 같은 값을 여러 곳에서 따로 들고 있으면 어느 것이 진실인지 알 수 없다. 이 유일한 주인을 진실의 원천이라고 부른다. 둘째, 데이터는 아래로 흐르고, 변경은 위로 전달된다. 상태는 소유한 뷰에서 자식 뷰로 내려가고, 자식이 값을 바꾸고 싶으면 직접 고치는 게 아니라 주인에게 요청한다. 이 두 원칙만 지키면 속성 래퍼 선택은 거의 자동으로 정해진다.

상태(진실의 원천)뷰(화면)사용자 입력상태가 화면을 만든다(아래로)입력이 상태를 바꾼다(위로)

🟣 @State와 @Binding: 소유와 참조

@State는 뷰가 직접 소유하는 작은 로컬 상태다. 화면 표시 여부나 토글 값처럼, 그 뷰 안에서만 의미가 있는 값에 쓴다. 소유자는 하나여야 하므로 @State는 관례상 private으로 둔다.

문제는 자식 뷰가 그 값을 읽고 바꿔야 할 때다. 값을 복사해서 넘기면 자식이 고친 값이 부모에 반영되지 않는다. 부모의 상태가 진실의 원천인데, 자식이 자기 사본을 고쳐 봐야 소용없다. 이때 쓰는 것이 @Binding이다. @Binding은 값을 소유하지 않고 원래 상태를 가리키는 참조다. 부모는 $ 접두사로 자기 @State의 바인딩을 만들어 자식에게 넘긴다.

struct ParentView: View {
    @State private var isOn = false   // 진짜 주인
 
    var body: some View {
        VStack {
            Text(isOn ? "켜짐" : "꺼짐")
            ToggleRow(isOn: $isOn)    // $ 로 바인딩을 넘긴다
        }
    }
}
 
struct ToggleRow: View {
    @Binding var isOn: Bool           // 소유하지 않고 부모 값을 가리킨다
 
    var body: some View {
        Toggle("스위치", isOn: $isOn) // 여기서 바꾸면 부모의 isOn이 바뀐다
    }
}

자식이 isOn을 바꾸면 실제로 바뀌는 것은 부모의 @State다. 그리고 그 변화가 부모의 body를 다시 계산하게 만든다. 값은 부모에서 자식으로 내려가고, 변경은 바인딩을 통해 다시 부모로 돌아온다. 앞에서 말한 두 원칙이 그대로 코드가 된 모습이다.

부모@State isOn자식@Binding isOn하나의 진짜 값소유참조($)

🟣 참조 타입 모델: @StateObject와 @ObservedObject

@State는 값 타입에 맞는 도구다. 화면 하나에 갇힌 단순한 값이면 충분하다. 그런데 여러 화면이 공유하는 모델이거나, 네트워크 응답을 받아 여러 프로퍼티를 함께 바꾸는 객체라면 클래스로 만드는 편이 자연스럽다. 이때는 그 클래스를 ObservableObject로 선언하고, 화면이 반응해야 하는 프로퍼티에 @Published를 붙인다.

final class CartModel: ObservableObject {
    @Published var items: [Item] = []   // 바뀌면 구독하는 뷰가 갱신된다
 
    func add(_ item: Item) {
        items.append(item)
    }
}

이 모델을 뷰에서 붙잡는 방법이 두 가지고, 여기서 초보자가 가장 많이 헷갈린다. @StateObject는 그 모델을 처음 만들어 소유하는 뷰에 쓰고, @ObservedObject는 이미 만들어진 모델을 바깥에서 받아 쓰는 뷰에 쓴다. 둘의 차이는 소유권이다. @StateObject로 소유한 모델은 뷰의 body가 여러 번 다시 계산돼도 그대로 유지된다. @ObservedObject로 받은 모델을 뷰 안에서 = CartModel()처럼 새로 만들면, body가 다시 계산될 때 모델이 초기화돼 데이터가 사라지는 버그가 생긴다.

규칙은 단순하다. 모델을 만드는 자리에서는 @StateObject, 넘겨받는 자리에서는 @ObservedObject를 쓴다.

struct CartScreen: View {
    @StateObject private var cart = CartModel()   // 여기서 만들어 소유
 
    var body: some View {
        CartList(cart: cart)                       // 자식에게 넘김
    }
}
 
struct CartList: View {
    @ObservedObject var cart: CartModel            // 받아서 관찰만
    ...
}

🟣 @EnvironmentObject: 깊은 계층에 값을 흘리지 않고 꽂기

뷰 계층이 깊어지면 바인딩을 손에서 손으로 넘기는 일이 번거로워진다. 최상단의 로그인 상태를 다섯 단계 아래 화면에서 써야 하는데, 중간 뷰들은 그 값을 쓰지도 않으면서 그저 전달만 하려고 프로퍼티를 달아야 한다. 이런 전달 사슬이 길어지면 코드가 지저분해진다.

@EnvironmentObject는 이 사슬을 건너뛴다. 상위 뷰에서 .environmentObject(model)로 한 번 주입해 두면, 그 아래 어느 뷰든 중간 전달 없이 @EnvironmentObject로 꺼내 쓴다. 앱 전체가 공유하는 세션, 테마, 로그인 상태 같은 값에 잘 맞는다.

@main
struct MyApp: App {
    @StateObject private var session = SessionModel()
 
    var body: some Scene {
        WindowGroup {
            RootView().environmentObject(session)  // 위에서 한 번 주입
        }
    }
}
 
struct DeepChildView: View {
    @EnvironmentObject var session: SessionModel   // 중간 전달 없이 바로 사용
    ...
}

편한 만큼 함정도 있다. 주입하지 않은 채 @EnvironmentObject를 읽으면 실행 중에 앱이 멈춘다. 컴파일러가 잡아 주지 못하므로, 아무 데나 쓰기보다 정말 넓게 공유하는 값에만 제한해서 쓰는 편이 안전하다. 좁은 범위의 값이면 바인딩으로 명시적으로 넘기는 쪽이 오히려 읽기 쉽다.


🟣 정리

  • 상태에는 진짜 주인이 하나 있어야 한다(진실의 원천). 데이터는 아래로 흐르고 변경은 위로 전달된다.
  • @State는 뷰가 소유하는 로컬 값, @Binding은 그 값을 가리키는 참조다. $로 바인딩을 넘긴다.
  • 참조 타입 모델은 ObservableObject + @Published로 만든다. 만드는 자리는 @StateObject, 받는 자리는 @ObservedObject다.
  • 만든 모델을 @ObservedObject로 받아 놓고 뷰 안에서 새로 생성하면 body 재계산 때 초기화되는 버그가 난다.
  • @EnvironmentObject는 깊은 계층에 값을 꽂아 넣지만, 주입을 빠뜨리면 실행 중 멈추므로 넓게 공유하는 값에만 쓴다.

면접에서 @StateObject@ObservedObject의 차이는 자주 나오는 질문이다. 소유권과 수명이라는 관점으로 답하면 깊이가 드러난다. 소유한 뷰가 모델의 수명을 책임지고, 관찰만 하는 뷰는 남의 모델을 구독할 뿐이라는 것이다. 실무에서도 데이터가 사라지는 버그의 상당수가 이 선택 실수에서 나온다. 다음 장에서는 이렇게 흘려보낸 상태로 화면을 실제로 켜고 끄는 방법, 즉 Alert과 조건부 뷰를 본다.