grep

iOS

UIKit에서 SwiftUI로의 도약: 여기어때 iOS 아키텍처 변천사

김지영Tori(토리) / iOS 개발팀여기어때

2024년 12월 31일

원문에서 보기 ↗

안녕하세요. 여기어때컴퍼니 iOS개발팀 토리입니다.

저는 지난 5년간 여기어때 iOS 앱의 변화를 경험하며, iOS 아키텍처의 진화과정을 함께 했습니다. 최근에는 SwiftUI 도입에 적합한 아키텍처를 모색하며 또 다른 전환의 시기를 맞이하고 있습니다.

모텔 검색에서 시작해 해외 숙소까지 아우르는 종합 숙박 플랫폼으로

여기어때 앱의 초창기 모습을 기억하시나요? 중소형호텔(모텔) 검색 서비스 로 시작했던 여기어때 앱은 이제 해외 숙소 예약 까지 포괄하는 종합 숙박 플랫폼으로 성장했습니다. 이 과정에서 앱과 팀의 규모는 커지고, 기술적 요구사항 또한 복잡해졌습니다. 이러한 변화에 대응하기 위해 앱의 아키텍처 또한 끊임없이 진화해왔습니다.

이 글에서는 여기어때 iOS 앱의 아키텍처가 변화해온 여정 과, 현재 도입 시작중인 SwiftUI 에 적합한 MVI 패턴에 대해 이야기하려고 합니다. MVI를 구현하는 다양한 방법을 예제코드와 함께 알아보겠습니다.

지금부터 여기어때 iOS 앱의 변천사 여정을 떠나볼까요?🚀😆

여기어때의 아키텍처 변천사: MVC, MVVM, MVI

MVC(Model-View-Controller): 2014년 여기어때 앱 출시

초창기 여기어때 iOS 앱 은 MVC 패턴을 채택하여 개발했습니다. 왜 MVC를 당시 채택할 수 밖에 없었는지 이해하기 위해 2014년 당시의 상황을 한번 보시죠.

먼저, 2014년 당시 애플 은 공식 개발 문서와 튜토리얼을 통해 MVC 패턴을 권장하고 있었습니다. 그로 인해 당시 대부분의 iOS 앱은 MVC 패턴을 기반으로 개발되었습니다.

여기어때 앱 로고

또한, 당시 여기어때는 스타트업으로, 빠른 기능 구현과 최적화된 사용자 경험이 최우선이었을 것입니다. 개발팀은 작은 규모로 구성되어 있었고, 이러한 환경에서는 엄격한 아키텍처 보다는 속도 와 유연성이 중요한 의사결정 요소로 작용했을 가능성이 큽니다.

MVC 는 당시 목표를 달성하는 데 적합한 선택이었고, 빠른 개발 과 기능 구현에 유리한 구조였습니다. 그러나 시간이 지나면서 MVC의 한계가 드러나게 됩니다.

MVC 패턴의 한계

MVC는 코드의 역할을 명확히 분리하여 개발 초기의 속도와 단순성을 보장하는 데 효과적이었습니다. 그러나 MVC의 구조적 한계는 많은 개발자들이 익히 알고 있을 것입니다. 특히, ViewController가 지나치게 비대해지는 Massive ViewController 문제가 있습니다. ViewController가 비대해지면서 유지보수 편의성과 코드 재사용성이 떨어졌습니다.

MVVM(Model-View-ViewModel)+RxSwift 도입

2015년 RxSwift 의 등장과 함께, 반응형 프로그래밍(Reactive Programming)이 iOS 개발자들 사이에서 점차 주목받기 시작했습니다. MVVM은 데이터 바인딩과 상태 관리를 보다 명확하게 분리할 수 있도록 설계되어, 앞서 말씀드린 Massive ViewController 문제를 해결할 수 있습니다**.**

