개발/ios

[iOS] RxSwift + Reactorkit 을 사용해서 프로젝트를 구성해보자 - Project Template

나인에스 2022. 2. 27. 15:48

플라워로드 기술 블로그 : http://blog.flowerroad.ai


해당 글은 2021년 5월 24일에 작성되었습니다.

아래 글에서 설명하고 있는 FLo는 현재 서비스 중인 플라워로드 앱을 의미하지만, 이 글은 해당 앱의 기능적인 특징보다 RxSwift + Reactorkit을 이용해서 프로젝트의 SW architecture를 어떻게 구성 하는가에 초점을 두고 있습니다.

배경

iOS - FLo(플라워로드 앱) 1.0의 경우 서비스 초기 MVP를 target으로 개발되었기 때문에 개발 당시 구조를 생각하지 않고 최대한 빠르게 simple한 기능만을 가지는 output를 만드는것이 목표였습니다. 이후 기능들이 많아지고, 시나리오가 복잡해져감에 따라 코드의 복잡도는 더욱 심각하게 복잡해지고 읽기 힘든코드가 되었습니다. 거기다가 여기저기 버그들이 발생하기 시작하고 이제는 해당 버그들이 구조적으로 변경하지 않으면 수정할 수 없는 이슈가 발생하거나, 한곳에서 무언가를 수정하면 다른곳에서 다른 이슈가 발생하는 등 더이상 코드를 유지할 수 없다는 판단에 FLo 2.0이라는 이름으로 다시 개발하게 되었습니다.


사용 기술

FLo 2.0은 생산성 향상을 위해서 아래 나열된 것과 같은 Swift에서 많이 사용 되는 기술 혹은 extension, framework, tool, library들을 사용하기로 결정 했습니다.

RxSwift

크게 설명할 필요도 없이 너무 유명하고 많이 사용 되는 extension입니다. 기본적으로 ReactiveX 진영에서 Swift용으로 개발된 extension이고, UI/UX layer와 data processing을 stream형태로 연결 및 관리, 개발 할수 있어서 처리된 내용을 화면에 표현, 표시 해주기 쉽도록 되어 있습니다. 그리고, 기존 async 처리를 위한 completion 형태의 callback 지옥의 코드 구조를 벗어날 수있다는 점이 매력적 입니다. 사실 이게 observable, observer, subject등 용어나 사용법에서 초기 진입장벽이 조금 높은 편이긴 한데 사용하다보면 기존형식의 코딩은 할 수 없을 정도록 매력이 있습니다........만 extension에 코드가 종속되어 버린다는 아주 큰 단점이 있기는 합니다.

ReactorKit

RxSwift를 조금더 명확하고 쉽게 사용 할 수 있도록 wrapping한 framework이라고 볼수 있습니다. 기본적으로 SW Architecture에서 RxSwift를 사용한 view 와 viewModel 혹은 view와 controller간의 연결을 조금 쉽게 할 수 있게끔 되어 있습니다. 전체 구조는 view, 에서 시작된 action이 reactor 로 전달되고 여기에 연결되어 있는 service들을 이용해서 데이터 처리를 한 후 state를 통해서 view에 업데이트를 해주는 방식입니다. SW Architecture관점에서 reactor는 MVVM에서 viewModel의 일부를 포함하고, service는 MVVM의 viewModel과 Model을 일부 포함하는 구조입니다. 물론 Service에서 Model영역을 분리해서 viewModel과 동일한 layer로 만들어서 사용할 수도 있습니다.(유연성, 확장성을 고려한다면 이렇게 하는편이 좋습니다.)

Moya

조금 더 편하게 REST API를 사용하기 위한 extension 입니다. 많은 Swift project에서 HTTP request를 이용하기 위해서 Alamofire를 wrapping한 class를 만들어서 사용합니다. 이를 조금더 편하게 Android의 Retrofit와 비슷한 형태의 파일, 폴더, class구조를 만들기 위해서 사용되었습니다.

 

SnapKit

FLo 2.0에서의 UI 구현은 storyboard를 전혀 사용하지 않습니다. 따라서, code 상에서 autolayerout을 구현해야하고 이를 위해서는 SnapKit는 선택이아닌 거의 필수가 되었습니다.

 

SideMenu

