YunDaeHyeon
← Lab

SwiftUI Property Wrapper

스터디2025.07.23
  • SwiftUI
  • Swift

출처 : https://swiftuipropertywrappers.com/

SwiftUI의 Property Wrapper 총정리

📌 @State

View 계층 구조에 저장하는 특정 값 유형에 대해 상태를 SSOT(단일 진실 공급원)로 사용한다.

_🤔 단일 진실 공급원(SSOT, Single Source of Truth)이란?_
  • 뜻 그대로 하나의 출처를 의미한다.
  • 모든 데이터를 하나의 중앙 시스템에 저장하고 관리하여 일관되고 정확한 정보를 유지하는 원칙을 말한다.

→ 이를 통해 여러 시스템에 동일한 데이터가 중복 저장되는 것을 방지하여 정보의 일관성을 보장할 수 있다.

> `@State` 는 View 내부에 속해야 할 때 사용해야합니다.

State는 View 내부에서 초기화를 해야하며 다른 객체로부터 State를 받는 것을 권장하지 않는다.

따라서 View 내부에서 State로 선언된 프로퍼티는 외부에서 접근하여 수정할 수 없도록 private로 관리해야한다.

SwiftUI는 State로 감싼 프로퍼티를 내부적으로 저장한다. 이후 State 프로퍼티의 변경사항이 존재한다면 View는 새롭게 렌더링이 이뤄진다!

만약, 데이터를 전달해야한다면 Binding<T> 를 이용해 상위 View에서 선언된 State 프로퍼티를 자식 View에 전달할 수 있으며, 자식 뷰에서 이것을 수정할 수 있다! → @Binding

직접 코드로 확인해보자

import SwiftUI

struct ContentView: View {
    @State private var isPlaying: Bool = false

    var body: some View {
        Button {
            isPlaying.toggle()
        } label: {
            isPlaying ? Text("게임이 시작되었습니다.") : Text("게임이 중지되었습니다.")
        }
    }
}

#Preview {
    ContentView()
}

사용자가 버튼을 클릭했을 때 .toggle()이 실행되면서 State 로 감싸진 isPlaying 프로퍼티가 초기화된다!

그 후 변화된 값으로 Button이 다시 렌더링된다.

⭐ 이때 Button이 isPlaying의 값에 직접적으로 접근하는 것처럼 보이지만, 사실은 State 속성으로 생성된 변수를 참조하는 상태라고한다. ← 이게 무슨 의미인지는 잘 모르겠다..


📌 @Binding

Binding을 사용하여 데이터를 저장하는 속성과 데이터를 표시하고 변경하는 뷰 사이에 양방향 연결을 생성한다.

@Binding 은 다른 View(혹은 부모 뷰)로부터 프로퍼티들을 받을 때 사용된다.

Binding을 받은 View는 외부로부터 생성된 프로퍼티 변경 사항에 대해 렌더링이 수행된다.

Binding 프로퍼티 래퍼의 경우 수정이 가능하며 Binding을 수정하게 되면 State로 연결된 제공한 뷰의 프로퍼티 역시 같이 업데이트 된다.

⭐ State를 자식 뷰에 넘길때는 $를 붙혀야 한다!

View가 다시 렌더링될 때 Binding은 유지되지 않는다.

→ 어차피 외부에서부터 전달되기 때문에 유지할 필요가 없다.

→ 다만, State는 유지된다.

직접 코드로 보자.

import SwiftUI

struct PlayerView: View {
    @State private var isPlaying: Bool = false

    var body: some View {
        VStack {
            Text("축구 경기")
                .foregroundStyle(isPlaying ? .primary : .secondary)
            PlayButton(isPlaying: $isPlaying)
        }
    }
}

struct PlayButton: View {
    @Binding var isPlaying: Bool

    var body: some View {
        Button {
            isPlaying.toggle()
        } label: {
            Text(isPlaying ? "게임이 진행중입니다." : "게임이 일시 중지되었습니다.")
        }
    }
}

#Preview {
    PlayerView()
}

  • 코드를 봤을 때 PlayerView에서는 isPlaying을 State 프로퍼티로 초기화해서 가지고 있는 상태이다.
  • 또한 PlayButton은 isPlaying을 Binding 로 선언된 프로퍼티로써 가지고 있으며 초기화 시점에 PlayerView로부터 State를 주입받고 있다.
  • 따라서 PlayButton에서 사용자가 버튼을 클릭한다면 Binding으로 연결된 Text의 내부 내용이 변경되며 그와 연결된 부모의 @State isPlaying 또한 변경되고 있음을 알 수 있다.

