[번역] Point-Free #65 SwiftUI와 상태 관리 - 파트 1

본문 링크

거대하고 복잡한 아키텍쳐를 만드는 일엔 어떤 장애물이 도사리고 있을까요? 오늘은 SwiftUI로 앱을 만들어보면서 애플은 그 장애물을 어떻게 극복했는지 알아보려고 합니다.

바로잡기

이 에피소드는 Xcode 11 베타 3을 기반으로 작성되어서 최근 버전과 비교해 큰 차이가 있을 수 있습니다. 확인된 사항들은 정정해두었으며 더 자세한 내용은 블로그에 작성해두었습니다.

시작하며

오늘은 어플리케이션 아키텍쳐에 대해 토론하는 시간을 가져봅시다. 지금까지는 사이드 이펙트를 바깥으로 빼고, 순수 함수를 사용하며, 읽기 쉬운 함수를 만들고, 이를 합성하면서 이해하기 쉽고 테스트가 가능한 어플리케이션을 만드는 방법에 대해 익혔습니다.

이 정도면 함수형 프로그래밍의 토대가 되는 지식은 모두의 머릿속에 깊이 자리잡았다고 생각합니다. 이제는 이러한 아이디어를 섞어서 복잡하고 거대한 어플리케이션을 만들 수 있는 하나의 아키텍쳐에 대해 이야기할 시간입니다! 하지만 그전에 우리 앞에 어떤 문제가 도사리고 있으며, 복잡한 아키텍쳐를 만들 때 겪을 시련의 종류엔 무엇이 있는지 알아보는 것이 먼저입니다. 이번 에피소드에선 함수형 프로그래밍에 대한 내용은 많지 않습니다. 함수형 프로그래밍이 아키텍쳐에서 어떤 역할을 해야하는지에 대한 배경 설명을 먼저 할 것이기 때문입니다.

이번 시리즈에선 어플리케이션 개발에서 발생하는 다양한 문제의 예를 주로 볼 예정입니다. 아마도 그 문제 상황들은 변경되거나 지속되어야 할 상태(State)를 가질 것입니다. 어떤 경우엔 API 리퀘스트에서 데이터를 불러와서 알림을 띄워주기도 하고, 수 많은 화면들이 존재하는데 각 화면이 글로벌 앱 상태를 변경해야 하는 경우도 있을 것입니다. 오랜 기간 유지되며 확장 가능한 아키텍쳐를 만드려면 이러한 업무를 처리하는 일관된 방식이 있는 것은 필수입니다. 물론 사람 수에 상관없이 중요합니다.

예제는 Swift 5.1과 Xcode 11 베타를 사용해서 만들었지만, 앞으로 얘기할 내용은 UIKit 기반 어플리케이션에도 동일하게 적용됩니다. SwiftUI를 선택한 이유는 애플이 UIKit 시절과 다르게 아키텍쳐가 어떻게 생겨야 하는지에 대해 어느정도 강력한 태도를 가지고 있다고 생각했기 때문입니다. 저희는 이번 시간이 아키텍쳐의 중요성을 이해하고 애플이 제공하는 도구를 발판으로 사용해서 니즈에 딱 맞는 아키텍쳐를 만들기 위한 완벽한 기회라고 생각하기 때문에 SwiftUI를 선택했습니다.

오늘은 SwiftUI의 작동 방식 중 우리에게 꼭 필요한 내용이 아니면 깊게 파지 않을 것입니다. 꼭 필요한 부분은 자세히 설명하겠지만 전체적으로 깊이 알아보는 것은 다른 에피소드에서 다루도록 하겠습니다.

어플리케이션 만들기

오늘 만들 어플리케이션은 간단한 숫자 카운팅 앱이고, 숫자가 소수(prime number)인지 확인하고 즐겨찾기 목록에 추가/제거 할 수 있는 기능을 가지고 있습니다. 또한 n번째 소수를 찾는 기능도 있는데, 이는 Wolfram Alpha의 API를 사용할 것입니다. 즐겨찾기한 소수의 목록도 볼 수 있고 원하면 제거도 할 수 있습니다. 어플리케이션의 데이터가 어떻게 흐르는지 보여주는 것에 집중하기 위해 스타일링은 최소한으로 넣었습니다.

