Skip to content

[홍승현] ‐ Regular property, State, Binding를 어떻게 사용해야할까?

SeungHyun Hong edited this page Jul 11, 2024 · 3 revisions

독서 클럽 뷰

독서를 하다보니, 제가 읽고 있는 책의 현황과 다음 모임을 위한 메모를 작성하는 앱을 만들고 싶어졌습니다.

그래서 제가 읽고 있는 각 책을 나타내는 뷰의 프로토타입을 만들고 싶었어요. 여기서 잠깐, SwiftUI에서 새로운 뷰를 시작할 때, 생각해보아야 할 세 가지 핵심 질문이 있습니다.

1. 이 View가 작업을 수행하는 데 필요한 데이터는 무엇인가?(What data does this view needs to its job?)

2. View는 그 데이터를 어떻게 조작하는가?(How will the view manipulate that data?)

3. Data는 어디서 오는가?(Where will the data come from?)

Important

이 세 가지 질문은 본문의 핵심을 다루므로, 시작하기 전에 머릿속에 깊이 새겨두시길 바랄게요.

Prototype Screen

이 View가 작업을 수행하는 데 필요한 데이터는 무엇일까요?

  • 이 예제에서 뷰는 책 표지의 썸네일, 제목, 저자의 이름, 그리고 내가 읽은 책의 퍼센트가 필요해요.

View는 그 데이터를 어떻게 조작하나요?

  • 이 뷰는 데이터를 표시하기만 해요. 즉, 변경하지 않는거죠.

Data는 어디서 오나요?

  • 이것이 바로 Source of Truth 예요.

우리가 보게 될 것처럼, Source of Truth에 대한 이 질문은 데이터 모델 설계에서 가장 중요한 질문이에요. View를 구축하고 그 Source of Truth이 무엇이어야 하는지 살펴봅시다.

BookCard

struct BookCard: View {
  let book: Book
  let progress: Double

  var body: some View {
    HStack {
      BookCover(book.coverName) // 표지 이름(cover name)
      VStack(alignment: .leading) {
        TitleText(book.title) // 제목(title)
        AuthorText(book.author) // 저자(author)
      }
      Spacer()
      RingProgressView(value: progress) // 퍼센트(progress)
    }
  }
}

이 View가 작업을 수행하기 위해 필요한 데이터는 무엇일까요?

이 View는 책의 표지 이름(coverName), 저자(author), 제목(title)이에요. 또한, 진행 상황에 대한 정보로 완료된 작업의 퍼센트(progress)가 제공돼요.

여기서 View는 이 데이터를 조작하나요?

이 데이터는 뷰에 의해 단지 표시될 뿐 변경되지 않아요. 즉, let 프로퍼티 선언될 수 있어요.

그리고 Data는 어디서 오나요?

데이터의 출처는 상위 뷰에서 BookCard가 인스턴스화될 때 전달돼요. 즉, Source of Truth는 뷰 계층 구조 상위에 위치해 있고, 상위 뷰는 BookCard를 생성할 때 실제 데이터를 제공해요.

상위 뷰의 body가 실행될 때마다 새로운 BookCard 인스턴스가 생성되며, 이 인스턴스는 SwiftUI가 렌더링하는 데 필요한 시간 동안만 존재한 후 사라지게 돼요.

내부 구조를 다이어그램을 사용해 시각화 해 본다면 다음과 같을 거에요.

BookCard Diagram

왼쪽의 보라색 상자들은 BookCard를 구축하기 위해 구성된 SwiftUI 뷰들을 나타내요.

오른쪽의 노란 캡슐은 View를 렌더링하는 데 사용되는 데이터를 나타냅니다. 이러한 다이어그램으로 이야기 전반에 걸쳐 들어갈 거예요.

Book View

Detail Screen

조금 더 흥미로운 View를 살펴볼까요?

책을 탭하면, 나의 진행상황을 검토할 수 있는 이 화면으로 이동해요.

“Update Progress” 버튼을 탭했을 때 진행 상황을 업데이트하고 메모를 추가하는 방법을 구현하고 싶습니다. 다음과 같은 화면처럼요.

Parent View와 이 Sheet가 어떻게 함께 작동하는지 코드를 살펴볼게요.

struct BookView: View {

  func presentEditor() { ... } // 시트를 보여주는 코드
  var body: some View {
    ...
    Button(action: presentEditor) { ... } // action으로 presentEditor를 전달
    ...
  }
}