여기어때 iOS팀은 2019년 초부터 RxSwift + MVVM 패턴 을 본격적으로 도입하기 시작했습니다. 특히, 호텔타임 앱에 적극적으로 활용했는데요. 호텔타임 앱은 개발 초기 단계부터 RxSwift와 MVVM 패턴을 도입했기 때문에 기존 코드로 인한 제약이 없었습니다.

호텔타임에서 RxSwift + MVVM 패턴의 성공적인 도입은 새로운 기술과 아키텍처를 실험하며 그 가능성을 충분히 검증하는 계기가 되었습니다.

호텔타임 앱(2023년 서비스 종료) 로고

다만, RxSwift+MVVM 패턴을 여기어때 앱에 도입하려 했을 때, 같은 MVVM 구조라도 팀원마다 구현 스타일이 조금씩 달라 일관성을 유지하기 어려운 문제가 있었습니다. 또한, RxSwift의 비동기 흐름 관리에서 발생하는 복잡성과 상태 및 이벤트 간의 의존성으로 인해 디버깅과 유지보수가 까다로워지는 문제도 확인되었습니다.

MVVM+RxSwift의 한계

이러한 문제를 인식한 후, iOS 팀은 팀 전체가 합의할 수 있는 코드 스타일과 아키텍처 규칙을 정립하기 위한 고민을 시작하게 되었습니다.

ReactorKit의 도입

MVVM + RxSwift의 한계 를 해결하기 위해, iOS개발팀은 MVVM 스타일을 일관되게 강제할 수 있는 아키텍처를 모색했습니다. 이 과정에서, 단방향 데이터 흐름을 강제하는 ReactorKit이 이러한 고민을 해결해줄 새로운 프레임워크로 떠올랐습니다.

ReactorKit 도입과 아키텍처 확립

ReactorKit은 MVVM의 단점을 보완하며, Reactor라는 계층을 통해 상태와 액션을 단방향으로 관리하는 프레임워크입니다.

ReactorKit 다이어그램

MVVM에서는 개발자마다 패턴에 대한 이해와 구현 방식이 달라 협업 시 코드스타일을 매번 논의해야하는 번거로움이 있을 수 있습니다. 반면, ReactorKit은 일관된 템플릿을 적용할 수 있어 코드 스타일을 통일하고 공통 구조를 도입하기 용이했습니다.

2021년, 여기어때 iOS 앱은 5.0 버전 으로 전환하면서 모든 주요 기능을 ReactorKit 기반으로 개발하기 시작했습니다. 동시에 기존 MVC/ MVVM 코드를 ReactorKit으로 점진적으로 리팩토링하는 작업도 병행했습니다. 이러한 전환이 가능했던 것은 팀단위에서 다음과 같은 단계적인 노력이 뒷받침되었기 때문입니다.

  1. 소규모 토이 프로젝트 스터디 ReactorKit의 구조와 개념을 팀원들이 자연스럽게 이해할 수 있도록, 간단한 토이 프로젝트를 제작하고 이를 공유했습니다. 이 과정을 통해 ReactorKit의 장점과 실제 사용법에 대한 공통된 이해를 형성할 수 있었습니다.
  2. 기술 공유 세션 여기어때 iOS 팀은 매일 1시간씩 기술을 공유하는 시간을 가지고 있습니다. ReactorKit 도입 초기에는 이 시간을 활용해 스터디한 내용과 작성한 코드 사례를 리뷰하고 개선 사항을 논의했습니다. 이러한 세션은 단순히 학습을 넘어, 팀 내 코드 스타일 표준화 와 아키텍처 방향성 합의를 이끌어내는 데 중요한 역할을 했습니다.
  3. 점진적 전환과 적용 초기에는 신규 기능에만 ReactorKit을 도입하고, 기존 기능은 단계적으로 전환하는 방식을 채택했습니다. 이를 통해 전환 과정에서의 리스크를 최소화하고, ReactorKit 기반 코드가 팀 전체에 자연스럽게 확산될 수 있도록 했습니다.

ReactorKit 도입과 안정화

