테크

2024. 07. 29

13살 에브리타임 앱에 SwiftUI 적용하기

모던한 UI로의 첫걸음

13살 에브리타임 앱에 SwiftUI 적용하기13살 에브리타임 앱에 SwiftUI 적용하기

에브리타임은 “대학생들이 더욱 편리하고 즐거운 대학생활을 만들어 나갈 수 있는 방법이 무엇일까?”라는 한 대학생의 고민에서 출발했습니다. 2010년에 웹 서비스로 처음 시작된 에브리타임은 2011년에 iOS 앱 버전도 출시되어 지금까지 서비스 운영을 이어가고 있습니다.


에브리타임의 앱 버전이 출시된 지 무려 13년이 지난 만큼, 현재는 레거시 프레임워크로 취급되는 Storyboard를 필요에 따라 SnapKit와 혼합하여 사용하고 있는데요. 그러나 Storyboard는 코드의 버전 관리가 어렵고, diff만으로는 리뷰하기 힘들다는 단점이 있습니다.



이러한 단점을 해결하기 위해 새로운 솔루션 모색에 나섰습니다. WWDC 2020 이후, Widget 구현에 SwiftUI 사용이 필수적으로 요구됨에 따라, 에브리타임의 핵심 기능인 시간표 위젯을 SwiftUI로 작성했고, 이를 통해 저희 팀은 SwiftUI의 높은 생산성을 체감할 수 있었습니다. 처음에는 이 경험을 바탕으로, 최소 지원 버전이 iOS 14로 상향되는 시점부터 SwiftUI를 도입하려 했습니다. 하지만 앱의 규모가 작지 않기 때문에 전체를 SwiftUI로 전환하는 데는 오랜 시간이 필요할 것으로 예상되었습니다. 이에 따라 부분적으로 SwiftUI를 적용하고 점차 확장하는 방식을 선택했습니다.


하나의 화면을 UIKit에서 SwiftUI로 전환하는 방법에 대한 자료는 많았지만, 프로덕트 전체를 조금씩 전환하는 방법에 관한 자료는 찾기 어려웠습니다. 우리는 조각조각 흩어진 자료들을 모으며 삽질을 시작했습니다. SwiftUI로의 이전이 아직 완료되지는 않았지만, 이러한 노력 덕분에 현재 에브리타임 앱의 5개 탭 중 3개 탭을 SwiftUI로 전환할 수 있었습니다.


이번 글을 통해, 프로덕트의 규모가 큰 ‘에브리타임’에 SwiftUI를 적용하기 위해 고군분투한 개발팀의 경험을 상세하게 풀어보겠습니다.



삽질의 기록(1) leaf 단위부터 적용하기

에브리타임에 SwiftUI를 점진적으로 도입하기로 결정한 후, ‘어디서부터 전환할 것인가?’에 대한 고민이 이어졌습니다. 그리고 고민 끝에 SwiftUI를 leaf 단위부터 적용하기로 결심했는데요. leaf 단위부터 적용은 어플리케이션의 화면 계층 구조에서 가장 하위에 위치한, 즉 더이상 다른 화면으로 전환되지 않는 마지막 화면부터 변경하는 방식을 의미합니다. 예를 들어, 화면 전환이 A → B → C 순으로 이루어진다면, 가장 마지막 화면인 C부터 SwiftUI를 적용하고, 그 다음 B, 그리고 A 순으로 SwiftUI로 전환해 나가는 방식이죠.


leaf 단위부터 SwiftUI를 적용하지 않을 경우, UIKit과 SwiftUI 사이에 서로 전환해야 하는 상황이 발생하게 됩니다. 이렇게 되면 UIKit에서 SwiftUI로, 그리고 다시 SwiftUI에서 UIKit으로의 네비게이션을 두 번 구현해야 하는 문제가 생깁니다. 이를 방지하기 위해 우리는 마지막 화면 단위부터 전환을 시작하였고, 한 단계씩 위로 올라가며 SwiftUI로 전환해 나갔습니다.


삽질의 기록(2) 네비게이션 방식 통일하기