사용자가 “Update Progress” 버튼을 탭하면, presentEditor 메서드를 호출합니다. 그리고 이는 시트가 나타나게하는 어떤 상태를 변경할 거예요.

그렇다면, 이 View가 Sheet를 제어하는 데 필요한 데이터는 무엇이 있을까요?

struct BookView: View {
  var isEditorPresented = false // Sheet 표시 추적용
  var note = "" // 메모 추적용
  var progress: Double = 0 // 진행 상황 추적용
  func presentEditor() { ... }
  var body: some View {
    ...
    Button(action: presentEditor) { ... }
    ...
  }
}

Sheet가 표시되었는지 추적하기 위한 Boolean이 필요하고, 메모를 추적하기 위한 String이 필요하며, 진행 상황을 추적하기 위한 Double값이 필요할 거예요.

이처럼 여러 프로퍼티를 가지게 될 때 저는 이들을 구조체로 빼는 것을 선호하는 편입니다.

struct EditorConfig {
  var isEditorPresented = false
  var note = ""
  var progress: Double = 0
}

struct BookView: View {
  private var editorConfig = EditorConfig() // EditorConfig로 캡슐화
  func presentEditor() { ... }
  var body: some View {
    ...
    Button(action: presentEditor) { ... }
    ...
  }
}

BookView를 더 읽기 쉽게 만들 뿐만 아니라, 이 접근 방식에는 두 가지의 큰 장점이 있어요. 우리는 캡슐화의 모든 이점을 얻을 수 있어요. EditorConfig는 프로퍼티에 대한 불변성을 유지하고 독립적으로 테스트될 수 있어요. 그리고 EditorConfig가 값 타입이기 때문에, EditorConfig의 프로퍼티, 예를 들어 그 진행 상황에 대한 변경은 EditorConfig 자체에 대한 변경으로 나타나요.

자 이제 질문을 하나 해 볼게요.

여기서 View는 이 데이터를 어떻게 조작할까요?

업데이트 버튼이 탭되면, isEditorPresented를 True로 설정하고 현재 진행 상황에(current progress)맞게 진행 상황(progress)를 업데이트해야 해요.

상태를 EditorConfig 구조체로 추출했으므로 그 업데이트를 담당하게 하고 BookView에게는 단순히 EditorConfig에게 작업을 요청할 수 있어요.

그런 다음, EditorConfig에 다음과 같은 변경 가능한 메서드를 추가해요.

struct EditorConfig {
  var isEditorPresented = false
  var note = ""
  var progress: Double = 0
  mutating func present(initialProgress: Double) {
    progress = initialProgress
    note = ""
    isEditorPresented = true
  }
}

struct BookView: View {
  private var editorConfig = EditorConfig()
  func presentEditor() { editorConfig.present(...) } // 일은 객체한테 담당
  var body: some View {
    ...
    Button(action: presentEditor) { ... }
    ...
  }
}

자 이제.. 마지막 질문을 마무리지어보죠.

Data는 어디서 오나요?

Editor Configiguration는 이 View의 local이어서 어떤 상위 뷰가 전달하지는 않아요. 그래서 local Source of Truth를 설정해야 해요. 그리고 가장 간단한 Source of Truth@State이죠.

이 프로퍼티를 @State로 표시하면, SwiftUI가 그 저장소를 관리하는 것을 담당해요.

이를 다이어그램으로 살펴볼게요.

editorConfig에 관한 다이어그램

그림을 다시 설명하자면, 왼쪽의 Box는 View를, Data는 노란 캡슐형태로 나타낼 수 있어요.

노란 캡슐 모양의 디자인이 두꺼운 테두리를 갖고 있는 것에 주목해주세요.

저는 이 테두리를 SwiftUI가 우리를 위해 관리하는 데이터를 나타내기 위해 사용했어요.

음… 왜 이것을 주목하라고 제가 얘기했을까요?


우리의 뷰는 일시적으로만 존재해요. 즉, SwiftUI가 랜더링 패스(Rendering pass)를 완료한 뒤 구조체 자체는 사라지는 거예요.

하지만, 프로퍼티는 @State로 표시했기 때문에, SwiftUI가 우리를 위해 유지해줘요. 프레임워크가 이 View를 다시 렌더링해야 할 다음 번에, 구조체를 다시 인스턴스화하고, 기존 저장소에 다시 연결해요.

아래의 gif를 보면 이해가 빠를 거에요. Untitled

Binding의 탄생

