SwiftUI Study – 폼 입력·검증 로직을 View에서 분리하고 재사용 가능한 Validation 구조로 설계하는 방법
Dev Study/SwiftUI 2025. 12. 9. 10:38반응형
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이 계산한 결과를 사용하는 식으로 유지해야 한다.
- 이 패턴을 따르면 로그인/회원가입/설정/카드 등록 등 다양한 폼 화면을 일관성 있게 확장할 수 있다.
반응형

