ji_iin
iOSLog
ji_iin
전체 방문자
오늘
어제
  • 분류 전체보기 (56)
    • Swift (8)
    • iOS (6)
    • 알고리즘 (34)
    • CS (3)
    • 회고 (3)
    • 제품리뷰 (2)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • 그래프탐색
  • 정렬
  • 너비우선탐색
  • 백트래킹
  • 수학
  • 공식문서
  • django
  • 재귀
  • SWiFT
  • opional
  • swiftUI
  • 다이나믹 프로그래밍
  • 자바
  • 회고
  • 백준
  • 깊은복사와 얕은복사
  • 알고리즘
  • 2022년 회고
  • 구조체와 클래스
  • ios
  • 대기업코테
  • 프로그래머스
  • 파이썬
  • Bye2023
  • 깊이우선탐색
  • 브루트포스 알고리즘
  • 알고리즘개념
  • 그래프이론
  • Python
  • 개발회고

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
ji_iin
iOS

iOS 커스텀 아키텍처 고민하며 구상해보기 (Unidirectional Data Flow / Based ReactorKit)

iOS 커스텀 아키텍처 고민하며 구상해보기 (Unidirectional Data Flow / Based ReactorKit)
iOS

iOS 커스텀 아키텍처 고민하며 구상해보기 (Unidirectional Data Flow / Based ReactorKit)

2024. 7. 21. 00:30

 

1. 개요

현재 회사에서 메인으로 맡은 앱의 아키텍처라 함은 뷰모델만 간신히 분리되어 있는 MVC가 혼재된 MVVM 형태이다. 외주 업체로부터 넘어온 앱이라 코드의 일관성도 없고 아래 사진처럼 무한 중첩 클로저가 장풍처럼 쏴져있고 그에 RxSwift가 조금 곁들여진 쉽지 않은 프로젝트 구조다.

올해 초 리팩토링 할 시간이 주어져 아키텍처에 대한 고민을 잠깐 한 적이 있었다. 혼자 앱을 맡기도 했고 새로운 기술 검토를 할 여유가 없어 이전 팀원이 작업한 Protocol ViewModel 베이스의 MVVM 구조 그대로 한 화면 정도 리팩토링 했었다.

 

아키텍처를 커스텀 한 배경

그 때 느꼈던 MVVM의 단점은 상태관리가 제대로 안된다는 것이다. 그나마 Input-Output 구조로 단방향 흐름은 대략 잡을 수 있었지만 화면의 상태를 관리하는 방식을 구체화 하지 않았다. Rx Subject를 난발해 쓰기도 했고 선언한 상태 변수가 어디서 쓰이는지 매번 흐름을 찾으며 확인해야하는 번거로움 등 여러 문제를 몸소 느꼈다.

 

상태관리가 가능한 단방향 흐름의 아키텍처를 고려하게 되었고 대표적으로 떠오르는 건 ReactorKit 이다. 이미 많은 회사들에서 사용하고 있어 안정성이 보장되고 Presenter Layer에 일부만 도입 가능하다는 장점이 있지만 RxSwift 의존성이 크다는 점이 도입을 고민하게 되었다.

벌써 Swift Concurrency나 Combine이 나온지 3년이 넘어가고 꽤나 안정적으로 사용되는 현시점에 서드파티가 강하게 의존된 아키텍처를 선택하기는 조심스러웠다. 또한 전반적인 코드 베이스가 RxSwift에 많이 엮여있는 상황도 아니여서 굳이 라는 생각도 들었다.

 

그렇다면 ReactorKit의 구조는 그대로 가져가되 비동기 방식을 퍼스트 파티로 사용해 커스텀 한다면..? 단점을 극복하면서 내가 추구하고자 하는 방향성 모두 가져갈 수 있을 것 같았다.

그래서 직접 구현하기로 했다!

회사 컨플루언스에 작성한 초안…

 

2. 아키텍처 설명