Main menu를 위해서 사용하게 되었습니다. Left 혹은 Right 메뉴를 여러가지 테마로 쉽게 사용할 수 있어서 사용하게 되었습니다.

 

Amplify

FLo의 모든 Backend 기능들은 AWS에서 동작하고 관리되도록 구현 하고 있습니다. 로그인 서버도 AWS의 cognito service를 사용하고 있고, 따라서 client에서 이를 편리하게 이용하기 위해서 Amplify를 사용하게 되었습니다.

 

etc

이외에 Then, ReusableKit, SwiftyColor, SwiftyImage, CGFloatLiteral등 여러 library, tool들이 개발의 생산성 향상을 위해서 사용되고 있습니다.


Architecture & Structure

아래에서 다룰 FLo2.0의 Architecture는 구조를 설계할때 대부분의 앱을 초기에 구성할 때에 필요한 common한 기능과 가까운 미래에 업데이트 될 수 있는 일반적인 내용들을 고려해서작성 되었고, 굳이 현 상황에 필요하지 않는 내용들은 과감히 배제 하거나 추후 추가 할 수 있는 형태로 설계되었습니다. 그리고, 소수의 개발 인원에 맞게 layer를 아주 많이 나누지 않고 간소화된 형태로 설계되었습니다. 따라서, 구조가 간혹 특이하거나 clean architecture와는 조금 상반된 내용이 있을 수도 있습니다만 이는 프로젝트의 resource, cost, 기간등 주어진 상황이 매우 풍부하지 않다는 전제 하에서의 최선의 방향을 선택한 것이고, 만약 불합리하거나 더 좋은 방향이 있다면 언제든지 업데이트 될 수 있습니다.

Basic Structure

기본 구조는 ReactorKit를 이용한 구조입니다. ReactorKit에서 설명하듯이 view에서 시작된 Action이 Reactor로 전달 되고 해당 Action에 연결되어 있는 동작 혹은 데이터 처리를 Service를 이용해서 처리하고, 다시 UI 표시를 위해서 State를 변경해서 View에서 변경된 State를 보여주는 형태 입니다.

다만 여기에는 SW Architecture 관점에서 확장성, 유연성을 고려할때에 불리한 부분도 있고, Action이 view가 아닌 다른 곳에서도 발생할 수 있는 조건(connection fail, state updated by server 등)이 있기 때문에 몇가지 추가되었습니다.

Basic Structure

전체적인 Architecture는 Android와의 일관성을 위해서 MVVM구조에 최대한 일치하도록 구조를 만들고 있습니다. ReactorKit에서는 Reactor영역이 viewModel역활을, Service가 Model역활을 수행하고 있으나 이는 추후 확장성, 유연성을 고려할때에 수정해야할 부분이 많아 수도 있다는 위험이 있어서 전통적인 Model형태의 Repository layer 를 추가했습니다.

현재는 REST API를 이용해서 Backend로부터 데이터를 받고 있지만 추후 gRPC, WebSocket, MQTT 등을 이용하거나 이를 조합해서 데이터 받는 방법으로 바뀌게 된다면 Service 뿐만 아니라 Reactor layer 또한 수정이 필요한 상황이 발생 할 수 있습니다. 이를 위해서 Model역할을 하는 repository를 따로 분리해서 변경시의 risk를 최소화 하고 수정해야 하는 layer의 간섭도 최소화 할수 있습니다.

View(ViewController)

UI를 drawing하고 UI에서 전달되는 action을 Rx로 받아서 reactor로 전달해주는 역활을 합니다. 전통적인 View와 크게 다르지 않습니다.

StroyBoard를 사용하지 않고 코드를 이용해서 View를 구성하기 때문에 view component에 대한 boilerplate 형태의 code가 다수 포함되기는 합니다.

 

ViewController은 다른 ViewController를 present할때에 Factory형태의 ViewCotrollerProvider를 이용합니다. 이 ViewControllerProvider는 CompositionRoot에 의해서 생성되어 초기화시 주입되는 형태이고, navigator혹은 menu등에 의해서 다른 viewController로 전환 될때에 사용됩니다.

 

 

Reactor

