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

📑 DiaryEditor 유효성 검사 설계부터 UI 처리까지

by 밤새는 탐험가89 2025. 11. 18.
728x90
SMALL

앞에서 DiaryContentCell의 구조를 바꾸면서
ContentSections 구조체로 각 필드를 분리해서 전달하는 구조를 만들었다.

 

이제 이 데이터를 가지고 어떻게 유효성 검사를 하고,
에러가 발생했을 때 UI를 어떻게 업데이트하는지 흐름을 정리해본다.


1. 유효성 검사가 필요한 필드 정의

감정일기에서 반드시 입력되어야 하는 값은 다음과 같다.

 

감정 (Emotion)

상황 (Situation)

생각 / 원인 (Thought)

새로운 시각 / 반박 (Reeval)

다음 행동 (Action)

 

이미지는 선택사항이고, 날짜는 기본값이 Date()이기 때문에
유효성 검사 대상에서 제외했다.


2. 어떤 기준으로 설계했는가?

처음 유효성 검사를 설계할 때 기준은 크게 네 가지였다.

 

1. ViewModel 층에서 검증

ViewController가 직접 판단하지 않고,

비즈니스 로직은 ViewModel 안에서 처리.

 

2. 필드별 에러를 명확하게 구분

“그냥 실패했다”가 아니라

“어느 필드가 왜 실패했는지”를 알 수 있어야 함.

 

3. 한 번에 여러 필드 에러도 표현 가능

상황 + 생각 둘 다 비었으면

에러도 2개가 같이 올라가야 함.

 

4. MVVM + Combine으로 깔끔하게 전달

ViewModel은 @Published로 결과만 발행

ViewController는 구독해서 UI 업데이트만 담당


3. Validation 모델 만들기

먼저 “어디서 에러가 났는지”를 표현하기 위해 필드를 enum으로 정의했다.

enum DiaryField: Hashable {
    case emotion
    case situation
    case thought
    case reeval
    case action
}

 

 

각 에러는 어떤 필드에서, 어떤 메시지를 보여줄지를 담는 구조체로 표현했다.

struct DiaryValidationError {
    let field: DiaryField
    let message: String
}
 

그리고 유효성 검사의 최종 결과를 감싸는 DiaryValidationResult:

struct DiaryValidationResult {
    let errors: [DiaryValidationError]
    
    var isValid: Bool {
        return errors.isEmpty
    }
}

 

이렇게 만들어두면,

1. 에러가 없으면 isValid == true

2. 에러가 하나라도 있으면 isValid == false + errors 배열에 상세 정보

이런 식으로 다룰 수 있다.


4. ViewModel에서 유효성 검사 로직 구현

4-1. ViewModel에서 검증 진입점: attemptSaveDiary

ViewController는 저장 버튼을 눌렀을 때 ViewModel의 attemptSaveDiary를 호출한다.

extension DiaryEditorViewModel {
    
    // 유효성 검사 + 저장 진입점
    func attemptSaveDiary(
        situation: String,
        thought: String,
        reeval: String,
        action: String
    ) {
        let result = validateDiaryInputs(
            situation: situation,
            thought: thought,
            reeval: reeval,
            action: action
        )
        
        // 실패면: 결과만 publish → VC에서 UI 처리
        guard result.isValid else {
            validationResult = result
            return
        }
        
        // 성공이면: 저장 로직 실행
        saveDiary()
    }
}

 

여기서 핵심은:

- ViewModel은 “저장 버튼 눌렀다”라는 이벤트를 받으면

1.  validateDiaryInputs로 검증하고

2. 실패하면 validationResult에 에러를 publish

3. 성공하면 saveDiary()를 호출

VC가 직접 “이 값이 비었네?” 같은 조건문을 갖지 않는다는 점이 중요하다.

 

4-2. 실제 유효성 검사 함수: validateDiaryInputs

