propertyWrapper: 눈 떠보니 Source of Truth가 내 앞에

bono·2024년 3월 24일
9

안녕하세요 보노입니다! 🦦

근 나흘 간 애정을 들여 쓴 글이 벨로그에서 날아가고... 멘붕이 왔지만,

돌아볼 기회라 생각하고 다시금 앉아 차근히 포스팅을 시작합니다.

(이제는 사라진 제 포스팅의 흔적 입니다. 수정하는 과정에서 덮어쓰기 당해 사라졌답니다...)

그러니 이번이 진짜 진짜 진짜 최종이 되겠군요...

하지만 제 머릿속에 남아 있기 때문에 괜찮습니다.

저는 SwiftUI를 사용하며,

SwiftUI가 제공하는 상태 관리자 @State, @Binding ... 등등을 통해 PropertyWrapper를 처음 접했습니다.

structView 내부에 선언하여 View 간에 상태 값을 관리할 수 있다니, SwiftUI가 제공하는 도구들은 마법같이 느껴졌습니다.

하지만 안타깝게도 SwiftUI를 사용하는 짧지 않은 기간 동안 그 이상의 이해는 없었습니다.

제가 뜯어서 이해할 수 있는 영역이 아니라 판단했기 때문입니다.

propertyWrapper에 대한 학습할 시기가 있기는 했지만, 이를 SwiftUI 가 제공하는 propertyWrapper 타입과 연관지어 떠올리진 못했습니다.

그런 제가 다시금 PropertyWrapper를 열어 보게 된 것은 TCA에서 곧 Release 될 SharedState 때문 입니다.

이슈(공유상태)를 해결하기 위해 제시한 해법이 예상치 못한 것 이었고, 그 형태가 까다롭지 않음에 가벼운 절망감이 들었습니다.

propertyWrapper라는 도구를 도구로서 바라보지 못했음을 반성하는 계기가 되기도 했습니다.

더불어 학습이 늦었지만, propertyWrapper란 무엇인가 찾아가는 과정 속에서 SwiftUI가 말하는 Source of Truth가 무엇인 지 느낄 수 있었습니다.

그러니 본 포스팅의 끝에는 아래 질문의 논점을 이해할 수 있을 것입니다.

@Binding var someState: Bool의 타입은 Binding<Bool> 인가?

본문 내에는 주관적인 의견이 다수 들어가 있습니다.

제가 많이 헤맸던 만큼, 꼭 공식문서와 소스를 통해 개념을 먼저 접하고 오개념을 흡수하지 않도록 유의하여 읽으셨으면 합니다.

늘 그랬듯 누군가에겐 도움이 되는 글이길 바랍니다.

PropertyWrapper란?

propertyWrapperAttributes의 하위 항목으로 분류되기도,
Properties의 하위 항목으로 분류 되기도 한다.

Attributes에서의 PropertyWrapper

링크

Properties에서의 PropertyWrapper

링크

내게 더 와닿는 문장은 properties 하위의 정의에 해당했다.

핵심은 아래와 같다 느꼈다.

프로퍼티 래퍼는 프로퍼티의 저장 방식을 관리하는 코드프로퍼티의 선언부와 분리하여, 특정 로직이나 관리 코드를 프로퍼티에 적용할 수 있게 해준다.

좀 더 와닿는 형태로 바꾸어 말하자면,

프로퍼티 래퍼는 저장 방식을 관리하는 인스턴스를 생성하고, 이를 독립된 선언부연결한다.

어쩌다 이런 역할을 수행하는 도구(propertyWrapper)의 필요를 느꼈을까?

PropertyWrapper의 필요

PropertyWrapper의 존재 의의는

독립된 속성 값에 반복적으로 적용되는 로직을 인스턴스로 빼내어, 원할 때 마다 입혀줄 수 있음

에 있다 생각한다.

이에 대한 이해로 공식소스의 예제를 들어보자.

아래는 Swift Evolution의 가장 간단한 예제 @Lazy이다.

이는 propertyWrapper를 구성하는 요소에 익숙해 지기 위해 가져온 예시로, @Lazy 코드의 쓰임에 대해 이해하는 것을 목표하지 않는다.