이 앱은 절대 실제 서비스되는 어플리케이션과 같을 수는 없습니다. 오히려 토이 프로젝트에 가깝다고 할 수 있죠. 하지만 우리의 어플리케이션 아키텍쳐에 있을 문제는 실제 서비스 어플리케이션과 비교해 모자라지 않기 때문에 예제로는 충분하다는 결정을 내렸습니다.

  • 사용자에게 보여줘야 할 UI의 상태 변경이 아주 많습니다. 예를 들어, 버튼을 누르면 모달이 뜨고, 모달의 컨텐츠는 이전 화면에서 일어난 일에 의존성을 가질 것입니다. 게다가 버튼은 네트워크 리퀘스트를 발생시키고, 리퀘스트 중에는 버튼을 비활성화 할 예정입니다.

  • 즐겨찾기한 소수의 목록같이 여러 화면에 걸쳐서 유지돼야하는 상태도 존재합니다. 소수를 추가하거나 제거할지 선택하면 그 선택에 따라 모든 즐겨찾기한 소수의 목록을 보여주는 리스트를 업데이트 해야하기 때문입니다.

  • 앱을 아주 작은 단위의 서브 컴포넌트로 나눌 수 있습니다. 이러한 상황이 왜 좋냐면 특정 화면을 만들 때 큰 범위인 어플리케이션을 고민하지 않고 고립된 상태에서 개발할 수 있습니다. 더 나아가면 Swift 패키지로 만들 수도 있겠죠?

  • n번째 소수를 찾기 위한 계산을 하는 API 리퀘스트 같은 사이드 이펙트가 앱내에 존재합니다. 여기서 사이드 이펙트를 처리하는 방식에 대한 생각을 말씀드리자면, 아무데서나 네트워크 호출을 하지 않게 만들고 싶습니다. 무차별한 네트워크 호출은 앱 내의 데이터 흐름을 이해하기 어렵게 만들고 뷰를 테스트하기 어렵게 만들기 때문입니다.

네비게이션 화면

실전으로 들어가기 전의 SwiftUI 살짝 맛보기 시간입니다! 다음과 같은 루트 뷰를 만듭니다. 이 뷰는 타이틀 뷰와 수직으로 쌓여있는 두 개의 버튼으로 구성돼있습니다. SwiftUI는 항상 View 프로토콜에 따른 구조체를 생성해야 하는데 일단 내용은 EmptyView로 채워놓겠습니다.

import SwiftUI

struct ContentView: View {
  var body: some View {
    EmptyView()
  }
}

import PlaygroundSupport

PlaygroundPage.current.liveView = UIHostingController(
  rootView: ContentView()
)

여러분은 여러분도 모르게 Swift 5.1에서 발표된 새로운 기능을 이미 사용하셨습니다!

  • body 타입의 속성에 some 키워드가 있습니다. 당장 이 키워드의 작동 방식에 대해서 깊이 이해할 필요가 당장은 없습니다. 나중에 다른 에피소드에서 some의 개념에 대해 자세히 다룰 것이니 지금은 View 프로토콜에 맞는 어떠한(some) 값을 반환하기만 하면 된다고 이해하시면 되겠습니다.

  • 한 줄 짜리 블록에선 return을 굳이 명시하지 않아도 알아서 값을 반환합니다.

플레이그라운드에서 뷰를 렌더링하기 전에 UIHostingController으로 한 번 감싸는 것을 보셨나요? 이는 SwiftUI 세상과 UIKit 세상을 연결하는 작업입니다.

이제 뷰의 내용을 채워볼까요? 다른 화면으로 이동해야하니 루트 뷰는 NavigationView로 바꾸겠습니다.

struct ContentView: View {
  var body: some View {
    NavigationView {
    }
  }
}

NavigationView를 사용하면 다른 화면으로 갈 수 있는 버튼인 NavigationLink를 사용할 수 있게 됩니다.

struct ContentView: View {
  var body: some View {
    NavigationView {
      NavigationLink(destination: EmptyView()) {
        Text("Counter demo")
      }
      NavigationLink(destination: EmptyView()) {
        Text("Favorite primes")
      }
    }
  }
}