func validateDiaryInputs(
    situation: String,
    thought: String,
    reeval: String,
    action: String
) -> DiaryValidationResult {

    var errors: [DiaryValidationError] = []
    
    let trimmedEmotion   = diary.emotion.trimmingCharacters(in: .whitespacesAndNewlines)
    let trimmedSituation = situation.trimmingCharacters(in: .whitespacesAndNewlines)
    let trimmedThought   = thought.trimmingCharacters(in: .whitespacesAndNewlines)
    let trimmedReeval    = reeval.trimmingCharacters(in: .whitespacesAndNewlines)
    let trimmedAction    = action.trimmingCharacters(in: .whitespacesAndNewlines)

    // 1) 감정 선택 여부
    if trimmedEmotion.isEmpty {
        errors.append(
            DiaryValidationError(
                field: .emotion,
                message: NSLocalizedString(
                    "validation_error_emotion_required",
                    comment: "Message shown when emotion is not selected"
                )
            )
        )
    }

    // 2) [상황]
    if trimmedSituation.isEmpty {
        errors.append(
            DiaryValidationError(
                field: .situation,
                message: NSLocalizedString(
                    "validation_error_situation_required",
                    comment: "Message shown when situation is empty"
                )
            )
        )
    }

    // 3) [생각 / 원인]
    if trimmedThought.isEmpty {
        errors.append(
            DiaryValidationError(
                field: .thought,
                message: NSLocalizedString(
                    "validation_error_thought_required",
                    comment: "Message shown when thought is empty"
                )
            )
        )
    }

    // 4) [새로운 시각 / 반박]
    if trimmedReeval.isEmpty {
        errors.append(
            DiaryValidationError(
                field: .reeval,
                message: NSLocalizedString(
                    "validation_error_reeval_required",
                    comment: "Message shown when reeval is empty"
                )
            )
        )
    }

    // 5) [다음 행동]
    if trimmedAction.isEmpty {
        errors.append(
            DiaryValidationError(
                field: .action,
                message: NSLocalizedString(
                    "validation_error_action_required",
                    comment: "Message shown when action is empty"
                )
            )
        )
    }

    return DiaryValidationResult(errors: errors)
}

 

여기서 포인트 몇 가지:

1. trimmed... 변수들로 양쪽 공백/줄바꿈 제거

2. 각 필드가 비어 있으면 DiaryValidationError를 추가

3. 최종적으로 errors 배열만 넘김

 

이 덕분에,

1.“상황 + 생각 + 다음 행동” 같이 여러 필드를 동시에 비워도

2.각각의 에러가 배열에 쌓여서 한 번에 처리할 수 있다.


5. ViewModel → ViewController: Combine으로 결과 전달

ViewModel에서는 유효성 검사 결과를 @Published로 노출한다.

@Published var validationResult: DiaryValidationResult?

 

ViewController에서는 이를 구독해서 UI를 업데이트한다.

private func bindViewModel() {
    diaryEditorVM.$validationResult
        .receive(on: RunLoop.main)
        .sink { [weak self] result in
            guard let self, let result else { return }
            self.applyValidationErrors(result.errors)
        }
        .store(in: &cancellables)
}

 

여기서 중요한 건:

 

- ViewModel은 유효성 검사 결과만 던진다.

- “어떻게 표시할지(UI)”는 전부 ViewController 역할.

 

완전히 MVVM스럽게 역할이 분리된 지점이다.


6. ViewController에서 에러를 UI로 매핑하기

6-1. 에러 적용 엔트리: applyValidationErrors(_:)

private func applyValidationErrors(_ errors: [DiaryValidationError]) {
        
    guard !errors.isEmpty else {
        clearAllValidationUI()
        return
    }
    
    for error in errors {
        switch error.field {
            
        case .emotion:
            showEmotionError(message: error.message)
            
        case .situation, .thought, .reeval, .action:
            showContentError(field: error.field, message: error.message)
        }
    }
    
    // 첫 오류 위치로 스크롤
    if let first = errors.first {
        scrollToField(first.field)
    }
}

 

여기서 하는 일:

1. 에러가 없으면 → 기존 에러 UI 전체 제거

2. 에러가 있으면 → 필드별로 분기해서 각각의 셀에 전달

3. 첫 번째 에러 난 위치로 스크롤 (scrollToField)

 

6-2. 기존 에러 UI 모두 지우기: clearAllValidationUI()

