SwiftUI 팀 프로젝트 혼자 리팩토링하기 #12: 전체적인 회고

·2024년 8월 9일
0

리팩토링 일지

목록 보기
13/13

애플 디벨로퍼 아카데미에서 마지막으로 진행했던 프로젝트를 혼자 리팩토링했다.

아직 소소한 것들이 남아 있지만,
전체적으로 내가 계획했던 것들은 마무리되어서 회고를 남겨두려고 한다.


레포지토리 주소: https://github.com/minnnidev/Wote
기간: 7월 한달 정도
인원: 나 혼자!


0번째 리팩토링 일지에서 내가 리팩토링하고자 했던 포인트를 정리하면 다음과 같다.
(https://velog.io/@minnnidev/SwiftUI-팀-프로젝트-혼자-리팩토링하기-0)


  • 아키텍처 설계
    - 클린 아키텍처로 리팩토링
    - MVVM에 대한 정의를 똑바로 내리고, 사용하기
    - SwiftUI의 강력한 무기 Preview 지대로 사용하기🍀
  • 효율적인 NavigationStack 관리
  • DTO & Model의 분리
  • 위 내용들을 통한 서버 통신 리팩토링

설계

  • 나는 ViewModel을 View에 보여줄 데이터, 모델을 정의하는 곳으로 정의했지만, 구현하다가도 항상 애매해지는 느낌이 들곤 했다.

    → API 통신을 viewmodel에서 하지 않으면 어디서?
    → 찾아보니까 usecase, repository로 분리할 수 있다고 함
    → 그리고 이건 clean architecture 기반이 될 수 있다고 함.

    이러한 흐름으로 clean architecture에 관심을 가지게 되었고, 뭐야 뷰모델을 더 명확하게 정의하는데 클린 아키텍처가 도움이 되겠는데? 해서 공부하게 됨!


클린 아키텍처의 이점들은 검색하면 수두룩하게 나오니 패스하고, 내가 직접 느꼈던 장단점을 적어보자면!

장점

독립성

  • 내가 클린 아키텍처를 공부해 보겠다는 이유도 뷰모델이 하는 일을 명확히 하고 싶어서였고, 실제로 아주 잘 되는 것을 확인.
  • 뷰모델에서는 UI에 나타낼 것들만 관리하도록 분리하였음.
  • https://velog.io/@minnnidev/SwiftUI-팀-프로젝트-혼자-리팩토링하기-2
    - 나는 여기 써놨던 대로 명확하게 역할을 분리하여 구현하려고 노력했음.
  • 그리고 이건 어떤 한 곳에 수정이 필요할 때 다른 곳까지 수정해야 하는 행위를 최소한으로 줄일 수 있었다고 생각함.

의존성 관리

  • 클린 아키텍처는 의존성 역전을 기반으로 설계됨

  • 유지보수나 의존성 측면에서도 많은 이점이 될 수 있음!

  • 나는 이를 SwiftUI의 Preview를 사용하는 데도 활용했음.
    - 리팩 전 우리 프로젝트는 의존성 주입 자체는 사용했지만, 의존성 역전이 적용되지는 않았음. (나도 이것에 대한 지식이 부족해서 이게 크게 잘못된 것인진 몰랐었다… 😥)
    - 그러다보니 preview에서 viewModel 의존성 주입 시에 여러 에러가 발생했고, preview를 살리기 애매한 상황이면 그냥 주석처리하거나 삭제했었음.
    - 지금 생각해 보면 preview가 SwiftUI의 얼마나 강력한 무기인데…! 싶다.

  • 암튼 그래서 viewModel에서 DIP 적용을 위해 UseCase를 주입할 때

            // DIP를 위한 인터페이스 프로토콜
            protocol MyPageUseCaseType {
                func getMyVotes(page: Int, size: Int) -> AnyPublisher<MyVotesModel, WoteError>
                func getMyReviews(page: Int, size: Int, visibilityScope: VisibilityScopeType) -> AnyPublisher<MyReviewsModel, WoteError>
            }
            
            // MyPageUseCase
            final class MyPageUseCase: MyPageUseCaseType {
            
                private let userRepository: UserRepositoryType
            
                init(userRepository: UserRepositoryType) {
                    self.userRepository = userRepository
                }
            
                func getMyVotes(page: Int, size: Int) -> AnyPublisher<MyVotesModel, WoteError> {
                    userRepository.getMyVotes(page: page, size: size)
                }
            
                func getMyReviews(page: Int, size: Int, visibilityScope: VisibilityScopeType) -> AnyPublisher<MyReviewsModel, WoteError> {
                    userRepository.getMyReviews(page: page, size: size, visibilityScope: visibilityScope)
                }
            }
            
            // MyPageUseCase stub
            final class StubMyPageUseCase: MyPageUseCaseType {
            
                func getMyVotes(page: Int, size: Int) -> AnyPublisher<MyVotesModel, WoteError> {
                    Just(MyVotesModel(total: 1, votes: [.myVoteStub]))
                        .setFailureType(to: WoteError.self)
                        .eraseToAnyPublisher()
                }
            
                func getMyReviews(page: Int, size: Int, visibilityScope: VisibilityScopeType) -> AnyPublisher<MyReviewsModel, WoteError> {
                    Just(MyReviewsModel(total: 1, myReviews: [.reviewStub1]))
                        .setFailureType(to: WoteError.self)
                        .eraseToAnyPublisher()
                }
            }
            
            // View
            #Preview {
                MyPageView(viewModel: .init(
                    myPageUseCase: StubMyPageUseCase(),
                    userUseCase: StubUserUseCase())
                )
            }