전반적인 개념 및 구조는 ReactorKit에서 사용하는 Reactor와 비슷하다.

차이점은 비동기 처리방식을 async/await로 사용하고 @Pulished로 뷰에서 상태값을 구독하여 사용한다.

 

Q. ReactorKit 구조 비슷하게 가져간다면서 왜 비동기 처리 방식을 RxSwift와 비슷한 Combine을 사용하지 않았나요?

라는 의문이 드실 분들을 위해 (사실 제가 고민한 부분…)

 

처음 구현할 땐 리액티브 프로그래밍 구조 그대로 따라 구현하려 했다. 찾아보니 이미 구현된 레포도 있었고 이를 참고해서 코드를 검증할 겸 하나씩 뜯어 공부해볼까 싶었지만 Combine을 많이 사용해보지 않아 학습시간 + 구현하는 시간이 꽤나 소요될 것 같았다.

또한 유저 액션이나 UI 관련 이벤트들은 다양해서 여러 데이터 스트림이 필요할 수 있지만, 오히려 액션을 처리하고 상태를 변경하는 내부 비즈니스 로직 자체는 간단한 비동기 처리 방식이 적합하다는 생각이 들었다.

 

그래서 아래와 같은 구조로 비교적 심플하게 구현할 수 있었다.

(단방향 아키텍처 커스텀 경험이 있는 친구의 자문을 구해 도움을 많이 받았다 🙇‍♀️)

@MainActor struct TestState {
    // MARK: - viewState
    
    // MARK: - domainState
    var domainState = DomainState()
    
    struct DomainState {
        
    }
    
    init() {}
}

protocol HasTestDomainState: AnyObject {
    @MainActor var domainState: TestState.DomainState { get }
}

@MainActor final class TestStore: HasTestDomainState, ObservableObject {
    
    @Published private(set) var state: TestState
    var domainState: TestState.DomainState { self.state.domainState }
    let worker: TestWorkerProtocol
    
    enum Action {
        
    }
    
    enum Mutate {
        
    }
    
    init(
        worker: TestWorkerProtocol,
        state: TestState
    ) {
        self.worker = worker
        self.state = state
    }
    
    @discardableResult func executeAction(_ action: Action) -> Task<Void, Never> {
        Task { [weak self] in
            await self?.executeAction(action)
        }
    }
    
    @MainActor func executeMutation(_ mutation: Mutate) async {
        self.state = self.executeMutation(state: self.state, mutation: mutation)
    }
}

// MARK: - Implementation
extension TestStore {
    
    private func executeAction(_ action: Action) async {

    }
    
    private func executeMutation(state: TestState, mutation: Mutate) -> TestState {
        
    }
}

 

1) 핵심 요소

State

화면에 보여질 상태값 및 도메인에 사용될 상태값을 갖는 구조체

 

상태에는 뷰의 상태와 비즈니스 로직 상태(DomainState) 두가지로 구분되는데 분리하면 더욱 직관적으로 사용될 수 있다. 또한 도메인 상태값은 외부에서도 프로토콜로 분리해 가져다 사용될 수 있도록 구현했다.

@MainActor struct SearchBookState {
    // view state
    var bookListViewModel: [BookViewModel] = []
    var isLoading: Bool = false
    // domainState
    var domainState = DomainState()
    struct DomainState {
			var bookList: [Book] = [] // example
    }
}

 

프로토콜로 명세된 DomainState는 외부에서 해당 화면의 도메인 상태값이 필요할 때 접근만 가능하도록 구현되어 있다.

protocol HasSearchBookDomainState: AnyObject {
    @MainActor var domainState: SearchBookState.DomainState { get }
}

 

또한 상태와 관련된 부분에 모두 @MainActor가 붙어있는데 이는 메인 스레드를 보장하는 것 뿐만 아니라 시리얼큐에 들어가면서 스레드 안정성을 보장하기 위해 사용 되었다. 그래서 상태가 여러 곳에서 접근하여 변경되어도 안전하게 순차적으로 사용 된다.

 

