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

본문 링크

지난 두 에피소드에서 SwiftUI 어플리케이션을 만들면서 이런 생각이 드셨을 것입니다. “그래서 요점이 뭔가요?” 이번 에피소드에선 SwiftUI가 앱 아키텍처에서 하는 역할과 애플이 아직 풀지 못한 문제에 대해 알아보도록 하겠습니다.

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

바로잡기

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

그래서 요점이 뭔가요?

드디어 SwiftUI로 만든 꽤 복잡한 어플리케이션이 완성됐네요! 솔직히 말하면 놀랍습니다. UIKit을 사용했다면 지금쯤 프로토콜과 델리게이트의 미로에서 갇혀서 수많은 버그와 싸우고 있었을 것입니다.

여기까지도 아주 멋지지만, 나무에서 숲을 보는 “그래서 요점이 뭔가요?” 시간도 필요하겠죠? 이미 많은 새로운 내용이 나왔지만, 그럼에도 불구하고 아직 중요한 내용이 많이 남아있습니다.

일단 저희가 SwiftUI의 가장 사랑하는 부분을 보여드리고 애플이 아직 SwiftUI에서 해결하지 못한 문제들에 대해 짚어보겠습니다.

가장 좋아하는 기능

먼저, 뷰를 선언적으로 그리는 방식을 통해 UI를 설명한다는 것은 정말 멋진 개념입니다. 예전에 UIKit를 통해 뷰를 그렸던 것에 비하면 차원이 다르다고 할 수 있습니다. 뷰를 이해하기 위해서 봐야 하는 지점이 body 하나에서 시작한다는 것과 뷰를 하나의 함수로 만들어서 상태를 입력받게 만든 것은 끝내주게 강력한 기능이라 생각합니다. 애플이 뷰 작성에 있어서 이러한 스탠스를 취하는 것이 매우 기쁩니다.

저희는 SwiftUI가 어플리케이션의 상태를 관리하기 위해 제공하는 도구를 사랑합니다. 여러 뷰 계층을 아우르지 않고 순수한 로컬의 상태만 사용하는 경우엔 @State 속성을 사용하고, 만약 변경이 있을 경우엔 특정 뷰를 다시 그리도록 만들 수 있습니다.

로컬 상태로 충분하지 않을 경우, 여러 뷰에 걸쳐서 데이터를 유지할 수 있게 해주는 @ObjectBinding 속성도 있습니다. 그러면 상태에 변경이 생겼을 경우에 다른 화면에서도 변화를 감지하고 그에 맞게 뷰를 업데이트 할 수 있습니다.

마지막으로, 어플리케이션이 어떤 아키텍처를 가져야 하는지에 대해 SwiftUI가 강력한 주관을 가지고 있는 것이 마음에 들었습니다! UIKit 세상에선 대부분의 개념이 각자 여러 가지 해석이 있을 정도로 애플이 아무것도 정해주지 않았습니다. 예를 들어 UIViewUIViewController의 차이가 뭐라고 할 수 있을까요? 둘 다 사용자 이벤트를 받을 수 있고 서브뷰도 그릴 수 있는데 어떤 사람은 UIView에선 오직 뷰를 그리는 것만 고민해야 하고 모든 로직은 뷰 컨트롤러로 가야 한다고 말하기도 합니다. 실제로 앱을 만들 때는 뷰와 컨트롤러에 데이터가 왔다 갔다 해야 하는데 이런 상황은 기존 해석에 혼란을 일으켰습니다. 어떤 사람은 두 객체가 같은 목적을 위해 존재하는 것이라고 해석했을 수도 있을 정도입니다.

