SwiftUI에서 struct와 class를 고를 때 ‘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에서 이어서 볼 수 있다.
참고 자료
'배움과 성장 > 소프트웨어 개발' 카테고리의 다른 글
| SwiftUI View 구조: var body·struct·상태 객체의 역할 (0) | 2024.11.11 |
|---|---|
| Swift mutating: struct·enum에서 self를 바꾸는 규칙 (4) | 2024.11.11 |
| Swift ARC의 strong·weak·unowned: 수명 관계로 참조 고르기 (2) | 2024.11.10 |
| Swift closure와 callback: capture·escaping·호출 시점 구분하기 (4) | 2024.11.09 |
| Swift 초기화와 @main: 저장 프로퍼티·App 진입점·onAppear 구분 (2) | 2024.11.09 |
댓글