View로부터 전달된 Action에 따라서 service를 이용해서 필요한 처리를 합니다. 이때 Reactor영역은 View에 조금더 가깝습니다. 다시말해서 reactor 영역에서 직접적인 데이터의 처리를 일체 하지 않고 service에서 받은 결과를 바탕으로 이를 state에 반영해주는 역할을 합니다. 또한 중요한점은 View에서 발생하지 않은 Action event 또한 받아서 처리를 하게 됩니다. 예를 들어서 repository에서 socket을 이용해서 Backend와 연결되어 있을때 연결이 끊어진것을 감지 하게 되면 Service를 통해서 reactor에게 이를 전달해주고 이때 필요한 처리를 Service를 이용해서 처리 한 다음 필요하다면 View에 반영을 합니다.

Reactor에서 service를 사용할때에도 provider를 이용합니다. Provider은 Factory와 거의 동일한 형태이나 항상 instance를 생성하지는 않고 singleton형태와 같이 lifecycle scope의 범위가 넓은 service의 경우 이미 생성되어 있는 instance 를 share해서 그대로 사용합니다. 쉽게 말해서 DI Container와 Factory 가 합쳐져 있는 형태 입니다.

 

 

Service

Service는 Data를 처리하는 layer로서 위에서 언급한것과 같이 MVVM의 viewModel과 동일한 형태입니다.

View에 보여줘야 할 데이터 혹은 사용자가 요청한 데이터들을 Repository로부터 요청해서 받은 후 가공을 거쳐서 요청한 Reactor에게 전달이 되는 형태 입니다.

여기서 중요한 점은 각 Reactor에서 모니터링이 필요한 데이터가 있을때에 Service에 observer를 설정해둘 수 있습니다. 예를 들어 특정 Reactor에서 user state를 모니터링 하고 싶을 경우 해당 Service에 User State를 위한 Subject를 만들고 만들어진 Subject를 Reactor의 transform에 연결해서 View가아닌 Service에서 발생한 event를 Action 혹은 mutate, state형태로 받을 수 있습니다.

 

 

Repository

Android의 MVVM구조에서 사용되는 Repository와 동일합니다. 각각의 repository는 HTTP component혹은 WebSocket component를 사용해서 Backend로부터 데이터를 get, post, put, delete를 할수 있고, 획득한 최신 데이터를 class내에 유지해서 필요할때에 service로 전달하거나 service로부터 요청이 있을때 갱신합니다.

기본적으로 Interface를 통해서 외부에서 사용되고 내부적으로는 서버로 전달되거나 서버로부터 전달 받는 데이터를 정의하고 저장해두기 위한 Data Model 들을 가질 수 있습니다.

 

 

 


Component Structure

DI (Dependency Injection)

Swift에서의 DI는 SwInject가 가장 평이 좋은 extension입니다. 하지만 이것도 아주 옛날에 업데이트 되고(약 2년전?) 이후 유지 보수가 없는 상태이긴 합니다. 처음에는 SwInject를 사용하려고 검토 및 예제 코드까지 작성해서 테스트 했으나 프로젝트의 규모가 아주 크지 않기 때문에 직접 DI를 구현하기로 결정 했습니다. ComponsitionRoot를 class를 생성해서 각각의 instance의 scope를 정의하는 DI Graph 정의 및 구현했습니다. class의 추가, 사용시 해당 class의 scope와 필요한 다른 class, object들을 파악해서 provider이라는 factory 형태의 function을 이용해서 주입해주는 형태입니다.