UseCaseType 프로토콜을 만들고, 이를 채택한 StubUseCase 를 만들어 프리뷰에서 주입시켰다. API가 아직 나오지 않았을 때도 쉽게 작업이 가능하고, 프리뷰만 보고도 빠르게 UI 작업을 할 수 있었다.


단점

  • 코드 양이 일단 훨씬 많아진다. 한 10줄 쓸 걸 50줄을 쓰는 느낌
    • 하지만 요즘 나는 미래의 나 혹은 팀원이 어떻게 하면 수정을 덜할 수 있을까..? 라는 마음으로 요즘 고민하며 코드를 쓰고 있어서… 크게 단점으로 다가오진 않았다.
  • 각 레이어의 정의
    • 물론 보편적인 정의가 있지만, 애매한 부분에서는 팀들만의 정의도 필요한 것 같다.
    • 나는 팀이 없어서 이전에 올렸던 포스팅대로 정의하고 계속해서 사용했음.

기존에는 그냥 하나의 enum에 NavigationStack destination을 전부 두고 관리했음.

이는 모든 destination을 한 뷰에서 관리하도록 했고, 가독성도 떨어지고 코드 길이가 엄청나게 길어지게 했음.

이를 해결하기 위해서 feature별로 나눠서 enum으로 만들었다.
그치만 공통된 곳에 사용되는 경우가 많아서 어느 정도 공통적인 destination을 만드는 건 불가피하긴 했다.

기존

enum AllNavigation: Decodable, Hashable {
    case considerationView
    case writeReiview
    case detailView(postId: Int,
                    dirrectComments: Bool = false,
                    isShowingItems: Bool = true)
    case reviewView
    case makeVoteView
    case testIntroView
    case testView
    case settingView
    case mypageView
    case searchView
    case notiView
    case reviewDetailView(postId: Int?, 
                          reviewId: Int?,
                          directComments: Bool = false,
                          isShowingItems: Bool = true)
    case reviewWriteView(post: SummaryPostModel)
    case profileSettingView(type: ProfileSettingType)
}

@Observable
final class NavigationManager {
    var navigatePath = [AllNavigation]()

    func navigate(_ route: AllNavigation) {
        guard !navigatePath.contains(route) else {
            return
        }
        navigatePath.append(route)
    }

    func back() {
        navigatePath.removeLast()
    }

    func countPop(count: Int) {
        navigatePath.removeLast(count)
    }
    
    func countDeque(count: Int) {
        navigatePath.removeFirst(count)
    }

    func gotoMain() {
        navigatePath.removeAll()
    }
}

