스택 기반 내비게이션
컬렉션으로 모델링하는 스택 기반 내비게이션을 알아봅니다. 도메인을 모델링하고, 기능을 통합하고, 기능을 테스트하는 방법 등을 다룹니다.
개요
스택 기반 내비게이션은 상태 컬렉션으로 내비게이션을 모델링하는 방식입니다. 평평한 데이터 컬렉션을 만들고 SwiftUI에 전달하기만 하면 앱의 어떤 상태로든 딥 링크할 수 있습니다. 나머지는 SwiftUI가 처리합니다. 앱에서 복잡하고 재귀적인 내비게이션 경로도 만들 수 있습니다.
기본
이 방식의 내비게이션에 필요한 도구는 StackState, StackAction, Reducer/forEach(_:action:destination:fileID:filePath:line:column:)-9svqb 연산자입니다. 또한 일반 initializer처럼 동작하지만 The Composable Architecture에 맞춰진 NavigationStack의 SwiftUI/NavigationStack/init(path:root:destination:fileID:filePath:line:column:) initializer도 사용합니다.
기능을 내비게이션 스택에 통합하는 작업은 크게 두 단계로 나뉩니다. 기능의 도메인을 통합하고, 스택의 모든 뷰를 설명하는 store로 NavigationStack을 구성합니다. 보통은 도메인부터 통합합니다. 먼저 스택에 push할 수 있는 모든 기능의 도메인을 보관하는 새 reducer를 정의합니다. 보통 이 reducer의 이름은 Path입니다.
참고:
Pathreducer는 enum을 사용할 때 트리 기반 내비게이션용으로 만드는Destinationreducer와 같습니다. 자세한 내용은 Enum 상태를 참고하세요.
Path reducer를 정의했다면 내비게이션 스택을 관리하는 기능에서 StackState와 StackAction을 보관할 수 있습니다.
팁:
StackAction은Path도메인의 상태와 액션을 모두 제네릭으로 받습니다. 따라서StackActionOf타입 별칭으로 문법을 조금 단순하게 만들 수 있습니다.Action하나만 제네릭으로 받는PresentationAction과 다른 점입니다.
그다음 Reducer/forEach(_:action:) 메서드로 이동할 수 있는 모든 기능의 도메인을 부모 기능의 도메인에 통합해야 합니다.
참고:
@Reducer enum Path에서 자동으로 추론할 수 있으므로forEach의 trailing closure에서Path()를 지정할 필요가 없습니다.
이것으로 내비게이션 스택을 위한 자식과 부모 기능 통합을 마쳤습니다.
다음으로 자식과 부모 뷰를 통합해야 합니다. 이 라이브러리의 특수 initializer인 SwiftUI/NavigationStack/init(path:root:destination:fileID:filePath:line:column:)를 사용하는 NavigationStack으로 통합합니다. 이 initializer는 세 인자를 받습니다. 도메인의 StackState와 StackAction에 초점을 맞춘 store 바인딩, 스택 루트 뷰를 위한 trailing view builder, 스택에 push할 수 있는 모든 뷰를 위한 trailing view builder입니다.
첫 번째 인자를 채우려면 루트 기능에 이미 있는 path 상태와 path 액션으로 store의 바인딩을 scope하면 됩니다.
루트 뷰에는 원하는 어떤 뷰든 사용할 수 있습니다. 보통은 도메인의 StackState에 새 데이터를 push하는 NavigationLink나 다른 버튼을 둡니다.
마지막 trailing closure에는 Path 도메인의 store가 전달됩니다. Store/case 계산 프로퍼티로 Path의 각 case를 분해하면 해당 case에만 초점을 맞춘 store를 얻을 수 있습니다.
이 방식은 Path.State enum의 모든 case를 처리했다는 사실을 컴파일 타임에 보장합니다. 스택에 새 목적지 유형을 추가할 때 유용합니다.
각 case에서 원하는 모든 종류의 뷰를 반환할 수 있지만, 최종적으로는 store를 Path.State enum의 특정 case로 scope해야 합니다.
이것으로 여러 자식 기능을 간결하게 모델링한 도메인으로 내비게이션 스택에 통합했습니다. 이 단계를 마치면 Path reducer의 상태와 액션 enum에 새 case를 추가해 스택에 기능을 쉽게 더할 수 있습니다. 또한 부모에서 각 자식 기능에서 일어나는 일을 완전히 살펴볼 수 있습니다. 자세한 내용은 통합을 계속 읽어 보세요.
기능을 스택에 push하기
위에서 설명한 대로 도메인을 통합하고 뷰에 NavigationStack을 추가했다면, 기능을 스택에 push하는 대표적인 방법은 두 가지입니다. 가장 단순한 방법은 NavigationLink의 SwiftUI/NavigationLink/init(state:label:fileID:filePath:line:column:) initializer를 사용하는 것입니다. 이 initializer에는 스택에 push할 기능의 상태를 지정해야 합니다. Path reducer의 상태까지 거슬러 올라가는 전체 상태를 지정해야 합니다.
링크를 탭하면 StackAction/push(id:state:) 액션이 전송됩니다. path 컬렉션이 변경되고 .detail 상태가 스택 끝에 추가됩니다.
이 방법은 화면으로 이동하는 가장 단순한 방법이지만 단점도 있습니다. 특히 NavigationLink를 가진 뷰는 Path.State 타입에 접근할 수 있어야 합니다. 즉 이동할 수 있는 모든 기능을 포함하는 Path reducer 전체를 구성해야 하므로 모듈화가 어려워집니다.
이 때문에 스택에 표시할 수 있는 각 기능을 완전히 분리된 상태로 개별 구성할 수 없습니다. 모든 기능을 함께 구성해야 합니다. 기술적으로는 모든 기능의 State 타입만 별도 모듈로 옮길 수 있습니다. 그러면 모든 기능 reducer를 구성하지 않고도 기능이 그 모듈에만 의존하게 만들 수 있습니다.
다른 방법은 NavigationLink를 전혀 사용하지 않고, 자식 기능 도메인의 액션을 보내는 Button을 사용하는 것입니다.
그러면 루트 기능은 이 액션을 수신하고 새 상태를 path에 추가해 내비게이션을 구동할 수 있습니다.
통합
위 단계로 기능을 통합하면 부모 기능은 내비게이션 스택 내부에서 일어나는 모든 일에 즉시 접근할 수 있습니다. 이를 스택 요소 기능과 부모 기능의 로직을 통합하는 수단으로 활용할 수 있습니다. 예를 들어 편집 기능에서 Save 버튼을 탭했는지 감지하려면 해당 액션을 분해하면 됩니다. StackAction, StackAction/element(id:action:) 액션, 관심 있는 기능, 마지막으로 관심 있는 액션을 차례로 패턴 매칭합니다.
이 case 안에서는 기능 상태를 추출해 추가 로직을 수행할 수 있습니다. 예를 들어 edit 기능을 pop하고 수정한 항목을 데이터베이스에 저장할 수 있습니다.
StackAction/element(id:action:) 액션을 분해하면 자식 도메인에서 발생한 액션뿐 아니라 스택 요소의 ID에도 접근할 수 있습니다. StackState는 스택에 추가하는 모든 기능의 ID를 자동으로 관리합니다. StackState/subscript(id:fileID:filePath:line:column:)으로 스택의 특정 요소를 조회하고, StackState/pop(from:)으로 스택에서 요소를 pop할 때 이 ID를 사용할 수 있습니다.
해제
스택의 기능을 해제하려면 StackState/popLast(), StackState/pop(from:) 같은 StackState 메서드로 상태를 변경하면 됩니다.
하지만 이렇게 하려면 스택 상태에 접근할 수 있어야 하며, 보통은 부모만 접근할 수 있습니다. 그러나 부모와 명시적으로 통신하지 않고 자식 기능 안에 기능 해제 로직을 캡슐화하고 싶을 때가 많습니다.
SwiftUI는 자식 _뷰_가 부모와 명시적으로 통신하지 않고도 스스로 해제할 수 있는 훌륭한 도구를 제공합니다. dismiss라는 환경 값이며, 다음과 같이 사용할 수 있습니다.
self.dismiss()를 호출하면 SwiftUI는 내비게이션 스택에서 표시된 가장 가까운 부모 뷰를 찾고, 스택을 구동하는 컬렉션에서 해당 상태를 제거합니다. 이는 매우 유용하지만 뷰 계층으로 한정됩니다. dismiss는 관찰 가능한 객체 같은 다른 곳에서는 사용할 수 없으므로 검증이나 비동기 작업처럼 세밀한 해제 로직을 구현할 수 없습니다.
TCA(The Composable Architecture)에도 비슷한 도구가 있습니다. 다만 기능의 다른 로직과 동작이 있는 reducer에서 사용하기에 알맞습니다. 라이브러리의 의존성 관리 시스템(DependencyManagement 참고)을 통해 DismissEffect로 접근합니다.
참고:
DismissEffect함수는 비동기 함수이므로 reducer 안에서 직접 호출할 수 없습니다. 대신Effect/run(priority:operation:catch:fileID:filePath:line:column:)에서 호출해야 합니다.
self.dismiss()를 호출하면 내비게이션 스택을 구동하는 StackState에서 해당 값을 제거합니다. 시스템에 StackAction/popFrom(id:) 액션을 다시 보내 기능 상태를 제거하는 방식입니다. 따라서 부모와 명시적으로 통신하지 않고 자식 도메인 안에 자식 기능을 해제하는 로직 전체를 캡슐화할 수 있습니다.
참고: 해제는 액션을 보내 처리하므로
dismiss()를 호출한 뒤에는 절대로 액션을 보내면 안 됩니다.이렇게 하면 더 이상 스택에 없는 기능에 액션을 보내게 됩니다. Xcode에서는 런타임 경고가 발생하고, 테스트를 실행하면 실패합니다.
주의: SwiftUI의 환경 값
@Environment(\.dismiss)와 TCA의 의존성 값@Dependency(\.dismiss)는 비슷한 목적을 가지지만 완전히 다른 타입입니다. SwiftUI의 환경 값은 SwiftUI 뷰 안에서만 사용할 수 있고, TCA의 의존성 값은 reducer 안에서만 사용할 수 있습니다.
테스트
이 라이브러리 도구로 내비게이션 스택을 모델링하면 테스트가 매우 쉬워집니다. 또한 내비게이션을 테스트할 때는 몇 가지 상위 수준 세부 사항만 검증하고 모든 상태 변경과 effect를 검증할 필요가 없는 경우가 많으므로, 비완전 테스트(non-exhaustive testing, TestingTCA#Non-exhaustive-testing 참고)를 활용하면 좋습니다.
예를 들어 count가 5 이상이면 스스로 해제하려는 간단한 counter 기능을 살펴보겠습니다.
그런 다음 이 기능을 부모 기능에 포함합니다.
이제 자식 counter 기능의 count가 5 이상이 되면 스스로 해제된다는 것을 증명하는 Feature reducer 테스트를 작성해 보겠습니다. counter가 하나만 스택에 있는 상태에서 시작하는 Feature용 TestStore를 만듭니다.
그다음 스택 안의 counter 자식 기능에 .incrementButtonTapped 액션을 보내 count가 1 증가하는지 확인합니다. 이때 ID를 제공해야 합니다.
통합에서 설명했듯 StackState는 각 기능의 ID를 자동으로 관리하며, 이 ID는 외부에서는 대부분 불투명합니다. 하지만 테스트에서는 ID가 정수형이며 세대별로 관리됩니다. 즉 ID는 0에서 시작하고 스택에 push하는 기능마다 전역 ID가 하나씩 증가합니다.
따라서 요소 하나가 이미 스택에 있는 상태로 TestStore를 만들면 그 요소에는 ID 0이 부여됩니다. 이 ID로 액션을 보낼 수 있습니다.
다음으로 액션이 전송됐을 때 스택의 counter 기능이 어떻게 바뀌는지 검증해야 합니다. 여러 계층을 거쳐야 합니다. 먼저 ID로 subscript하고, 그 subscript가 반환하는 Optional 값을 unwrap하고, Path.State enum의 case를 패턴 매칭한 뒤 값을 변경합니다.
라이브러리는 이 모든 단계를 한 번에 수행하는 두 가지 도구를 제공합니다. 먼저 XCTModify helper를 사용할 수 있습니다.
XCTModify 함수는 첫 번째 인자로 inout enum 상태를 받고 두 번째 인자로 case path를 받습니다. case path로 해당 case의 payload를 추출하고, 값을 변경한 뒤, 데이터를 enum에 다시 넣습니다. 따라서 위 코드에서는 ID 0으로 subscript하고 Path.State enum의 .counter case를 분리한 뒤, 1 증가한 count를 4로 변경합니다. $0.path[id: 0]의 case가 case path와 일치하지 않으면 테스트가 실패합니다.
다른 방법은 StackState/subscript(id:case:)-7gczr로 스택의 ID와 path enum의 case를 동시에 subscript하는 것입니다.
상태에서 변경할 항목이 많다면 XCTModify 스타일이 좋고, 단순한 변경이라면 StackState/subscript(id:case:)-7gczr 스타일이 좋습니다.
테스트를 계속 진행해 count가 5가 되는지 확인하려면 한 번 더 액션을 보냅니다.
마지막으로 자식 기능이 스스로 해제되기를 기대합니다. 이 동작은 counter 기능을 스택에서 pop하기 위해 StackAction/popFrom(id:) 액션이 전송되는 것으로 나타납니다. TestStore의 TestStore/receive(_:timeout:assert:fileID:file:line:column:)-53wic 메서드로 이를 검증할 수 있습니다.
특정 자식 액션을 수신했는지 검증해야 한다면 요소 ID로 \.path case를 subscript해 특정 자식 요소 액션의 case key path를 만들 수 있습니다.
예를 들어 자식 기능이 .response 액션을 보내는 effect를 수행했다면, 수신 여부를 테스트할 수 있습니다.
이 예제는 내비게이션 스택에서 부모와 자식 기능이 서로 어떻게 상호작용하는지에 관한 세밀한 테스트를 작성하는 방법을 보여 줍니다.
하지만 기능이 복잡해질수록 통합을 테스트하기도 더 번거로워집니다. 기본적으로 TestStore는 완전한 검증을 요구합니다. 모든 상태 변경, 모든 effect가 시스템에 데이터를 다시 전달하는 방식, 그리고 테스트가 끝날 때 모든 effect가 끝났는지를 검증해야 합니다(TestingTCA 참고).
하지만 TestStore는 실제로 관심 있는 기능 부분만 검증할 수 있는 비완전 테스트(non-exhaustive testing, TestingTCA#Non-exhaustive-testing 참고)도 지원합니다.
예를 들어 test store에서 exhaustivity를 끄면(TestStore/exhaustivity 참고), increment 버튼을 두 번 탭했을 때 결국 StackAction/popFrom(id:) 액션을 받는다는 사실을 상위 수준에서 검증할 수 있습니다.
이는 앞선 테스트와 본질적으로 같은 내용을 증명하지만, 훨씬 적은 줄로 작성할 수 있고 특별히 관심 없는 기능의 향후 변경에도 더 견고합니다.
StackState와 NavigationPath 비교
SwiftUI에는 내비게이션 스택의 데이터를 모델링하는 강력한 타입인 NavigationPath가 있습니다. 그렇다면 왜 NavigationPath를 활용하지 않고 StackState라는 자체 데이터 타입을 만들었는지 궁금할 수 있습니다.
NavigationPath 데이터 타입은 NavigationStack에 맞춰진 타입 소거 데이터 목록입니다. Hashable하기만 하면 어떤 종류의 데이터든 path에 추가할 수 있으므로 스택의 기능을 최대한 분리할 수 있습니다.
SwiftUI는 데이터 타입에 맞춰 스택에 push할 뷰를 설명해서 이 데이터를 해석합니다.
이 방식은 강력하지만 단점도 있습니다. 기반 데이터가 타입 소거되어 있으므로 SwiftUI는 데이터 타입에 관한 API를 많이 노출하지 않습니다. 예를 들어 위에서 본 것처럼 path 끝에 데이터를 추가하거나, 끝에서 데이터를 제거할 수만 있습니다.
또는 path의 요소 수를 셀 수 있습니다.
이것이 전부입니다. 끝이 아닌 위치에는 요소를 넣거나 뺄 수 없으며, path를 순회할 수도 없습니다.
따라서 스택에 무엇이 있는지 분석하거나 스택 전체의 데이터를 집계하기가 매우 어려울 수 있습니다.
The Composable Architecture의 StackState는 NavigationPath와 비슷한 목적을 가지지만 trade-off가 다릅니다.
StackState는 완전히 정적으로 타입이 지정되므로 어떤 종류의 데이터든 추가할 수는 없습니다.- 하지만
StackState는Collection프로토콜(RandomAccessCollection,RangeReplaceableCollection포함)을 준수합니다. 따라서 컬렉션을 조작하고 스택 내부를 살펴보는 다양한 메서드에 접근할 수 있습니다. - 기능 데이터를
StackState에 넣기 위해Hashable일 필요는 없습니다. 이 데이터 타입은 내부적으로 기능의 안정적인 식별자를 관리하고, 이 식별자에서 hash 값을 자동으로 파생합니다.
StackState는 런타임의 완전한 유연성과 정적인 컴파일 타임 보장 사이에서 좋은 균형을 제공하며, The Composable Architecture에서 내비게이션 스택을 모델링하는 데 적합한 도구라고 생각합니다.
UIKit
이 라이브러리는 UIKit의 UINavigationController를 상태 기반으로 사용할 수 있는 도구도 제공합니다. 위에서 설명한 대로 StackState로 도메인을 모델링했다면, 특수한 NavigationStackController 타입으로 스택용 view controller를 구현할 수 있습니다.