심지어 UIKit은 상태 변화를 알아차리기 위한 방식을 아주 다양하게 제공해서 UI를 업데이트하는 것엔 문제가 없었지만, 상태에 기반해서 UI를 업데이트하는 방식을 한 가지로 고정하기엔 부족했습니다. 예를 들면 KVO, 델리게이트, 타겟 액션 그리고 콜백 클로저 추가 심지어는 서브클래싱 등이 있겠네요. 그리고 이러한 노티피케이션 메커니즘은 수많은 명령형 상태를 실행하는 것입니다. 예를 들어 A 버튼을 숨기거나, B 텍스트 필드를 비활성화하거나, C 레이블의 텍스트를 설정하는 것처럼요. 몇몇 사람들은 UIKit의 특성을 좋아했을 수 있지만 UIKit의 주관이 없었기 때문에 커뮤니티에선 여러가지 앱 아키텍처에 대한 내용이 퍼졌다는 주장에 반대하는 사람은 없을 것입니다.

UIKit과는 반대로 SwiftUI는 어플리케이션이 어떤 아키텍처를 가져야 하는지에 대한 주관을 뚜렷하게 제공하는 것 같습니다. 만약 화면에 어떠한 뷰를 그리고 싶다면 여러분에겐 View 프로토콜을 따르는 구조체를 만들어서 body 속성에 넣는 것밖에 방법이 없습니다. 만약 이 뷰를 동적으로 업데이트하고 싶다면 반드시 상태를 추가해줘야 하며, 상태를 추가하는 방법에도 선택지가 제한적입니다.

SwiftUI가 제한적인 선택지를 제공하는 것은 UIKit을 괴롭혀오던 수많은 문제를 해결했을 정도로 긍정적인 변화입니다! 애플은 어떠한 작업에 대해 어떤 식으로 처리하는지에 대한 어느 정도 강력한 주장을 가지고 SwiftUI를 만들었습니다. 그 결과, 엄청난 도구가 탄생했습니다.

번거로운 영구 상태 API 사용법

아쉽게도 애플이 풀지 못한 문제가 아직 존재합니다. 크고 복잡한 어플리케이션을 만들 때 SwiftUI가 아직 제공하지 않는 기능이 있습니다. 코드를 예로 들어서 어떤 것인지 알아보고 가능한 해결책에는 어떤 것이 있는지도 알아보겠습니다.

AppState 클래스를 선언해서 BindableObject로 만들기는 쉬웠지만 거대한 앱을 만들 때의 올바른 작업 방식은 아닙니다.

지금 당장은 두 개의 속성만 있지만, 이 상태 클래스가 수십 개의 속성을 가진 클래스로 자라는 것은 금방입니다. 더 나아가선 각 필드에 서브 상태 클래스가 있을 수도 있겠네요.

예를 들어 로그인한 사용자의 정보를 다음과 같은 구조체로 정의하겠습니다.

struct User {
  let id: Int
  let name: String
  let bio: String
}

그리고 AppState에 옵셔널인 User를 추가합니다.

var loggedInUser: User? = nil {
  didSet { self.didChange.send() }
}

바로잡기

이 에피소드는 Xcode 11 베타 3으로 녹화되었습니다. 이후 버전에서 SwiftUI의 BindableObject 프로토콜은 Combine 프레임워크에서 소개된 ObservableObject로 변경되었습니다. 이 속성은 ObservableObjectPublisherobjectWillChange 속성을 가집니다. 이는 모델의 변화가 있기 전에 알림을 받습니다. 그래서 didSet 대신 willSet을 사용해야 합니다.

var loggedInUser: User? {
 willSet { self.objectWillChange.send() }
}

@Published라는 프로퍼티 래퍼를 사용하면 위의 보일러플레이트 코드를 전부 제거할 수 있습니다.

@Published var loggedInUser: User?

didSet 로직은 빼놓을 수 없는 작업입니다. 왜냐하면 이 코드를 빼먹으면 미래에 사용자가 로그인해서 UI에 변경을 적용해야 할 일이 있더라도 알림을 받지 못할 것이기 때문입니다.

그리고 사용자가 앱에서 하는 모든 활동에 대한 활동 피드를 만들게 되었다고 해보겠습니다. 당연히 아래와 같은 상태를 AppState 클래스에 추가합니다.