이 때 문제가 발생했습니다. UIKit과 SwiftUI는 각각 다른 네비게이션 방식을 사용하기 때문에, 두 가지 방식을 모두 구현해야 했습니다. 이로 인해 전체적인 복잡도가 높아졌습니다. 특히, 푸시 알림과 같은 앱 스킴을 사용하여 화면을 전환할 때, SwiftUI를 사용하는 View부터는 새롭게 화면 스택을 쌓아주어야 했습니다. 이러한 상황에서 앱 스킴을 두 번 호출하는 것은 필연적이었고, 결국 네비게이션 방식을 하나로 통일해야 한다는 결론에 도달했습니다.



네비게이션 방식을 하나로 통일하기 위해, SwiftUI로 구현된 View를 UIHostingViewController 단독으로 사용하지 않았고, 대신 UIViewController의 내부에 ContainerView를 추가했습니다. 그 ContainerView 안에 UIHostingController를 넣어 rootView에 SwiftUI 뷰를 적용하는 방식으로 구현했죠. 이 과정에서 SwiftUI View에 UIViewController를 넘겨줬습니다.



삽질의 기록(3) 상태 관리 도구의 필요성

Alert과 Modal도 AlertController나 다른 ViewController를 통해 표시되기 때문에, 이 기능은 ViewController가 담당하도록 역할을 분리했습니다. 그래서 Alert과 Modal에서 발생한 사용자 행동에 따라 SwiftUI의 상태가 변경되어야 하는데요. 이는 SwiftUI View의 상태가 자체적으로 변경될 뿐만 아니라 ViewController에서도 변경될 수 있음을 의미합니다. 다시 말해, UIKit만 사용할 때보다 더 넓은 범위에서 상태 변경이 이루어졌고, 이로 인해 상태 관리 도구의 필요성을 느꼈습니다.


// MyPageViewController.swift
...
func onTapCloseButton() {
    self.dismiss(animated: true)
}

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    let identifier = segue.identifier
    if identifier == "ChangeDept" {
        let webView = segue.destination as? WebViewController
        webView?.actionURLString = ".../my/dept"
    }
}
...
@IBSegueAction func embedMyPage(_ coder: NSCoder) -> UIViewController? {
    let myPage = MyPage(viewController: self)
    let hostingController = UIHostingController(coder: coder, rootView: myPage)
    return hostingController
}


// MyPage.swift
weak var viewController: MyPageViewController?
...
.onTapGesture {
    viewController?.handleProgressView(false)
    viewController?.performSegue(withIdentifier: "ChangeDept", sender: nil)
}
...
Button {
    viewController?.onTapCloseButton()
} label: {
    Text("close")
}


당시 SwiftUI와 함께 사용하기 가장 좋은 상태 관리 라이브러리는 The Composable Architecture(TCA)였습니다. TCA를 사용하면 간단한 값 타입들로 애플리케이션의 상태를 관리할 수 있습니다. 특히, 테스트를 염두에 두고 있었기 때문에 TCA의 자체 DI 라이브러리인 Dependencies를 사용하여 간편하게 의존성을 주입할 수 있다는 점이 매력적으로 다가왔습니다. 그렇게 TCA의 도입을 결정했습니다.


TCA를 상태 관리 도구로 도입하면서, ViewController와 View에서 발생하는 모든 상태 변경을 TCA가 제공하는 Reducer로 전달하여 데이터의 흐름을 단방향으로 유지했습니다. 이를 통해 복잡한 UI의 상태를 간편하게 관리할 수 있었습니다. 물론, TCA를 도입하는 과정이 쉽지는 않았습니다. 이 과정에서 발생한 여러 문제와 개선 사항은 다음 기회에 자세히 다루도록 하겠습니다.



에브리타임 iOS앱의 현재와 미래


현재 에브리타임 iOS앱은 UIViewController에서 네비게이션을, SwiftUI에서 화면 표시를, Reducer에서 상태 관리를 담당하도록 각 기능의 역할을 분리해 사용하고 있습니다. 이를 명확하게 구분하기 위해, 기능별로 폴더를 만들도록 컨벤션을 강제했습니다.


구체적으로 설명하면, UIHostingController를 보여주는 ViewController와 UIHostingController의 rootView인 SwiftUI View는 Page라는 이름으로 사용하고, SwiftUI View의 상태를 관리하는 Reducer는 Reducer라는 이름을 필수적으로 구현했습니다. 또한, 내부의 컴포넌트를 Views폴더 내에 View라는 이름을 포함하여 구현 및 추가하는 형태로 사용했습니다.



