SwiftUI 팀 프로젝트 혼자 리팩토링하기 #9 : UseCase 관리 / API 연결

·2024년 7월 17일
0

리팩토링 일지

목록 보기
10/13

요약


API 연결

3개의 API 모두 viewModel로부터 분리

  • ViewModel에서 직접 호출 x
  • clean architecture을 기반으로 레이어를 분리하여 API 연결 진행

UseCase 관리

  • 앱에서 투표 결과를 계산하여 보여줘야 한다.
    • 서버에서는 투표 계산 결과가 아닌 투표 결과 목록만 준다.
    • 기존에도 클라이언트단에서 계산을 하도록 구현되어 있음
  • 기존 계산 로직은 ViewModel에 존재
  • 투표 리스트뷰, 투표 디테일 뷰 두 곳 모두에서 계산이 필요함

→ View에 보여줄 데이터를 가공한다는 의미에서는 ViewModel에 존재하는 게 맞지만, 여러 곳에서 필요한 로직이기도 하고, 이를 UseCase에서 진행하는 것이 역할 분리가 더 명확해질 것이라는 판단을 내려 UseCase로 이사햇음


VoteUseCase

extension VoteUseCase {
		
    private func filterSelectedResult(voteInfoList: [VoteInfoModel]) -> (agree: [VoteInfoModel], disagree: [VoteInfoModel]) {
        return (voteInfoList.filter { $0.isAgree }, voteInfoList.filter { !$0.isAgree })
    }

		// 투표 결과의 상위 2가지를 계산
    private func getTopConsumerTypes(for votes: [VoteInfoModel]) -> [ConsumerType] {
        return Dictionary(grouping: votes, by: { $0.consumerType })
            .sorted { $0.value.count > $1.value.count }
            .prefix(2)
            .map { ConsumerType(rawValue: $0.key) }
            .compactMap { $0 }
    }

		// 투표 비율을 계산
    private func calculateRatio(for count: Int?, totalCount: Int?) -> Double {
        guard let count = count, let totalCount = totalCount, totalCount > 0 else { return 0 }
        return (Double(count) / Double(totalCount)) * 100
    }
}
  • 투표를 계산하는 로직을 VoteUseCase에 뒀다
    func vote(postId: Int, myChoice: Bool) -> AnyPublisher<(Double, Double), WoteError> {
        voteRepository.vote(postId: postId, myChoice: myChoice)
            .map{ voteCounts in
                let total = voteCounts.agreeCount + voteCounts.disagreeCount
                let agreeRatio = self.calculateRatio(for: voteCounts.agreeCount, totalCount: total)
                let disagreeRatio = self.calculateRatio(for: voteCounts.disagreeCount, totalCount: total)

                return (agreeRatio, disagreeRatio)
            }
            .eraseToAnyPublisher()
    }
  • 예시로 가져온 vote 함수 in UseCase
  • repository에서 찬성 개수와 반대 개수가 방출되면, 계산을 실시하고 비율로 매핑
  • ViewModel에서는 이를 사용하여 바로 투표 결과를 비율로 반영할 수 있음

위와 같은 내용을 활용하여 ViewModel을 리팩토링했다.

ViewModel

<리팩토링 전>

import Combine
import SwiftUI

final class DetailViewModel: ObservableObject {
    private var appLoginState: AppLoginState
    private var cancellables: Set<AnyCancellable> = []
    @Published var isMine = false
    @Published var error: NetworkError? 
    @Published var postDetail: PostDetailModel?
    @Published var agreeTopConsumerTypes = [ConsumerType]()
    @Published var disagreeTopConsumerTypes = [ConsumerType]()

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

    func fetchPostDetail(postId: Int) {
        appLoginState.serviceRoot.apimanager
            .request(.postService(.getPostDetail(postId: postId)),
                           decodingType: PostDetailModel.self)
        .compactMap(\.data)
        .receive(on: DispatchQueue.main)
        .sink { completion in
            switch completion {
            case .finished:
                break
            case .failure(let failure):
                if failure == .noMember || failure == .noPost {
                    self.error = failure
                }
            }
        } receiveValue: { data in
            self.postDetail = data
            guard let isMine = data.post.isMine else { return }
            self.isMine = isMine
            self.setTopConsumerTypes()
        }
        .store(in: &cancellables)
    }

    func votePost(postId: Int,
                  choice: Bool,
                  index: Int?) {
        appLoginState.serviceRoot.apimanager
            .request(.postService(.votePost(postId: postId, choice: choice)),
                           decodingType: VoteCountsModel.self)
        .compactMap(\.data)
        .receive(on: DispatchQueue.main)
        .sink { completion in
            switch completion {
            case .finished:
                break
            case .failure(let failure):
                if failure == .noMember || failure == .noPost {
                    self.error = failure
                }
            }
        } receiveValue: { data in
            if let postIndex = index {
                self.updatePost(index: postIndex,
                                myChoice: choice,
                                voteCount: data)
            }
            self.fetchPostDetail(postId: postId)
        }
        .store(in: &cancellables)
    }

    func deletePost(postId: Int) {
        appLoginState.serviceRoot.apimanager
            .request(.postService(.deletePost(postId: postId)),
                            decodingType: NoData.self)
             .sink { completion in
                 switch completion {
                 case .finished:
                     break
                 case .failure(let error):
                     print("error: \(error)")
                 }
             } receiveValue: { _ in
//                 self.appLoginState.appData.postManager.deleteReviews(postId: postId)
//                 self.appLoginState.appData.postManager.removeCount += 1
                 NotificationCenter.default.post(name: NSNotification.voteStateUpdated, object: nil)
             }
             .store(in: &cancellables)
     }