이제 ProgressEditor를 다음으로 살펴볼게요.

struct EditorConfig {
  var isEditorPresented = false
  var note = ""
  var progress: Double = 0
  mutating func present(initialProgress: Double) { ... }
}

struct BookView: View {
  private var editorConfig = EditorConfig()
  var body: some View {
    ...
    ProgressEditor(...)
    ...
  }
}

struct ProgressEditor: View {
  var editorConfig: EditorConfig
}

ProgressEditor작업을 수행하는 데 필요한 데이터는 무엇인가요?

아마 EditorConfig의 모든 데이터겠죠. 그렇다면, 이 View는 해당 데이터를 어떻게 조작해야 할까요?

변경해야 하므로, var를 사용할 거예요.

그리고 데이터는 어디서 오나요? 흥미롭네요. 다이어그램으로 돌아갑시다.

BookViewProgressEditor에 집중해보죠. EditorConfig를 ProgressEditor에게 일반 프로퍼티로 단순히 전달한다고 가정해볼게요.

EditorConfig가 값 타입이기 때문에, Swift는 Copy버전을 만들 거고, ProgressEditor가 Note나 Progress를 변경한 값은 이 새 복사본만 변경할 거예요. 즉, SwiftUI가 우리를 위해 관리하는 원래 값은 변경하지 않는 거예요.

ProgressEditor와 BookView가 서로 통신하지 않는 거죠.

그렇다면, ProgressEditor에 자체 State 프로퍼티를 주는 건 어떨까요? 실제로 원하는 것처럼 보일 수도 있지만, SwiftUI가 새로운 데이터 조각을 관리하는 것이기 때문에 잘못된 방법이에요.

엄밀히 말하자면, 잘못된 방법은 아니나, ProgressEditor가 설정한 모든 변경값은 BookView와 공유하고 싶은 거예요. 즉, 우리가 원하는 건 **Single Source of Truth**예요. 하지만, @State는 새로운 Source of Truth를 만들기 때문에 우리가 원하는 방식은 아닌 거예요.

우리는 BookView의 Source of Truth에 대한 write access를 ProgressEditor와 공유하는 방법이 필요해요.

그래서 우리는 Binding이라는 Annotation을 사용해볼 수 있어요.

여기 다이어그램에서는 BookViewEditorConfig에 대한 Binding을 생성해요. 기존 데이터에 대한 read-write access 에 대한 참조를 생성하고 ProgressEditor와 공유해요(초록색 점선 화살표).

그래서 Binding을 통해 EditorConfig를 업데이트하면, ProgressEditorBookView가 사용하는 동일한 상태를 변경해요. SwiftUI는 EditorConfig에 대한 변경 사항을 인지하는 거죠. BookViewProgressEditor가 그 값에 의존하고 있는 걸 알고 있어서 값이 변경될 때 해당하는 여러 View를 다시 렌더링할 거예요.

struct EditorConfig {
  var isEditorPresented = false
  var note = ""
  var progress: Double = 0
  mutating func present(initialProgress: Double) { ... }
}

struct BookView: View {
  private var editorConfig = EditorConfig()
  var body: some View {
    ...
    ProgressEditor(editorConfig: $editorConfig) // 값 전달
    ...
  }
}

struct ProgressEditor: View {
  @Binding var editorConfig: EditorConfig // @Binding 설정
  ...
    TextEditor($editorConfig.note) // Binding 변수 사용
  ...
}

코드를 볼까요?

@Binding을 사용하기 위해서는 앞서 말한 바와 같이 read-write access에 대한 참조를 전달해야 해요.

그것을 달러 기호($)로 생성해서 전달할 수 있어요.

그리고 @BindingProgressEditorBookViewEditorConfig State 사이에 데이터 의존성을 생성해요. 그래서 데이터가 업데이트된다면, View가 re-rendering돼요.


이렇게해서 우리는 세 가지 질문을 통해 데이터 흐름을 어떻게 가져가야하는지를 배웠어요.

한 번 더 언급해보자면, 이렇게 정리할 수 있어요.

첫째, 구현할 View에는 앞으로 어떤 데이터가 필요할까?

둘째, 그 데이터를 어떻게 사용할까?

셋째, Data는 어디서 올까?

만약 변경되지 않는 데이터라면 regular property로 적용하세요.

View가 소유한 일시적인 데이터에 대해서는 State를 사용하세요.

다른 View가 데이터를 변경하는 데에는 Binding을 사용하세요.

Clone this wiki locally