Notice
Recent Posts
Recent Comments
Link
«   2025/03   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
Archives
Today
Total
관리 메뉴

찹모찌의 기록일지

MVVM + RxSwift 직접 써보기 // 주어진 API에서 최선을 다히기 본문

iOS

MVVM + RxSwift 직접 써보기 // 주어진 API에서 최선을 다히기

찹모찌 2024. 4. 13. 01:19

현재 관심 종목의 일자별 시세를 알고, 그래프로 나타내는 주식 애플리케이션을 개발 중에 있다. 필자는 관심 종목 검색 및 일자별 시세 조회 기능을 담당하고 있는데, 이때 한국투자증권 API의 특징에 맞춰 기능 구현 및 MVVM과 RxSwift를 어떻게 활용하고 있는지에 대해 서술한다.

 

1. 프로젝트 구조

MVVM + RxSwift를 사용했다. 주식 애플리케이션의 특성상 현재는 일자별 시세 조회에 그치지만 더욱 외부 변화에 맞춰 애플리케이션이 동작할 필요가 있어보여 반응형 프로그래밍이 적합하다 판단했고, RxSwift의 특징인 반응형 프로그래밍과 잘 맞는 디자인 패턴이 MVVM이라고 생각했다.

2. 구현해야 하는 기능

검색 기능과 일자별 시세 조회 기능 중 일자별 시세 조회 기능을 먼저 진행하기로 하였다. 사용자가 관심 종목 리스트를 저장해두면, 그 관심 종목 리스트의 일자별 시세를 조회해 표시한다.

관심 종목이 여러개일수 있다. 그러나 한국투자증권 API의 주식 API는 한 번에 하나의 주식 시세 밖에 조회하지 못한다. 만약 서버와 협업 중이었다면 리스트로 보내도 여러 개의 시세를 받아올 수 있도록 했겠지만, 이미 구현된 API를 사용해야 하기 때문에 그렇지 못했다. 따라서 한 번에 하나씩 조회한다.

3. 기능 로직

처음에 고민했던게, 한 번에 하나씩 조회해야 하기 때문에 관심 종목 리스트의 모든 시세를 조회하는 것을 다 기다리려면 오랜 시간이 걸릴 것 같다는 생각이 들었다.

그래서 하나의 종목 시세 조회를 완료했을 때, 해당 데이터를 UICollectionView에 바로바로 적용해주는 것으로 했다. UICollectionView를 활용하고, iOS 버전도 13보다 높은 버전을 목표로 하고 있었기 때문에 UICollectionViewDiffableDataSource를 활용하면 될 것 같다는 생각이 들었다.

기본적인 Flow는 다음과 같다.

Flow

이 때 시세 조회를 통해 Stock Information이 들어오면 UICollectionView가 update 되도록 반응형 프로그래밍을 사용하는 것이 적합할 것이다.

4. 기본 코드

Clean Architecture를 따르고 있기 때문에 Repostiory -> UseCase -> ViewModel로 데이터들이 전달된다. 물론 요청은 ViewModel -> UseCase -> Repository 순.

관심 종목 ViewModel에서 각 부분에 요청을 보낸다.

import Foundation
import RxSwift

final class InterestStocksViewModel {
    private let loadInterestStocksUseCase: LoadInterestStocksUseCase
    private let checkTodayPriceUseCase: CheckTodayPriceUseCase

    private var checkStockTasks: [String:Cancellable?] = [:]
    private let mainQueue: DispatchQueueType

    private var stockInformationArray : [StockInformation] = []
    private let stockInformationsSubject = BehaviorSubject<[StockInformation]>(value: [])

    struct Input {
        let viewDidLoadEvent: Observable<Void>
    }

    struct Output {
        let stockInformations: Observable<[StockInformation]>
    }

    init(loadInterestStocksUseCase: LoadInterestStocksUseCase = DefaultLoadInterestStocksUseCase(),
         checkTodayPriceUseCase: CheckTodayPriceUseCase = DefaultCheckTodayPriceUseCase(),
         mainQueue: DispatchQueueType = DispatchQueue.main) {
        self.loadInterestStocksUseCase = loadInterestStocksUseCase
        self.checkTodayPriceUseCase = checkTodayPriceUseCase
        self.mainQueue = mainQueue
    }

    func transform(from input: Input, disposeBag: DisposeBag) -> Output {
        let output = createViewModelOutput()
        input.viewDidLoadEvent.subscribe(
            onNext: { [weak self] _ in
                guard let self = self else { return }
                // view가 나타나면 load
                self.load()
            }
        )
        .disposed(by: disposeBag)
        return output
    }
}

private extension InterestStocksViewModel {
    func createViewModelOutput() -> Output {
        Output(stockInformations: stockInformationsSubject.asObservable())
    }

    func load() {
    	// 저장된 관심 종목 리스트를 받아온다.
        let names: [String] = loadInterestStocksUseCase.execute()
        // 종목 리스트를 돌면서 해당 종목의 시세를 조회!
        names.forEach { name in
            let checkStockTask = checkTodayPriceUseCase.execute(stockName: name) { [weak self] result in
                self?.mainQueue.async {
                    switch result {
                    case .success(let stockInformation):
                    // 종목 리스트의 내용이 추가되면서 변하기 때문에 BehaviorSubject를 사용해 이벤트를 전달한다.
                        self?.stockInformationArray.append(stockInformation)
                        guard let stockInformationArray = self?.stockInformationArray else { return }
                        self?.stockInformationsSubject.onNext(stockInformationArray)
                    case .failure(let error):
                        print(error)
                    }
                }
            }
            checkStockTasks[name] = checkStockTask
        }
    }
}

