Swift Composable Architecture 를 도입하며 겪었던 문제와 해결법

TCA를 도입하며 겪었던 문제와 해결법

Woody • Won Heo, iOS Engineer

  • 테크 인사이트

안녕하세요 🖐️ 채널톡 iOS 엔지니어 우디입니다.

채널톡 iOS 팀에서는 SwiftUI를 적극적으로 활용하고 있는데요. 준비 중인 신규 서비스는 SwiftUI와 TCA를 사용해서 개발하고 있습니다. 이번 포스팅에서는 TCA를 도입하며 겪었던 문제와 해결법에 대해서 공유하고자 합니다.


TCA ( Swift Composable Architecture )를 도입하게 된 이유

팀 내에서는 Redux와 유사한 ReSwift를 주요 기술 스택으로 사용하고 있습니다. 단방향으로 처리되는 상태 변경, 편리한 상태 구독 등. Redux 패턴이 주는 여러 장점을 통해 복잡한 기능들을 간결하게 구현했습니다.

이후 신규 앱에 SwiftUI를 사용하면서 여러 아키텍처들을 고민하기 시작했습니다. 그 과정에서 ReSwift와 유사한 단방향 아키텍처 중 하나인 TCA를 채택하게 되었는데요. 도입하게 된 이유는 다음과 같습니다.


1. 상태 관리와 데이터 플로우를 일관된 구조로 작성할 수 있습니다

SwiftUI는 @State, @StateObject, @EnvironmentObject 등등 여러 Property Wrapper를 통해 상태와 데이터 플로우를 관리할 수 있습니다. 하지만 사용하는 Property Wrapper와 View 구조에 따라 상태가 초기화되는 조건이 다르고 특히 EnvironmentObject는 주입하지 않았을 때 런타임 에러가 발생하게 됩니다.

TCA는 Scope 된 Store와 ViewStore를 통해 의존성 주입을 강제화하고 있습니다. 또한 상태는 Store를 통해 개발자가 직접 제어합니다. 이를 통해 일관된 구조로 코드를 작성할 수 있습니다.

2. Composition을 통해 여러 Reducer를 분리하고 조립해서 사용할 수 있습니다

Reducer는 State, Action, Reduce의 구성으로 이루어져 있습니다. ifLet, ForEach, Scope 등등 다양한 연산자를 통해 기능들을 분리할 수 있습니다. 특히 분리한 Reducer는 모듈화하여 쉽게 사용이 가능하고 조합해서 기능을 구현할 수 있습니다.

3. 풍부한 커뮤니티가 존재합니다

TCA는 포스팅 작성일 기준으로 1.7.0 버전이 릴리즈되었는데요. 많은 사람이 사용하며 방향성에 대해 의논하고 발생하는 이슈가 빠르게 해결되고 있습니다. 또한 메인 컨트리뷰터가 직접 운영하는 사이트를 통해 라이브러리를 어떻게 설계하고 구현했는지 살펴볼 수 있습니다.

위와 같은 TCA의 장점들과 단방향 아키텍처의 이해도를 바탕으로 여러 기능을 빠르게 개발할 수 있었는데요. 하지만 코드가 늘어날수록 아래와 같은 문제점들이 발생하기 시작했습니다.


TCA를 사용하며 겪었던 문제점과 해결법

Action 구분하기

Swift

TCA를 사용해서 기능을 구현하면 일반적으로 Action은 위와 같이 구성되게 됩니다. 해당 코드는 크게 2가지 문제점을 가지고 있습니다.

첫째로 View에서 모든 Action에 접근이 가능합니다. ~Loaded로 끝나는 Action들은 비동기 작업에서만 사용이 되지만 View에서도 해당 Action을 전송할 수 있습니다.

둘째로 case 가 많아질수록 Action을 구분하기 어렵습니다. 기능을 구현하면서 Action이 점점 복잡해지게 되는데요. 목적이 다른 Action들이 같은 Enumerations에서 나열되기 때문에 가독성이 떨어지는 문제가 있습니다.

아쉽게도 현재 Swift에서는 Enumeration Case에 접근제어자를 제공하지 않고 있습니다. (관련 논의) 따라서 해당 문제점들을 개선하기 위해 Nested Enumerations을 적용하는 Protocol를 도입하게 되었습니다.

Swift

FeatureAction Protocol은 목적으로 구분된 5개의 static 함수를 가지고 있습니다. Reducer Action에서는 해당 Protocol을 준수하면 됩니다. 그러면 위와 같이 Nested Enumerations 형태로 Action을 구분 지어 사용할 수 있습니다.

Swift

View에서는 ViewAction을 기준으로 ViewStore을 생성하면 다른 Action에는 접근할 수 없게 되고 ViewAction에 정의한 Action만을 사용할 수 있습니다.

이후 FeatureAction의 도입으로 Action들을 명확하게 구분할 수 있었습니다. 위와 같은 규칙들은 현재 코드 컨벤션에 의존하고 있는데요. 추후에 린터등을 통해 개선할 예정입니다.