private func clearAllValidationUI() {
    
    // Emotion Cell
    if let cell = diaryCollectionView.cellForItem(
        at: IndexPath(item: 0, section: DiaryEditorSection.emotion.rawValue)
    ) as? EmotionCell {
        cell.clearError()
    }
    
    // Content Cell
    if let cell = diaryCollectionView.cellForItem(
        at: IndexPath(item: 0, section: DiaryEditorSection.content.rawValue)
    ) as? DiaryContentCell {
        cell.clearAllErrors()
    }
}

 

- 감정 셀(EmotionCell)

- 내용 셀(DiaryContentCell)

각각에 대해 에러 표시를 초기화한다.

 

6-3. 감정 섹션 에러 표시

private func showEmotionError(message: String) {
    let indexPath = IndexPath(item: 0, section: DiaryEditorSection.emotion.rawValue)
    guard let cell = diaryCollectionView.cellForItem(at: indexPath) as? EmotionCell else { return }
    cell.showError(message: message)
}

 

EmotionCell 내부에서는:

func showError(message: String) {
    errorLabel.text = message
    errorLabel.isHidden = false
    addEmotionButton.layer.borderColor = UIColor.systemRed.cgColor
    addEmotionButton.layer.borderWidth = 1.0
}
    
func clearError() {
    addEmotionButton.layer.borderColor = UIColor.clear.cgColor
    addEmotionButton.layer.borderWidth = 0.0
    errorLabel.isHidden = true
}

 

 

 

감정이 비어 있을 경우 → 빨간 border + 에러 문구 노출

감정을 선택하면 → configure(with:) 호출 후, 내부에서 상태에 맞게 에러 제거

 

6-4. 내용 섹션 에러 표시

private func showContentError(field: DiaryField, message: String) {
    let indexPath = IndexPath(item: 0, section: DiaryEditorSection.content.rawValue)
    guard let cell = diaryCollectionView.cellForItem(at: indexPath) as? DiaryContentCell else { return }
    cell.showError(for: field, message: message)
}

 

 

DiaryContentCell 쪽에서는 대략 이런 느낌으로 대응한다:

func showError(for field: DiaryField, message: String) {
    switch field {
    case .situation:
        situationSection.showError(message)
    case .thought:
        thoughtSection.showError(message)
    case .reeval:
        reevalSection.showError(message)
    case .action:
        actionSection.showError(message)
    default:
        break
    }
}

 

각 DiaryContentView는:

- 텍스트뷰 테두리를 빨갛게

- 아래쪽에 에러 라벨 노출

이런 식으로 “당근마켓 글쓰기”처럼 필드별 안내 문구가 보이는 UX를 구성했다.

 


7. 첫 에러 위치로 자동 스크롤

에러가 여러 개일 때는 사용자에게 어디부터 고쳐야 하는지를 알려주는 게 중요하다.

private func scrollToField(_ field: DiaryField) {
    switch field {
        
    case .emotion:
        scrollTo(section: .emotion)
        
    case .situation, .thought, .reeval, .action:
        scrollTo(section: .content)
    }
}

private func scrollTo(section: DiaryEditorSection) {
    let indexPath = IndexPath(item: 0, section: section.rawValue)
    diaryCollectionView.scrollToItem(at: indexPath, at: .top, animated: true)
}

 

- 감정 에러 → 감정 섹션으로 스크롤

- 내용 에러 → 내용 섹션으로 스크롤

 

이 덕분에, 사용자가 “어디가 문제인지 한 번에 눈으로 확인할 수 있게 된다.


8. 입력 시 에러 제거 (UX 마무리)

마지막으로, UX 측면에서 중요한 포인트 하나 더.

 

- 에러가 떠 있는 상태에서

- 사용자가 해당 필드에 다시 입력을 시작하면
에러 문구가 자연스럽게 사라져야 한다.

 

이를 위해:

- DiaryContentView.textChanged → 텍스트 변경 시 ViewController가 currentContentSections를 업데이트

- Emotion 선택 시 → EmotionCell에서 clearError() 호출

 

이렇게 되어 있어서,

사용자가 “상황”을 입력하기 시작하면 상황 에러 라벨이 사라지고,

감정을 선택하면 감정 에러도 바로 없어지는 흐름이 된다.

 

728x90
LIST