전체 코드는 위와 같다.

checkTodayPriceUseCase.execute의 결과로(주식 시세를 조회한다) 해당 주식의 정보를 받게 되면, 그 받은 내용을 stockInformationArray에 append 하고 stockInformationsSubject에 onNext의 파라미터로 전달한다. 그러면 stockInformationsSubject는 Output의 stockInformations에 asObservable 메서드를 사용하여 전달한다.

그럼 bind된 ViewController에서는 어떤 동작을 하고 있을까?

//
//  InterestStocksViewController.swift
//  Or-rock-Nari-lock
//
//  Created by 황지웅 on 2/21/24.
//

import UIKit
import RxSwift

final class InterestStocksViewController: UIViewController {

    private lazy var collectionView: UICollectionView = {
        let config = UICollectionLayoutListConfiguration(appearance: .plain)
        let collectionView: UICollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewCompositionalLayout.list(using: config))
        collectionView.register(StockCell.self, forCellWithReuseIdentifier: StockCell.identifier)
        return collectionView
    }()

    private lazy var dataSource =  UICollectionViewDiffableDataSource<UUID, StockInformation>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: StockCell.identifier, for: indexPath) as? StockCell else { return UICollectionViewCell() }
        cell.setContent(itemIdentifier)
        return cell
    }

    private let disposeBag: DisposeBag = DisposeBag()
    // TODO: Coordinator에서 생성
    private var viewModel: InterestStocksViewModel = InterestStocksViewModel()
    // viewLoad는 Subject로서 viewWillAppear에서 view가 load됨을 알리고, ViewModel의 viewDidLoadEvent의 Input으로 전달한다.
    private let viewLoad = PublishSubject<Void>()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        bindViewModel()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        viewLoad.onNext(())
    }

    private func setupViews() {
        setConstraints()
        collectionView.dataSource = dataSource
    }

    private func setConstraints() {
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collectionView)
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: view.topAnchor),
            collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
    }

    private func bindViewModel() {
        let input = InterestStocksViewModel.Input(viewDidLoadEvent: viewLoad.asObservable())
        let output = viewModel.transform(from: input, disposeBag: disposeBag)
	// stockInformations에 주식 정보가 추가 되면 관심 종목 리스트를 업데이트한다.
        output.stockInformations
            .subscribe(onNext: { [weak self] stockInformationArray in
                guard let self = self else { return }

                var snapshot = NSDiffableDataSourceSnapshot<UUID, StockInformation>()
                snapshot.appendSections([UUID()])
                snapshot.appendItems(stockInformationArray)
                self.dataSource.apply(snapshot, animatingDifferences: true)
            })
            .disposed(by: disposeBag)
    }

}

#Preview {
    InterestStocksViewController()
}

먼저 View가 로드될 때, 관심 종목 리스트를 불러올 것이기 때문에 bindViewModel에서 ViewModel의 Input에 viewLoad를 전달한다. 그리고 viewWillAppear에서 viewLoad.onNext를 통해 view가 나타남을 알린다. 이 프로젝트는 아직 개발 중이긴 한데, viewWillAppear가 viewDidLoad가 적합해 보이기도 한다(뷰가 매번 나타날 때마다 API를 쏴댈 테니). 무튼무튼아무튼

이 로직을 알기 쉽게 그림으로 정리해 보면,

ViewController와 ViewModel간 로직

위의 그림과 같다.

bind를 할 때, ViewController에서 Input으로 viewLoad를 onNext로 ViewModel에 이벤트를 알리고, output으로 stockInformations를 Observable로 받아 구독한다.

ViewModel에서는 Input으로 viewLoad를 Observable로 받아 구독하고 Output으로 stockInformations을 ViewController에 전달한다.

viewLoad.onNext()로 인해 구독 중이던 ViewModel에서 load가 실행돼 주식 정보를 stockInformations에 추가하면 stockInformations를 구독중이던 ViewController에서 dataSource를 업데이트한다.

 

5. 결론

한 번에 하나만 조회해야 하는 API의 조건에 맞춰 MVVM과 RxSwift를 활용하여 관심 종목 리스트의 주식 정보를 추가하고 View를 업데이트할 수 있었다.

bind를 통해 ViewController와 ViewModel의 양방향 연결을 하는 부분에서 RxSwift의 반응형 프로그래밍의 특성을 발휘할 수 있었다.

추가적으로 CleanArchitecture를 적용함에 따라 Repository, UseCase, ViewModel을 RxSwift로도 연결할 수 있다. RxSwift로 연결하는 방법과 개선효과에 대해서는 다음 글에서 서술하겠다.

https://chopmozzi.tistory.com/25

 

RxSwift로 코드 개선하기(with. Clean Architecture)

RxSwift는 반응형 프로그래밍으로서의 장점뿐만 아니라 Completion Handler로 발생하는 Clousre의 코드 depth 또한 해결할 수 있다. 기존코드와 개선한 코드를 비교해가며 이를 알아보자. 1. 기존 코드 기존

chopmozzi.tistory.com

현재 진행중인 프로젝트의 코드가 궁금하다면

https://github.com/App-in-App-le/Or-rock-Nari-lock/tree/iOS/dev

 

GitHub - App-in-App-le/Or-rock-Nari-lock: 주식 정보 iOS 애플리케이션

주식 정보 iOS 애플리케이션. Contribute to App-in-App-le/Or-rock-Nari-lock development by creating an account on GitHub.

github.com