일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- uikit toast
- 버튼 피드백
- reactorkit
- custombottomsheet
- swift concurrency
- BFS
- domain data
- identifiable
- UIKit
- swift dashed line
- swift 백준
- traits
- custom navigation bar
- swift 점선
- claen architecture
- swift bottomsheet
- RxSwift
- Tuist
- SWIFT
- button configuration
- paragraph style
- DP
- rxdatasources
- swift navigationcontroller
- task cancel
- coordinator
- swift custom ui
- 타임라인 포맷팅
- custom ui
- task cancellation
- Today
- Total
김경록의 앱 개발 여정
[Swift MVVM] UI와 독립적인 ViewModel, 어디까지 고려해야 할까? (feat. ReactorKit) 본문
[Swift MVVM] UI와 독립적인 ViewModel, 어디까지 고려해야 할까? (feat. ReactorKit)
Kim Roks 2025. 4. 17. 18:19📘 개요
MVVM 패턴에서 ViewModel(이하 VM)은 UI 요소와 독립적이어야 한다고들 합니다.
그런데 그 '독립성'의 기준은 어디일까요?
예를 들어, UIKit을 import 하지 않으면 독립적인 걸까요?
UILabel의 텍스트처럼 화면에 표현될 값을 포장하여 다루는 건 괜찮을까요? (ex: n 개, n원)
혹은 View가 isHidden일지 말지를 판단하는 Bool값을 ViewModel에서 들고 있는 건 괜찮을까요?
이 글은 제가 실제로 진행한 프로젝트에서 MVVM을 따르고 있다고 생각했지만, 결과적으로는 안티 패턴에 가까운 코드를 작성했던 경험을 되돌아보며,
ReactorKit을 기준으로 어떤 식으로 View와 ViewModel의 책임을 분리했는지 정리한 글입니다
🎭 “State는 화면의 상태”라는 말의 함정
View에 보여줄 수 있는 정보를 담고 있는 데이터의 묶음.
ViewModel이 그 상태를 가공하거나 변경하고, View는 그 상태를 구독해서 화면을 그린다.
저는 단순히 State는 뷰가 가질 상태(화면에 보여질 내용)에 대한 내용이라고 생각했습니다.
지금 돌이켜보면 그 표현이 조금은 모호하다고 생각되고, 그로 인한 실수가 생길 수 있다고 생각합니다.
저는 State를 더 나아가 '사용자 의도'를 중심으로 생길 '비즈니스 로직'으로 인해 표시될 뷰의 상태라고 해석해야한다고 생각합니다.
제가 작성했던 일부코드로 예시를 들어보겠습니다.
⏱️ 타이머 뷰의 가시성 제어
//Mutation
case updateTimerState(isHidden: Bool, remainTime: Int)
//State
var isTimerHidden = true
var remainTime = 0
시나리오는 다음과 같습니다.
1. 인증 번호를 요청한다.
2. 인증번호가 정상 전송되면, 숨겨져 있던 타이머가 나타나고 시간이 설정된다.
다른 UIKit등을 활용하지도 않았고 Swift 자체 타입으로 작성되었으니
UI요소에 직접 관여하지 않았다.라고 생각될 수 있지만 이는 오류가 있습니다.
isTimerHidden이 view.isHidden을 직접적으로 건드리지 않았더라도
이는 VM이 이미 정확히 어떤 View가 만들어질 것을 상정하고 작성되었다는 점을 알 수 있습니다.
ViewModel이 “어떤 뷰가 있고”, “언제 보일지”를 이미 알고 있는 셈입니다.
그럼 어떻게 개선할 수 있을까요?
✅ 개선 방식
// Mutation
case updateRemainingTime(Int)
//State
var remainTime: Int = 0
뷰가 숨겨질지 말지는 순전히 View의 관심사입니다.
리액터(VM)는 오직 남은 시간만 전달합니다.
해당 리액터를 사용하는 VC에선 다음과 같이 처리하면 됩니다.
reactor.state.map { $0.remainingTime }
.bind { [weak self] remainingTime in
self?.timerLabel.isHidden = remainingTime == 0
self?.timerLabel.text = timerString
}
.disposed(by: disposeBag)
이렇게 하면 ViewModel은 View가 어떤 구성인지 전혀 알 필요가 없습니다.
🧩 View가 가진 enum을 사용한 문제
처음엔 View에서 사용하는 enum인 EveryTipTextFieldStatus를 그대로 State에 썼습니다:
//State
var textFieldStatus: [TextFieldType: (status: EveryTipTextFieldStatus, errorMessage: String?)]
문제점
- EveryTipTextFieldStatus는 View에서 정의된 UI 상태 표현용 타입입니다.
- ViewModel이 View를 의존하고 있습니다
- 어떤 텍스트필드에 어떤 상태일 때를 직접적으로 처리하고 있습니다.
- 또한 뷰에 뿌려 줄 텍스트까지 직접 관리하고 있네요
근데 여기서 충돌이 생깁니다.
제가 이 리액터를 사용하는 화면에서 텍스트필드는 4개입니다.