누군가는 이러한 필요성으로 propertyWrapper의 필요를 느끼는 구나! 이러이러한 방식으로 동작하기에 원하는 구현이 가능하구나!

를 이해하는 것이 전부다.

propertyWrapper가 개선 가능한 상황

lazy 지연 할당 상황

struct Foo {
  // lazy var foo = 1738
  private var _foo: Int?
  var foo: Int {
    get {
      if let value = _foo { return value }
      let initialValue = 1738
      _foo = initialValue
      return initialValue
    }
    set {
      _foo = newValue
    }
  }
}

종종 optional한 값을 노출시키지 않기 위해 lazy 할당하고 싶은 상황을 마주한다.

이를 위해 swift는 lazy 라는 지연할당 키워드를 제공 하지만 해당 키워드는 타 언어와 호환성이 떨어진다.

그렇다고 lazy 라는 언어 지원 없이 같은 효과를 원한다면 위와 같이 많은 보일러 플레이트가 필요하다.

class Foo {
  let immediatelyInitialized = "foo"
  var _initializedLater: String?

  // We want initializedLater to present like a non-optional 'let' to user code;
  // it can only be assigned once, and can't be accessed before being assigned.
  var initializedLater: String {
    get { return _initializedLater! }
    set {
      assert(_initializedLater == nil)
      _initializedLater = newValue
    }
  }
}

더불어 우리는 지연 할당 되기를 바라는 optional 값이 할당 된 이후엔 불변해도 상관 없는 상황에 자주 놓인다.

var는 let 에 비해 많은 안정성을 포기하는 것도 아쉬운 부분이다.

이를 propertyWrapper를 통해 개선할 수 있다.

매번 선언해야 하는 불필요한 코드 중복을 줄이고, 원하는 값 만을 노출한다.

@Lazy var foo = 1738

이런 식으로 말이다.

위 코드는 foo가 내보내는 임시값이 1738이고, 외부에 의해 할당될 때 (lazy의 지연 할당 처럼) 그 값으로 다시금 할당 됨을 의미한다.

(위에서 언급한 한 번 할당된 값이 불변하도록 하는 작업은 Lazy 내부 코드에서 일어나고 propertyWrapper의 특성과는 무관하다)

Lazy가 어떤 propertyWrapper인지 말하기에 앞서, 나는 위 코드 형태에 익숙함을 느낄 수 있다.

@State var state: String = "하이"

라던가,

@Binding var bindingValue: String

라던가 말이다.

결국 한 방향으로 흐르는 개념이니 길을 잃지 않는 것이 중요하다.

propertyWrapper @Lazy

다시 lazy를 대신하여 지연 속성을 구현해 줄 Lazy를 살펴보자

코드는 아래와 같다.

@propertyWrapper
enum Lazy<Value> {
  case uninitialized(() -> Value)
  case initialized(Value)

  init(wrappedValue: @autoclosure @escaping () -> Value) {
    self = .uninitialized(wrappedValue)
  }

  var wrappedValue: Value {
    mutating get {
      switch self {
      case .uninitialized(let initializer):
        let value = initializer()
        self = .initialized(value)
        return value
      case .initialized(let value):
        return value
      }
    }
    set {
      self = .initialized(newValue)
    }
  }
}

(case의 연관값을 이용하여 initialized 이후의 값 유지를 강제하다니...!)

위 코드에서 가져가야 할 부분은 @propertyWrapper가 wrappedValue라는 속성을 강제한다는 점이다.

@propertyWrapper가 꾸며주는 타입 Lazy는 아직 결정되지 않은 어딘가의 property를 wrap할 주체인 Wrapper로서의 의미를 가진다.

그리고 Lazy에 의해 wrap 당할 프로퍼티는 선언값으로서 Wrapper 내부속성인 wrappedValue와 엮이게 될 것이다.

명칭이 직관적이다.

Wrapper(Lazy)가 가지는 wrappedValueWrapperwrapproperty, 즉 속성값을 의미한다.

코드로 보자면, 아래 Lazy 인스턴스 속성 wrappedValue와 엮인 property가 바로 foo다.

@Lazy var foo = 1738

어떤 타입 내부 속성인 foo는 위 코드로 생성될 Lazy 인스턴스 내부의 wrappedValue를 대변하게 된다. wrapper에 의해 한 번 Wrap 되는 것이다.