우리 팀은 ReactorKit 기반 아키텍처를 채택한 2020년 후반부터 4년간 큰 변경 없이 안정적으로 사용해왔습니다. 이는 ReactorKit 기반 아키텍처가 우리의 서비스에 잘 맞고 충분히 검증되었음을 보여줍니다.

이 기간동안에는 iOS개발팀에서 모듈화를 도입한 시기이기도 한데요. 모듈화와 관련된 글은 엔비께서 다루고 있으니 참고해 보실 수 있습니다.

10살 여기어때 iOS앱의 모듈화 여정

여기어때 iOS 앱은 2014년 국내 숙소 숙박 서비스로 시작하여 딱 10년째인 2024년 지금은 해외여행, 항공권, 공간대여, 티켓까지 다양한 서비스를 제공하는 복합 플랫폼으로 성장했습니다. 여기어때 프로젝트는…techblog.gccompany.co.kr

UIKit에서 SwiftUI로의 도약

ReactorKit은 도입이후 4년간 여기어때에서 사용하면서 안정화 되었지만, SwiftUI의 등장으로 새로운 아키텍처에 대한 논의가 필요해졌습니다. 기존 ReactorKit 아키텍처가 SwiftUI와 완벽히 호환되지 않는 한계가 있었기 때문입니다.

SwiftUI의 등장

SwiftUI는 애플이 2019년 WWDC에서 처음 선보인 UI 프레임워크입니다. SwiftUI가 UIKit과 다른점은 iOS, macOS, watchOS, tvOS 등 모든 Apple 플랫폼에 걸쳐 일관된 UI 개발 환경을 제공한다는 점입니다.

모든 Apple OS 에서 사용할 수 있는 SwiftUI

SwiftUI의 등장은 iOS 개발에서 UIKit 이후로의 새로운 패러다임을 제시했습니다. 이는 iOS 초기의 Objective-C에서 Swift로의 언어 전환과 유사한 흐름을 보여줍니다. 마찬가지로, Objective-C 기반의 UIKit에서 Swift 기반의 SwiftUI로의 전환도 불가피한 변화일 것입니다.

저희 여기어때 iOS 팀도 SwiftUI 도입을 2024년 상반기부터 고려하기 시작했습니다.

점진적 SwiftUI 도입과 아키텍처 재검토

SwiftUI는 Combine과의 높은 호환성을 제공하므로, 기존 RxSwift 기반 아키텍처인 ReactorKit을 대체할 새로운 설계가 필요하다는 결론에 이르렀습니다. 이에 따라 SwiftUI와 잘 어울리는 MVVM 패턴과 기존에 사용하던 MVI 패턴에 대해 스터디를 진행하며 팀원들과 공유하였습니다. 이를 바탕으로, 어떤 아키텍처가 가장 적합할지 검토를 시작했습니다.

MVI와 MVVM의 차이점

MVI를 이해하기 위해 먼저 MVVM을 되짚어볼 필요가 있습니다. MVVM은 데이터가 양방향으로 흐르는 구조로, View와 ViewModel 사이에서 데이터 바인딩을 통해 상호작용이 이루어집니다.

MVVM 다이어그램

MVVM의 데이터는 양방향으로 데이터를 주고 받는 형태로 레이어가 그려진 걸 볼 수 있습니다. MVVM에서는 데이터의 흐름을 단방향으로 강제하지 않는다는 점이 MVI와 가장 큰 차이점 중 하나입니다. 이때, ViewModel의 데이터 변경은 View에 자동으로 반영되며, 사용자 입력은 ViewModel을 자동으로 업데이트합니다. 이 방식은 양방향 통신이 가능하지만, 데이터 흐름을 추적하기 어렵고 복잡성을 증가시킬 수 있습니다.

MVI 는 MVVM과 다르게, 단방향 데이터 흐름 을 강제하는 패러다임입니다. MVI는 Model, View, Intent의 세 가지 요소로 구성됩니다.

