오늘은 42CM에서 어떠한 기준을 가지고 기술 스택 및 아키텍처를 선택했는 지에 대해 적어보고자 합니다! 그리고 선택 과정 중, 고민하거나 궁금했던 부분들을 함께 적어보고자 합니다.
결론적으로 42CM에서는 Code Based UIKit, MVVM, RxSwift를 채택하였습니다. 이외에도 아래와 같은 스택을 활용할 예정입니다.
PayGateAlamofireKingFisherSocketIORealmSnapKitThenToast오늘은 Code Based UIKit, MVVM, RxSwift를 중심으로 글을 작성해보고자 합니다 :)
일단 UIKit을 선택한 이유는, 기존에 UIKit으로 구현된 레거시 코드들이 많기 때문에, 이에 대해 좀 더 심도있게 공부해보고 싶었기 때문입니다. 뿐만 아니라, UI 요소들의 레이아웃을 세밀하게 조정하고 싶은 욕심도 있었습니다.
UIKit을 선택한 이후엔 스토리보드 방식과 코드 베이스 방식 중에서 고민했습니다.
저는 코드 베이스가 재사용 가능한 코드를 쉽게 만들고 관리할 수 있다고 여겨져 코드 베이스를 선택했습니다. 예를 들어, 아래처럼 IconBtn 파일을 만들어서 자주 쓰이는 컴포넌트를 여러 화면에서 사용할 수 있게끔 하였습니다.
// IconBtn 파일
import UIKit
class IconButton: UIButton {
init(image: String) {
super.init(frame: .zero)
var config = UIButton.Configuration.plain()
config.image = UIImage(systemName: image)?
.withRenderingMode(.alwaysOriginal)
.withTintColor(.offWhite)
config.imagePadding = 0
config.baseForegroundColor = .offWhite
config.baseBackgroundColor = .clear
self.configuration = config
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
뿐만 아니라 스토리보드로 구현시, 동적 레이아웃을 구현한다던가 복잡한 로직을 다뤄야할 때 결국엔 코드로 관리해야하기 때문에, 스토리보드와 코드의 일관성이 떨어진다고 여겨진 점도 있었습니다.
그럼에도 불구하고 개발 속도가 빠르다는 점에서 스토리보드도 좋은 방법이라고 생각합니다!
SnapKit과 Then 사용하였습니다. 둘을 사용하여 코드를 작성하니 코드 가독성이 더 좋아졌다고 느꼈습니다.
저는 기존에 MVC로 프로젝트를 진행했던 경험이 있는데요, MVC로 개발하다보니 문제점이 있었습니다. 사용자 입력 처리, 네트워크 요청, 데이터 처리, UI 업데이트 등을 모두 담당하게 되면서 코드가 복잡해지고 유지보수가 어려워졌습니다. 로직을 수정해야하는 일이 생기면 어디부터 고쳐야할지 찾기 어렵고 가독성이 너무 안 좋아지더라구요.. 소규모 프로젝트에서는 MVC로 하는 것이 적합하다고 생각하지만, 이번엔 소규모 프로젝트가 아니기에 MVC는 적합하지 않다고 생각했습니다.
이외에도 경험해보진 않았지만 MVP, Viper 등 많은 아키텍쳐가 있습니다. 그중에서 MVVM을 선택한 이유는 다음과 같습니다.
각 역할은 다음과 같이 분리할 예정입니다.
Model
View
ViewModel
기존에는 Custom Observable, Notification Center, Callback, Delegate 패턴을 사용해 비동기 작업과 이벤트 관리를 처리했지만, 이 방식들은 각각 코드의 복잡성과 결합도를 높이고 가독성을 떨어뜨렸습니다. Custom Observable은 직접 구현해야 하는 번거로움과 재사용성 부족의 문제가 있었고, Notification Center는 전역 사용으로 인해 이벤트 발생 원인을 추적하기 어렵다는 점이 있었습니다. Callback은 중첩이 많아질수록 코드가 복잡해지고, Delegate는 객체 간 강한 결합을 초래했습니다. 이러한 불편함을 해소하기 위해 이번 프로젝트에서는 처음으로 RxSwift를 도입했습니다. RxSwift는 반응형 프로그래밍을 통해 비동기 작업과 이벤트 스트림을 간결하고 선언적으로 관리할 수 있어, 코드의 가독성과 유지보수성을 크게 향상시켰습니다.
Combine을 사용해도 됐다고 생각합니다.
하지만 그럼에도 불구하고 Combine이 아닌 RxSwift를 선택한 이유는 UIKit 컴포넌트와 데이터를 쉽게 바인딩할 수 있기 때문입니다. 예를 들어 collectionView.rx.items 같은 경우 RxCocoa를 이용해 Observable 시퀀스를 컬렉션 뷰에 간단히 연결하여 데이터 변경 시 자동으로 UI를 갱신할 수 있습니다. 예를 들어 아래와 같이 구현 가능합니다.
import UIKit
import RxSwift
import RxCocoa
class RxSwiftViewController: UIViewController {
var collectionView: UICollectionView!
let items = Observable.just(["Item 1", "Item 2", "Item 3"])
let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
let layout = UICollectionViewFlowLayout()
collectionView = UICollectionView(frame: self.view.bounds, collectionViewLayout: layout)
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
view.addSubview(collectionView)
// RxCocoa를 사용한 데이터 바인딩
items
.bind(to: collectionView.rx.items(cellIdentifier: "Cell", cellType: UICollectionViewCell.self)) { (row, element, cell) in
cell.contentView.backgroundColor = .cyan
let label = UILabel(frame: cell.contentView.bounds)
label.text = element
cell.contentView.addSubview(label)
}
.disposed(by: disposeBag)
}
}
반면, UIKit+Combine은 이러한 자동화된 바인딩 도구가 기본적으로 제공되지 않습니다. 동일한 기능을 구현하기 위해 추가적인 구현이 필요하기 때문에 상대적으로 복잡합니다.
import UIKit
import Combine
class CombineViewController: UIViewController, UICollectionViewDataSource {
var collectionView: UICollectionView!
var dataSource: [String] = []
var cancellable: AnyCancellable?
override func viewDidLoad() {
super.viewDidLoad()
let layout = UICollectionViewFlowLayout()
collectionView = UICollectionView(frame: self.view.bounds, collectionViewLayout: layout)
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
collectionView.dataSource = self
view.addSubview(collectionView)
// Combine을 사용한 데이터 바인딩
let dataPublisher = Just(["Item 1", "Item 2", "Item 3"])
.eraseToAnyPublisher()
cancellable = dataPublisher
.sink(receiveCompletion: { _ in },
receiveValue: { [weak self] data in
self?.dataSource = data
self?.collectionView.reloadData()
})
}
// UICollectionViewDataSource 메서드 구현
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataSource.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
cell.contentView.backgroundColor = .cyan
let label = UILabel(frame: cell.contentView.bounds)
label.text = dataSource[indexPath.row]
cell.contentView.addSubview(label)
return cell
}
}
이렇듯 Combine에서는 기본적으로 UI 컴포넌트와 퍼블리셔를 바인딩하는 기능이 없어, UI 이벤트를 퍼블리셔로 변환하려면 번거로운 작업이 필요합니다. 그리고 collectionView.reloadData()를 통해서 모든 셀을 새로 그리게 되어 성능에 영향을 미칠 수 있습니다. 반면, RxCocoa는 collectionview.rx.items가 필요한 부분만 업데이트하게 되어 더 효율적입니다.
이건 비단 CollectionView만 해당되는 것은 아닙니다. 예를 들어, UITextField의 텍스트 변경 이벤트를 감지하려면 NotificationCenter을 사용해 관련 알림을 퍼블리셔로 변환하거나 커스텀 퍼블리셔를 구현해야 합니다. UIButton과 같은 UIControl 요소는 publisher를 사용할 수 있지만, 다른 UI 요소에 대해서는 직접 구현해야 하는 경우가 많습니다. 이러한 수작업은 코드의 복잡성을 증가시키고 유지보수를 어렵게 만들 수 있겠다고 판단했습니다.
반면, RxCocoa는 이러한 데이터 바인딩을 간편하게 처리할 수 있는 도구를 제공하기에 훨씬 편리하다고 생각했습니다.
그럼에도 불구하고, Combine은 Apple이 설계하고 유지 보수하는 라이브러리로, iOS와 macOS의 내부 구조에 맞춰 최적화되어 있습니다. 이는 서드파티 라이브러리보다 시스템 리소스를 효율적으로 사용하고, 성능이 더 나을 가능성이 클 것 같습니다. 나중에는 SwiftUI + Combine 조합으로 프로젝트를 하나 제작해보고자 합니다!
글은 이렇게 마무리 하고자 합니다. 읽어주셔서 감사합니다!
부족한 부분이나 수정 필요한 부분이 있다면 댓글 남겨주세요 :)