SwiftUI 팀 프로젝트 혼자 리팩토링하기 #8 : API 연결

·2024년 7월 16일
0

리팩토링 일지

목록 보기
9/13

요약

  • 레이어 분리
  • Model & DTO 분리
  • 투표 게시글 목록 조회 리팩
    • 투표 게시글 목록 API 연동

코드 상세 보기


세 가지가 전부 투표 게시글 목록 조회와 연관되기 때문에 묶어서 작성하려고 한다.

레이어 분리

  • 기존의 뷰모델에서 투표와 관련된 UseCase, Repository, DataSource를 분리하고 각 레이어에서 작업했다.

Model & DTO 분리

  • 해당 앱의 게시글 종류는 투표글과 후기글이 있는데, 서버로부터 공통된 response 를 받는다.
  • 따라서 이를 각각 필요한 프로퍼티만 사용하기 위해서 Model을 따로 분리해 줬다.
    • 기존에는 공통된 response를 사용하고, 거기다 그냥 이 response에 사용되는 dto 자체를 view 단에서 사용했기에 사용 과정이 매우 복잡했다.
  • 그리고 둘을 분리함에 따라 View, Domain: 모델 사용, Repository: Model ↔ DTO, Data: DTO를 사용할 수 있게 되었다.

투표 게시글 목록 조회 리팩

<리팩토링 전>


import Combine
import SwiftUI

final class ConsiderationViewModel: ObservableObject {
    @Published var isLoading = true
    @Published var error: NetworkError?
    @Published var currentVote = 0
    private var cancellables: Set<AnyCancellable> = []
    private var isLastPage = false
    private var page = 0
    private var appLoginState: AppLoginState

    init(appLoginState: AppLoginState) {
        self.appLoginState = appLoginState
    }

    func fetchPosts(page: Int = 0,
                    size: Int = 5,
                    visibilityScope: VisibilityScopeType,
                    isFirstFetch: Bool = true,
                    isRefresh: Bool = false) {
                    
        appLoginState.serviceRoot.apimanager
            .request(.postService(.getPosts(page: page,
                                            size: size,
                                            visibilityScope: visibilityScope.rawValue)),
                     decodingType: [PostModel].self)
        .compactMap(\.data)
        .receive(on: DispatchQueue.main)
        .sink { completion in
            switch completion {
            case .finished:
                break
            case .failure(let failure):
                print(failure)
            }
        } receiveValue: { data in
            self.appLoginState.appData.postManager.posts.append(contentsOf: data)
            self.isLastPage = data.count < 5
            self.isLoading = false
        }
        .store(in: &cancellables)
    }

    func fetchMorePosts(_ visibilityScope: VisibilityScopeType) {
        guard !isLastPage else { return }

        page += 1
        fetchPosts(page: page,
                   visibilityScope: visibilityScope,
                   isFirstFetch: false)
    }

    func votePost(postId: Int,
                  choice: Bool,
                  index: Int) {
				// 투표하기 API 연동
    }

		// ...
}

<리팩토링 후>

import Combine
import SwiftUI

final class VoteListViewModel: ObservableObject {

    enum Action {
        case loadVotes
        case loadMoreVotes
        case calculateRatio(voteCount: Int, agreeCount: Int)
        case vote(selection: Bool)
    }

    @Published var isLoading: Bool = true
    @Published var currentVote: Int = 0
    @Published var votes: [VoteModel] = []
    @Published var agreeRatio: Double?
    @Published var disagreeRatio: Double?

    private var cancellables: Set<AnyCancellable> = []
    private var page: Int = 0

    private let voteUseCase: VoteUseCaseType

    init(voteUseCase: VoteUseCaseType) {
        self.voteUseCase = voteUseCase
    }

    func send(action: Action) {
        switch action {
        case .loadVotes:
            isLoading = true

            voteUseCase.loadVotes(page: 0, size: 5, scope: .global)
                .sink { [weak self] completion in
                    self?.isLoading = false
                } receiveValue: { [weak self] votes in
                    self?.isLoading = false
                    self?.votes = votes
                }
                .store(in: &cancellables)

        case .loadMoreVotes:
            page += 1

            voteUseCase.loadVotes(page: page, size: 5, scope: .global)
                .sink { _ in
                } receiveValue: { [weak self] votes in
                    self?.votes.append(contentsOf: votes)
                }
                .store(in: &cancellables)

        case let .calculateRatio(voteCounts, agreeCount):
            // TODO: 비율 계산

        case let .vote(selection):
            // TODO: 투표하기 API 연동
            return
        }
    }
}
  • viewModel에서 필요한 action을 정의하였다.
    • 투표 게시글 목록 로드
    • 무한 스크롤을 위한 로드
    • 투표하기
  • 필요한 경우 VoteUseCase에 접근하여 수행한다.
    - loadVotes에 대한 공통 함수를 만들까 했으나, 현재 상태가 읽기 더 쉬운 것 같다.
  • apiManager에 접근하지 않고, useCase에 접근한다.