이를 가능하게 하는 것이 바로 어트리뷰츠인 @propertyWrapper이다.

그러니 foo의 타입은 Lazy 내부 wrappedValue 타입과 같고, 해당 접근은 Lazy 인스턴스 속성인 wrappedValue로의 접근과 같다.

그런데, Lazy 인스턴스는 어디에, 언제 생성되는 걸까?

우선 생성된 Lazy 인스턴스는 @propertyWrapper에 의해 보이지 않는다.

보이지는 않지만, @propertyWrapper에 의해 wrap 할 속성과 같은 scope에 위치된다.

더불어 wrappedValuefoo를 통해서 인스턴스 본체에 접근할 방법이 존재하는데, 이것이 바로 _(언더바)를 통하는 것이다.

즉, foo라는 Lazy 인스턴스 속성 값 앞에 언더바를 붙이면 그게 foo가 속한 인스턴스 본체를 가르키는 값이 된다.

이를 명확히 하기 위해서 짚고 넘어가야 할 부분이 있는데, @propertyWrapper 타입의 특별한 상황에 대해서 제공되는 초기화 방식이다.

@Lazy var foo = 1738

@propertyWrapper의 타입을 생성할 때에는 이런 식으로 = 할당 연산자를 사용하는 걸까?

맞는 말이기도 틀린 말이기도 하다.

@propertyWrapper특정 상황을 충족하면 개발자에게 생성 편의를 돕기 위해 propertyWrapper 타입 생성 경로wrappedValue 경로와 엮어준다.

특정 상황이란 아래와 같다.

  1. @propertyWrapper로 선언된 타입 (Lazy)의 생성자 (init)의 파라미터명wrappedValue 일 것
  2. 파라미터 wrappedValue의 타입이 내부 속성 wrappedValue 타입과 같을 것

그러니 만약 파라미터 명이 아래와 같이 수정하여 특정 상황에서 벗어난다면

init(wrapped: @autoclosure @escaping () -> Value) {
    self = .uninitialized(wrapped)
  }

컴파일러는 에러를 띄운다.

왜 에러가 날까?

우선 특별한 조건을 충족하여 LazywrappedValue(foo)로의 접근이 Lazy 생성자와 연결되지 못했을 뿐더러,

아직 인스턴스 Lazy가 생성되지도 않았기에 접근할 foo (_foo.wrappedValue)도 없다.

여기서 꼭 짚고 싶은 부분이 이것이다.

보이지 않지만, @propertyWrapper를 통해 관리하고자 하는 코드 로직이 존재하는 공간은 Lazy 인스턴스이다.

그러니 이를 사용하고자 한다면, Lazy 인스턴스를 생성해야 한다.

@Lazy var foo = 1738

위의 코드는 마치 foo(_foo.wrappedValue)의 생성이 곧 @propertyWrapper의 시작점으로 보이게 한다.

위는 특별한 상황에 대한 특별한 이니셜라이저를 이용하여 Lazy 인스턴스를 만든 상황이다. 즉 위 코드가 Lazy 인스턴스를 생성하는 코드임을 이해해야 한다.

방심하면 foo를 생성하는 것이 @propertyWrapper를 사용하는 조건 처럼 보이게 하므로 꼭 짚고 넘어가고 싶었다.

만약 특별한 상황 (생성자 파라미터명이 wrappedValue, 타입도 일치)을 충족하지 않았다면 어떨까.

바로 이렇게 Lazy 생성자를 이용하여 인스턴스를 생성해 주어야 한다.

위 형태는 마치

@Binding var bindingValue: String

과 일부 닮아 아직 그 자체로는 속성 값을 가지는 뷰(또는 값)의 초기화가 완료되지 않았다 생각하게 만들지만,

두 코드는 상황이 다르다.

Lazy로 작성된 속성이 존재하는 뷰는 생성자로 아무것도 받지 않더라도 에러가 발생하지 않는다.

init() {} // 에러 없음

이미 선언된 내부 속성의 초기값이 충족 되었기 때문이다.

_의 의미