📌 @StateObject

뷰 계층 구조에 저장하는 참조 유형에 대한 SSOT 로 사용합니다.

StateObject는 Object의 State 버전이라고 생각하면 쉽다.

이때, StateObject는 중요한 포인트가 하나 있다.

내부 구현체를 보면 StateObject 는 ObserableObject를 채택한 구체 타입 이어야 한다는 것을 알 수 있다.

즉, StateObject는 언제나 참조 타입 인 클래스여야한다는 것을 알 수 있다.

→ StateObject는 ObservableObject를 채택한 클래스에 적용된다는 것이다! (구체 타입)

ObservableObject를 채택한 클래스 내부의 Published는 속성이 변경될 때마다 SwiftUI에게 알린다.

StateObject를 사용하는 뷰는 내부적으로 ObservableObejct를 생성하게 되면 SwiftUI는 StateObject와 관련된 인스턴스를 따로 설정하고 View가 초기화될 때 다시 사용하게 된다!

⭐ 쉽게 말해서 ObservableObject는 Published 속성이 변경되기 전에 변경된 값을 내보낸다.

→ ObservableObject를 채택한 객체 내부의 Published 속성이 변경될 때마다 랜더링이 수행되며 처음 생성된 StateObject 프로퍼티를 유지한다는 의미이다.

직접 코드로 확인해보자.

import SwiftUI

class DataProvider: ObservableObject {
    @Published var number: Int = 0

    func increase() {
        number += 1
    }
}

struct ContentView: View {
    @StateObject private var provider: DataProvider = .init()

    var body: some View {
        VStack {
            Text("숫자: \(provider.number)")
        }

        Button {
            provider.increase()
        } label: {
            Text("+")
        }
    }
}

#Preview {
    ContentView()
}

사실상 StateObject가 ObservableObject를 채택한 DataProvider라는 것 이외에는 State와 별 다른 점이 없다.

ContentView에 존재하는 버튼을 클릭했을 시 StateObject로 연결된 DataProvider의 .increase()를 호출하게 되면서 DataProvider 내부에 존재하는 number 의 값이 변경되며 ContentView의 뷰가 변경되고 있음을 확인할 수 있다.


📌 @ObservedObject

  • State와 Binding이 한 쌍이라면 StateObject와 ObservedObject가 한 쌍이다.

→ ⭐ 이때, ObservableObject와 ObservedObject는 다르다!!

  • 다만 State를 하위 뷰에 전달할 때 $를 붙혀 전달하지만 ObservedObject 는 $를 붙힐 필요가 없다.

ObservedObject는 StateObject와 동일한 유형의 객체에 적용된다. (ObservableObject를 채택한 객체)

쉽게 말하면 프로퍼티의 소유권의 차이이다.

  • @StateObject → 부모가 프로퍼티를 소유한다.
  • @ObservedObject → 부모가 프로퍼티를 소유하지 않는다.

직접 코드로 확인해보자.

import SwiftUI

class DataProvider: ObservableObject {
    @Published var number: Int = 0

    func increase() {
        number += 1
    }
}

struct ContentView: View {
    @StateObject private var provider: DataProvider = .init()

    var body: some View {
        VStack {
            ObservedObjectView(provider: provider)
            Text("숫자: \(provider.number)")
            Button {
                provider.increase()
            } label: {
                Text("+")
            }
        }
    }
}

struct ObservedObjectView: View {
    @ObservedObject var provider: DataProvider

    var body: some View {
        Text("ObservedObjectView : \(provider.number)")
    }
}

#Preview {
    ContentView()
}

위 코드를 통해 알 수 있는 사실은 아래와 같다.

  1. StateObject는 객체를 소유한다는 것이다.

→ ContentView는 StateObject 프로퍼티 래퍼로 선언된 DataProvider를 직접 소유한다.

→ 소유한다는 것은 ContentView가 DataProvider 인스턴스의 생명주기를 직접 관리한다는 것이며 ⭐ContentView가 다시 생성되어도 같은 인스턴스를 유지할 수 있다는 것이다!

  • ContentView는 값 타입이기 때문에.
  • 또한, SwiftUI의 View는 기본적으로 값 타입이기 때문에 상태가 변경되면 매번 새로 생성되어 그려진다. 그럴때마다 ViewModel이나 외부 데이터가 새로 생성된다면 데이터의 일관성이 사라질 것이다.
  • 그러다보니 View가 다시 생성되어도 한 번 생성된 인스턴스를 계속 동일하게 사용한다는 것이다!
  1. ObservedObject는 객체를 소유하지 않는다.

