반응형

SwiftUI Study – 폼 입력·검증 로직을 View에서 분리하고 재사용 가능한 Validation 구조로 설계하는 방법

 

1. 왜 중요한가 (문제 배경)

SwiftUI로 로그인 화면, 회원가입, 설정 편집, 카드 등록 등 폼(Form) 화면을 만들다 보면 다음과 같은 문제가 자주 나타납니다.

  • 각 TextField 옆에 에러 문구를 띄우기 위해 View 안에 if 문이 난무
  • 버튼 활성/비활성을 위해 여러 조건을 View에서 직접 계산
  • 비밀번호/이메일 등 여러 화면에서 쓰는 규칙을 매번 재작성
  • 서버 검증(중복 체크 등)까지 섞이면서 View 코드가 복잡해짐
  • 검증 로직을 테스트하기 어려움

핵심 문제는 다음과 같습니다.

“폼 검증은 도메인 규칙 + 상태 관리의 영역인데,

이를 View 안에서 처리하면 구조가 금방 무너진다.”

검증을 별도 계층으로 분리하면 다음을 얻을 수 있습니다.

  • View는 “표현”에만 집중
  • 재사용 가능한 Validation 규칙
  • 테스트 가능한 구조
  • 다른 화면으로의 확장 용이성

 

2. 잘못된 패턴 예시

❌ 예시 1: View 안에서 직접 검증 로직을 모두 처리

struct WrongSignUpView: View {
    @State private var email = ""
    @State private var password = ""
    @State private var confirm = ""

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("이메일", text: $email)
            if !email.contains("@") && !email.isEmpty {   // ❌ View 안에 직접 규칙
                Text("올바른 이메일 형식이 아닙니다.")
                    .foregroundColor(.red)
            }

            SecureField("비밀번호", text: $password)
            if password.count < 8 && !password.isEmpty {  // ❌ 중복 가능성 큰 규칙
                Text("비밀번호는 8자 이상이어야 합니다.")
                    .foregroundColor(.red)
            }

            SecureField("비밀번호 확인", text: $confirm)
            if !confirm.isEmpty && confirm != password {  // ❌ View에 도메인 규칙
                Text("비밀번호가 일치하지 않습니다.")
                    .foregroundColor(.red)
            }

            Button("회원가입") {
                if email.isEmpty || !email.contains("@") || password.count < 8 || confirm != password {
                    // ❌ 다시 if 지옥
                } else {
                    // 요청
                }
            }
            .disabled(email.isEmpty || password.isEmpty || confirm.isEmpty)
        }
        .padding()
    }
}

문제점

  • 검증 규칙이 View에 박혀 있어 재사용 불가
  • 이메일/비밀번호 규칙이 바뀌면 View 파일을 다 수정해야 함
  • 회원가입, 로그인, 프로필 수정 등 여러 화면에 걸쳐 규칙이 분산
  • 테스트하거나 공통 컴포넌트로 빼기 어려움

❌ 예시 2: 서버 검증까지 View에서 모두 처리

Button("닉네임 중복 확인") {
    Task {
        let isDuplicated = await api.checkNickname(name)
        if isDuplicated {
            errorMessage = "이미 사용 중인 닉네임입니다."   // ❌ View에서 의미 해석까지
        } else {
            errorMessage = nil
        }
    }
}

문제점

  • 서버 응답 해석(도메인 규칙)이 View에 들어감
  • 여러 화면에서 닉네임 검증이 필요할 때 코드 중복
  • 비즈니스 규칙이 UI 레이어에 노출

 

3. 올바른 패턴 예시

✅ 예시 1: Validation 규칙을 별도 타입으로 분리

enum ValidationError: LocalizedError, Equatable {
    case empty
    case invalidEmail
    case tooShort(min: Int)
    case notMatch

    var errorDescription: String? {
        switch self {
        case .empty:
            return "값을 입력해 주세요."
        case .invalidEmail:
            return "올바른 이메일 형식이 아닙니다."
        case let .tooShort(min):
            return "최소 \(min)자 이상이어야 합니다."
        case .notMatch:
            return "값이 일치하지 않습니다."
        }
    }
}

struct Validators {
    static func email(_ value: String) -> ValidationError? {
        if value.isEmpty { return .empty }
        guard value.contains("@") else { return .invalidEmail }
        return nil
    }

    static func password(_ value: String, min: Int = 8) -> ValidationError? {
        if value.isEmpty { return .empty }
        guard value.count >= min else { return .tooShort(min: min) }
        return nil
    }
}

장점

  • 이메일/비밀번호 규칙이 한 곳에 모임
  • View가 아닌 Validation 계층에 규칙이 존재
  • 테스트 코드로 규칙을 손쉽게 검증 가능

