SwiftUI에서 struct와 class 고르기: @State·@Observable 기준

반응형

SwiftUI에서 structclass를 고를 때 ‘struct는 가볍고 class는 무겁다’는 식의 성능 문장부터 시작하면 판단이 흐려진다. 먼저 값 자체가 중요한지, 같은 identity를 여러 view가 공유해야 하는지, state의 owner가 누구인지를 묻는 편이 낫다. SwiftUI의 View가 주로 struct라는 사실도 모든 model을 struct로 만들라는 뜻은 아니다.

struct는 값, class는 identity를 중심으로 본다

struct는 value semantics를 가진다. 다른 변수에 대입하거나 함수로 전달했을 때 논리적으로 독립된 값으로 다룬다. compiler 최적화나 standard collection의 copy-on-write 때문에 실제 memory가 매번 즉시 전체 복사된다고 단정할 수는 없다.

class는 reference semantics를 가진다. 여러 변수가 같은 instance를 가리킬 수 있고 identity와 lifetime이 있다. let으로 class reference를 선언하면 다른 instance로 재대입할 수 없지만, class의 var property는 여전히 바뀔 수 있다.

struct SettingsValue {
    var isDarkMode = false
}

final class SettingsStore {
    var isDarkMode = false
}

var first = SettingsValue()
var second = first
second.isDarkMode = true
print(first.isDarkMode)   // false

let shared = SettingsStore()
let sameStore = shared
sameStore.isDarkMode = true
print(shared.isDarkMode)  // true

다음 질문으로 선택 범위를 좁힐 수 있다.

  • 복사된 뒤 독립적으로 바뀌어야 하는 snapshot·configuration인가: struct를 먼저 검토한다.
  • 여러 화면이 같은 장바구니·session·document identity를 공유하는가: class를 검토한다.
  • inheritance가 실제로 필요한가: class만 가능하지만 protocol composition도 비교한다.
  • mutation과 lifetime을 명시적으로 한 곳에서 관리해야 하는가: reference model이 이해하기 쉬울 수 있다.

struct의 property를 method 안에서 바꾸는 규칙은 Swift mutating에서 더 자세히 볼 수 있다.

SwiftUI View가 struct인 이유를 성능으로만 설명하지 않는다

SwiftUI view value는 현재 UI가 어떻게 보여야 하는지를 선언한다. framework는 state가 변하면 body를 다시 평가해 새로운 description을 비교한다. 따라서 view value 자체를 장기 identity와 mutable storage의 주인처럼 다루지 않는다.

import SwiftUI

struct CounterView: View {
    @State private var count = 0

    var body: some View {
        Button("Count: \(count)") {
            count += 1
        }
    }
}

@State는 transient UI state의 source of truth를 SwiftUI가 view identity에 맞춰 관리하게 한다. body 안에서 network 요청이나 database write를 직접 시작하면 재평가 횟수에 따라 side effect가 반복될 수 있다. task와 user action, model method처럼 lifecycle이 분명한 위치로 옮긴다.

iOS 17 이후 Observation을 쓰는 reference model

현재 Apple 문서가 안내하는 Observation 방식에서는 model class에 @Observable을 붙이고, view가 model instance를 소유할 때 @State로 저장할 수 있다. Observation의 SwiftUI 통합은 iOS 17, iPadOS 17, macOS 14, tvOS 17, watchOS 10 이상에서 제공된다.

import Observation
import SwiftUI

@Observable
final class CounterModel {
    var count = 0

    func increment() {
        count += 1
    }
}

struct CounterScreen: View {
    @State private var model = CounterModel()

    var body: some View {
        CounterEditor(model: model)
    }
}

struct CounterEditor: View {
    @Bindable var model: CounterModel

    var body: some View {
        VStack {
            Text("Count: \(model.count)")
            Button("Increase", action: model.increment)
            Stepper("Value", value: $model.count)
        }
    }
}

@State는 struct만 저장하는 wrapper가 아니다. Observation model의 reference를 view가 직접 만들고 lifetime을 소유할 때도 쓸 수 있다. @Bindable은 observable model의 mutable property에 binding이 필요할 때 사용한다. ancestor가 model을 제공한다면 initializer나 environment로 주입해 owner를 한 곳으로 유지한다.

ObservableObject를 쓰는 기존 code의 owner 구분

이전 OS target이나 기존 Combine 기반 code에서는 ObservableObject, @Published, @StateObject, @ObservedObject를 계속 만날 수 있다.

import SwiftUI

final class LegacyCounter: ObservableObject {
    @Published var count = 0
}

struct LegacyOwner: View {
    @StateObject private var model = LegacyCounter()

    var body: some View {
        LegacyChild(model: model)
    }
}

struct LegacyChild: View {
    @ObservedObject var model: LegacyCounter

    var body: some View {
        Text("Count: \(model.count)")
    }
}

@StateObject는 view가 ObservableObject instance를 생성하고 소유할 때, @ObservedObject는 외부에서 받은 instance를 관찰할 때 쓰는 구분이다. @ObservedObject var model = LegacyCounter()처럼 child가 매번 새 instance를 만드는 형태를 owner pattern으로 사용하면 view 재생성과 lifetime을 이해하기 어려워진다.

새 Observation 방식과 legacy wrapper를 한 화면에서 무작정 섞지 말고 deployment target과 migration 범위를 먼저 정한다. 기존 wrapper의 더 넓은 관계는 SwiftUI 상태 관리 annotation 정리와 함께 비교할 수 있다.

선택 체크리스트

질문 우선 검토
독립적인 값·snapshot인가 struct
공유 identity와 mutable state가 필요한가 class
view 내부의 작은 UI state인가 @State value
최신 OS에서 관찰 가능한 공유 model인가 @Observable class
legacy ObservableObject를 view가 소유하는가 @StateObject
legacy ObservableObject를 주입받는가 @ObservedObject

마지막 기준은 ‘class가 더 빠른가’가 아니라 mutation과 identity가 code에서 예측 가능한가다. class를 선택했다면 strong reference cycle과 lifetime도 함께 점검한다. 이 부분은 Swift ARC의 strong·weak·unowned에서 이어서 볼 수 있다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글