각 텍스트 필드는 위와 같이 유저의 입력 상태 등에 대한 상태처리가 되어있습니다.
이걸 기존의 정의된 Status를 사용하지 않는다면
각 필드별로
- First FieldEditingDidBegin
- First textEditingDidEnd
- First textEditingDidEndOnExit
- First textChanged...
- Second FirstFieldEditingDidBegin...
필드가 여러 개일 경우, 이 모든 케이스를 ViewModel에서 따로 처리하려면
매우 많은 보일러플레이트 코드가 발생합니다.
그렇다고 ViewModel이 View 전용 타입을 계속 쓰는 것도 문제입니다.
즉, 현실적으로 UI에 의존하지 않으면서도 깔끔한 상태 표현이 필요합니다.
✅ 개선 방향
ViewModel 내부에서 사용할 별도의 중립적 상태 표현 타입을 정의할 수 있을듯합니다.
enum InputValidationState {
case notEntered
case invalid(reason: String?)
case valid
case editing
}
State 정의도 이렇게 변경합니다:
var textFieldStatus: [InputPurpose: InputValidationState] = [:]
그리고 View에서 바인딩 시 변환하여 사용합니다
extension InputValidationState {
func toEveryTipTextFieldStatus() -> EveryTipTextFieldStatus {
switch self {
case .normal: return .normal
case .editing: return .editing
case .error(let message): return .error(message)
case .disabled: return .notEnabled
}
}
}
이 또한 View에 있는 비슷한 코드를 반복 작성하는 경향이 있고, 실용적인 처리를 위한 입장에선 굳이? 란 생각이 들 수도 있겠지만
현실적인 타협안이 될 수 있을 거라고 생각됩니다.
✨ 마무리
사실 텍스트필드를 포함한 많은 부분이 아직도 고민이 끝나지 않았고 마무리 짓지 못했습니다.
하지만 무얼 말하고자 하는지엔 적합한 예시라 생각되어서 포함시켜 봤습니다.
이번 경험을 통해 느낀 건, ViewModel을 작성할 때 관점 자체를 달리할 필요가 있다는 점이었습니다.
ViewModel이 직접적으로 View를 모르고도 잘 작동하게 만들기 위해선, 단순히 UIKit을 import 하지 않는다거나, 뷰를 직접 조작하지 않는 정도로는 부족합니다.
진짜 중요한 건 "이 상태가 사용자의 어떤 의도를 반영하는가?",
"이 데이터는 도메인과 어떤 연관이 있는가?"
이런 질문을 끊임없이 던지는 거라고 생각해요.
ViewModel은 단순히 뷰를 그리기 위한 값들을 가공하는 레이어가 아니라,
사용자 의도를 해석하고, 그 흐름을 상태로 풀어내는 '의미 중심의 로직 레이어'에 가까워야 한다고 생각이 들었습니다..
그런 관점으로 ViewModel을 구성하면 테스트도 쉬워지고, 재사용성도 높아지고, 무엇보다 "이게 왜 필요한 상태지?"라는 고민을 하게 됩니다.
그 고민이 쌓이면서 자연스럽게 UI와 로직이 분리된 건강한 설계로 나아갈 수 있게 되더라고요.
물론 저도 아직도 시행착오를 겪고 있고, 정답은 없다고 생각합니다.
하지만 중요한 건 지금 내가 어떤 관점에서 ViewModel을 설계하고 있는가?를 자각하고 계속 개선해 보려는 태도인 것 같습니다.
'Trouble Shooting' 카테고리의 다른 글
[Swift UIKit] Button 터치 피드백이 안보이는 경우 (0) | 2025.05.02 |
---|---|
[Swift UIKit] 앱 전반에서 사용 될 Toast Message 구현기 (0) | 2025.04.16 |
[Swift UIKit] 점선 스타일 보더 적용하기(Dashed Border Style) (0) | 2025.04.16 |
[Swift UIKit] Custom Bottom Sheet (0) | 2025.04.11 |
[Swift UIKit] 앱 전반에서 사용되는 Custom NavigationContoller (0) | 2025.04.11 |