MVI 다이어그램

Model (State)

View

Intent

이 중에서 Intent 가 생소하게 느껴질 수 있는데요. 이는 사용자의 특정 행동 을 정의하는 개념입니다. 예를 들어 사용자가 홈 화면을 열거나 카테고리를 탭하는 등의 행동은 모두 Intent로 표현됩니다. 특히 Intent는 특정 UI 컴포넌트를 지칭하지 않습니다. 이는 데이터 흐름을 명확히 하고, 상태 변화와 액션을 일관되게 관리할 수 있도록 도와줍니다.

MVI의 본질과 ReactorKit과의 연관성

MVI의 핵심은 상태와 이벤트 사이의 흐름을 명확히 하여, 단방향 데이터 흐름을 유지하는 데 있습니다. 다음은 팀내에서 MVVM, MVI 차이점을 논의해본 결론입니다.

기존에 사용하고 있던 ReactorKit 도 MVI 패턴의 일종으로 볼 수 있습니다. ReactorKit은 단방향 데이터 흐름과 상태 관리를 중심으로 설계되었기 때문입니다.

결론적으로, SwiftUI에 알맞는 MVI를 도입하자고 결론이 났습니다. MVVM의 단점을 보완한것이 MVI이기도 하고, SwiftUI를 도입하면서도 기존 아키텍처인MVI을 유지할 수 있기 때문입니다.

다양한 MVI 구현

이런 결론에 도달한 이후 2024년 하반기, 팀내에서는 SwiftUI를 도입하기 위해 여러 시도를 해보고 있습니다.

기존 UIKit 기반 앱을 SwiftUI로 바로 전환하기에는 여러 제약이 따릅니다. 여기어때 iOS 앱 역시 UIKit과 SnapKit으로 작성된 코드베이스와 RxSwift 기반 아키텍처를 유지하고 있어 SwiftUI로의 전환이 쉽지 않은 상황입니다. 이러한 현실적 제약을 감안하여, SwiftUI를 신규 피처 단위로 점진적으로 도입하는 접근 방식을 채택하였습니다.

홈화면의 간소화 코드로 MVI를 다양하게 구현해본 과정을 코드예제와 함께 보여드리겠습니다. 여기어때 홈화면에 SwiftUI를 적용한 내용은 레이너께서 작성한 글에서 잘 설명되어있기에 읽어보시길 권장합니다.

UIKit 환경에서 SwiftUI 적용기: 여기어때 Home 화면 편

안녕하세요. 여기어때컴퍼니 iOS개발팀 레이너입니다.techblog.gccompany.co.kr

SwiftUIReactorKit(ReactorView)

여기어때 홈에 현재 적용되어있는 코드입니다. 리액터 킷을 swiftUI에서도 사용할수 있도록 도와주는ReactorKit-SwiftUI 라이브러리를 사용하면, 기존 ReactorKit에서 사용한 Reactor를 그대로 사용할 수 있습니다.

import ReactorKit

final class ReskinHomeReactor: Reactor {
    enum Action {
        case loadHome
    }
    
    enum Mutation {
        case setHomeData(CMSScreen)
        case setHomeDataError(Error)
    }
    
    struct State {
        @Pulse var uiModels: [HomeAppModel]?
    }
    
    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .loadHome:
            return getHomeData()
                .map { $0.data }
                .map { Mutation.setHomeData($0) }
                .catch { error in
                    .just(Mutation.setHomeDataError(error))
                }
        }
    }
    
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        
        switch mutation {
        case .setHomeData(let cmsScreen):
            newState.uiModels = converter.convertToAppModel(cmsScreen: cmsScreen)
        case .setHomeDataError(_):
            // 에러 처리 로직
            break
        }
        
        return newState
    }
}

View를 구현한 부를 보면 ReactorView 프로토콜을 채택하여 Reactor의 State를 SwiftUI 뷰와 바인딩하여 UI 업데이트를 처리합니다.

import SwiftUIReactorKit