저장 속성 이름에 밑줄(_)을 접두사로 사용하는 것은 의도적입니다. 이는 비공개 저장 속성에 대한 기존 규칙에 부합하는 예측 가능한 이름을 합성된 저장 속성에 제공합니다.

앞선 설명에서 _를 미리 접할 수 있었듯, _는 wrappedValue를 가진, 인스턴스를 의미한다.

@propertyWrapper가 의도적으로 감춘(private, _의 의미) 인스턴스가 존재하는 공간으로 접근하는 경로이기에 _를 사용한다고 한다.

위의 예시 코드가 아니더라도, 우리는 익숙히 _ 를 사용해 왔다.

struct TestView: View {
	@Binding var bindingValue: String
	init(bindingValue: Binding<String>) {
		_bindingValue = bindingValue
	}
}

바로 위의 상황에서 말이다.

bindingValue = bindingValue는 불가능한 지, 이제는 알고 있다.

bindingValue가 나타내는 것은 _bindingValue.wrappedValue와 같다.

생성자의 파라미터와는 타입도 다르고, 초기화 되지 않은 Lazy 인스턴스를 초기화 시켜주는 역할도 수행하지 못한다.

$의 의미

$propertyWrapper 타입 인스턴스projectedValue에 접근하는 경로로 사용된다.

wrappedValue와 다르게, 그 구현 여부가 선택적이다.

wrappedValue 외에 더 필요한 정보가 있다면 해당 속성을 사용하여 $로 접근할 수 있다.

즉 위 Lazy 타입 내부 속성으로 이름projectedValue 로 추가한다면

해당 속성값에 접근 시 $foo (_foo.projectedValue) 로의 접근이 가능하게 된다.

물론 $는 SwiftUI를 사용하는 이라면 @State를 사용할 때 숨쉬듯 사용하는 키워드이기도 하다.

@State var state: Int = 1

Binding을 인자로 받는 뷰에 state를 넘기려면 $state로 넘겨야 하기 때문이다.

여기서 propertyWrapper 타입State가 갖는 의미는, State<Value> 타입의 projectedValue가 반환하는 값이 Binding<Value>라는 점이다.

정리 : Property Wrapper란?

@Field(name: "first_name") public var firstName: String

// 아래 형태로 확장

private var _firstName: Field<String> = Field(name: "first_name")

public var firstName: String {
  get { _firstName.wrappedValue }
  set { _firstName.wrappedValue = newValue }
}

public var $firstName: Field<String> {
  get { _firstName.projectedValue }
  set { _firstName.projectedValue = newValue }
}

이제 포스팅 서두에 놓인 질문에 나름의 답을 해보자.

@Binding var someState: Bool의 타입은 Binding<Bool> 인가?

그렇다.

위 문장의 타입이 있냐는 질문은 모호한 구석이 있어, 위 코드를 사용하기 위해서 어떤 타입이 필요하느냐고 질문을 해석하였다.

@propertyWrapper를 동작시키는, Binding을 생성하기 위한 타입이 무엇이냐 묻는다면 해당 propertyWrapperBinding을 생성하기 위해 Binding<Bool> 타입 인스턴스 생성이 필요하다.

SwiftUI에서 source of Truth 란?

이제껏 @State@Binding을 사용하면서도,

wrappedValue를 통한 변경이 body 내부에서 가능한 이유를 일찍이 고민하지 못했음이 참 아쉽다. 이를 알았더라면 뷰를 다루는 시각이 확연히 넓었을 것 같다.

StateBinding이나, 각각의 propertyWrapper 타입은 wrappedValue{get nonmutating set } 계산속성을 가진다.

이 말은 즉슨 State와 Binding 인스턴스가 wrappValue의 set 통해 변경시키는 값은 해당 인스턴스 내부에 없다는 뜻으로 풀이된다.

그 값이 어디에 보관되고, 어떠한 방식으로 변화를 읽어 View를 갱신시키는 지는 알 수 없다.

그저 추측하는 것은 wrappedValuenonmutating set의 실행이 접근하는 곳에 뷰의 source or truth가 있을 것이라는 것이다.

뷰가 상태값을 안고 있는 구조가 아니라 추측하면 View가 struct여도 일관된 상태 연결을 유지하는 이유를 납득할 수 있다.