공유 상태 처리

앱을 개발하다 보면 View간의 데이터 공유 설계가 필수적으로 필요합니다. ( 댓글, 메시지가 여러 화면에서 보일 때 특정 View만 업데이트된다면 무척 어색한 경험이 될 것입니다. )

Swift

TCA에서는 Computed Property를 통해 상태를 공유할 수 있는데요. MainState에 있는 SharedState를 SubState에서도 사용해야 한다면 get set을 위와 같이 작성하면 됩니다.

하지만 State 형태가 복잡해질수록 불필요한 연산이 자주 발생하고 State의 계층이 깊은 경우 Computed Property 코드의 가독성이 떨어지는 문제가 생깁니다.

AState -> BState -> CState -> DState -> EState ... // ( A에서 E로 sharedState 값을 전달하는 경우 get set을 모두 정의해야 합니다. )

따라서 위와 같은 문제점을 해결하기 위해 전역적으로 공유되는 SharedState를 도입하게 되었습니다.

Swift

GlobalService에는 공유되어야 하는 State를 가지고 있습니다. 이를 AsyncStream 형태로 랩핑합니다. Reducer에서는 미리 등록한 @Dependency에 접근해서 사용합니다.

SideEffect에서는 .run 내부에서 AsyncStream을 구독해서 사용할 수 있는데요. 이벤트에 따라 Action을 방출하며 SharedState에 따라 자신의 State를 업데이트 할 수 있습니다.

View에서는 .task 모디파이어에서 finish()를 await 합니다. 그러면 자동으로 View 가 onAppear 될 때 구독을 시작하고 onDisappear 될 때 구독을 취소합니다.

메인스레드 성능 저하

TCA는 [공식 문서]를 통해 성능이 저하될 수 있는 요인들을 잘 설명해 주고 있습니다. 특히 버전마다 내부 로직을 개선하며 성능이 개선되고 있는데요. 따라서 일반적인 상황에서는 문제를 겪기 어렵습니다.

하지만 앱의 기능이 많고 극단적으로 큰 리스트가 있는 상황에서 문제가 발생할 수 있습니다. 이러한 경우에 부모의 State를 구독하고 있는 ViewStore의 개수가 많아집니다. 또한 Composition 하는 기능의 개수가 늘어날수록 State와 Reducer가 거대해집니다. 이에 따라 Action을 처리하는 호출 스택이 깊어지고 State를 업데이트하는 데 시간이 걸립니다.

이러한 모든 과정은 메인스레드를 통해 수행되므로 다음과 같이 View 레이아웃 성능이 떨어지게 됩니다.

( 430ms의 애니메이션, 레이아웃, 상태 업데이트를 처리하는데 대략 1,230ms가 걸립니다. )

해당 문제는 Store 분리를 통해 개선할 수 있습니다. Scope를 통해 부모 Store 와 연결하지 않고 직접 생성해서 사용하는 방식입니다.

분리된 Store는 적은 비용으로 Action을 처리하고 State를 업데이트할 수 있습니다. 또한 다른 곳과 상태를 공유하지 않게 되므로 코드를 명확하게 분리할 수 있습니다.

Swift

하지만 Scope 되지 않은 경우 다른 Store에서 Action을 받아 처리할 수 없게 되는데요. 이를 해결하기 위해 StoreBox 컨테이너를 구현하게 되었습니다.

Swift

StoreBox 컨테이너를 사용하면 Store 의 Dependency를 원하는 시점에 업데이트할 수 있습니다. 이를 통해 다른 Store와 소통할 수 있는 Bridge를 만들어서 주입할 수 있습니다.

( 기존 1,230ms -> 개선 후 724ms로 코드 실행시간이 약 41% 단축되었습니다. )

StoreBox 컨테이너 도입을 통해 메인 스레드 부하를 개선하고 View 레이아웃 성능을 높일 수 있었습니다. 현재 프로젝트에서는 TCA 1.2.0 버전을 사용하고 있는데요. 추후 업데이트를 통해 1.7.0 버전에서 지원하는 Observation을 사용하면 ViewStore를 제거하고 더 좋은 성능과 구조로 개선할 수 있을 것으로 기대하고 있습니다.


마무리

이번 포스팅을 통해 TCA를 도입하며 겪었던 문제와 해결법에 대해서 알아보았습니다. SwiftUI & TCA를 사용하며 높은 생산성으로 신규 서비스를 개발해 나가고 있는데요. 위와 같은 문제점들을 겪으며 아키텍처는 항상 장단점이 공존하고 사용할 때 이를 잘 고려 해야겠다고 생각했습니다.

TCA를 사용하시는 분들께 도움이 되었으면 좋겠습니다. 긴 글 읽어주셔서 감사합니다.

We Make a Future Classic Product

채널팀과 함께 성장하고 싶은 분을 기다립니다

사이트에 무료로 채널톡을 붙이세요.

써보면서 이해하는게 가장 빠릅니다

회사 이메일을 입력해주세요