struct ReskinHomeView: ReactorView {
   var reactor: ReskinHomeReactor

   func body(reactor: ReskinHomeReactor.ObservableObject) -> some View {
      ScrollView(showsIndicators: false)  {
         VStack(spacing: 0) {
            if let uiModels = reactor.state.uiModels {
                ForEach(uiModels, id: \.id) { model in
                   // 각 모델에 따른 뷰들 생성
                }
            }
         }
      }
   }
}

이 방식은 기존 ReactorKit 사용 경험을 살리면서 SwiftUI로의 점진적인 전환을 지원하며, 선언적 UI 패러다임에 적합한 구조를 제공합니다. 하지만 ReactorKit은 RxSwift 기반이라 Combine을 사용하는 SwiftUI와의 호환성 문제가 있습니다. 그래서 RxSwift를 모두 제거하고 Combine과 SwiftUI로만 이뤄진 개발을 하는 것이 최종 목표라고 할 수 있습니다. 그래서 팀내에서는 combine기반으로 기존 ReactorKit과 동일한 구조의 MVI를 커스텀하여 사용하고 있기도 합니다.

커스텀 MVI

여기어때 iOS 팀에서는 기존 ReactorKit을 Combine과 async/await 를 활용하여 커스텀 MVI 프레임워크를 도입하기 시작했습니다. 이를 통해 기존 ReactorKit 아키텍처의 경험을 유지하면서도 SwiftUI에 적합한 코드를 작성할 수 있습니다.

커스텀 MVI에서Reducer는 Reactor과 동일한 역학을 합니다.액션(Action)에 따라 비동기 작업을 처리하고, 결과를 기반으로 Mutation을 생성하며, 이를 통해 새로운 상태(State)를 반환합니다.

import GCBaseDependencyInterface

final class ReskinHomeReducer: Reducer {
    enum Action {
        case loadHome
    }
    
    enum Mutation {
        case setHomeData(CMSScreen)
        case setHomeDataError(Error)
    }
    
    struct State {
        @Pulse var uiModels: [HomeAppModel]?
    }
    
  func handle(action: Action, continuation: AsyncStream<Mutation>.Continuation) async {
        switch action {
        case .loadHome:
            return getHomeData()
                .map { $0.data }
                .map { continuation.yield(.setHomeData($0)) }
                .catch { error in
                    .just(continuation.yield(.setHomeDataError(error)))
                }
        }
    }
    
    func reduce(state: State, mutation: Mutation) -> State {
        var newState = state
        
        switch mutation {
        case .setHomeData(let cmsScreen):
            newState.uiModels = converter.convertToAppModel(cmsScreen: cmsScreen)
        case .setHomeDataError(_):
            // 에러 처리 로직
            break
        }
        
        return newState
    }
}

ReskinHomeReducerView는 ReskinHomeReducer를 사용하여 상태(State)와 UI를 연결합니다. 상태 변화를 ObservedObject로 구독하며, 상태가 업데이트될 때 뷰를 자동으로 갱신합니다.

import SwiftUI
import GCBaseDependencyInterface

struct ReskinHomeReducerView: View {
   @ObservedObject var observedReducer: ReskinHomeReducer

    var body: some View {
      ScrollView(showsIndicators: false)  {
         VStack(spacing: 0) {
            if let uiModels = observedReducer.observableState.uiModels {
                ForEach(uiModels, id: \.id) { model in
                   // 각 모델에 따른 뷰들 생성
                }
            }
         }
      }
   }
}

이 커스텀 MVI 구조는 Combine과 async/await를 활용해 ReactorKit의 설계 철학과 단방향 데이터 흐름을 유지하면서도 SwiftUI에 맞는 새로운 구조로 발전시켰습니다. 다만, 기존 ReactorKit을 Combine 기반으로 전환하는 데 커스터마이징 비용이 발생할 수 있습니다.

TCA(The Composable Architecture)