struct Activity {
  let timestamp: Date
  let type: ActivityType

  enum ActivityType {
    case addedFavoritePrime(Int)
    case removedFavoritePrime(Int)
  }
}
var activityFeed: [Activity] = [] {
  didSet { self.didChange.send() }
}

didSet 로직을 다시 한 번 확인하겠습니다. 이 과정을 잊으면 UI 버그가 생길 것이니 항상 확인해줘야 합니다.

상태 클래스가 점점 복잡해지기 시작했는데 didSet은 절대 잊으면 안 됩니다.

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

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

var activityFeed: [Activity] = [] {
  didSet { self.didChange.send() }
}

var loggedInUser: User? = nil {
  didSet { self.didChange.send() }
}

이러한 코드 구성은 주인공인 상태가 어노테이션들에 가려지는 느낌이 듭니다.

바인딩 가능한 객체인 클래스에 값 타입을 래핑하고 값에서 발생하는 변화를 알리기 위해 그 값의 didSet을 탭해서 이 문제에 대한 전체적인 해결책을 마련할 수 있습니다. 하지만 SwiftUI를 사용하는 동안 강력하게 주관을 내비치던 애플이 이 부분엔 의견이 없어서 우리만의 방식을 사용해야 한다는 점이 아쉽습니다.

바로잡기

이 에피소드는 Xcode 11 베타 3으로 녹화되었습니다. 이후 버전에선 이러한 보일러플레이트 코드를 엄청나게 없앨 수 있는 기능이 소개되었습니다.

  • ObservableObjectPublisher가 자동으로 적용됩니다.
  • @Published로 싸여있는 속성은 자동으로 SwiftUI가 구독해서 willSet 파티를 열지 않아도 됩니다.
    class AppState: ObservableObject {
    @Published var count = 0
    @Published var favoritePrimes: [Int] = []
    @Published var activityFeed: [Activity] = []
    @Published var loggedInUser: User?
    ...
    }
    

흩어져있는 상태 변화 코드

다음으로 알아볼 문제는 비록 상태 변화를 일으키는 코드를 작성하기 쉽고 UI에 바로 적용된다고 할지라도 그 변화를 일으키는 코드를 모으는 방식에서 생기는 불명확함입니다. 지금 우리 코드에는 뷰의 변화를 일으키는 코드가 화면 군데군데 흩어져 있습니다. 그중 가장 최악은 바로 Counter 뷰입니다.

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

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

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