final class CompositionRoot {
    static func resolve() -> DependencyInjection {
        let window = UIWindow(frame: UIScreen.main.bounds)
        window.backgroundColor = .white
        window.makeKeyAndVisible()
        
        // init auth service
        let authService = AmplifyAuthService()
        let authAPIPlugin = AuthPlugin(authService: authService)
        
        // init requester
        let userDataRequest = HTTPRequester<userDataAPI>(plugins: [authAPIPlugin])
        
        // init repository
        let userDataRepo = userDataRepoImpl(userDataRequester: userDataRequest)
        
        // init user service
        let userService = UserServiceImpl(userDataRepo: userDataRepo)
        
        
        // MARK: test view controller
        let testViewControllerProvider: (()->TestViewController)!
        testViewControllerProvider = {
            let testViewServiceProvider = TestViewServiceProviderImpl(authService: authService, userService: userService)
            let testViewReactor = TestViewReactor(serviceProvider: testViewServiceProvider)
            return TestViewController(reactor: testViewReactor)
        }
        
        
        // MARK: Main View
        var presentMainView: (()->Void)!
        presentMainView = {
            // Service provider
            let mainViewServiceProvider = MainViewServiceProviderImpl(authService: authService)
            // View Controller Provider
            let mainViewControllerProvider = MainViewControllerProviderImpl(testViewControllerProvider: testViewControllerProvider)
            
            // slide view
            let slideMenuViewReactor = SlideMenuViewReactor()
            let slideMenuViewController = SlideMenuViewController(reactor: slideMenuViewReactor)
            // slide menu navigator
            let slideMenuNavigator = SlideMenuNavigator(rootViewController: slideMenuViewController).defaultSetting()
            
            // construct main view
            let mainViewReactor = MainViewReactor(serviceProvider: mainViewServiceProvider)
            let mainViewController = MainViewController(reactor: mainViewReactor,
                                                        viewControllerProvider: mainViewControllerProvider,
                                                    slideMenuNavigator: slideMenuNavigator)
            
            let navigationController = UINavigationController(rootViewController: mainViewController)
            window.rootViewController = navigationController
        }

        // Splash View
        let splashViewReactor = SplashViewReactor(authService: authService)
        let splashViewController = SplashViewController(reactor: splashViewReactor, presentMainView: presentMainView)
        window.rootViewController = splashViewController
        
        
        return DependencyInjection(window: window)
    }
}

Network

FLo에서는 현재 HTTP 를 이용한 REST API만 이용해서 데이터를 처리하고 있으나, 가까운 미래에 WebSocket, gRPC, MQTT 등 양방향이고 stream 형태의 통신을 위한 network framework 이 추가될 예정입니다.

HTTP는 Moya를 이용한 requester를 구현해서 사용하고, 인증을 위한 plugin을 추가한 형태입니다.

STREAM은 아직 구현은 되어 있지 않고, 추후 위에서 언급한 양방향, stream 형태의 통신을 위해 구현하게 될 예정입니다.

Util

구현을 하면서 필요한 다양한 유틸리티를 구현합니다.

기본적으로 로그를 위한 Logger, 앱 내에서 사용하는 색상을 한곳에 정의해서 모아두기 위한 FLoColorDeclaration등 유틸리티 성격의 내용들이 모두 포함됩니다.

Rx

Rx를 위한 extension들을 구현 합니다.


Folder & File Tree Structure

DI (Dependency Injection)

DI를 위한 구현 파일이 위치하는 폴더

DIContainer의 경우 현재 사용되지 않고, 추후 SwInject를 사용해야 하는 상황이 발 생했을때에 사용할 예정. USE_SWINJECT의 환경 변수를 두고 Enable일때 활성화 되는 구조 입니다..

Views

view를 위한 구현체가 위치하는 폴더

각 view를 위한 component들은 xxxView 폴더를 생성해서 하위 폴더내에 생성.

xxxViewController - view controller

xxxViewReactor - reactor

xxxViewServiceProvider - 해당 view에서 사용하는 service를 사용하기 위한 provider

xxxViewControllerProider - 해당 view에서 다른 view로 전환할때 사용되는 provider

Service

Service의 구현 파일이 위치하는 폴더.

기본적으로 하나의 파일에 interface(protocol)과 구현체(class)를 구현하나 동일한 interface를 사용하는 여러개의 구현체가 있는 경우 하위 폴더를 만들고 interface(protocol)과 구현체(class)파일을 분리. (ex. AuthService)

Repository

Repository의 구현파일이 위치하는 폴더. 서버와의 통신에 사용되는 데이터를 정의하는 struct파일들이 위치하는 DataModels 폴더가 하위에 있음.

xxxRepo - Backend와 연동해서 데이터를 send/recv하고 해당 데이터를 caching하는 구현체

xxxDataAPI - HTTP API Spec를 정의

 

Network

Stream - 연결 유지를 위한 통신 모듈 구현체가 위치

Http - HTTP통신을 위한 모듈 구현체가 위치

 

Ref

https://github.com/ReactiveX/RxSwift

 

'개발 > ios' 카테고리의 다른 글

[iOS] UI Test 를 자동화 해보자. - Basic 사용법  (0) 2022.02.20
[iOS] DI(Dependency Inject) with SwInject  (0) 2022.01.28