이재용의 iOS

SwiftUI에서 복잡한 View 계층의 데이터 흐름

2022년 4월 21일 • ☕️ 6 min read

스유에서 길고 복잡한 뷰를 작성할 때 가독성을 높이기 위한 방법으로 무엇이 있을까?

body내에 코드들을 커스텀뷰로 만들어 메인 뷰단을 이쁘게 만드는 방법이 있을 수 있다. 하지만 이는 데이터 흐름이 복잡해진다는 단점이 있다. 그렇다면 이 데이터 흐름을 간단하게 만들면서 가독성도 높일 수 있지 않을까? 이 글에 커스텀 뷰에서 관리하는 데이터들을 한 곳에 모아 간단한 데이터 흐름을 만드는 내용을 담아보려 한다. (필자의 생각들은 셀프필터링 필요)

스유의 데이터 흐름

state-and-data-flow

먼저 스유의 데이터 흐름을 알아보자.

user에 의하여 action이 발생하고 이에 의해 변화가 일어난다. action에 의해 데이터가 변경되면 View의 State가 변경되며 해당 State에 연결된 View를 새로 그린다.

이처럼 스유는 UI Interface인 View와 앱에 보여질 Model들을 연결(바인딩)하여 관리한다. 이를 도와주는 키워드들로 State, Binding 등이 있다.

두 키워드들을 간단히 살펴보면, 스유에선 struct로 이루어진 view 내부의 값을 바꾸기 위해서 @State가 필요하다. 그리고 @Binding을 이용해 State나 observable object로 선언된 데이터의 레퍼런스를 공유함으로서 바인딩이 이루어진다. 더 자세한 역할을 알고 싶다면 공식 문서를 읽어보자.

아래는 model을 view에 연결하는 간단한 예시이다.

struct ContentView: View {
    @State var text: String = ""
    var body: some View {
        VStack {
            Text(text)
                .padding()
            ChildView(text: $text)
        }
    }
}

struct ChildView: View {
    @Binding var text: String
    var body: some View {
        Button {
            text = "Hello"
        } label: {
            Text("Hello 나타나게하기")
                .padding()
        }
    }
}

ContentView의 text와 ChildView의 text는 Binding으로 인해 같은 레퍼런스를 공유하고 있다. 그래서 ChildView에 위치한 버튼을 누르게 되면, 데이터가 변경되고 연결된 뷰의 상태가 업데이트되며 뷰가 새롭게 그려진다.

커스텀뷰를 만들수록 데이터 흐름을 읽기 힘들다.

스유로 복잡한 뷰를 작성하게 되면 View 내의 코드가 어마어마하게 길어진다. 필자는 오늘의 집과 같이 컨텐츠가 많은 복잡한 화면을 만들어보진 않았지만 스크롤이 없는 간단한 화면이어도 길어진 코드때문에 가독성이 떨어지는 것을 경험헀다. 여기서 가독성이란, 코드가 한 눈에 들어오지 않을 뿐만 아니라 해당 뷰가 어떤 데이터를 가지고 있고 어떤 액션을 취하고 있는 지 등을 확인하기 어려움을 의미한다.

그렇다면 위와 같이 긴 코드를 어떻게 가독성을 좋게 만들까?

먼저, 커스텀뷰로 쪼개는 방식을 생각할 수 있다. 하지만 커스텀뷰를 계속 만들다보면 메인뷰단에서 데이터의 흐름이 복잡해질 수 있다.

메인 뷰안에 ChildView1과 ChildView2가 존재하고, ChildView안에 CardView가 들어가는 경우 경우를 생각해보자.

데이터를 바인딩시켜주기 위하여 메인 뷰단에서 @State를 선언하고 @Binding을 이용하여 ChildView를 거쳐 CardView로 전달하여야 한다.

UI요소들이 점점 추가될수록 이러한 데이터 흐름은 더욱 복잡해질 것이다.

뷰 State를 한 곳에서 관리하자.

여기까지 전부 서론이었고 여기서부터가 이 글에서 하고 싶은 이야기이다.

스유는 View를 State로 관리한다. 데이터가 변경되면 State가 변경되고, 바인딩된 View를 다시 그려준다. 이 개념을 생각하면 View의 State를 한 곳에서 관리하고 EnvironmentObject를 통해 꺼내 쓰게 된다면 View가 가지고 있는 데이터들을 없애고 데이터 흐름을 모든 State를 관리하는 곳 -> 여러개의 View로 생각해볼 수 있다.

@EnvironmentObject var store: AppStore

위 코드의 store가 앱의 모든 상태(데이터, bool값 등)을 가지고 있다고 생각하자. 만일 뷰에서 데이터가 필요하다면 위 코드 한줄을 작성하여 store에서 꺼내서 사용하면 된다. 얼마나 편리한가.

단방향 데이터 흐름이 중요하다.

