본문 바로가기
감정일기(가칭)

📘 단계형 UI(Form Wizard)에서 유효성 검사를 어떻게 설계할까?— 감정일기 작성 화면(DiaryWrite)을 구현하면서 얻은 설계 인사이트

by 밤새는 탐험가89 2025. 12. 9.
728x90
SMALL

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, 가입 프로세스, 예약 프로세스 등
어떤 복잡한 흐름도 훨씬 쉽게 처리할 수 있다.

728x90
LIST