Button(action: {
  nthPrime(self.state.count) { prime in
    self.alertNthPrime = prime
  }
}) {

onDismiss: { self.showModal = false }

게다가 변화는 글로벌과 로컬을 신경쓰지 않고 하고 발생합니다. 이는 매번 다른 의사결정의 결과입니다. 심지어 알림창 같은 코드는 두 단계의 바인딩을 사용하고 있습니다.

.presentation(self.$alertNthPrime) { n in

바로잡기

이 에피소드는 Xcode 11 베타 3으로 녹화되었습니다. 이후 버전부터 presentation API의 이름에 변화가 생겨서 alert(isPresented:content:)alert(item:content:)로 변경되었습니다.

이 코드는 SwiftUI로 하여금 nil이 오면 알림창을 닫게 되어있는데, 이는 이 코드에 적혀있지 않은 숨겨져 있는 변화로 볼 수 있습니다!

문제는 이 코드를 처음 보는 개발자에겐 이 코드만으로는 상태 변화가 어떤 식으로 일어나는지 알 길이 없다는 것입니다. 이는 우리가 해결해야 할 중요한 문제입니다.

또 다른 문제는 뷰에 변경이 추가되면 추가될수록 화면이 점점 선언적이지 않은 방향으로 변한다는 것입니다. 이는 변경(mutation) 자체가 선언적이지 않고, 그저 실행시켜야 하는 명령을 모아놓은 작은 클로저이기 때문입니다.

뷰의 body 속성이 우리의 상태를 뷰 계층으로 변환해서 화면에 그려주는 것은 정말 환상적입니다. 이는 이해가 아주 잘 되며 변화를 일으키기도 쉬운 데다가 테스트하기도 편합니다.

앱에 두 가지 기능을 추가해서 이를 증명해봅시다. 먼저, Wolfram Alpha API에 리퀘스트를 보낸 동안은 “n번째 소수” 버튼을 비활성화시키도록 해보겠습니다. SwiftUI에는 disabled에 불리언 값을 보내는 것으로 UI 컨트롤의 활성화 여부를 변경할 수 있습니다.

.disabled(<#disabled: Bool#>

그러면 API 리퀘스트의 진행 상태를 알기 위해 불리언 값을 가지는 로컬 상태를 생성하겠습니다.

@State var isNthPrimeButtonDisabled = false

그 후에 버튼 액션 시작 부분에 true로 상태를 변경하고 API 리퀘스트가 끝나면 false를 보내도록 코드를 작성합니다.

self.isNthPrimeButtonDisabled = true
nthPrime(self.state.count) { prime in
  self.alertNthPrime = prime
  self.isNthPrimeButtonDisabled = false
}

마지막으로 이 상태 값이 버튼의 활성화 여부를 변경할 수 있도록 이어주겠습니다.

.disabled(self.isNthPrimeButtonDisabled)

버튼 액션 코드가 조금 긴 것 같지만 일단 테스트를 해보겠습니다.

작동은 잘 하네요. 그렇지만 액션 클로저 하나에 이렇게 많은 로직을 넣는 것이 그렇게 좋아 보이지만은 않습니다. 상태를 뷰 계층으로 변환하는 데 대부분을 집중하는 이 코드는 이미 선언적 특성에서 벗어나기 시작했습니다.

다음과 같이 헬퍼 메소드를 추출하는 방법도 존재할 것입니다.

func nthPrimeButtonAction() {
  self.isNthPrimeButtonDisabled = true
  nthPrime(self.state.count) { prime in
    self.alertNthPrime = prime
    self.isNthPrimeButtonDisabled = false
  }
}

다음과 같이 사용하면 되겠습니다.

Button(action: self.nthPrimeButtonAction) {

하지만 지금 코드에 있는 변화를 일으키는 코드는 두 가지 방식으로 흩어져 있습니다. 몇몇은 뷰에 붙어있고 또 다른 몇몇은 헬퍼 메소드입니다. 이 코드로 협업한다면 반드시 변화를 일으키는 코드를 어느 선에서 헬퍼 메소드로 바꿀지에 대한 가이드라인을 만들어야겠지만, 이는 아주 간단한 작업을 수행하기 위한 추가적인 과정일 뿐입니다.

변화를 일으키는 코드를 흩어지게 하는 것은 동기화를 더 어렵게 만드는 나쁜 행동입니다. 예를 들어, 앱 상태에 추가한 활동 피드의 변화를 추적하면서 그에 맞는 코드를 구현하고자 하고자 합니다. 그러기 위해선 CounterView로 가서 버튼 액션을 다음과 같이 수정해야겠죠.

Button(action: {
  self.state.favoritePrimes.removeAll(where: { $0 == self.state.count })
  self.state.activityFeed.append(.init(type: .removedFavoritePrime(self.state.count)))
})

Button(action: {
  self.state.favoritePrimes.append(self.state.count)
  self.state.activityFeed.append(.init(type: .addedFavoritePrime(self.state.count)))
})

매우 간단하네요! 하지만 불행히도 이는 잘못된 코드입니다. 여기엔 꽤 중요한 버그가 존재하는데, 바로 즐겨찾는 소수 목록에서 소수를 제거하는 상황에 대해 처리를 하지 않았다는 점입니다. 아래 코드입니다.

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

이 로직에서도 활동 피드를 추가할 수 있게 바꿔봅시다.

.onDelete(perform: { indexSet in
  for index in indexSet {
    let prime = self.state.favoritePrimes[index]
    self.state.favoritePrimes.remove(at: index)
    self.state.activityFeed.append(Activity(type: .removedFavoritePrime(prime)))
  }
})

여기까지는 쉬웠습니다. 하지만 더 나은 방법이 존재했으니 바로 이 변화를 일으키는 코드를 AppState로 옮겨서 이산가족 상봉을 하는 것입니다.

extension AppState {
  func addFavoritePrime() {
    self.favoritePrimes.append(self.count)
    self.activityFeed.append(Activity(timestamp: Date(), type: .addedFavoritePrime(self.count)))
  }

  func removeFavoritePrime(_ prime: Int) {
    self.favoritePrimes.removeAll(where: { $0 == prime })
    self.activityFeed.append(Activity(timestamp: Date(), type: .removedFavoritePrime(prime)))
  }

  func removeFavoritePrime() {
    self.removeFavoritePrime(self.count)
  }

  func removeFavoritePrimes(at indexSet: IndexSet) {
    for index in indexSet {
      self.removeFavoritePrime(self.favoritePrimes[index])
    }
  }
}

이건 그냥 트릭으로 볼 수도 있지만, 우리에게 상태 변화를 일으키는 코드를 두는 장소가 하나 더 늘었다고 생각할 수 있습니다. 하나는 액션 블럭 안에 넣는 것, 다른 하나는 뷰의 메소드에 넣는 것, 마지막은 바인딩 가능한 객체에 추가하는 것입니다. 물론 이건 여러분의 팀이 변화를 일으키는 코드를 어떨 때 추출할지 등에 대한 가이드라인에 따라 다르겠지만, 마지막에 보여드린 방법도 미래에 뷰에서 일어날 변화에 대해 보장해주진 않습니다. 그리고 애플도 SwiftUI에서 이 문제를 해결하기 위한 가이드를 제공하고 있지 않습니다.

사이드 이펙트에 대한 이야기가 없음

다음으로 알아볼 문제는 바로 애플이 아직도 사이드 이펙트를 다루는 방법을 제공하고 있지 않다는 것입니다. 포인트 프리에서도 사이드 이펙트가 무엇이고 왜 복잡한지에 대해 알아보는 시간을 1년도 더 전에 얘기했었습니다.

우리 앱에선 외부 사이드 이펙트가 딱 하나 있는데 바로 Wolfram Alpha API입니다. 앞에서 작성한 코드는 저희가 생각한 가장 직관적인 방법이었다고 생각할 수 있습니다. 그런데 이게 올바른 방법일까요?

func nthPrimeButtonAction() {
  self.isNthPrimeButtonDisabled = true
  nthPrime(self.state.count) { prime in
    self.alertNthPrime = prime
    self.isNthPrimeButtonDisabled = false
  }
}

지금 당장은 사이드 이펙트가 그냥 공허로 발사되는 형태인 것 같습니다. 우리에겐 이 리퀘스트를 취소할 방법도 없고, 여러 번의 리퀘스트가 발생했을 때 하나로 묶어줄 수 있는 debounce 기능도 없습니다. 심지어 테스트할 수도 없죠.

이러한 관측을 통해 사이드 이펙트가 제어되지 않는다는 사실을 알게 되었습니다. 클로저에서 이 사이드 이펙트를 직접 실행하고 있으며 그 대신 원하는 것은 사이드 이펙트의 데이터 타입 표현으로 모든 타입의 값처럼 사이드 이펙트를 조장할 수 있는 것입니다.

불행히도 애플은 그러기 위한 가이드를 아무것도 제공하고 있지 않습니다. Combine 프레임워크가 도움을 줄 수 있다고 희망하지만, 지금 당장은 SwiftUI랑 쓰는 데 필요한 정보가 턱없이 부족한 상태입니다.

합성하기 어려운 상태 관리 방식

이번 문제는 SwiftUI에서 거대한 상태를 작은 상태로 쪼갤 수 있는 쉬운 방법을 제공하지 않아서 모듈화가 어렵다는 점입니다.

예를 들어 FavoritePrimes 뷰를 살펴보겠습니다.

struct FavoritePrimes: View {
  @ObjectBinding var state: AppState

이 뷰는 AppState 전부를 받지만 실제로 읽고 쓰는 것은 favoritePrimes 하나뿐입니다.

우리에게 필요한 것은 화면에서 필요한 만큼의 상태만 신경을 쓸 수 있게 해주는 기능입니다.

원하는 상태만 신경쓸 수 있게 된다면 뷰만 따로 떼서 Swift 패키지로 배포해서 다른 어플리케이션에서도 쓸 수 있을 것입니다. 바로 모듈화 어플리케이션 디자인의 첨탑을 세울 수 있다는 것이죠. 만약 이 화면을 완벽하게 고립시킬 수 있다면 UI를 이해할 때도 다른 컴포넌트를 볼 필요 없이 이 화면의 UI만 이해하면 될 것입니다.

불행히도 지금 당장 SwiftUI에선 가능한 방법이 없는 것 같습니다. 가장 근접한 해결책은 ObjectBinding을 따르는 래퍼 클래스를 만들고 서브 상태의 일부만 표시하는 것입니다. 그러면 이니셜라이저도 따로 정의해야 하고, 글로벌 상태의 didChange를 computed 속성과 함께 표시해줘야 합니다.

바로잡기

이 에피소드는 Xcode 11 베타 3으로 녹화되었습니다. 만약 Observable Binding에서 Binding의 서브 상태를 얻을 수 있게 되더라도 이 변경 가능한 상태가 프레젠테이션 영역을 넘을 수 없는 버그가 존재합니다. 예를 들면 Navigation Link나 Modal Sheet에서 말이죠. 이 버그는 Xcode 11 베타 5에서 해결됐으며 상태는 더욱 합성 가능해졌습니다.

FavoritePrimesState를 정의하는 대신에 FavoritePrimeView에 두 가지 바인딩을 넘기는 방법이 있습니다.

struct FavoritePrimesView: View {
  @Binding var favoritePrimes: [Int]
  @Binding var activityFeed: [AppState.Activity]
class FavoritePrimesState: BindableObject {
  var didChange: PassthroughSubject<Void, Never> {
    self.state.didChange
  }

  private var state: AppState
  init(state: AppState) {
    self.state = state
  }
}

그리고 관심을 갖고자 하는 서브 상태들을 computed 속성에 연결해줘야 합니다. favoritePrimes가 좋겠네요.

var favoritePrimes: [Int] {
  get { self.state.favoritePrimes }
  set { self.state.favoritePrimes = newValue }
}

다음은 activityFeed를 표시하는 방법입니다.

var activityFeed: [AppState.Activity] {
  get { self.state.activityFeed }
  set { self.state.activityFeed = newValue }
}

이제 글로벌 앱 상태에 로컬 상태를 표시하게 하는 래퍼가 생겼습니다.

class FavoritePrimesState: BindableObject {
  var didChange: PassthroughSubject<Void, Never> {
    self.state.didChange
  }

  private var state: AppState
  init(state: AppState) {
    self.state = state
  }

  var favoritePrimes: [Int] {
    get { self.state.favoritePrimes }
    set { self.state.favoritePrimes = newValue }
  }

  var activityFeed: [AppState.Activity] {
    get { self.state.activityFeed }
    set { self.state.activityFeed = newValue }
  }
}

정말 보일러 플레이트 코드가 많죠.

작동하게 만들어봅시다. 먼저 즐겨찾는 소수 화면의 객체 바인딩을 바꿔보겠습니다.

struct FavoritePrimesView: View {
  @ObjectBinding var state: FavoritePrimesState

위 코드 말고는 이 화면에서 더 이상의 변경도 필요하지 않습니다.

루트 뷰에서 뷰를 생성할 때 이젠 다음과 같이 생성해야 합니다.

NavigationLink(destination: FavoritePrimesView(state: FavoritePrimesState(state: self.state))) {

위 코드는 예전처럼 작동하지만, 글로벌 상태와는 조금 멀어졌고 실제로 뷰의 신경쓰고 싶은 상태만 남겨둘 수 있게 되었습니다.

불행히도 이 방법이 문제의 해결책은 아닙니다. 여전히 보일러 플레이트 코드는 많고 컴포넌트들을 각자의 모듈로 고립시킬 방법이 존재하지 않습니다. 하지만 이게 해결책에 가장 가까운 방법은 맞는 것 같습니다. 이것보다 쉬운 방법은 아직 본 적이 없습니다. 미래엔 분명 Swift에 새로운 기능이 추가돼서 해결해주겠지만 지금 당장은 이게 최선인 것 같습니다.

모듈화는 언제나 항상 우리 모두의 문제였기 때문에 저희는 이것이 중요하게 해결해야 할 문제라고 생각합니다. 그렇지 않고 계속 만들다 보면 코드 고립, 모듈화 등은 꿈도 못 꾸고 코드 복잡도만 올라갈 것입니다.

테스트하기 어려운 구조

마지막으로 빠르게 테스트 가능성에 대해 언급하고 가겠습니다. 지금은 애플이 SwiftUI의 뷰를 어떻게 테스트해야 하는지에 대한 가이드나 도구를 제공하고 있지 않기 때문에 SwiftUI가 테스트가 수월한 형태라고 할 수 없는 상태입니다. 우리가 작성한 코드만 봐도 상태(state)나 변화를 일으키는 코드(mutation)가 뷰에서 서로 얽혀있는 것을 확인할 수 있습니다. 플러스 버튼을 탭해서 실제로 카운트가 올라가는지 또는 “즐겨찾는 소수에 추가하기” 버튼을 탭하는 것이 실제로 즐겨찾는 소수 목록에 추가되는지 등에 대한 테스트를 할 수 있는 간단한 방법이 없는 것에서 실제로 테스트 방법이 명확하지 않음을 확인할 수 있습니다.

테스트는 소프트웨어 개발자에게 아주 중요한 영역 중 하나입니다. 테스트는 우리가 참이길 바라는 부분이 실제로 참인지 증명해주고, 이를 통해 테스트를 믿고 리팩토링을 할 수 있게 만들어줍니다. 어플리케이션의 로직을 테스트하는 방법은 꼭 해결돼야 하는 문제라고 생각합니다.

결론

여기까지 SwiftUI를 사용해서 상태 관리를 했을 때 나타날 수 있는 문제에 대해 알아보았습니다. 어플리케이션 아키텍처 관점에서 봤을 때 해결해야 할 문제를 정리하면 다음과 같습니다.

  • 변화하는 상태를 관리하는 방법
  • 사이드 이펙트를 실행시키는 방법
  • 거대한 어플리케이션을 작은 어플리케이션으로 분할하는 방법
  • 마지막으로, 어플리케이션을 테스트하는 방법

그래서 다음 시리즈엔 함수형 프로그래밍이 상태 관리에 있어서 할 수 있는 일에 대해 알아볼 예정입니다. 먼저, 약간의 선행 작업을 거친 후에 앱 상태와 앱의 변화(mutation)를 하나로 합치는 메커니즘을 알아보고, 하나의 간단한 패키지에서 사이드 이펙트를 실행시키는 방법을 알아보도록 하겠습니다. 물론 SwiftUI가 제공하는 환상적인 기술은 하나도 빠짐없이 사용할 예정입니다. (애플도 아직 해결하지 못 한 문제는 어쩔 수가 없네요) 그런 맥락에서 정말 좋은 점은 저희가 준비한 방식이 UIKit으로도 잘 작동해서, SwiftUI에 한정적이지 않다는 것입니다. 게다가 UIKit이 더 적합할 경우엔 UIKit과 SwiftUI를 잘 섞어볼 예정입니다.

그럼 다음 시간에 만나요!


참고한 자료

  • SwiftUI Tutorials - Apple

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

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

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