Action

유저의 액션 및 뷰의 이벤트를 표현하는 타입

enum Action {
    case searchBook(SearchBook.FetchBookList.Request)
    case didSelectedBook(Int)
}

Mutate

상태를 변경하고 업데이트 하는 작업처리 단위

enum Mutate {
    case updateBookList([Book])
    case updateLoading(Bool)
    case showAlert(String)
    case occurError(Error)
}

 

2) 상태관리 플로우

 

async/await는 리액티브 프로그래밍 처럼 관찰가능한 객체가 존재하지 않기 때문에 직접 작성되어야 하는 구현부와 해당 구현을 실행하는 실행부가 존재한다.

구현부

액션을 받아 외부 부수효과를 작업하고 비즈니스 로직 처리
  • ReactorKit의 func mutate(action: Action) -> Observable<Mutation> 동일한 역할
func executeAction(_ action: Action) async {
    switch action {
    case .searchBook(let request):
        await self.executeMutation(.updateLoading(true))
        let books = await self.fetchBookList(title: request.title, index: request.startIndex)
        await self.executeMutation(.updateBookList(books))
        await self.executeMutation(.updateLoading(false))
    case .didSelectedBook(let index):
        let title = self.state.books[index].title
        let message = "선택된 책은 \\(index)번째 \\(title)입니다."
        await self.executeMutation(.showAlert(message))
    }
}
새로운 상태를 생성하고 상태를 변경하는 구체적인 작업 처리
  • ReactorKit의 func reduce(state: State, mutation: Mutation) -> State 동일한 역할
func executeMutation(state: SearchBookState, mutation: Mutate) -> SearchBookState {
      var newState = state
      switch mutation {
      case .updateBookList(let books):
          newState.books = self.map(to: books) // 1. vo -> viewmodel, 2. Save viewState
          newState.domainState.books = books // Save domainState
      case .updateLoading(let isLoading):
          newState.isLoading = isLoading
      case .showAlert(let message):
          self.worker.showToast(message: message) 
      case .occurError(let error):
          self.worker.showToast(message: error.localizedDescription)
      }
      return newState
  }

 

실행부

각각의 액션 및 상태가 하나의 Task로 묶여 비동기적으로 작업

 

executeAction에 Task를 리턴하면서 @discardableResult를 붙인 이유는 아래 같은 상황들에 사용된다.

  • 액션에 해당되는 테스크를 관리하고자 할 때 리턴받은 테스크로 작업 수행
  • 테스크가 끝나고 이후 작업을 구현이 필요할 때, 액션 테스크를 중간에 끊어야 하는 경우 등등 
@discardableResult public func executeAction(_ action: Action) -> Task<Void, Never> {
    Task { [weak self] in
        await self?.executeAction(action)
    }
}
@MainActor func executeMutation(_ mutation: Mutate) async {
    self.state = self.executeMutation(state: self.state, mutation: mutation)
}

 

사실 이 부분은 자문을 구하고 참고해서 구현된 부분이라 내가 의문이 들었던 지점이였다.

아직 테스크를 끊어야 될 상황이 없어 구현해본 경험은 없지만 필요한 부분이라 생각된다!

 

3) 추가 개념

State의 특정 프로퍼티 값이 변하거나 새롭게 할당 될 때 특정 이벤트가 오는게 아니라 전체 State의 값이 변할 때마다 이벤트를 방출하기 때문에 의도치 않은 결과가 발생할 수 있다.

그래서 ReactorKit에서는 @Pulse 프로퍼티를 사용하면 특정 프로퍼티의 값(value)이 어떤 방식으로든 변경될 때 이벤트를 발생시킨다.

커스텀한 아키텍처도 비슷한 개념이 필요했고 2가지 방식을 생각했다.

 

  1. ReactorKit의 Pulse 코드를 그대로 가져와 Combine을 Extension 하여 사용하도록 구현
  2. 여러가지 잡일을 하는 Worker를 두어 관리

 

