본문 바로가기
Project/뿌대식: 부산대학교 학식 알리미

뿌대식: 클린 아키텍처 및 리팩토링 도입

by songmoro 2024. 12. 2.
  • 이전
    • 문제점
      • 모델
        • iOS와 위젯이 같은 모델을 사용하는 데 사용하는 방법은 조금씩 다름
          • 그래서 iOS의 로직을 변경하고 싶어도 변경하지 못하거나, 기존 코드를 지우지 못함
      • 로직
        • 데이터를 변경하는 로직이 흩어져있어 로직을 파악하기 어려움
        • 결합도가 높아 하나의 메서드를 고치기 위해 전체적으로 수정해야 함
        •   class MainViewModel: ObservableObject {
                /// 현재 선택된 요일
                @Published var selectedWeekComponent: WeekComponent? = .today
                /// 네트워크 요청을 통해 받은 응답 목록
                @Published var cafeteriaResponseArray = [CafeteriaResponse]()
                /// 모달 시트 여부
                @Published var isSheetShow = false
                /// 선택한 캠퍼스
                @Published var selectedCampus: Campus = .부산 {
                    didSet {
                        fetchCafeteriaArray()
                    }
                }
                /// 사용자가 설정한 앱 시작 시 기본으로 보여줄 캠퍼스
                @Published var defaultCampus: Campus = .부산 {
                    didSet {
                        saveDefaultCampus()
                    }
                }
                /// 사용자가 설정한 앱 시작 시 먼저 보여줄 식당 목록
                @Published var bookmark: [Cafeteria] = [] {
                    didSet {
                        saveBookmark()
                    }
                }
                // ...
            }
      • 네트워킹
        • 모델과 같은 이유로 request 모델의 흐름을 파악하기 어려움
        • 하드 코딩에 가까워서 다른 api에서 사용하기 힘듦
        • struct FilterByCampusRequest: Codable {
            init(queryType: QueryType, campus: Campus) {
                // 중략
          
                self.filter = Filter(and: [Filter.Or(or: code), Filter.Or(or: condition)])
            }
          
            init(queryType: QueryType, name: String, category: String) {
                // 중략
          
                self.filter = Filter(and: [Filter.Or(or: code), Filter.Or(or: [condition]), Filter.Or(or: [categoryType])])
            }
          
            var filter: Filter
          
            struct Filter: Codable {
                var and: [Or]
          
                struct Or: Codable {
                    var or: [ConditionalExpression]
          
                    struct ConditionalExpression: Codable {
                        var property: String
                        var rich_text: RichText
          
                        struct RichText: Codable {
                            var equals: String
                        }
                    }
                }
            }
          }
      • MVVM
        • MVVM을 적용했다고 생각하고 있었는데, 위 문제들이 발생하는 게 코드를 모델, 뷰, 뷰 모델로 나누기만 했을 뿐 아키텍처를 제대로 적용하지 못하고 있기 때문이라고 생각

 

 

 

  •  개선
    • MVVM + 클린 아키텍처
      • 일전에 학습한 프로젝트를 베이스로 리팩토링
        • 목표
          • 단계 별 추상화 및 역할의 분리
              • 이벤트에 대한 로직을 보유하지 않고, 이벤트가 발생했다는 걸 뷰 모델에 전달
              • 뷰 모델의 데이터를 표현만 함
            • 뷰 모델
              • init 시 AppState의 데이터 바인딩
              • 뷰로부터 전달받은 이벤트를 서비스를 통해 수행
            • 서비스
              • 상세한 로직 구현
              • 수행 결과를 앱 상태 혹은 뷰 모델로 전달
              • 직접 데이터를 보유하지 않음
            • 레포지터리
              • 네트워킹, 유저 데이터와 같은 데이터와 관련한 로직 담당
            • 앱 상태(AppState)
              • 현재 앱의 상태를 나타냄
              • 예를 들어, 현재 선택된 캠퍼스 혹은 요일 탭
          • 의도한 바
            • 뷰: 내가 보여줄 건 이거(뷰 모델의 데이터)고, 이 이벤트는 쟤(뷰 모델)가 할 거야
            • 뷰 모델: 쟤(뷰)가 시킨 건 얘(서비스)가 해서 알려줄 거야.
            • 서비스: 이건(이벤트) 이런 방식으로 하면 되고, 저건(데이터 이벤트) 쟤(레포지터리)가 해줄 거야.
    • 네트워킹
      • 기존 콜백 함수를 continuation으로 래핑 하여 사용하고 있던 걸 concurrency로 교체
    • 로직
      • didSet, onChange를 combine으로 뷰 모델 Init 시 파이프라인 연결 후 가공하도록 수정

 

 

 

  •  이후
    • 결과
      • iOS와 위젯 분리
        • 공통으로 사용하던 모델, 로직을 위젯 용, iOS 용으로 분리해서 사용하게 변경
          • 같은 파일이 위젯과 iOS에 각각 있음
          • 추후, 추상화해서 확장해서 사용할 생각
        • 데이터 관리
          • 전역적으로 사용하는 데이터는 AppState에서 보유, 뷰 모델에서 연결
          • init(container: DIContainer) {
                      self.container = container
                      let appState = container.appState
            
                      self._bookmark = .init(initialValue: appState.value.userData.bookmark)
                      self._filterdCafeteriaResponseArray = .init(initialValue: appState.value.cafeteria.filterByDay)
            
                      self.cafeteria = appState[\.cafeteria.list]
            
                      bind()
                  }
            
                  func bind() {
                      let appState = container.appState
            
                      cancelBag.collect {
                          appState.map(\.cafeteria.filterByDay)
                              .removeDuplicates()
                              .assign(to: \.filterdCafeteriaResponseArray, on: self)
            
                          // 후략
                  }    
            }
      • 네트워킹
        • Concurrency로 교체 및 Moya 제거 후 URLSession 사용
        • request 모델을 좀 더 재사용 가능하게 변경
        • protocol ConditionOperator: CustomStringConvertible { }
          
          struct SingleFilter: CustomStringConvertible {
            let filter: any ConditionOperator
          
            init(_ filter: any ConditionOperator) {
                self.filter = filter
            }
          
            var description: String {
                """
                {
                    "filter": \(filter)
                }
                """
            }
          }
          
          struct MultiFilter: CustomStringConvertible {
            let filter: [any ConditionOperator]
          
            var description: String {
                """
                {
                    "filter": [ \(filter.map { $0.description + ",\n" }) ]
                }
                """
            }
          }
          
          struct And: ConditionOperator, CustomStringConvertible {
            let and: [any ConditionOperator]
          
            init(_ and: [any ConditionOperator]) {
                self.and = and
            }
          
            var description: String {
                """
                {
                    "and": \(and)
                }
                """
            }
          }
          
          struct Or: ConditionOperator, CustomStringConvertible {
            let or: [any ConditionOperator]
          
            init(_ or: [any ConditionOperator]) {
                self.or = or
            }
          
            var description: String {
                """
                {
                    "or": \(or)
                }
                """
            }
          }
          
          struct RichTextExpression: ConditionOperator, CustomStringConvertible {
            let property: String
            let rich_text: RichText
          
            init(property: String, rich_text: String) {
                self.property = property
                self.rich_text = .init(equals: rich_text)
            }
          
            struct RichText: Codable {
                let equals: String
            }
          
            var description: String {
                """
                {
                    "property": "\(property)",
                    "rich_text": {
                        "equals": "\(rich_text.equals)"
                    }
                }
                """
            }
          }

 

 

 

  •  후기
    •  
    • 코드를 분리해서 수정이 있을 때 개발하기 좀 더 편해짐
    • 레이어가 깊어질수록 구현의 정도가 높아져서 서비스나 레포지터리의 변경 때문에 뷰나 뷰 모델의 코드를 변경하지 않아도 됨
    • 아키텍처 개념이 부족한 상태에서 시작해서 중간에 브랜치 날리고 처음부터 했음
    • AppState에서 데이터를 관리하는데, 전역적으로 관리가 필요한 데이터가 아니면 이 프로젝트처럼 Input, Output으로 전달해도 좋을 것 같다.
    • 테스트 코드를 도입할 생각이었는데, 리팩토링이 끝난 마당에 도입하긴 애매한 것 같다
      • 만약, 다시 리팩터링 한다면 요구사항에 맞는 테스트 코드를 작성하고 리팩토링 할 듯
    • 감명 깊게 본 영상