각 Page는 UIViewController와 Reducer를 감시하는 ViewStore를 가지도록 구현했습니다. 이를 통해 ViewStore의 특정 상태 변화를 관찰하고, 해당 변화를 UIViewController에 전달할 수 있는 bind 함수를 구현했습니다.


// Page
struct LetterBoxPage: View {
    ...
    @ObservedObject var viewStore: ViewStoreOf<LetterBoxReducer>
    weak var viewController: LetterBoxViewController?
    
    init(store: StoreOf<LetterBoxReducer>, viewController: LetterBoxViewController?) {
        let viewStore = ViewStore(store, observe: { $0 })
        self.viewStore = viewStore
        self.viewController = viewController
        bind(viewStore)
    }
    
    var body: some View { ... }
    
    private mutating func bind(_ viewStore: ViewStoreOf<LetterBoxReducer>) {
        viewStore.publisher.shouldHandleAbuseResult
            .sink { [weak viewController, weak viewStore] abuseResult in
                guard abuseResult else { return }

                viewController?.handleAbuseResult()
                viewStore?.send(.inner(.setShouldHandleAbuseResult(false)))
            }
            .store(in: &cancellables)

        viewStore.publisher.networkError
            .sink { [weak viewController, weak viewStore] networkError in
                guard networkError else { return }

                viewController?.handleError(error: networkError)
                viewStore?.send(.inner(.setNetworkError(nil)))
            }
            .store(in: &cancellables)
    }
}


반대로 UIViewController에서 사용자의 행동이 상태 변화를 일으키고 이를 Reducer에 전달해야할 때에는 protocol을 활용하여 ViewStore에게 이 상태 변화를 전달하고, Reducer가 업데이트된 상태를 반영합니다. 특히, UIViewController가 처리하는 Alert에서 사용자의 행동을 반영하는 경우가 대표적입니다.


// PageEvent
protocol LetterBoxPageEvent {
    func handleTapOk()
}

// ViewController
final class LetterBoxViewController: UIViewController {
    var pageEvent: LetterBoxPageEvent?
    ...
    func handleError(error: NetworkError) {
        let alert = UIAlertController(
            title: NSLocalizedString("err_unknown", comment: ""),
            message: nil,
            preferredStyle: .alert).ensureActionSheet(self)
        let okAction = UIAlertAction(
            title: NSLocalizedString("ok", comment: ""),
            style: .default,
            handler: { [weak self] _ in
                self?.pageEvent?.handleTapOk()
            })
        alert.addAction(okAction)
        present(alert, animated: true)
    }
}

// Page
extension LetterBoxPage: LetterBoxPageEvent {
    func handleTapOk() {
        viewStore.send(.delegate(.handleTapOk))
    }
}


지금까지 100% UIKit으로 구성된 에브리타임 iOS앱에 SwiftUI를 부분적으로 적용하기 위해 저희가 사용한 방법을 소개했는데요. 두 가지 프레임워크를 동시에 사용하는 것은 장점도 있지만, 여러가지 문제점들이 발생했을 때 해결 방안을 참고할 수 있는 사례들을 찾기 어렵다는 단점도 있었습니다.


도입 당시 에브리타임 앱의 최소 지원 버전은 iOS 14였습니다. 그러나 SwiftUI의 네비게이션 기능이 iOS 16 미만에서는 UIKit의 네비게이션 스택과 동일한 방식으로 동작하지 않아서 SwiftUI 네비게이션을 활용하는 데 어려움이 있었습니다. 그럼에도 불구하고 SwiftUI의 높은 생산성을 활용하기 위해 UINavigationController를 이용해 네비게이션을 유지하기로 결정했고, 이는 두 마리 토끼를 모두 잡을 수 있는 매력적인 요소였습니다.


이 글을 통해 이러한 전제에 집중하여 SwiftUI를 도입하는 방법 중 하나를 소개했는데요. 앞으로 저희가 사용한 방법이나 더 나은 방식으로 SwiftUI를 적용한 후기가 많아지기를 기대합니다.


Written by 황영수