// Destination 설정
.navigationDestination(for: AllNavigation.self) { destination in
                switch destination {
                case .profileSettingView(let viewType):
                    ProfileSettingsView(viewType: viewType,
                                        viewModel: ProfileSettingViewModel(appState: loginStateManager))
                case .considerationView:
                    ConsiderationView(visibilityScope: $visibilityScope,
                                      scrollToTop: $tabScrollHandler.scrollToTop,
                                      viewModel: ConsiderationViewModel(appLoginState: loginStateManager))
                case .detailView(let postId,
                                 let showDetailComments,
                                 let isShowingItems):
                    DetailView(viewModel: DetailViewModel(appLoginState: loginStateManager),
                               isShowingItems: isShowingItems,
                               postId: postId,
                               directComments: showDetailComments
                    )
                case .makeVoteView:
                    VoteWriteView(viewModel: VoteWriteViewModel(visibilityScope: visibilityScope, 
                                                                apiManager: loginStateManager.serviceRoot.apimanager), 
                                  tabselection: $tabScrollHandler.selectedTab)
                case .testIntroView:
                    TypeTestIntroView()
                        .toolbar(.hidden, for: .navigationBar)
                case .testView:
                    TypeTestView(viewModel: TypeTestViewModel(apiManager: loginStateManager.serviceRoot.apimanager))
                case .reviewView:
                    ReviewView(visibilityScope: $visibilityScope,
                               viewModel: ReviewTabViewModel(loginState: loginStateManager))
                case .writeReiview:
                    Text("아직")
                case .settingView:
                    SettingView(viewModel: SettingViewModel(loginStateManager: loginStateManager))
                case .mypageView:
                    MyPageView(viewModel: MyPageViewModel(loginState: loginStateManager),
                               selectedVisibilityScope: $visibilityScope)
                case .searchView:
                    SearchView(viewModel: SearchViewModel(apiManager: loginStateManager.serviceRoot.apimanager,
                                                          selectedVisibilityScope: visibilityScope))
                case .reviewDetailView(let postId, 
                                       let reviewId,
                                       let isShowDetailComments,
                                       let isShowingItems):
                    ReviewDetailView(viewModel: ReviewDetailViewModel(loginState: loginStateManager),
                                     isShowingItems: isShowingItems,
                                     postId: postId,
                                     reviewId: reviewId,
                                     directComments: isShowDetailComments
                    )
                case .reviewWriteView(let post):
                    ReviewWriteView(viewModel: ReviewWriteViewModel(post: post,
                                                                    apiManager: loginStateManager.serviceRoot.apimanager))
                case .notiView:
                    NotificationView( viewModel: notiManager)
                }
            }
        }

현재

enum WoteDestination: Hashable {
    case search
    case setting
    case notification
    case voteDetail(postId: Int)
    case reviewDetail(postId: Int)
    case reviewWrite(postId: Int)
}

enum TypeTestDestination: Hashable {
    case testIntro
    case test
    case testResult(typeResult: ConsumerType)
}

enum VoteDestination: Hashable {
    case voteWrite
}

enum MyPageDestination: Hashable {
    case modifyProfile
}

// navigation stack destination
// 필요한 곳에서, 각 feature마다 
// 밑은 TypeTestDestination ex
.navigationDestination(for: TypeTestDestination.self) { dest in
		switch dest {
				case .testIntro:
								TypeTestIntroView()
										.environmentObject(router)
        case .test:
	              TypeTestView(viewModel: appDependency.container.resolve(TypeTestViewModel.self)!)
                    .environmentObject(router)

	      case let .testResult(resultType):
                 TypeTestResultView(spendType: resultType)
                     .environmentObject(router)
      }
}


DTO와 Model의 분리

사실 요 프로젝트에서는 DTO와 Model이 동일한 경우가 더 많았다.
그래도 클린 아키텍처에 정의된 각 레이어의 역할을 더 명확하게 하기 위해서 분리를 시켰고, DTO에는 Model로 매핑하는 함수들도 같이 추가했다.

그리고 model의 stub data를 stub usecase나 viewModel 등 필요한 곳에 호출하여 사용하도록 했다.

struct ProfileModel: Encodable {
    let createDate: String
    let modifiedDate: String
    let lastSchoolRegisterDate: String
    let nickname: String
    let profileImage: String?
    let consumerType: String?
    let school: SchoolModel
    let canUpdateConsumerType: Bool
}

extension ProfileModel {

    static var profileStub: ProfileModel {
        .init(
            createDate: "2024-07-15T14:44:44.993126",
            modifiedDate: "2024-07-17T15:07:21.032732",
            lastSchoolRegisterDate: "2024-07-16",
            nickname: "히히",
            profileImage: "https://www.wote.social/images/posts/78ec4f86-5676-4c70-ae16-0334c452ec72.jpg",
            consumerType: "TRENDSETTER",
            school: .schoolStub,
            canUpdateConsumerType: true
        )
    }
}


전체적인 서버 통신 리팩토링

원래는 Authentication 파트만 새로 구현해 보려고 했는데,

위에서 언급했던 clean archiecture, 의존성 주입, combine에 대해서 더 익숙해지기 위해서 현재까지 35개 API를 다시 연결해 보았다.

기간이 생각보다 훨씬 오래 걸리긴 했지만, 서버 통신을 수행하는 클린 아키텍처의 플로우를 알 수 있어서 잘한 행동인 것 같다.
(세세한 에러 핸들링은 다시 도전해 보려 한다!)

학교를 검색하고, 검색 결과를 로드하는 뷰모델로 예시를 하나만 들어보면

기존

final class SchoolSearchViewModel: ObservableObject {
    @Published var schools = [SchoolInfoModel]()
    @Published var isFetching = false
    private let baseURL = "http://www.career.go.kr/cnet/openapi/getOpenApi"