→ 위 코드의 ObservedObjectView는 현재 @ObservedObject 프로퍼티 래퍼로 선언된 DataProvider를 가지고 있는데 이는 외부에서 전달받은 것을 관찰만 하는 것이다.

  • 위 코드에서는 ContentView가 소유하고 있는 DataProvider를 ObservedObjectView 에게 주입하는 것이다.
  • 객체를 직접 소유하지 않다 보니 DataProvider의 생명주기에 대한 책임이 없다!

🤔 의문. ContentView가 사라지면 ObservedObjectView의 Provider 또한 사라지나?

StateObject는 객체를 직접 소유한다는 의미이기 때문에 ContentView가 사라지면 ContentView가 가지고 있는 Provider 인스턴스가 사라진다. (→ 소유자가 사라졌기 때문.)

그러다보니 ObservedObjectView의 provider 또한 접근이 불가능해진다.

다만, 조심해야하는 부분은 단순히 ContentView가 사라져서 접근을 못하는게 아니라 ContentView가 사라져서 Provider 가 메모리에서 해제되기 때문에 접근을 못하는 것이다!

비유를 해보자.

  • ContentView가 StateObject 로 provider를 소유하고 있는 상황

→ ContentView 입장에서는 _provider_는 내꺼야. 내가 만들고 내가 직접 관리할 거야. 하는 것이다.

  • ObservedObjectView가 ObservedObject로 provider를 관찰하는 상황

→ ObservedObjectView 입장에서는 _provider_는 내 것이 아니야. 나는 그냥 지켜볼게. 하는 것이다.


📌 @EnvironmentObject

  • EnvironmentObject는 자식 View나 App Scene에 의존성을 만드는 것에 주로 사용한다.

EnvironmentObject 또한 ObservableObject를 채택하고 있는 객체를 관리할 수 있어 EnvironmentObject 의 속성 중 하나가 변경되면 View 또한 다시 렌더링 된다.

⭐ 이때, @EnvironmentObject의 경우 값이 없을 때 접근하게 되면 크래시가 터질 수 있으니 유의하자!

직접 코드로 확인해보자.

import SwiftUI

class DataProvider: ObservableObject {
    @Published var number: Int = 0

    func increase() {
        number += 1
    }
}

struct CountCalculatorView: View {
    @StateObject private var provider: DataProvider = .init()

    var body: some View {
        VStack {
            EnvironmentObjectView()
                .environmentObject(provider)

            Button {
                provider.increase()
            } label: {
                Text("+")
            }
        }
    }
}

struct EnvironmentObjectView: View {
    @EnvironmentObject var provider: DataProvider

    var body: some View {
        Text("EnvironmentObjectView : \(provider.number)")
        SubView()
    }
}

struct SubView: View {
    @EnvironmentObject var provider: DataProvider

    var body: some View {
        Text("SubView : \(provider.number)")
    }
}

#Preview {
    CountCalculatorView()
}

EnvironmentObjectView는 EnvironmentObject로 감싼 DataProvider를 가지고 있다.

이 때문에 최초 초기화 시점. 즉, CountCalculatorView에서 EnvironmentObjectView를 초기화할 때 .environmentObject() 라는 메서드를 통해 StateObject를 넘겨주고 있음을 볼 수 있다.

그렇게 StateObject를 전달받았을 경우 EnvironmentObjectView 에서 자식 뷰를 생성할 때 자식 뷰 내부에 @EnvironmentObject를 선언해놓기만 하면 별도로 .environmentObject() 를 통해 주입할 필요 없이 사용할 수 있다.


📌 @Environment

  • Environment 는 EnvironmentObject와 비슷하지만 그 역할이 다르다.
  • Environment 는 View의 환경 변수를 읽는 데 사용된다!

→ 따라서 Environment가 변경되면 View는 다시 랜더링된다.

쉽게 말해서 Environment 는 View에 기본적으로 설정된 시스템 환경 변수에 접근할 때 사용하는 프로퍼티 래퍼이다!

⭐ 이때 Environment 프로퍼티는 읽기 전용 속성을 가지며 수정이 불가하다.

→ 만약, 수정하고 싶다면 .enviroment() 뷰 모디파이어를 사용해야한다.

이 또한 직접 코드로 확인해보자.

import SwiftUI

