iOS

SwiftUI 프로퍼티 래퍼

미니미니니

SwiftUI에서는 데이터가 변경될 때 마다 자동으로 UI를 업데이트가 되는데요. 이게 가능한 이유가 바로 프로퍼티 래퍼들 덕분입니다. 

프로퍼티 래퍼를 잘 활용하기 위해서 오늘은 SwiftUI에서 사용하는 상태관리, 데이터 흐름 관리에 필요한 프로퍼티 래퍼들을 알아보겠습니다.

 

먼저 공식 문서들을 살펴보면 "Source of Truth" 라는 말을 계속 이야기하고있습니다. 이 구문은 신뢰할 수 있는 데이터 즉 데이터는 여러곳에서 혼동되거나 중복하여 소유하고 사용하면 안되고, 한 곳에서 소유하고 관리되어야한다는 뜻입니다. 

이 "Source of Truth"라는 원칙을 지키기위해 설계된 것이 바로 SwiftUI의 프로퍼티 래퍼들인 것입니다.

 

@State

@State는 하나의 뷰 안에서만 사용하는 단일 값(Int, String, Bool 등)을 저장하기위해 사용합니다. @State가 선언된 뷰가 그 상태를 소유하고있습니다. @State를 사용하면 값이 변화되는 것을 계속 감지하고있기 때문에 값이 변경되었을 때 UI를 다시 그려줄 수 있습니다. 그리고 뷰와 생명주기가 같기 때문에 뷰가 없어지면 @State도 같이 없어지게 됩니다.

@Binding

@Binding은 원본 데이터를 빌려서 사용하고 수정할 수 있게 해주는 프로퍼티 래퍼 입니다. 데이터에 대한 소유권은 없으며, 부모 뷰에서 소유하고 있는 데이터를 자식뷰에서 변경하고자할 때 사용합니다.

// 자식 뷰: 스위치 컨트롤
struct LightSwitchControl: View {
    @Binding var isOn: Bool // 소유권 없이 연결만 받음

    var body: some View {
        Toggle("전등", isOn: $isOn)
    }
}

// 부모 뷰: 방
struct RoomView: View {
    @State private var isLightOn: Bool = false // isLightOn 상태를 직접 소유

    var body: some View {
        VStack {
            Image(systemName: isLightOn ? "lightbulb.fill" : "lightbulb.slash")
                .font(.largeTitle)
                .foregroundColor(isLightOn ? .yellow : .gray)

            // 자식 뷰에게 $를 붙여 바인딩 전달
            LightSwitchControl(isOn: $isLightOn)
                .padding()
        }
    }
}

 


@Published

@Published는 ObservableObject를 따르는 클래스의 내부에서 사용됩니다. 이 프로퍼티 래퍼가 붙은 변수의 값이 변하면 그 값이 할당되기 직전에 변경 이벤트를 발생시킵니다. 주로 @StateObject나 @ObservedObject가 이벤트를 구독하고 뷰를 업데이트하는 역할을 수행합니다.

 

@StateObject

@StateObject는 복잡한 참조타입의 객체를 뷰가 직접 생성하고 소유할 때 사용합니다. @State와 비슷한 역할을 하지만 @State는 비교적 간단한 값들을 소유하고, @StateObject는 MVVM에서 ViewModel과 같은 복잡한 객체를 소유합니다. 그리고 @StateObject는 @State와 달리 뷰와 생명주기를 같이하지않기 때문에 뷰가 다시 그려지더라도 @StateObject는 없어지지않고 생명주기를 유지합니다.

 

@ObservedObject

@ObservedObject는 객체를 소유하지않고 외부에서 만들어진 ObservableObject객체를 관찰하고 @Published의 변경 이벤트를 받으면 뷰를 업데이트 합니다. @StateObject와의 차이는 @ObservedObject는 객체를 소유하지않고 전달 받아서 관찰만 할 수 있다는 점이 차이점입니다. 

// 데이터와 로직을 가진 ViewModel
class GameViewModel: ObservableObject {
    @Published var score = 0 // 이 값이 바뀌면 자동으로 변경 이벤트 방출
}

// 자식 뷰: 점수판
struct ScoreDisplayView: View {
    @ObservedObject var viewModel: GameViewModel // 객체를 관찰만 함

    var body: some View {
        Text("Score: \(viewModel.score)")
            .padding()
            .background(.yellow)
    }
}

// 부모 뷰: 게임 화면
struct GameView: View {
    @StateObject private var viewModel = GameViewModel() // ViewModel을 직접 생성하고 소유

    var body: some View {
        VStack(spacing: 20) {
            ScoreDisplayView(viewModel: viewModel) // 자식에게 객체를 전달
            Button("점수 획득!") {
                viewModel.score += 10
            }
        }
    }
}

@EnvironmentObject

@EnvironmentObject는 앱의모든 뷰가 함께 공유해야하는 데이터를 관리할 때 사용합니다. 예를 들면 로그인 상태, 앱의 테마 설정 등이 @EnvironmentObject를 사용해야하는 데이터입니다. 

상위 뷰에서 .environmentObject()를 통해 객체를 등록해두면 하위 자식 뷰에서 따로 전달하거나 받을 필요없이 @EnvironmentObject를 통해서 바로 사용할 수 있습니다.

하지만 .environmentObject()로 주입할 수 있는 객체는 ObservableObject 프로토콜을 따르는 객체만 주입할 수 있습니다. 왜냐하면 이 프로퍼티 래퍼는 여러 뷰들이 함께 관찰하고 사용하는 객체를 제공하는 것이기 때문에 객체의 데이터가 변화한다면 감지할 수 있어야합니다. 그렇기 때문에 ObservableObject 프로토콜을 따르는 객체만 주입할 수 있습니다.

func environmentObject<T>(_ object: T) -> some View where T : ObservableObject

공식 문서에 있는 .environmentObject()함수 정의에도 ObservableObject를 따라야한다고 명시되어있습니다.

@main
struct MyApp: App {
    @StateObject private var settings = UserSettings()

    var body: some Scene {
        WindowGroup {
            GrandParentView()
                .environmentObject(settings)
        }
    }
}

// 자식 뷰
struct ChildView: View {
    @EnvironmentObject var settings: UserSettings

    var body: some View {
        // 부모로부터 직접 전달받지 않았지만, 값을 사용할 수 있습니다.
        Text("점수: \(settings.theme)")
    }
}