    var apiKey: String {
        guard let key = Bundle.main.object(forInfoDictionaryKey: "SCHOOL_API_KEY") as? String else {
            fatalError("SCHOOL_API_KEY error")
        }
        return key
    }

    @MainActor
    func setSchoolData(searchWord: String) async throws {
        schools.removeAll()
        isFetching = true

        let highSchoolValues: HighSchoolResponse = try await fetchSchoolData(schoolType: .highSchool, searchWord: searchWord)
        let middleSchoolValues: MiddleSchoolResponse = try await fetchSchoolData(schoolType: .middleSchool, searchWord: searchWord)
        let highSchoolSchools = highSchoolValues.dataSearch.content.map { $0.convertToSchoolInfoModel() }
        let middleSchoolSchools = middleSchoolValues.dataSearch.content.map { $0.convertToSchoolInfoModel() }

        schools.append(contentsOf: highSchoolSchools + middleSchoolSchools)
        isFetching = false
    }

    private func fetchSchoolData<T: Decodable>(schoolType: SchoolDataType, searchWord: String) async throws -> T {
        guard let encodedSearchWord = searchWord.addingPercentEncoding(withAllowedCharacters: .afURLQueryAllowed) else {
            throw URLError(.badURL)
        }

        let url = baseURL +
            """
            ?apiKey=\(apiKey)&svcType=api&svcCode=SCHOOL&contentType=json&gubun=\(schoolType.schoolParam)&searchSchulNm=\(encodedSearchWord)
            """

        return try await AF.request(url, method: .get)
                        .validate()
                        .serializingDecodable(T.self)
                        .value
    }
}
  • API 통신을 viewModel에서 직접 함
  • 통신해서 받은 응답을 view에 보여주기 위해 mapping하는 과정을 뷰모델에서 실시.

현재

import Foundation
import Combine

final class SchoolSearchViewModel: ObservableObject {

    enum Action {
        case submit(_ searchSchoolText: String)
    }

    @Published var schools: [SchoolInfoModel] = [SchoolInfoModel]()
    @Published var isLoading: Bool = false
    @Published var searchSchoolText: String = ""
    @Published var textFieldState: SearchTextFieldState = .inactive

    private let userUseCase: UserUseCaseType

    private var cancellables = Set<AnyCancellable>()

    init(userUseCase: UserUseCaseType) {
        self.userUseCase = userUseCase
    }

    func send(action: Action) {
        switch action {
        case let .submit(searchSchoolText):
            isLoading = true

            userUseCase.searchSchool(searchSchoolText)
                .sink { [weak self] _ in
                    self?.isLoading = false
                } receiveValue: { [weak self] schools in
                    self?.textFieldState = .submitted
                    self?.isLoading = false

                    self?.schools = schools
                }
                .store(in: &cancellables)
        }
    }
}
  • 최대한 SwfitUI나 UIKit은 import하지 않도록 함
  • UseCase의 <학교 검색 결과 로드>라는 비즈니스 로직에 접근
    • 값이 성공적으로 도착했을 시에는 검색된 학교 정보를 받아서 상태를 업데이트함
    • 로딩 인디케이터가 필요할 때 UI 처리

→ ViewModel은 화면에 보여줄 데이터에 집중, API 통신을 직접하는 행위는 하지 않도록 함


위와 같은 형식으로 다른 api들도 구현하였음!
내 기준으로 각 레이어들의 역할을 정의한 것을 최대한 지키도록 구현하였다.


아직 조금 의문인 점/미완성인 점

  • UseCase는 얼마나 자잘하게 분리되어야 할까
  • 확실한 에러 처리

를 고민 중이고, 요건 앞으로도 짬짬이 고민해 봐야 한다.


종합적인 후기

클린 아키텍처를 사용하면서 명확하게 분리를 할 수 있다는 점은 분명 너무 좋았지만

아직 테스트 코드를 작성해 본 경험이 없어 제일 큰 장점인 테스트 용이성을 경험해 보지 못한다는 게 넘 아쉽.

그리구 팀끼리도 써보면 내가 잘못 생각한 것들을 수정할 수 있을 것 같아서 의향을 물어봤는데 진입 장벽이 높고 시간이 오래 걸려서 고민이 필요하다는 답을 들음.

그러다 보니 클린 아키텍처의 단점도 꽤 크게 다가왔고, 꼭 클린 아키텍처를 사용하지 않아도 이런 레이어의 분리나 각 객체 역할만 명확하게 정의하는 코드를 작성하다면 미래의 나와 팀원에게 많이 도움이 되지 않을까라고 느꼈음!

0개의 댓글

관련 채용 정보