김경록의 앱 개발 여정

[Swift MVVM] UI와 독립적인 ViewModel, 어디까지 고려해야 할까? (feat. ReactorKit) 본문

Trouble Shooting

[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을 설계하고 있는가?를 자각하고 계속 개선해 보려는 태도인 것 같습니다.