SwiftUI는 요소가 나란히 있거나 하나가 다른 하나의 위에 있을 경우만 뷰를 그리기 때문에 지금같은 코드에선 첫 번째 NavigationLink만 보일 것입니다. 저희는 전자이니 ListNavigationLink들이 나란히 있다고 명시적으로 적어주곘습니다.

struct ContentView: View {
  var body: some View {
    NavigationView {
      List {
        NavigationLink(destination: EmptyView()) {
          Text("Counter demo")
        }
        NavigationLink(destination: EmptyView()) {
          Text("Favorite primes")
        }
      }
    }
  }
}

마지막으로 뷰의 타이틀을 세팅하기 위해 navigationBarTitleNavigationView의 최상단에 추가합니다.

struct ContentView: View {
  var body: some View {
    NavigationView {
      List {
        NavigationLink(destination: EmptyView()) {
          Text("Counter demo")
        }
        NavigationLink(destination: EmptyView()) {
          Text("Favorite primes")
        }
      }
      .navigationBarTitle("State management")
    }
  }
}

아직 무엇을 채워야 할지 확실하지 않은 화면에 EmptyView을 넣어보세요. UI를 천천히 채워넣어도 되는 것은 정말 끝내주게 편합니다. 만약 뷰가 꼭 있어야하는데 어떤 내용을 채워야 할지 모르겠다면 EmptyView가 제격입니다.

어느새 첫 번째 화면이 완성되었네요! SwiftUI를 사용하니 정말 빠르게 화면을 구성할 수 있어서 좋네요.

카운터 화면

다음은 카운터 화면을 작성할 시간입니다. 일단 구조만 먼저 잡아두겠습니다.

struct CounterView: View {
  var body: some View {
    EmptyView()
  }
}

import PlaygroundSupport

PlaygroundPage.current.liveView = UIHostingController(
//  rootView: ContentView()
  rootView: CounterView()
)

이제 가로 스택과 세로 스택을 나타내기 위해 HStackVStack을 사용해서 화면을 그려보겠습니다.

struct CounterView: View {
  var body: some View {
    VStack {
      HStack {
        Button(action: {}) {
          Text("-")
        }
        Text("0")
        Button(action: {}) {
          Text("+")
        }
      }
      Button(action: {}) {
        Text("Is this prime?")
      }
      Button(action: {}) {
        Text("What is the 0th prime?")
      }
    }
  }
}

Button을 사용하려면 버튼을 탭 했을 경우 해야 할 액션을 지정해줘야 합니다. 이 버튼에 역할이 없는 것은 아니지만 지금 당장은 화면만 만드는 중이니 나중을 위해 빈 클로저를 넣어주겠습니다.

이번 에피소드에서는 스타일링에 대해 신경쓰지 않기로 했지만 화면의 가독성을 헤치는 건 원치 않기 때문에 타이틀 사이즈로 폰트를 설정해주었습니다.

.font(.title)

드디어 핵심 UI가 제자리를 찾았네요. 하지만 루트 뷰인 ContentView를 완성시켜서 버튼을 누르면 카운터 화면으로 보내는 일이 남았습니다.

