원클릭으로
ios-viewmodel-pattern
// iOS MVVM 패턴의 ViewModel 구조 정의. Input/Output/Route/Config/Dependency/State 패턴으로 ViewModel을 생성합니다. Combine 기반으로 작동하며, 다른 iOS 프로젝트에서도 재사용 가능합니다.
// iOS MVVM 패턴의 ViewModel 구조 정의. Input/Output/Route/Config/Dependency/State 패턴으로 ViewModel을 생성합니다. Combine 기반으로 작동하며, 다른 iOS 프로젝트에서도 재사용 가능합니다.
테크스펙 문서를 기반으로 새로운 피처를 구현하는 전체 파이프라인을 자동화합니다. 구현 → 테스트 → 수정 → 커밋까지 자동으로 수행합니다.
iOS Repository 패턴 정의. Protocol + Impl 구조 및 API enum + RequestType 확장 패턴으로 네트워크 레이어를 구현합니다. async/await와 Result 타입을 사용하며, 다른 iOS 프로젝트에서도 재사용 가능합니다.
iOS ViewModel 유저 플로우 기반 테스트 코드 자동 생성. Given-When-Then 패턴으로 Input/Output 검증 테스트를 생성합니다. XCTest와 Combine을 사용하며, 다른 iOS 프로젝트에서도 재사용 가능합니다.
| name | ios-viewmodel-pattern |
| description | iOS MVVM 패턴의 ViewModel 구조 정의. Input/Output/Route/Config/Dependency/State 패턴으로 ViewModel을 생성합니다. Combine 기반으로 작동하며, 다른 iOS 프로젝트에서도 재사용 가능합니다. |
Combine 기반 MVVM 패턴의 ViewModel 구조를 정의합니다. BaseViewModel을 상속하며, Input/Output/Route/Config/Dependency/State 패턴을 따릅니다.
뷰컨트롤러에서 ViewModel로 전달하는 이벤트를 정의합니다. PassthroughSubject로 선언합니다.
struct Input {
let load = PassthroughSubject<Void, Never>()
let didTapClose = PassthroughSubject<Void, Never>()
let didTapEdit = PassthroughSubject<Void, Never>()
let loadMore = PassthroughSubject<Void, Never>()
}
규칙:
ViewModel에서 뷰컨트롤러로 전달하는 데이터 및 이벤트를 정의합니다.
struct Output {
let screenName: ScreenName = .storeContributors
let items = PassthroughSubject<[SDUItem], Never>()
let route = PassthroughSubject<Route, Never>()
let error = PassthroughSubject<Error, Never>()
}
규칙:
화면 전환 경로를 정의합니다. 라우팅 로직이 없다면 생략 가능합니다.
enum Route {
case dismiss
case pushDetail(DetailViewModel)
case pushEditStore(EditStoreViewModelInterface)
}
규칙:
ViewModel 생성 시 필요한 초기 설정 값을 정의합니다.
public struct Config {
let storeId: Int
let store: UserStoreResponse?
public init(storeId: Int, store: UserStoreResponse?) {
self.storeId = storeId
self.store = store
}
}
규칙:
외부 의존성 (Repository, LogManager 등)을 정의합니다. 의존성이 없다면 생략 가능합니다.
struct Dependency {
let storeRepository: StoreRepository
let logManager: LogManagerProtocol
init(
storeRepository: StoreRepository = StoreRepositoryImpl(),
logManager: LogManagerProtocol = LogManager.shared
) {
self.storeRepository = storeRepository
self.logManager = logManager
}
}
규칙:
ViewModel 내부 상태를 정의합니다. 상태가 없다면 생략 가능합니다.
struct State {
var cursor: String?
var isLoading: Bool = false
}
규칙:
import Foundation
import Combine
import Common
import Log
import Model
import Networking
extension MyViewModel {
struct Input {
let load = PassthroughSubject<Void, Never>()
let didTapButton = PassthroughSubject<Void, Never>()
}
struct Output {
let screenName: ScreenName = .myScreen
let items = PassthroughSubject<[MyItem], Never>()
let route = PassthroughSubject<Route, Never>()
let error = PassthroughSubject<Error, Never>()
}
enum Route {
case dismiss
case pushDetail(MyDetailViewModel)
}
public struct Config {
let id: Int
let initialData: MyData?
public init(id: Int, initialData: MyData?) {
self.id = id
self.initialData = initialData
}
}
struct Dependency {
let repository: MyRepository
let logManager: LogManagerProtocol
init(
repository: MyRepository = MyRepositoryImpl(),
logManager: LogManagerProtocol = LogManager.shared
) {
self.repository = repository
self.logManager = logManager
}
}
struct State {
var cursor: String?
var isLoading: Bool = false
}
}
public final class MyViewModel: BaseViewModel {
let input = Input()
let output = Output()
private var state: State
private let config: Config
private let dependency: Dependency
public init(config: Config, dependency: Dependency = Dependency()) {
self.config = config
self.dependency = dependency
self.state = State()
}
public override func bind() {
// Input → Output 바인딩
input.load
.withUnretained(self)
.sink { (owner, _) in
Task { [weak owner] in
await owner?.fetchData()
}
}
.store(in: &cancellables)
input.didTapButton
.map { Route.dismiss }
.subscribe(output.route)
.store(in: &cancellables)
}
@MainActor
private func fetchData() async {
guard !state.isLoading else { return }
state.isLoading = true
let result = await dependency.repository.fetchData(
id: config.id,
cursor: state.cursor
)
state.isLoading = false
switch result {
case .success(let response):
state.cursor = response.cursor
output.items.send(response.items)
case .failure(let error):
output.error.send(error)
}
}
}
input.load
.withUnretained(self)
.sink { (owner, _) in
// 작업 수행
}
.store(in: &cancellables)
input.load
.withUnretained(self)
.sink { (owner, _) in
Task { [weak owner] in
await owner?.fetchData()
}
}
.store(in: &cancellables)
input.didTapClose
.map { Route.dismiss }
.subscribe(output.route)
.store(in: &cancellables)
input.didTapEdit
.withUnretained(self)
.sink { (owner, _) in
owner.pushEditStore()
}
.store(in: &cancellables)
private func pushEditStore() {
guard let store = config.store else { return }
let viewModel = WriteInterface.getEditStoreViewModel(store: store)
output.route.send(.pushEditStore(viewModel))
}
@MainActor
private func fetchData() async {
// 중복 요청 방지
guard !state.isLoading else { return }
state.isLoading = true
// 네트워크 호출
let result = await dependency.repository.fetchData(
id: config.id,
cursor: state.cursor
)
state.isLoading = false
// 결과 처리
switch result {
case .success(let response):
state.cursor = response.cursor
output.items.send(response.items)
case .failure(let error):
output.error.send(error)
}
}
규칙:
extension ContributorsViewModel {
struct Input {
let load = PassthroughSubject<Void, Never>()
let didTapClose = PassthroughSubject<Void, Never>()
let didTapEdit = PassthroughSubject<Void, Never>()
let loadMore = PassthroughSubject<Void, Never>()
}
struct Output {
let screenName: ScreenName = .storeContributors
let items = PassthroughSubject<[SDUItem], Never>()
let route = PassthroughSubject<Route, Never>()
let error = PassthroughSubject<Error, Never>()
}
enum Route {
case dismiss
case pushEditStore(EditStoreViewModelInterface)
}
public struct Config {
let storeId: Int
let store: UserStoreResponse?
}
struct State {
var cursor: String?
var isLoading: Bool = false
}
struct Dependency {
let storeRepository: StoreRepository
let logManager: LogManagerProtocol
}
}
ViewController에서 ViewModel을 사용할 때:
// 1. ViewModel 생성
let config = MyViewModel.Config(id: 123, initialData: nil)
let viewModel = MyViewModel(config: config)
// 2. Input 이벤트 전달
viewModel.input.load.send(())
// 3. Output 구독
viewModel.output.items
.receive(on: DispatchQueue.main)
.sink { items in
// UI 업데이트
}
.store(in: &cancellables)
테스트 시 Dependency를 Mock으로 주입할 수 있습니다:
let mockRepository = MockMyRepository()
let dependency = MyViewModel.Dependency(
repository: mockRepository,
logManager: MockLogManager()
)
let viewModel = MyViewModel(config: config, dependency: dependency)