struct ContentView: View {
    // View에 존재하는 다크모드 설정 유무
    @Environment(\.colorScheme) var colorScheme

    var body: some View {
        VStack {
            Text("현재 모드: \(colorScheme == .dark ? "다크모드" : "라이트 모드")")
        }
    }
}

#Preview {
    ContentView()
}

위 코드에서 @Environment(\.colorScheme) 을 통해 View 내부에 존재하는 colorScheme 값을 가지고 옴을 알 수 있다.


💡 SwiftUI Property Wrapper 정리

구분@State@Binding@StateObject@ObservedObject@EnvironmentObject@Environment
사용처View 내부의 값 타입 상태 관리부모 View의 State를 자식 View에서 읽고 쓰기View가 소유하는 참조 타입 객체 관리외부에서 전달받은 참조 타입 객체 관찰여러 View에서 공유하는 커스텀 객체SwiftUI 시스템 환경 값 접근
적용 타입값 타입 (Int, String, Bool 등)값 타입 (Int, String, Bool 등)ObservableObject 채택 클래스ObservableObject 채택 클래스ObservableObject 채택 클래스시스템 제공 값 타입
소유권View가 소유소유하지 않음 (참조만)View가 소유소유하지 않음 (관찰만)소유하지 않음 (환경에서 주입)소유하지 않음 (시스템 제공)
초기화 위치View 내부에서 직접 초기화부모로부터 전달받음 ($State)View 내부에서 직접 초기화부모로부터 전달받음 (객체 직접 전달)상위 View에서 .environmentObject()로 주입시스템이 자동 제공 (기본값 존재)
생명주기View 재생성 시 값 유지원본 State의 생명주기를 따름View 재생성 시 인스턴스 유지소유자의 생명주기를 따름주입된 객체의 생명주기를 따름시스템이 관리
크래시 가능성없음없음 (부모가 관리)없음없음 (부모가 관리)있음 (주입 안 하면 크래시)없음 (기본값 존재)
변경 감지값 변경 시 자동 렌더링원본 State 변경 시 렌더링@Published 속성 변경 시 렌더링@Published 속성 변경 시 렌더링@Published 속성 변경 시 렌더링시스템 환경 변경 시 렌더링
읽기/쓰기읽기/쓰기 가능읽기/쓰기 가능읽기/쓰기 가능읽기/쓰기 가능읽기/쓰기 가능읽기 전용 (수정은 .environment()로)
접근 제어private 권장제한 없음private 권장제한 없음제한 없음제한 없음
전달 방법$state로 Binding 전달직접 전달받음객체 직접 전달객체 직접 전달.environmentObject()로 주입자동 제공 (명시적 전달 불필요)
주요 특징SSOT 원칙, View 내부 전용양방향 바인딩객체 소유 및 생명주기 관리객체 관찰만 (소유 안 함)깊은 계층 구조에서 편리시스템 설정 값 접근
대표 예시버튼 클릭 상태, 텍스트 입력Toggle, TextField 양방향 연결ViewModel, DataProvider부모가 생성한 ViewModel 사용전역 사용자 설정, 테마colorScheme, scenePhase

💡 관계도

  • State ↔ Binding: 부모-자식 간 값 타입 데이터 공유
  • StateObject ↔ ObservedObject: 부모-자식 간 참조 타입 객체 공유
  • EnvironmentObject: 여러 계층의 View에서 객체 공유
  • Environment: SwiftUI가 제공하는 시스템 환경 값 접근

🤔 어떤 경우에 활용할까?

  1. View 내부의 간단한 상태 관리 → @State
  2. 부모의 State를 자식이 수정해야 할 때 → @Binding
  3. View가 복잡한 로직/데이터 객체를 소유해야 할 때 → @StateObject
  4. 부모가 생성한 객체를 자식이 관찰만 할 때 → @ObservedObject
  5. 여러 계층의 View에서 같은 객체를 공유할 때 → @EnvironmentObject
  6. 시스템 설정(다크모드, 언어 등)을 읽어야 할 때 → @Environment

📖 참고 문서

Swift UI Property Wrappers

Understanding Data Flow in SwiftUI | Kodeco

medium.com/delightroom/swiftui%EB%A5%BC-%EB%A7%9B%EA%B9%94%EB%82%98%EA%B2%8C-%EC%86%8C%ED%99%94%ED%95%98%EB%8A%94-%ED%82%A5-property-wrapper-656cf67173d8

Data Flow Through SwiftUI - WWDC19 - Videos - Apple Developer