1번은 이해가 되는데 Worker는 뭐지? 싶을 수 있는데…

이는 CleanSwift 개념에서 가져왔다. 비즈니스 로직을 처리하는 레이어(Interactor)와 밀접하게 연관되어 외부 서버와 통신하고 그 외 잡다한 역할을 하는 레이어다.

 

@Pulse

프로젝트에서는 Pulse 파일을 하나 생성해서 Pulse 코드도 가져오고 Combine의 Publisher를 Extension 하여 기존 Pulse 방식처럼 사용될 수 있도록 구현했다!

왼쪽은 Pulse.swift 파일, 오른쪽은 ViewController에서 bind()의 pulse 사용부

 

Worker

Worker는 여러가지 잡일들을 하는데 (그래서 이름이 워커이기도 하다..!) API 호출 등 네트워크 작업을 처리하고 DTO → VO를 바꿔주는 매퍼역할도 한다. 간단한 사용 예시를 들자면 알럿이나 토스트를 띄울 때 사용되는 메세지는 엄밀히 따지면 상태 관리가 필요한 값은 아니다. 이를 상태에 저장하는게 아니라 단순히 화면에 띄워주라고 던져주면 worker는 상태가 아닌 값들을 받아 처리된다.

또 다른 사용으론 앱 로깅 처리도 워커에서 처리될 수 있다.

예제 프로젝트의 Worker 사용 예시
Store의 executeMutation() worker 사용부분

 

3. 느낀점

아키텍처를 직접 구현하고 적용해본다는건 정말 쉽진 않은 것 같다. 거의 한달넘게 공부하고 고민하고 검증했다.

아직 예시 프로젝트에만 적용해봐서 실제 업무 프로젝트에 적용했을 땐 어떤 난항이 있을지 감이오진 않지만 그래도 하나씩 적용해보면 보완하면서 점차 발전하지 않을까 기대한다!

 

이번 기회에 단방향 흐름과 상태기반 아키텍처를 좀 더 고민해본 시간인 것 같아 좋았다.

사실 부족한 부분도 많이 존재하는데 적용해보면서 체감하지 않을까 싶다..!

저는 실제 피쳐에 적용해 본 후기 2탄으로 돌아오겠습니당.. (언제가 될진…. 핳ㅎ하)

 

참고 자료

발전하는 iOS와 Clean Swift Architecture

단방향 데이터 플로우(Unidirectial Data Flow, UDF) iOS 앱 아키텍처로 복잡한 상태 관리하기

저작자표시

'iOS' 카테고리의 다른 글

[AutoLayout] Hugging Priority vs Compression Resistance Priority  (2) 2022.05.01
[iOS] Table Views - Filling a Table with Data  (0) 2022.05.01
[UIKit] UserDefault로 데이터 저장하기  (0) 2021.12.27
[Timer] 타이머 숫자(초단위)를 { 분 : 초 }로 변경하기 (String format)  (0) 2021.09.11
[SwiftUI] NavigationLink를 이용해서 페이지 이동하기  (0) 2021.08.28
  • 1. 개요
  • 아키텍처를 커스텀 한 배경
  • 2. 아키텍처 설명
  • 1) 핵심 요소
  • Action
  • Mutate
  •  
  • 2) 상태관리 플로우
  •  
  • 3) 추가 개념
  • 3. 느낀점
  • 참고 자료
'iOS' 카테고리의 다른 글
  • [AutoLayout] Hugging Priority vs Compression Resistance Priority
  • [iOS] Table Views - Filling a Table with Data
  • [UIKit] UserDefault로 데이터 저장하기
  • [Timer] 타이머 숫자(초단위)를 { 분 : 초 }로 변경하기 (String format)
ji_iin
ji_iin
개발성장일지🐥

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.