UseCase

protocol VoteUseCaseType {
    func loadVotes(page: Int, size: Int, scope: VisibilityScopeType) -> AnyPublisher<[VoteModel], WoteError>
}

final class VoteUseCase: VoteUseCaseType {

    private let voteRepository: VoteRepositoryType

    init(voteRepository: VoteRepositoryType) {
        self.voteRepository = voteRepository
    }

    func loadVotes(page: Int, size: Int, scope: VisibilityScopeType) -> AnyPublisher<[VoteModel], WoteError> {
        voteRepository.getVotes(page: page, size: size, scope: scope)
            .eraseToAnyPublisher()
    }
}

Repository


final class VoteRepository: VoteRepositoryType {
    
    private let voteDataSource: VoteDataSourceType

    init(voteDataSource: VoteDataSourceType) {
        self.voteDataSource = voteDataSource
    }

    func getVotes(page: Int, size: Int, scope: VisibilityScopeType) -> AnyPublisher<[VoteModel], WoteError> {
        let requestObject: VoteRequestObject = .init(
            page: page,
            size: size,
            visibilityScope: scope.rawValue
        )

        return voteDataSource.getVotes(requestObject)
            .map { $0.map { $0.toModel() }}
            .mapError { WoteError.error($0) }
            .eraseToAnyPublisher()
    }
}
  • Repository에서는 request에 필요한 파라미터들을 requestObject로 변환하고 요청을 보낸다.
  • 응답으로 받은 ResponseObject를 Domain에서 사용하는 Model로 mapping한다.

DataSource


protocol VoteDataSourceType {
    func getVotes(_ object: VoteRequestObject) -> AnyPublisher<[PostResponseObject], APIError>
}

final class VoteDataSource: VoteDataSourceType {
    
    private let provider = MoyaProvider<VoteAPI>()

    func getVotes(_ object: VoteRequestObject) -> AnyPublisher<[PostResponseObject], APIError> {
        provider.requestPublisher(.getVotes(object))
            .tryMap {
                try JSONDecoder().decode(GeneralResponse<[PostResponseObject]>.self, from: $0.data)
            }
            .compactMap { $0.data }
            .mapError { APIError.error($0) }
            .eraseToAnyPublisher()
    }
}
  • 응답을 decoding해 준 뒤, data 부분을 compactMap하여 방출
  • DataSource 단에서는 APIError을 사용한다.
    • 세부 에러 처리는 아직 하지 못했다.

View 단에서도 리팩토링을 진행했다.

                ForEach(Array(zip(datas.indices,
                                  datas)), id: \.0) { index, item in
                    VStack(spacing: 0) {
                        VoteContentCell(viewModel: viewModel,
                                        data: item,
                                        index: index)
                        nextVoteButton
                            .padding(.top, 16)
                    }
                    .tag(index)
                    .onAppear {
                        if (index == datas.count - 2) {
                            viewModel.fetchMorePosts(visibilityScope)
                        }
                    }

리팩토링 전에는 이렇게 투표 목록들을 보여주는 cell에서 viewModel을 직접 주입했었다.
이유는 이 투표 목록에서도 화면 전환이나, 투표를 하는 등의 여러 action이 있었기 때문이었다.

하지만 cell마다 이렇게 viewModel을 넣어주는 것은 많이 비효율적이라고 생각했고,

VoteContentCell(
    vote: item,
    agreeRatio: viewModel.agreeRatio ?? 0,
    disagreeRatio: viewModel.disagreeRatio ?? 0,
    voteTapped: {
        viewModel.send(action: .vote(selection: $0))
    }, detailTapped: {
        voteRouter.push(to: VoteTabDestination.voteDetail(postId: item.id))
    }
)

위와 같이 클로저를 만들어서 외부의 viewModel을 사용하도록 변경했다.


오늘의 일지 끗

0개의 댓글

관련 채용 정보