✅ 예시 2: ViewModel이 “폼 상태 + 에러”를 관리하고 View는 표시만

@MainActor
final class SignUpFormViewModel: ObservableObject {
    @Published var email: String = ""
    @Published var password: String = ""
    @Published var confirm: String = ""

    @Published var emailError: ValidationError?
    @Published var passwordError: ValidationError?
    @Published var confirmError: ValidationError?

    var canSubmit: Bool {
        emailError == nil &&
        passwordError == nil &&
        confirmError == nil &&
        !email.isEmpty && !password.isEmpty && !confirm.isEmpty
    }

    func validateAll() {
        emailError = Validators.email(email)
        passwordError = Validators.password(password)
        confirmError = confirm == password ? nil : .notMatch
    }
}

View:

struct SignUpView: View {
    @StateObject private var vm = SignUpFormViewModel()

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            TextField("이메일", text: $vm.email)
            if let error = vm.emailError {
                Text(error.localizedDescription)
                    .foregroundColor(.red)
            }

            SecureField("비밀번호", text: $vm.password)
            if let error = vm.passwordError {
                Text(error.localizedDescription)
                    .foregroundColor(.red)
            }

            SecureField("비밀번호 확인", text: $vm.confirm)
            if let error = vm.confirmError {
                Text(error.localizedDescription)
                    .foregroundColor(.red)
            }

            Button("회원가입") {
                vm.validateAll()
                if vm.canSubmit {
                    // 실제 회원가입 액션
                }
            }
            .disabled(!vm.canSubmit)
        }
        .padding()
    }
}

장점

  • View는 에러 메시지를 “표시”만 하고, 규칙은 ViewModel/Validator가 관리
  • 다른 화면에서도 같은 ViewModel/Validator 재사용 가능
  • 규칙이 바뀌어도 View는 거의 수정 불필요

✅ 예시 3: 서버 검증(닉네임 중복 등)은 UseCase로 분리

struct NicknameCheckUseCase {
    let api: APIClient

    func validateNickname(_ nickname: String) async -> ValidationError? {
        if nickname.isEmpty { return .empty }
        let duplicated = await api.isDuplicated(nickname: nickname)
        return duplicated ? .tooShort(min: 0) : nil   // 예시: 별도 케이스로 분리 가능
    }
}

ViewModel:

@MainActor
final class NicknameViewModel: ObservableObject {
    @Published var nickname: String = ""
    @Published var error: ValidationError?

    private let useCase: NicknameCheckUseCase

    init(useCase: NicknameCheckUseCase) {
        self.useCase = useCase
    }

    func check() async {
        error = await useCase.validateNickname(nickname)
    }
}

View:

Button("중복 확인") {
    Task { await vm.check() }
}

장점

  • 서버 검증 규칙이 View가 아니라 UseCase에 존재
  • 여러 화면에서 동일 유즈케이스 재사용
  • 네트워크/도메인 규칙을 테스트하기 쉬움

 

4. 실전 적용 팁

✔ 팁 1 – “이 화면에만 쓰는 규칙”이 아니라면 무조건 외부로 분리

이메일, 비밀번호, 닉네임 규칙 등은 Validator/UseCase로 모아두기.

✔ 팁 2 – 에러 타입은 enum + LocalizedError로 설계

View는 오류를 해석하지 말고, localizedDescription만 표시.

✔ 팁 3 – ViewModel은 “값 + 에러 + 제출 가능 여부”를 한 번에 관리

버튼 활성/비활성 조건을 View에서 계산하지 않기.

✔ 팁 4 – 서버 검증이 섞이면 반드시 UseCase 계층을 둔다

UI에서 직접 API를 부르며 의미 판단까지 하면 구조가 금방 무너진다.

✔ 팁 5 – 폼이 복잡해질수록 “View는 얇게, 검증은 밖으로”를 철칙으로 삼기

이 원칙 하나로도 중대형 프로젝트의 유지보수성이 크게 올라간다.

 

5. 정리

  • 폼 검증을 View 안에서 처리하면 if/else 지옥과 규칙 중복, 테스트 어려움이 필연적으로 따라온다.
  • Validation 규칙은 별도 타입(Validator/UseCase)으로 분리하고, ViewModel이 값 + 에러 상태를 관리하는 구조가 가장 안정적이다.
  • View는 그저 “현재 상태를 표현”하고, 버튼 활성·에러 메시지 표시는 ViewModel이 계산한 결과를 사용하는 식으로 유지해야 한다.
  • 이 패턴을 따르면 로그인/회원가입/설정/카드 등록 등 다양한 폼 화면을 일관성 있게 확장할 수 있다.
반응형
Posted by 까칠코더
,