이전부터 Viewstruct인 이유에 대하여 다양한 가설을 세워왔다.

나름 답을 내렸다 생각했는데, 한참 부족했음을 깨닫는다.

다양한 글들을 읽다보면, View에 변동을 야기하는 상태값들을 다양한 propertyWrapper를 통해 안전하게 처리하라는 말을 보았다.

상태 값은 위치는 뷰에게 있지 않다. 뷰는 누군가에 의해 갱신된다.

뷰가 갱신될 때 내부 속성 또한 초기화 될 수 있으므로 값을 안전하게 보관하라는 말의 뜻을 이제야 곱씹어 본다.

마치며,

새로운 깨달음에 기쁘기도 하지만 상태 관리를 SwiftUI에 맡겨 버렸던, 시야가 좁아져 더 많은 것을 깨닫지 못한 지난날이 아쉽습니다.

어쩌면 잘 모르고도 쓸 수 있게 만든 SwiftUI에 감탄을 해야하는 지도 모르겠습니다.

그리고 블로그를 꾸준히 쓰기 위해 글쓰기 모임에 가입했는데, 제게 주 1회 업로드는 생각보다 더 어려운 일이었습니다. 가벼운 주제를 택하고 싶었지만, 도저히 찾을 수 없었습니다 ...

이번주로 2주치 벌금이 누적되는 걸 보면서... 이러지 말아야지 다짐을 해 봅니다...

포스팅 글은 다른 곳에 작성 뒤 옮겨와야겠다는 것도요...

nonmutating을 사용한 @UserDefaults 라던가, 접근 경로에 따른 비교라던가,

떠나보낸 내용이 아쉽기도 하지만 이만 글을 마무리 하려합니다.

무엇이던 혼자만의 이야기로 간직하지 않게

늘 든든한 말벗이 되어주는 메이슨과 마이노 감사합니다.

다시 쓰는 것에 대한 압박으로 검토가 부족하였기에, 키워드 오류가 많을 것으로 예상됩니다. 혹시 발견하신다면 댓글 부탁드립니다!

감사합니다. 즐거운 개발 되세요! 🫶

profile
iOS 개발자 보노

4개의 댓글

comment-user-thumbnail
2024년 3월 27일

propertyWrapper에 대한 깊이 있는 블로그 글 잘 보았습니다!!

저는 개인적으로 노션에다가 미리 글을 적어놓은 다음에 통째로 벨로그로 옮겨오는 편입니다.
저도 예전에 벨로그에서 글을 작성하다가 글 전체가 날아간 적이 있어서요... ㅠ 노션은 자동 저장이 되기 때문에 뭘 잘못 누르지 않는 이상 글이 통째로 날아갈 일은 없어서 안전한 것 같습니다 ㅎㅎ

이렇게 깊이 있는 글을 적기 위해 보통 어디에서 소스를 참고하시는지 궁금합니다 (공식문서?, 해외 블로그??, 책?)
보노님만의 공부방법이 궁금해지네요 😊

1개의 답글
comment-user-thumbnail
2024년 3월 30일

일주일에 한개의 아티클을 쓰는건 정말쉽지않은거같아요!
단순히 이걸배웠습니다! 하는 짧은 글이 아닌 하나의 주제를 깊게탐구하는 아티클을쓴다고 생각하니까 더 그런거같아요 ㅎㅎ
하지만 시간이지날수록 고민들이쌓이면 그게 실력이되지않을까싶네요!
좋은글 감사합니다:)

답글 달기
comment-user-thumbnail
2024년 3월 30일

이런 이유로 SwiftUI는 MVVM을 염두에 두고 설계되었다고 하는 거군요
애플 개발팀 사람들이 어떻게 하면 뷰의 프로퍼티가 UI를 변경하는 것을 드러내지 않을 수 있을지 고민하다가 나온 결과물이 아닌가 싶습니다
Swift도 애노테이션이 많아져서 좀 슬프기도 하네요
처음 보는 사람에겐(물론 저도) 마치 모든 것이 마법처럼 일어나는 것 같고 그 코드만 봐서는 이해하기가 어려우니까요
마치 스프링으로 개발할 때 온갖 애노테이션을 이유는 모르지만 가져다 쓰는 것처럼요

답글 달기