     func closePost(postId: Int, index: (Int?, Int?)) {
         appLoginState.serviceRoot.apimanager
             .request(.postService(.closeVote(postId: postId)),
                            decodingType: NoData.self)
             .sink { completion in
                 switch completion {
                 case .finished:
                     break
                 case .failure(let error):
                     print("error: \(error)")
                 }
             } receiveValue: { _ in
                 if let postIndex = index.0 {
                     self.appLoginState.appData.postManager.posts[postIndex].postStatus
                            = PostStatus.closed.rawValue
                 }

                 if let myPostIndex = index.1 {
                     self.appLoginState.appData.postManager.myPosts[myPostIndex].postStatus
                            = PostStatus.closed.rawValue
                 }
                 self.fetchPostDetail(postId: postId)
             }
             .store(in: &cancellables)

     }

    func calculatVoteRatio(voteCounts: VoteCountsModel?) -> (agree: Double, disagree: Double) {
        guard let voteCounts = voteCounts else { return (0.0, 0.0) }
        let voteCount = voteCounts.agreeCount + voteCounts.disagreeCount

        guard voteCount != 0 else { return (0, 0) }
        let agreeRatio = Double(voteCounts.agreeCount) / Double(voteCount) * 100
        return (agreeRatio, 100 - agreeRatio)
    }

    private func setTopConsumerTypes() {
        guard let voteInfoList = postDetail?.post.voteInfoList else { return }
        let (agreeVoteInfos, disagreeVoteInfos) = filterSelectedResult(voteInfoList: voteInfoList)
        agreeTopConsumerTypes = getTopConsumerTypes(for: agreeVoteInfos)
        disagreeTopConsumerTypes = getTopConsumerTypes(for: disagreeVoteInfos)
    }

    private func filterSelectedResult(voteInfoList: [VoteInfoModel]) -> (agree: [VoteInfoModel],
                                                                disagree: [VoteInfoModel]) {
        return (voteInfoList.filter { $0.isAgree }, voteInfoList.filter { !$0.isAgree })
    }

    private func getTopConsumerTypes(for votes: [VoteInfoModel]) -> [ConsumerType] {
        return Dictionary(grouping: votes, by: { $0.consumerType })
            .sorted { $0.value.count > $1.value.count }
            .prefix(2)
            .map { ConsumerType(rawValue: $0.key) }
            .compactMap { $0 }
    }
}
  • ViewModel에서 직접 API 통신 request
  • 투표 계산 로직이 포함되어 있음

<리팩토링 후>

final class DetailViewModel: ObservableObject {

    enum Action {
        case loadDetail
        case vote(_ myChoice: Bool)
        case deleteVote
        case closeVote
    }

    @Published var agreeTopConsumerTypes: [ConsumerType]?
    @Published var disagreeTopConsumerTypes: [ConsumerType]?

    @Published var comments: CommentsModel?
    @Published var voteDetail: VoteDetailModel?
    @Published var isVoteResultShowed: Bool = false
    @Published var isVoteConsumerTypeResultShowed: Bool = false
    @Published var agreeRatio: Double?
    @Published var disagreeRatio: Double?

    private let postId: Int
    private let voteUseCase: VoteUseCaseType

    init(postId: Int, voteUseCase: VoteUseCaseType) {
        self.postId = postId
        self.voteUseCase = voteUseCase
    }

    private var cancellables: Set<AnyCancellable> = []

    func send(action: Action) {
        switch action {
        case .loadDetail:
            voteUseCase.loadVoteDetail(postId: postId)
                .sink { completion in
                } receiveValue: { [weak self] voteDetail in
                    self?.voteDetail = voteDetail
                    self?.agreeRatio = voteDetail.post.agreeRatio
                    self?.disagreeRatio = voteDetail.post.disagreeRatio

                    self?.agreeTopConsumerTypes = voteDetail.agreeTopConsumers
                    self?.disagreeTopConsumerTypes = voteDetail.disagreeTopConsumers

                    if voteDetail.post.postStatus == "CLOSED" || voteDetail.post.myChoice != nil {
                        self?.isVoteResultShowed = true
                    }

                    if voteDetail.post.voteCount ?? 0 > 0 { self?.isVoteConsumerTypeResultShowed = true }
                }
                .store(in: &cancellables)

        case let .vote(myChoice):
            voteUseCase.vote(postId: postId, myChoice: myChoice)
                .sink { completion in
                } receiveValue: { [weak self] _ in
                    self?.send(action: .loadDetail)
                }
                .store(in: &cancellables)

        case .deleteVote:
            return

        case .closeVote:
            return
        }
    }
}

(투표 삭제와 종료는 아직 구현되지 않았다.)

  • 투표 계산 로직을 useCase로 이동시켜 ViewModel의 역할을 명확히 했다.
    - useCases에서 계산한 투표 결과를 포함하여 viewModel로 가져온다.
  • viewModel은 voteUseCase를 호출하여 알맞은 로직 수행
  • 다른 action들도 enum Action으로 정의하여 가독성을 높였다.


오늘의 일지 끗

0개의 댓글

관련 채용 정보