하지만 이렇게 한곳에서 모든 State를 관리하기 위해서는 데이터 흐름이 중요하다. 만일 어느 곳에서나 State에 접근해서 변경하고 뷰에 바인딩할 수 있다고 해보자. 그렇다면 한 개의 State가 여러개의 뷰를 업데이트시키고, 한 개의 뷰가 여러개의 State를 조작한다면 데이터 흐름을 이해하기 어려워진다. 버그는 찾기 어려워지고 데이터 흐름을 이해하는데 시간을 많이 투자해야한다. 이는 UIKit의 대표적인 MVC 패턴에서도 찾아 볼 수 있는 문제이다.

flux-flow

위 그림은 흐름을 이해하기 어려운 상황을 개선하고자 만들어진 “단방향 데이터 흐름”이다. View는 바로 데이터를 변경하지 않고 Action을 넘겨준다. 이 때 action은 반드시 dispatcher를 지나야 하고 데이터 변경이 일어나면 View는 변경된 데이터를 Store를 통해서 받는다. 이 흐름을 통해 예측 불가능한 사이드 이펙트를 줄일 수 있다.

이 데이터 흐름은 2014년 페이스북이 고안해낸 FLux라는 아키텍처 패턴이다. 2015년 Dan Abramov가 ‘reducer’라는 개념과 함께 React에 적용하여 Redux(= React + Flux)가 등장하였다.

그래서 이 Redux 개념은 스유에 적합하지 않을 수 있지만, 코드의 가독성을 높이기 위해 적용해보았다. 먼저 간단한 용어를 정리하고 redux의 단방향 데이터 흐름을 살펴보자.

용어 정리

  • State : 앱의 모든 상태를 담은 객체
struct MilgoState {
    var homeAppState: AppState = .none 
    // ...
}
  • Action : 앱의 모든 행위를 담은 객체 (enum)
enum MilgoAction {
    case showPopup(Program)
}
  • Reducer : Action을 받고 State를 업데이트한다. 필요하다면 새로운 State를 반환한다.
typealias Reducer<State, Action> = (State, Action) -> State
  • Store : 단방향 흐름을 도와주는 상태관리 객체로, reducer, 현재 state를 관리한다. (middleware도 관리하지만 글의 주제인 데이터 흐름을 보여주는데 관련이 없기 때문에 생략) 여기서 주의할 점은 상태는 읽기 전용이다.
class Store<State, Action>: ObservableObject {
    @Published private(set) var state: State
    private let reducer: Reducer<State, Action> // 읽기 전용. 외부에서 set 불가능
    private let queue = DispatchQueue( // 하나의 queue에서 작동하여 예상치 못하는 경우 방지
        label: "com.woody",
        qos: .userInitiated)
    
    init(initial: State,
         reducer: @escaping Reducer<State, Action>]) {
        self.state = initial
        self.reducer = reducer
    }
    
    func dispatch(_ action: Action) {
        // 현재 State를 새로운 State로 업데이트
        queue.sync {
            let newState = reducer(self.state, action)
            state = newState
        }
    }

단방향 데이터 흐름

swiftui-data-flow

애플에서 그린 데이터 플로우를 위 개념을 이용해서 새로 그려보았다. 흐름을 설명하면 아래와 같다.

  1. 뷰가 액션을 디스패치하여 Store에 보낸다. (User에 의해, 혹은 외부 이벤트에 의해)
  2. Store의 Reducer가 동작한다.
  3. Store의 현재 State가 업데이트된다.
  4. View에 State가 업데이트되었다고 알린다. (이미 Store의 State가 View에 바인딩되어있음)

SwiftUI + redux와 관련된 내용은 대부분 getting-a-redux-vibe-into-swiftui 이 글을 참고하여 작성하였기 때문에 더 자세하게 알아보고 싶다면 읽어보길 바란다.

사용 예시

// 바인딩 
struct ContentView: View {
    @EnvironmentObject var store: AppStore
    var body: some View {
        switch store.state.homeAppState {
        case .none:
            HomeView()
        case .tracking:
            TrackingView()
        }
    }
}
// Action을 dispatch
struct VoiceMentorCardView: View {
    @EnvironmentObject var store: MilgoStore
    
    var body: some View {
        Button(action: {
            withAnimation {
                store.dispatch(.showPopup(program))
            }
        }) {
            // UI Code
        }
    }
}

결론

코드의 가독성을 높이기 위한 고민에서 시작되어 단방향 데이터 흐름을 알아보게 되었다.

커스텀뷰를 만들어 뷰의 body 내 코드량을 나누어 가독성을 높일 수 있고, 이로 인해 복잡해진 데이터 흐름은 @EnvironmentObject를 사용함으로 한 곳에서 상태를 관리할 수 있다. 이 때 단방향 데이터 흐름을 고려해야 한다.

하지만 body 내에 있는 UI관련 코드들은 여전히 지저분하다.. 🤯 스유는 어려워

참고