SwiftUI 전환을 위한 또 다른 대안으로 TCA를 검토하고 있습니다. TCA는 Point-Free에서 개발한 아키텍처입니다. 상태, 액션, 리듀서가 결합된 구조로, 다양한 기능을 독립적 모듈로 나누어 사용할 수 있습니다.

TCA는 MVI와 유사한 단방향 데이터 흐름을 유지하면서도 세분화된 기능과 모듈화를 지원합니다.

TCA를 적용하는 경우, Mutation 개념 없이 Reducer 내부에서 상태 변화를 정의합니다.

import SwiftUI
import ComposableArchitecture

public struct HomeReducer: Reducer {
    public struct State: Equatable {
        var logoAppModel: LogoAppModel?
        var homeCategoryAppModel: HomeCategoryAppModel?
       // 홈 화면을 구성하는 추가 앱 모델 ...
    }
    
    public enum Action: Equatable {
        case setHomeData(CMSScreen)
        case loadHome
        case setHomeDataError(Error)
    }
    
    public var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .loadHome:
                return .run { send in
                    do {
                        let result = try await getHomeData(interceptor: nil)
                        guard let data = result.data else {
                            throw NSError()
                        }
                        await send(.setHomeData(data))
                    } catch {
                        await send(.setHomeDataError(error))
                    }
                }
                
            case .setHomeData(let moduleType):
                // moduleType에 따른 상태 업데이트
                if moduleType == .logo {
                    state.logoAppModel = converter.makeLogoModuleAppModel(type: moduleType)
                }
                // 추가 모듈 처리 ...
                return .none
                
            case .setHomeDataError(let error):
                // 에러 처리 로직
                return .none
            }
        }
    }
}

StoreOf<HomeReducer>를 통해 뷰에서 상태와 로직을 연결하며, 중앙화된 상태 관리와 업데이트를 제공합니다. WithViewStore는 store의 전체 상태를 관찰하여, 상태 변화가 있을 때 뷰를 업데이트 합니다.

import SwiftUI
import ComposableArchitecture

struct ReskinHomeView_TCA: View {
    let store: StoreOf<HomeReducer>
    
    var body: some View {
        // 관찰할 상태를 \.self로 명시하여 전체 상태를 관찰
        WithViewStore(store, observe: \.self) { viewStore in
            ScrollViewReader { proxy in
                ScrollView(showsIndicators: false) {
                    VStack(spacing: 0) {
                        if let logoAppModel = viewStore.logoAppModel {
                            HomeLogoView(logoAppModel)
                        }
                        // ... 다른 UI 요소들 추가
                    }
                }
            }
        }
    }
}

TCA는 상태(State), 액션(Action), 리듀서(Reducer), 그리고 환경(Environment)을 명확히 분리하여 모듈화를 지원하며, 코드 재사용성과 독립적인 테스트가 용이합니다. 단방향 데이터 흐름을 유지하면서 상태 변경을 리듀서에서 명확히 정의해, 상태 관리가 예측 가능하고 명시적입니다.

하지만, TCA는 개념적으로 복잡하며, 상태, 액션, 리듀서 등의 개념이 얽혀 초기 학습 곡선이 있을 수 있습니다.

마치며

현재 팀 내에서는 동일한 시기에 시작된 기능들에 대해 서로 다른 프레임워크(ReactorView, 커스텀 MVI, TCA)를 적용하며 아키텍처적으로 과도기를 겪고 있습니다. 이러한 시도들이 점차 누적됨에 따라, 향후에는 보다 안정적이고 통일된 형태의 아키텍처로 정착할 것으로 기대하고 있습니다.

“UIKit에서 SwiftUI로의 전환을 언제 어떻게 할 것인가?”라는 고민은 많은 iOS 개발팀이 한 번쯤 겪게 되는 과제라고 생각합니다. 이 글이 비슷한 고민을 하고 계신 분들께 조금이라도 도움이 되길 바랍니다. 😊

[참고 자료]

ReactorKit

TCA