iOS 앱 LemonLog에서 감정일기 작성 화면은
총 6개의 단계(step)를 가로 스와이프 또는 Prev/Next 버튼으로 이동하는 단계형 UI(wizard) 구조로 되어 있다.
각 단계에서 필요한 입력을 모두 끝내야 다음 단계로 넘어갈 수 있게 하려면
“단계별 유효성 검사”가 필수적이다.
이 글에서는
- 처음에 부딪힌 문제
- 왜 특정 구조를 선택했는지
- ViewController/UI 로직과 ViewModel/비즈니스 로직을 어떻게 분리했는지
- 그리고 최종 완성된 코드
까지 “초보 개발자도 다시 따라할 수 있게” 정리해본다.
1️⃣ 처음에 부딪힌 문제:
❌ 유효성 검사를 어디에 두어야 하는지 감이 안 잡혔다
초기 구현은 다음과 같았다:
- Prev/Next 버튼이 ViewController에 있고
- 스와이프로도 페이지 이동이 가능하고
- 각 단계마다 검사해야 하는 필드는 다르고
- 감정은 최소 1개 이상 선택해야 하고
- 상황/생각/재평가/행동은 빈 문자열이면 안 되고
- 마지막 날짜/이미지 단계는 항상 통과
이걸 어디에 둘까?
ViewController에 switch문 잔뜩?
셀 내부에서 검사?
단계 enum에서 검사?
ViewModel에서 검사?
너무 많은 선택지가 있었고, UI와 비즈니스 로직이 엉킬 수 있었다.
2️⃣ 명확한 기준 설정:
🔥 “유효성 검사는 ViewModel이 담당한다”
왜?
✔ VC는 ‘UI 이벤트 처리’만 해야 한다
- Prev / Next 버튼 클릭
- 스와이프 시 페이지 이동
- 특정 셀로 스크롤 이동
VC는 절대 “감정 선택 최소 1개?” 같은 비즈니스 규칙을 몰라야 한다.
✔ ViewModel은 ‘데이터 상태’와 ‘비즈니스 규칙’을 담당한다
- 어떤 입력이 들어왔는지
- 그 값이 문제 없는지
- 다음 단계로 넘어가도 좋은지
즉, 책임을 명확히 분리하면 구조가 단순해진다.
3️⃣ 단계 enum 정의
enum DiaryWriteStep: Int, CaseIterable {
case emotion
case situation
case thought
case reeval
case action
case dateAndImages
}
step은 단순히 “순서”만 표현한다.
여기에는 유효성 검사 로직을 넣지 않는다.
→ 이유: enum은 데이터 표현만 하고 비즈니스 로직은 ViewModel이 갖는 것이 더 자연스럽다.
4️⃣ 유효성 검사 메시지 설계 (Localization 반영)
에러 메시지를 한 군데에서 관리하려고, 다음과 같이 enum을 만들었다:
enum DiaryValidationErrorMessage: String {
case emotionRequired = "validation_error_emotion_required"
case situationRequired = "validation_error_situation_required"
case thoughtRequired = "validation_error_thought_required"
case reevaluateRequired = "validation_error_reeval_required"
case actionRequired = "validation_error_action_required"
var localizedMessage: String {
NSLocalizedString(rawValue,
tableName: "Localizable",
bundle: .main,
value: defaultMessage,
comment: "")
}
}
이렇게 하면 나중에 메시지를 바꿔도 enum만 수정하면 되고,
다국어 환경에서도 유지보수가 쉬워진다.
5️⃣ ViewModel에서 단계별 유효성 검사 구현
핵심은 모든 단계 검사를 한 함수에 통합하는 것.
func canProceedToNextStep(_ step: DiaryWriteStep) -> Bool {
switch step {
case .emotion:
guard !editableDiary.emotion.subEmotion.isEmpty else {
triggerError(.emotionRequired)
return false
}
return true
case .situation:
guard !editableDiary.content.situation.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
triggerError(.situationRequired)
return false
}
return true
case .thought:
guard !editableDiary.content.thought.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
triggerError(.thoughtRequired)
return false
}
return true
case .reeval:
guard !editableDiary.content.reeval.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
triggerError(.reevaluateRequired)
return false
}
return true
case .action:
guard !editableDiary.content.action.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
triggerError(.actionRequired)
return false
}
return true
case .dateAndImages:
return true
}
}
여기서 ViewModel이 하는 역할:
- 현재 단계의 데이터 상태를 확인
- 규칙에 맞는지 검사
- 문제 있으면 triggerError로 VC에 전달
- 통과하면 true 반환
VC는 단지 true인지 false인지만 알고 있으면 된다.
6️⃣ ViewController에서는 어떻게 사용할까?
Next 버튼:
onTapNext = { [weak self] in
guard let self else { return }
let step = self.steps[self.currentStepIndex]
let allowed = self.viewModel.canProceedToNextStep(step)
guard allowed else { return }
let next = self.currentStepIndex + 1
self.scrollTo(step: next)
}
스와이프 시에도 똑같이:
func scrollViewWillEndDragging(...) {
if targetPage > currentPage {
let step = steps[currentPage]
if !viewModel.canProceedToNextStep(step) {
targetContentOffset.pointee.x = CGFloat(currentPage) * scrollView.frame.width
return
}
}
}
VC는 규칙을 알 필요가 없다.
그냥 "넘어가도 돼?" 라고 ViewModel에 묻고
응/아니오에 따라 UI만 업데이트한다.
7️⃣ 이 방식의 장점
| 책임 분리 | VC는 UI만, ViewModel은 규칙만 담당 |
| 유지보수 용이 | 단계 추가해도 ViewModel만 수정하면 됨 |
| 코드 가독성 증가 | switch 로직이 한 곳에 모여있음 |
| 테스트 용이 | ViewModel만 단위 테스트하면 됨 |
| 재사용 쉬움 | 같은 validate 로직을 Next/Swipe 어디서든 사용 가능 |
🧠 UI 로직은 VC에, 비즈니스 규칙은 ViewModel에.
🧠 여러 곳에서 쓰는 로직은 반드시 하나로 통합.
이 원칙만 지키면
단계형 UI, Form Wizard, 가입 프로세스, 예약 프로세스 등
어떤 복잡한 흐름도 훨씬 쉽게 처리할 수 있다.
'감정일기(가칭)' 카테고리의 다른 글
| 📱 UICollectionView 내부 셀의 날짜 갱신이 안 되는 이유와 해결 방법— 특히 “셀 자체를 전달하는 방식”의 강력함 (0) | 2025.12.10 |
|---|---|
| 📘 감정일기 작성 화면에서 날짜 선택 UI는 어떻게 설계해야 할까?— 달력을 직접 넣을까, 시트로 띄울까? 구조적 고민과 결론 정리 (0) | 2025.12.10 |
| 📘 DiaryWriteViewController 설계 문서 (함수·프로퍼티 단위 상세 버전) (0) | 2025.12.09 |
| 📘 DiaryWriteViewModel – 설계 문서 (Logic Blueprint) (0) | 2025.12.09 |
| 📘 Emotion Selection Logic - 설계 문서(개발 전 단계 로직 정리) (0) | 2025.12.09 |