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

본문 링크

오늘은 파트 1에서 만든 어플리케이션에 약간의 사이드 이펙트와 화면을 추가해서 어플리케이션을 완성해봅시다. 그 후에 “그래서 요점이 무엇인가요?” 시간을 가져볼 예정입니다.

이 시리즈는 총 세 편으로, 이전 글을 읽고 오시는 것을 추천드립니다.

#65: SwiftUI와 상태 관리 - 파트 1

바로잡기

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

시작하며

이전 글에서 우리는 각 뷰에 전역 앱 상태를 적절히 넣는 방법을 익혔지만 당장 우리가 얻는 이득은 그저 count 값이 모든 화면에서 유지되는 것밖에 없습니다. 이는 SwiftUI의 @ObjectBinding이 가진 힘의 극히 일부에 지나지 않습니다.

소수 확인 모달

이제 뷰에서 상태를 표시하는 방법과 상태가 바뀌면 뷰가 반응하게 하는 방법 그리고 더 나아가서 전체 어플리케이션에 걸쳐서 상태를 유지하는 방법을 알게 되었으니 새로운 화면을 하나 만들어보겠습니다. 바로 주어진 정수가 소수인지 아닌지 확인하는 모달입니다. 이 화면은 사용자가 “소수인가요?” 버튼을 누르면 나타나고 현재 카운터의 숫자가 소수인지 아닌지에 대한 결과를 레이블에 표시해주며, 원한다면 즐겨찾기 목록에 넣을 수 있는 버튼도 제공하는 기능을 가지고 있습니다.

화면이 어떻게 생겼는지 정리해봅시다. 카운터 뷰의 숫자가 소수인지 물어봤을 때, 모달이 뜨면서 사용자에게 소수인지 아닌지 알려주고, 만약 소수라면 즐겨찾는 목록에 추가 혹은 제거할 수 있는 기능을 제공해야 합니다.

모달은 다음과 같이 SwiftUI에서 프레젠테이션 정보만 설정해주면 바로 띄울 수 있습니다.

.presentation(modal: Modal?)

바로잡기

이 에피소드는 Xcode 11 베타 3으로 녹화되었고, 이후 베타 4 버전 이후로 많은 것이 바뀌었습니다. 모달 프레젠테이션 API엔 Binding의 상태를 받아서 화면을 띄우거나 닫을 수 있는 sheet라는 뷰 수정 메소드가 생겼습니다.

modalnil을 넘기면 아무 일도 일어나지 않고, Modal 값을 넘기면 현재 뷰 위에 새로운 모달을 띄웁니다. 모달을 닫고싶으면 이 함수에 nil을 넣어주면 되겠죠?

딱 봐도 이 화면을 띄우거나 닫기 위한 상태가 필요해 보이지 않나요? 여기서 선택지가 주어지는데요, 하나는 @State를 사용해서 로컬에 있는 상태를 사용하는 것이고 또 다른 하나는 전역 상태인 AppState를 사용하는 것입니다. 물론 전역적으로 이 정보가 필요한 경우도 존재합니다. 예를 들어 모달이 띄워져 있을 때 무언가 해야하거나 이 화면에 대해 딥링크를 구현한다면 AppState가 필요하겠습니다만, 지금 당장은 그럴 일이 없어보이니 @State를 사용하도록 하겠습니다.

@State var isPrimeModalShown: Bool = false

위와 같이 .presentation을 통해 띄울 화면의 상태를 만들어줍니다.

.presentation(
  self.isPrimeModalShown
    ? Modal(Text("I don't know if \(self.state.count) is prime"))
    : nil
)

당장 코드를 실행하면 아무 일이 일어나지 않는데요, 왜냐하면 모달의 띄울지 말지 결정하는 isPrimeModalShown이 변경되는 코드가 없기 때문입니다. 모달을 띄우기 위해 버튼의 action을 설정해주겠습니다.