NavigationLink(destination: CounterView()) {

이제 CounterView로 넘어갈 수 있겠네요.

그리고 타이틀이 제대로 된 곳에 위치하기 위해서는 카운터 뷰에 네비게이션 타이틀도 추가해야 합니다.

.navigationTitle("Counter demo")

아직 이 화면이 하는 일은 아무것도 없습니다. 버튼을 눌러봐도 아무 작동도 하지 않습니다.

SwiftUI가 제공하는 옵션 중 하나는 특정한 뷰에 상태를 이어주어서 상태가 바뀌면 뷰가 다시 그려지도록 할 수 있는 것입니다. 가장 간단한 옵션부터 시작해봅시다. @State를 사용해서 뷰에 상태를 이어주겠습니다.

@State var count: Int = 0

이는 Swift의 새로운 기능인 프로퍼티 래퍼(Property Wrapper) 입니다. 어떠한 타입을 어떤 기능을 제공하는 다른 타입으로 한 번 감쌀 수 있으면서 내부의 값을 바깥에서는 이전과 같이 사용할 수 있는 기능입니다. 위에서 @State 속성은 정수 타입을 한 번 감싸면서 새로운 객체로 만들었는데, 이 속성을 사용하면 다음과 같은 특징을 가지게 됩니다.

count 변수만 변경해도 뷰에 그려져 있는 값이 알아서 변경됩니다.

self.count // Int

가장 쉬운 확인 방법은 Text에 넣어보는 것이죠.

Text("\(self.count)")

그럼 이제 입력한 숫자가 “몇 번째” 소수인지 알려줄 수 있는 텍스트를 보여줘볼까요? 그럴려면 count의 값을 그냥 정수가 아닌 몇 번째인지 알려줄 수 있게 바꿔야겠네요. (예. 1st, 2nd, 3rd 등) NumberFormatter를 사용해서 작성해봅시다.

private func ordinal(_ n: Int) -> String {
  let formatter = NumberFormatter()
  formatter.numberStyle = .ordinal
  return formatter.string(for: n) ?? ""
}

이 헬퍼 함수를 이용해서 버튼의 내용을 업데이트합시다.

Button(action: {}) {
  Text("What's the \(ordinal(self.count)) prime?")
}

지금까지 예시로만 봤을 때 @State는 많은 일을 하지 않는 것처럼 보일 수도 있습니다. 그저 정수인 count를 직접적으로 참조하고 있었으니까요. 하지만 사실은 뒤에서 @StatecountBinding으로 감싸주고 있습니다.

self.$count // Binding<Int>

이게 바로 SwiftUI가 값이 언제 바뀌었는지 알아내고 변경된 정보를 통해 뷰를 다시 그리는 데에 사용하는 타입인 Binding입니다. 뷰를 작성하는 입장에서 변경될 수 있는 값을 이렇게 간단하고 익숙하게 만들 수 있기 때문에 이건 정말 강력한 기능이라고 생각합니다! 물론 SwiftUI는 수면 아래에서 열심히 값의 변경이 어떤 곳의 변화를 일으켜야 하는지 계속 계산하고 있겠죠.

이러한 이유로 +/- 버튼에선 count 변수만 건드리면 바로 값이 변경하는 코드가 완성됩니다.

Button(action: { self.count -= 1 }) {
  Text("-")
}
Button(action: { self.count += 1 }) {
  Text("+")
}

어느정도 작동하는 앱이 만들어졌네요! 이제 +/- 버튼을 누르면 counter 값이 변하는 데다가 버튼에 있는 레이블의 내용도 변경될 것입니다.

카운터 화면 상태 유지하기

지금까지는 아주 쉬웠으니 모두 이해하셨으리라 믿습니다. 사실은 심각한 문제가 하나 있습니다. @State 속성을 사용한다는 것은 뷰에게 신경써야 할 로컬 상태가 있다는 것을 의미합니다. 하지만 이 상태를 다른 화면으로 넘어갔을 때도 유지할 수 있는 방법이 없습니다. 이 상황은 쉽게 재현이 가능한데요, 카운터 화면에서 메인 화면으로 갔다가 다시 카운터 화면으로 가면 이전 값을 유지하는 것 대신 값을 0으로 초기화됩니다.

그 이유는 바로 @State는 로컬에 특화된 기능이라 다른 화면으로 유지되지는 않기 때문입니다. 대표적인 예시는 바로 버튼의 하이라이트 상태입니다. 버튼의 하이라이트 상태는 사용자의 터치에 의해 변경되고 화면 바깥으로 변경된 정보가 전달될 필요가 없기 때문입니다.

상태가 유지되길 원한다면 SwiftUI가 제공하는 기능인 @ObjectBinding을 사용해야 합니다. 이 기능의 작동 방식은 @State와 매우 비슷한데요, 다른 점은 여러분의 상태가 어느 위치에서 어떻게 작동해야하는지 SwiftUI 시스템에 알려줄 필요가 없다는 것입니다. @ObjectBinding을 사용하면 우리의 상태를 로컬 뷰에 묶이지 않고 좀 더 전역적인 객체로 만들고 앱 전체에 상태를 알릴 수 있습니다.

그럼 이제 @State@ObjectBinding으로 변경해보겠습니다.

@ObjectBinding var count: Int = 0

하지만 이 코드에는 두 가지 문제점이 있습니다.

  • 첫 번째, 이 화면을 열 때 항상 카운터 값이 0부터 시작하진 않을 것이기 때문에 기본값을 0으로 지정하면 안됩니다. 이 값이 전역적으로 유지되게 만드려면 기본값을 제거하고 var count: Int처럼 타입만 지정하면 되겠네요.

  • 두 번째, @ObjectBinding을 따르는 값의 타입은 항상 BindableObject 프로토콜을 따라야 합니다. 상태가 유지되려면 어떻게 값의 변경을 제어할지와 나머지 시스템에 어떻게 알릴지에 대한 프로토콜을 반드시 구현해줘야 합니다.

두 번째의 의미는 count를 프로토콜을 받을 수 있는 타입으로 한 번 감싸줘야 한다는 것을 의미합니다. struct(값 타입이 좋은 점은 지난 에피소드에서 알려드렸습니다!)가 이 프로토콜에 어울리는지 한 번 봅시다.

struct AppState: BindableObject {
  var count: Int
}

바로 문제가 생겼네요.

🛑 Non-class type ‘AppState’ cannot conform to class protocol ‘BindableObject’

이 에러는 BindableObject 프로토콜이 AnyObject를 따르기 때문인데요, AnyObject를 따른다는 것은 곧 이 객체가 class여야 한다는 것을 의미합니다. 잘 생각해보면 이 경우 사용해야 하는건 클래스가 맞는 것 같습니다. 왜냐하면 우리가 원하는 상태는 하나여야 하고(singular), 앱 상태는 어딜가나 유지돼야해서(persistent) 상태가 복사가 아닌 참조되는 것이 맞기 때문입니다. 바로 모든 작업이 하나의 진짜 데이터에서 일어나는 것이죠.

그러니 클래스로 변경하겠습니다.

class AppState: BindableObject {
  var count: Int
}

이제 우리에게 돌아오는 건 컴파일러가 보내는 count가 초기화되지 않았다고 하는 에러와 프로토콜의 요구 사항을 충족하지 않았다는 에러입니다.

일단 count의 기본값을 설정해줍니다.

class AppState: BindableObject {
  var count = 0
}

프로토콜을 충족하려면 반드시 didChange 프로퍼티를 제공해야 합니다. 자동 완성이 알려주는 모양이 조금 혼란스러울 수 있지만, 우리가 해야 하는 일은 단지 didChange 퍼블리셔를 제공하는 것입니다.

var didChange: AppState.PublisherType

바로잡기

Xcode 11 베타 5 이후 버전에선 SwiftUI의 BindableObject 프로토콜이 Combine 프레임워크의 ObservableObject 프로토콜로 변경되었습니다. 이 프로토콜은 ObservableObjectPublisher의 프로퍼티인 objectWillChange를 사용하고, 모델에 어떠한 변경이 일어나도 그 바로 직전에(이후가 아닙니다) 실행됩니다.

let objectDidChange = ObservableObjectPublisher()

또한 위의 보일러 플레이트 코드도 필요가 없어집니다. 왜냐하면 ObservableObject 프로토콜이 기본 퍼블리셔로 제공되기 때문입니다.

퍼블리셔(Publisher)는 Combine 프레임워크에서 나와서 SwiftUI에도 적용된 개념입니다. 이 퍼블리셔에 대해서는 나눌 얘기가 정말 많으니 다른 에피소드에서 따로 다루려고 합니다. 그러니 지금 당장은 모델에 변경이 일어났을 때 이 변경에 관심있는 구독자들에게 알림을 주기 위한 매커니즘 정도라고 생각하시면 되겠습니다. 우리는 PassthroughSubject를 사용하면 되겠네요. PassthroughSubject는 두 가지 제네릭을 가지고 있는데요, 하나는 값을 보낼 수 있는 제네릭과 다른 하나는 에러 상황에서 스트림을 끝내버리기 위한 제네릭입니다. 앞에서 했던 것처럼 지금 당장은 변화가 생겨도 구독자들에겐 아무 값도 보내지 않고 절대 실패하지 않는다는 의미의 VoidNever를 넣어두겠습니다.

var didChange = PassthroughSubject<Void, Never>()

이 매커니즘은 노티피케이션 센터가 작동하는 것과 매우 닮았지만 영향을 끼칠 수 있는 영역이 제한돼있다는 점이 다릅니다. 이 객체의 변화에 대해 누군가가 관심이 있다면 쉽게 구독할 수 있고, 우리는 변화가 발생했을 때 send 메소드에 값만 담아서 호출하면 됩니다.

드디어 컴파일러의 요구 조건을 모두 충족시킨 것 같습니다. 하지만 그렇다고 어떤 일이 일어나고 있는 것은 아닌 것 같네요. 어떤 일이 생기기 위해선 모델이 바뀔 때마다 퍼블리셔에게 값이 바뀌었다고 알려줘야 합니다. 그리고 다행히도 Swift에는 이러한 경우를 위한 우아하고 직관적인 기능이 존재합니다. 우리가 할 일은 그저 변경을 감시하고 싶은 속성에 didSet 핸들러만 붙여주는 것입니다.

var count = 0 {
  didSet {
    self.didChange.send()
  }
}

바로잡기

Xcode 11 베타 5 이후에는 didSet대신에 willSet을 사용해야 합니다.

var count = 0 {
  willSet {
    self.objectWillChange.send()
  }
}

길고 긴 보일러 플레이트 코드를 작성하지 않는 방법도 있습니다. 바로 @Published 프로퍼티 래퍼를 사용하는 것이죠.

@Published var count = 0

여기까지 어플리케이션 안에서 값이 유지되는 상태를 가지기 위해 우리가 해야하는 일에 대해 설명했습니다. 이제 뷰에 적용할 시간입니다.

@ObjectBinding var state: AppState

바로잡기

Xcode 11 베타 5 이후에는 SwiftUI의 @ObjectBinding 프로퍼티 래퍼가 Combine 프레임워크에서 소개된 @ObservedObject로 변경되었습니다.

이렇게 되면 더 이상 count 필드는 존재하지 않고, state를 통해 count에 접근해야 합니다.

Button(action: { self.state.count -= 1 }) {

Text("\(self.state.count)")

Button(action: { self.state.count += 1 }) {

Text("What's the \(ordinal(self.state.count)) prime?")

거의 다 고쳤습니다! 마지막은 카운터 뷰로 넘어갈 때 상태도 같이 넘겨주는 것입니다.

NavigationLink(destination: CounterView(state: <#AppState#>)) {

이러면 ContentView에도 앱 상태에 접근할 수 있어야하니 다음과 같이 추가해줍니다.

struct ContentView: View {
  @ObjectBinding var state: AppState

그럼 다음과 같이 작성할 수 있습니다.

NavigationLink(destination: CounterView(state: self.state)) {

정말 마지막으로 플레이그라운드 라이브 뷰를 생성할 때 AppState의 초기값을 넣어줍니다.

PlayergroundPage.current.live = UIHostingController(
  rootView: ContentView(state: AppState())
)

좋습니다. 지금까지 전역 앱 상태를 필요로 하는 뷰에 이어주는 배관 작업을 진행했습니다. 이러한 작업의 이점은 바로 count 값이 모든 화면에 걸쳐 유지된다는 점입니다! 우리는 카운터 화면에 들어가도, 여기서 count 값을 변경해도, 다시 메인 화면으로 돌아갔다가 다시 카운터 화면으로 돌아와도 유지되는 count 값을 확인할 수 있습니다. SwiftUI의 @ObjectBinding의 힘이 얼마나 강력한지 확인하는 하루였네요.

다음 시간 예고: 소수 확인기

이번 시간엔 뷰에 상태를 표시하는 방법, 뷰에 일어난 반응을 상태의 변화로 만드는 방법 그리고 전체 어플리케이션에 걸쳐 상태를 유지하는 방법을 알게되었으니, 이젠 새로운 화면을 하나 만들 것입니다. 그 화면은 바로 소수 확인기 모달입니다. 여기엔 “이 수가 소수인가요?” 버튼이 존재하고, 이 버튼을 탭하면 현재 카운터의 count 값이 소수인지 아닌지 레이블에 보여줄 것입니다. 그리고 즐겨찾기 소수 목록에 추가/제거 할 수 있는 버튼도 제공할 예정입니다.


연습문제

  1. 앱을 껐다가 켜도 상태를 불러와서 이전과 같이 유지되게 하려면 어떻게 할 수 있을까요? 몇 가지 단계를 거치면 가능합니다.

    • AppStateCodable을 따르도록 만들어야 합니다. 왜냐하면 PassthroughSubjectwillChange 프로퍼티에 CodingKeys를 제공하지 않으면 인코딩과 디코딩 모두 직접 구현 해야하기 때문입니다.
    • 모든 모델의 willSet에 가서 상태를 JSON으로 만들어서 UserDefaults에 저장하도록 만듭니다.
    • 시작 화면인 ContentView가 생성될 때 UserDefaults에서 AppState를 불러와서 상태로 만든 후 ContentView에 넣어줍니다.

    이 구현을 성공하신다면 여러분의 데이터는 여러 번의 플레이그라운드 실행에도 유지될 것입니다! 그렇지만 몇 가지 문제가 있긴 합니다. Codable을 구현하는 것은 PassthroughSubject때문에 좀 귀찮습니다. 우리는 상태가 변할 때마다 UserDefaults에 상태를 저장하는데 이는 매우 비효율적입니다. 게다가 모든 willSet에 대해 반복하게 해두었으니 정말 비효율적입니다. 이를 효율적으로 관리하는 방법에 대해서는 곧 알아볼 예정이니 걱정마세요.

  2. 어떤 정수가 주어졌을 때 이 정수가 소수인지 아닌지 확인하는 알고리즘을 찾고 Swift로 변환해보세요.

  3. 만약 현재 count 값이 소수라면 카운터 화면의 Text를 초록색으로, 아니라면 빨간색으로 바꾸도록 만들어보세요.

  4. SwiftUI의 모달을 나타내기 위해 옵셔널한 Modal 값 하나를 인자로 받는 presentation을 사용하신 분도 계실겁니다. 이 값이 있으면 모달이 나타나고 이 값이 nil이라면 화면이 닫힙니다.

    선택적으로 @State 값을 CounterView에 추가한 후, "이 수가 소수인가요?" 버튼을 눌렀을 때 그 결과에 맞춰서 화면을 보여주거나 숨길 수 있게 만들어보세요.

  5. var favoritePrimes: [Int] 필드를 AppState에 추가한 후 이 값이 변경되면 didChange에 잡히는지 확인해보세요.

    이 새로운 favoritePrimes 상태를 모달의 "즐겨찾기 소수에 추가" / "즐겨찾기 소수에서 제거" 버튼을 그리는 데에 상요해보세요. 또한 버튼을 누르면 이 즐겨찾기 소수 목록에서 추가 혹은 제거할 수 있는 기능을 구현해보세요.

  6. AppState에 새로운 상태를 추가하는 것이 아주 번거롭습니다. 상태의 어떠한 필드가 변경되면 willChange를 호출해야 하며, 여러 필드를 하나의 상태 클래스에 묶어두려면 더 많은 작업이 필요할 것입니다.

    이러한 문제는 제네릭 클래스인 Store<A>를 통해 해결할 수 있습니다. 이 클래스를 구현하고 어플리케이션 내부의 AppStateStore<AppState>로 변경해보세요.


참고자료

  • SwiftUI Tutorials

    Apple

    애플은 SwiftUI와 Combine과 동시에 새로운 개념에 대해 이해할 수 있는 초고퀄의 인터랙티브한 튜토리얼도 제공하고 있습니다.

  • Inside SwiftUI (About @State)

    kateinoigaku • Sunday Jun 9, 2019

    @State가 뒤에선 어떻게 작동되고 있는지 알고 있는 사람은 드물 것입니다. 때론 마법처럼 보이기도 하죠! 이 글은 @State가 내부적으로 어떻게 구현됐는지 알아보고, SwiftUI가 런타임에 사용할 수 있는 풍부한 메타데이터를 사용하고 있다고 합니다. (과거에 이와 관련해서 깊게 판 글쓴이의 다른 글도 여기있습니다)