Button(action: { self.isPrimeModalShown = true }) {

위 코드를 실행하면 실제로 작동하는 것처럼 보이긴 하지만, 여기엔 버그가 존재합니다. 버튼을 누르면 모달이 뜨거나 닫히지만 카운터의 숫자를 변경하면 (또는 뷰의 상태가 바뀌면) 모달이 갑자기 닫힙니다. 왜그럴까요?

모달이 닫히고 나서 isPrimeModalShownfalse로 바꾸지 않아서 SwiftUI가 다른 모달을 띄워야 한다고 이해했기 때문입니다. 이 버그는 Modal 값의 onDismiss 액션을 리셋하는 걸로 아주 쉽게 해결이 가능합니다.

.presentation(
  self.isPrimeModalShown
    ? Modal(
      Text("I don't know if \(self.state.count) is prime"),
      onDismiss: { self.isPrimeModalShown = false }
      )
    : nil
)

이렇게 하면 isPrimeModalShown 값이 원래대로 돌아오게 돼서 이젠 모달이 닫힌 후에도 정상적이게 작동할 것입니다.

여기까지 모달 자체는 정확하게 작동하지만 아직 모달 자체에 유용한 정보는 존재하지는 않습니다. 지금은 간단한 텍스트 뷰만 보여주고 있지만 이젠 더 복잡한 내용을 보여줘야 할 시간입니다. 텍스트 뷰와 버튼으로 구성된 VStack과 몇몇 로직을 추가해서 뷰를 작성해보겠습니다. 이 뷰는 복잡도가 높기 때문에 관련 로직이 합쳐진 새로운 타입을 만드는 것이 나을 것 같네요.

struct IsPrimeModalView: View {
  var body: some View {
    Text("I don't know if \(self.state.count) is prime")
  }
}

이제 프레젠테이션 코드에 이 뷰를 적용해보죠.

.presentation(
  self.isPrimeModalShown
    ? Modal(
      IsPrimeModalView(),
      onDismiss: { self.isPrimeModalShown = false }
      )
    : nil
)

🛑 Value of type ‘IsPrimeModalView’ has no member ‘state’

모달에게 상태를 소개할 시간이 왔네요.

struct IsPrimeModalView: View {
  @ObjectBinding var state: AppState
  var body: some View {
    Text("I don't know if \(self.state.count) is prime")
  }
}

그리고 상태를 넘겨줍니다.

.presentation(
  self.isPrimeModalShown
    ? Modal(
      IsPrimeModalView(state: self.state),
      onDismiss: { self.isPrimeModalShown = false }
      )
    : nil
)

모달 뷰를 고립시켜서 레이아웃에만 집중할 수 있게 되었으니 뷰를 작성할 시간입니다. 주어진 정수가 소수인지 아닌지 확인한 결과를 보여주는 텍스트와 즐겨찾는 소수 목록에 추가/제거하는 버튼을 만들어보겠습니다.

struct IsPrimeModalView: View {
  @ObjectBinding var state: AppState
  var body: some View {
    VStack {
      Text("I don't know if \(self.state.count) is prime")
      Button(action: {}) {
        Text("Save/remove to/from favorite primes")
      }
    }
  }
}

이제 로직이 필요한 시점입니다. 소수인지 아닌지 알아야 텍스트를 변경할 수 있겠죠? 이를 위해서 isPrime이라는 헬퍼 함수를 작성하겠습니다.

private func isPrime (_ p: Int) -> Bool {
  if p <= 1 { return false }
  if p <= 3 { return true }
  for i in 2...Int(sqrtf(Float(p))) {
    if p % i == 0 { return false }
  }
  return true
}

이 값을 이용해서 Text에 들어갈 내용을 만들어줍시다.

if isPrime(self.state.count) {
  Text("\(self.state.count) is prime 🎉")
} else {
  Text("\(self.state.count) is not prime :(")
}

이제 count 값이 소수인지 아닌지에 따라 모달에 다른 텍스트를 보여줍니다.

다음은 버튼의 차례입니다. count 값이 소수가 아니라면 숨겨야하니 if 안쪽으로 위치를 옮기겠습니다.

if isPrime(self.state.count) {
  Text("\(self.state.count) is prime 🎉")
  Button(action: {}) {
    Text("Save/remove to/from favorite primes")
  }
} else {
  Text("\(self.state.count) is not prime :(")
}

뷰는 일단 완성된 것 같으니 즐겨찾는 소수 목록을 생각해보죠.

그런데 즐겨찾는 소수 목록에 대해 우리가 고민한 적이 있었던가요?

일단 상태에 추가하는 것부터 시작하겠습니다.

AppState로 돌아가 배열을 하나 추가해서 사용자의 즐겨찾는 소수 목록을 만들어줍니다. 그리고 didSet을 override해서 변경이 생겼을 경우를 대비합니다.

var favoritePrimes: [Int] = [] {
  didSet { self.didChange.send() }
}

바로잡기

이 에피소드는 Xcode 11 베타 3으로 작성되었습니다. 베타 3 이후에 SwiftUI의 BindableObject가 없어지고 Combine 프레임워크에서 새롭게 소개된 ObservableObject 프로토콜이 추가되었습니다. 이 프로토콜은 ObservableObjectPublisher의 속성인 objectWillChange를 제공해줘서 모델이 변경되기 직전에 알 수 있습니다. 이러한 이유로 didSet 대신에 willSet을 사용합니다.

var favoritePrimes: [Int] = [] {
  willSet { self.objectWillChange.send() }
}

@Published라는 프로퍼티 래퍼를 사용하면 위의 보일러 플레이트 코드를 완전히 없앨 수도 있습니다!

@Published var favoritePrimes: [Int] = []

이건 BindableObject에 상태를 추가한다면 꼭 필요한 작업입니다. 항상 didSet에 들어가서 send 메소드를 통해 didChange를 호출하는 것을 잊지마세요.

이제 즐겨찾는 소수 배열의 접근 권한을 가지게 되었으니 필수 로직을 구현해볼까요?

if self.state.favoritePrimes.contains(self.state.count) {
  Button(action: {}) {
    Text("Remove from favorite primes")
  }
} else {
  Button(action: {}) {
    Text("Save to favorite primes")
  }
}

각 버튼에 들어가야 할 액션은 어떻게 될까요? 즐겨찾는 소수 목록에서 소수를 삭제하는 일은 꽤 간단합니다. Swift 표준 라이브러리에는 특정 조건에 맞는 모든 요소를 제거하는 API가 존재합니다.

Button(action: { self.state.favoritePrimes.removeAll(where: { $0 == self.state.count }) }) {

추가하는 것은 더 쉽습니다. 그냥 count 값을 배열에 추가하기만 하면 됩니다.

Button(action: { self.state.favoritePrimes.append(self.state.count) }) {

이제 어플리케이션을 실행해보면 버튼을 토글했을 경우 즐겨찾는 소수 목록에 추가 또는 제거가 되는 것을 확인하실 수 있을 것입니다. 상태 또한 유지될테니 모달을 닫았다가 다시 띄워도 내용이 그대로여야겠죠?

사이드 이펙트 뿌리기

즐겨찾는 소수 목록에 소수를 추가 / 제거할 수 있고, 자동으로 변경되는 것을 보니 모달은 완성된 것 같습니다.

이제 어플리케이션의 복잡도를 한 단계 높일 시간입니다. 화면에는 카운터의 n 값으로 “n번째” 소수를 찾는 버튼이 있습니다. 매번 이런 계산을 하는 것은 비용이 적지 않은데 isPrime 헬퍼가 모자란 것도 한 몫 하는 것 같네요. 이 로직을 로컬에서 더욱 효율적이게 만들 수 있겠지만, 우리는 좀 더 편한 방법인 Wolfram Alpha의 강력한 API를 사용하도록 하겠습니다.

다음은 Wolfram Alpha API를 사용하는데 도움을 줄 간단한 모델입니다. API에서 오는 데이터 구조 그대로 구조체로 모델링했습니다.

struct WolframAlphaResult: Decodable {
  let queryresult: QueryResult

  struct QueryResult: Decodable {
    let pods: [Pod]

    struct Pod: Decodable {
      let primary: Bool?
      let subpods: [SubPod]

      struct SubPod: Decodable {
        let plaintext: String
      }
    }
  }
}

쿼리 스트링을 입력으로 받는 함수는 입력받은 값을 Wolfram Alpha API로 보내고 돌아온 결과를 위의 구조체 모양으로 디코드한 후 콜백에 결과물을 넘겨줍니다.

func wolframAlpha(query: String, callback: @escaping (WolframAlphaResult?) -> Void) -> Void {
  var components = URLComponents(string: "https://api.wolframalpha.com/v2/query")!
  components.queryItems = [
    URLQueryItem(name: "input", value: query),
    URLQueryItem(name: "format", value: "plaintext"),
    URLQueryItem(name: "output", value: "JSON"),
    URLQueryItem(name: "appid", value: wolframAlphaApiKey),
  ]

  URLSession.shared.dataTask(with: components.url(relativeTo: nil)!) { data, response, error in
    callback(
      data
        .flatMap { try? JSONDecoder().decode(WolframAlphaResult.self, from: $0) }
    )
  }
  .resume()
}

헬퍼 함수 덕분에 Wolfram Alpha에게 n번째 소수를 찾게 하는 더 구체적인 API 리퀘스트를 생성할 수 있게 되었네요.

func nthPrime(_ n: Int, callback: @escaping (Int?) -> Void) -> Void {
  wolframAlpha(query: "prime \(n)") { result, response, error in
    callback(
      result
        .flatMap {
          $0.queryresult
            .pods
            .first(where: { $0.primary == .some(true) })?
            .subpods
            .first?
            .plaintext
        }
        .flatMap(Int.init)
    )
  }
}

천 번째 소수를 한 번 찾아볼까요?

nthPrime(1_000) { p in print(p) }
// 7919

로컬에서 계산하기 부담스러운 백만번째 소수도 이 API를 사용하면 쉽게 찾을 수 있습니다.

nthPrime(1_000_000) { p in print(p) }
// 15485863

이 API를 어디에 사용하냐구요? 일단 말로 설명드리겠습니다. “n번째 소수를 알려주세요” 버튼을 누르면 이 API 리퀘스트가 생성되고 결과 처리 후에 사용자에게 알림창을 띄웁니다.

알림창은 .presentation 메소드를 통해 모달을 띄웠던 것과 매우 비슷한 방법으로 띄울 수 있습니다. 약간 다른 점은 모달에서 했던 것처럼 옵셔널로 Alert값을 받는 것이 아닌 알림창의 상태를 컨트롤 하는 값인 Binding 을 명시해야 한다는 점입니다. 이 API에는 다음과 같이 두 가지 버전이 존재합니다.

.presentation(isShown: Binding<Bool>, alert: () -> Alert)
.presentation(data: Binding<Identifiable?>, alert: (Identifiable) -> Alert)

바로잡기

이 에피소드는 Xcode 11 베타 3을 기반으로 작성되었습니다. 베타 4부터 알림창의 presentation API의 이름이 alert(isPresented:content:)alert(item:content:)로 변경되었습니다.

Binding에 불리언 값을 받아서 true를 넘기면 알림창이 보여지고 false면 내려가게 하는 방식의 API와 옵셔널 값을 받아서 값이 있으면 알림창을 보여주고 nil이면 내려가게 하는 방식의 API 두 가지 입니다.

두 번째 API의 옵셔널 Binding 값은 @State@ObjectBinding을 통해 상태를 나타내기에 더 명확하기 때문에 이 API를 사용하겠습니다. 지금 당장 생각해봤을 때 화면의 알림창 띄우는 것에 대한 상태가 전역에서 가져올 필요가 없으니 @State를 사용하겠습니다.

@State var alertNthPrime: Int?

이제 이 값을 사용해서 알림창을 띄워보겠습니다.

.presentation(self.$alertNthPrime) { n in
  Alert(
    title: Text("The \(ordinal(self.state.count)) prime is \(n)"),
    dismissButton: Alert.Button.default(Text("Ok"))
  )
}

$alertNthPrime을 사용해서 불리언 대신 alertNthPrime을 바인딩했으니, 이 값이 정수가 된 시점에 클로저가 그 정수를 받아서 작동해서 알림창을 구성하게 되고 사용자에게 띄워집니다.

여기서 의문이 생깁니다. 저 상태의 값은 어떻게 설정할 수 있을까요? 프로세스를 생각해보겠습니다. n번째 소수를 찾는 버튼을 누르면 Wolfram Alpha에 API 리퀘스트를 발생시키고 리스폰스를 받으면 그 결과에 대한 알림창을 띄웁니다. 그러면 “n번째 소수를 알려주세요” 버튼으로 돌아가서 액션을 추가해야할 것 같네요.

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

위 코드를 실행해서 버튼을 눌러보면 네트워크 리퀘스트 동안은 잠시 앱이 멈췄다가 작업이 끝나면 알림창이 뜹니다. 그리고 사용자가 알림창을 닫으면 SwiftUI가 이 바인딩을 nil로 재설정 할 것입니다.

즐겨찾는 소수 목록

이제 어플리케이션이 어느정도 복잡해졌네요. 우리는 어플리케이션 전역에서 상태를 관리하고 유지할 수 있게 됐고, 렌더링하는 부분에 약간의 로직도 넣었으며, 이제는 외부 서비스와 통신하는 사이드 이펙트까지 섞을 수 있게 되었습니다! 하지만 아직 복잡함이 부족합니다. 결과물을 보면 아시듯이 아직 앱에 화면이 많지 않기 때문입니다. 앱 전역에서 상태를 유지하는 것의 강력함을 실감하려면 다른 화면도 좀 만들어야 할 것입니다. 그러면 이제 즐겨찾는 소수의 목록을 볼 수 있는 화면을 만들고 더 이상 좋아하지 않는 소수는 지울 수 있는 기능도 추가해봅시다!

이 화면의 기능과 들어가는 방법을 다시 한 번 정리해봅시다. 루트 네비게이션 뷰에서 즐겨찾는 소수 목록으로 갈 수 있으며, 이 목록은 즐겨찾기한 소수가 무엇이든 모두 보여줍니다. 그리고 원한다면 목록에서 소수를 지우는 기능도 가지고 있습니다.

새로운 화면을 위한 뼈대부터 잡겠습니다.

struct FavoritePrimes: View {
  @ObjectBinding var state: AppState

  var body: some View {
    EmptyView()
      .navigationBarTitle(Text("Favorite Primes"))
  }
}

그리고 루트 컨텐트 뷰에 추가하겠습니다.

NavigationLink(destination: FavoritePrimes(state: self.state)) {
  Text("Favorite primes")
}

즐겨찾는 소수 목록에 어울리는 뷰는 무엇일까요? 즐겨찾는 소수가 각 줄에 있는 리스트가 괜찮지 않을까요? 그러면 다음과 같이 구성할 수 있겠네요.

var body: some View {
  List {
    self.state.favoritePrimes.map { prime in
      Text("\(prime)")
    }
  }
    .navigationBarTitle(Text("Favorite Primes"))
}

하지만 SwiftUI에서 이런 뷰 구성은 지원하지 않습니다. 대신, 다음과 같이 ListForEach를 붙여서 사용할 수 있습니다.

var body: some View {
  List {
    ForEach(self.state.favoritePrimes) { prime in
      Text("\(prime)")
    }
  }
    .navigationBarTitle(Text("Favorite Primes"))
}

이제 소수 몇 개를 즐겨찾는 목록에 추가한 후 이 화면으로 다시 돌아와서 제대로 목록이 업데이트되는지 확인해봅시다. 다음은 삭제 기능을 추가할 차례입니다. 이 작업은 ForEach의 각 요소에 onDelete 핸들러만 추가해주면 됩니다.

.onDelete(perform: { indexSet in
  for index in indexSet {
    self.state.favoritePrimes.remove(at: index)
  }
})

드디어 소수를 즐겨찾는 목록을 리스트로 볼 수 있으며, 더 이상 좋아하지 않는 소수는 제거할 수 있는 화면이 완성되었네요.

다음 시간 예고: 그래서 요점이 무엇인가요?

드디어 SwiftUI를 이용해서 어느정도 복잡한 어플리케이션이 완성되었네요. 정말 어메이징하지 않나요? UIKit을 사용했다면 같은 시간안에 완성은 절대 불가능하다고 확신합니다. 게다가 뷰를 그리기 위해 구현했을 델리게이트와 프로토콜은 정말 복잡했을 것이고 중간중간 발생할 버그는 말할 것도 없습니다.

여기까지도 충분하겠지만 Point-Free의 “그래서 요점이 무엇인가요?”를 통해 나무에서 숲을 보는 시간을 가지지 않으면 아쉽곘죠? 이 에피소드는 자체로도 배울 것이 많지만, 설명할만한 내용도 많습니다.

다음 시간엔 SwiftUI를 사랑할 수 밖에 없는 이유와 아직 보여드리지 못한 매력에 대해 설명하고 마무리로 SwiftUI가 열어놓은 격차를 따라잡기 위해 할 수 있는 일에 대해 알아볼 예정입니다.

다음 시간에 만나요!


연습문제

  1. SwiftUI에는 @ObjectBinding외에도 또 다른 상태 관리 솔루션인 @EnvironmentObject가 존재합니다. 그런데 이 솔루션은 뷰를 초기화할 때 상태를 넘기는 것이 아닌 루트 뷰에서 environmentObject 메소드를 사용해서 상태를 주입합니다.

플레이그라운드에 작성한 코드의 @ObjectBinding@EnvironmentObject로 변경해보세요. @ObjectBinding과 비교해서 어떤 장점과 단점을 가지고 있을까요?


참고한 자료

  • SwiftUI Tutorials - Apple

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

  • Inside SwiftUI (About @State) - kateinoigaku • Sunday Jun 9, 2019

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