문제의 시작
이번 프로젝트 문의 폼에는 골프장명, 이름, 연락처 등 다양한 필드가 포함되었습니다. 초기에는 각 필드에 정규표현식을 직접 작성해 관리했는데, 필드 수가 적을 때는 이 방식이 직관적이고 효율적이라 생각했습니다.
하지만 기획이 변경될수록 고민이 깊어졌습니다. 필드가 추가되고 유효성 조건이 복잡해지면서 코드는 점차 비대해졌고, 산발적으로 흩어진 에러 메시지들은 유지보수를 어렵게 만들었습니다. 특히 유효성 검증 결과에 따라 버튼의 활성화 여부를 제어하고 각 필드에 에러 메시지를 실시간으로 노출해야 하는 요구사항이 더해지자, 기존 구조로는 한계가 있음을 느끼고 이제는 진짜 구조적인 개선이 필요한 시점임을 깨달았습니다.
라이브러리 선택
Vue 폼 유효성 검사 라이브러리로 가장 먼저 떠오른 건 vee-validate와 vuelidate였습니다.
이런 라이브러리들은 폼 상태 관리부터 에러 표시까지 모든 걸 다 해주지만, 그만큼 설정해야 할 것도 많고 공부해야 할 분량도 만만치 않았습니다. 우리 프로젝트의 폼이 기획 변경으로 복잡해진 건 사실이지만, 그렇다고 폼 관리 방식 자체를 라이브러리 전용 문법으로 통째로 갈아엎는 건 배보다 배꼽이 더 크다는 생각이 들었습니다.
그래서 고민 끝에 방향을 조금 틀었습니다. 폼의 상태 관리는 익숙한 Vue 방식 그대로 유지하되, 가장 머리 아팠던 '유효성 검사 로직'만 Zod로 떼어내기로 한 거죠. Zod는 꼭 폼이 아니더라도 데이터를 검증하는 데 쓰는 범용 도구라 훨씬 가벼웠습니다. 덕분에 폼 전체를 라이브러리에 의존시키지 않고도, 복잡해진 검증 로직만 깔끔하게 정리할 수 있었습니다.
Zod로 유효성 검사하기
스키마 정의
z.object()로 폼 데이터의 검증 규칙을 한 곳에서 선언합니다. 기존에는 각 필드 옆에 흩어져 있던 검증 조건들이 스키마 하나로 모입니다. 어떤 필드가 어떤 조건을 가지는지 한눈에 볼 수 있어서, 기획이 바뀌었을 때 어디를 수정해야 하는지 바로 찾을 수 있습니다.
import { z } from "zod";
const formSchema = z.object({
clubName: z.string().trim().min(1, "골프장명을 입력해주세요."),
firstName: z.string().trim().min(1, "이름을 입력해주세요."),
lastName: z.string().trim().min(1, "성을 입력해주세요."),
contactNumber: z
.union([z.number(), z.string()])
.refine((value) => value > 0, "연락처를 입력해주세요."),
email: z
.string()
.min(1, "이메일을 입력해주세요.")
.refine(emailCheck, "이메일 형식이 올바르지 않습니다."),
});
contactNumber에서 막혔던 부분
contactNumber 필드에서 예상치 못한 문제가 발생했습니다. input type="number" 니까 당연히 숫자 타입일 거라고 생각했는데, Zod가 타입 오류를 냈습니다.
원인을 찾아보니 HTML 스펙상 input의 value는 언제나 문자열이었습니다. type="number" 는 브라우저가 숫자 전용 UI를 보여줄 뿐, 실제 데이터 타입까지 숫자로 바꿔주지는 않았던 겁니다. Vue의 v-model 역시 .number 수정자를 따로 붙이지 않으면 문자열 그대로 데이터를 전달합니다.
게다가 상황에 따라 초기 데이터가 숫자 타입으로 들어올 때도 있어, 단순히 z.string() 만으로는 대응이 어려웠습니다. 결국 z.union([z.string(), z.number()])를 사용해 두 타입을 모두 허용하고, 실제 값에 대한 검증은 refine()으로 처리하는 방식으로 해결했습니다.
contactNumber: z.union([z.number(), z.string()])
.refine((value) => value > 0, "연락처를 입력해주세요."),
유효성 로직 분리
검증 함수를 따로 만들어서 제출 흐름과 분리했습니다.
const isSubmitted = ref(false);
const errors = ref({});
const validateForm = () => {
const result = formSchema.safeParse(formData);
if (!result.success) {
errors.value = result.error.format();
} else {
errors.value = {};
}
if (!isPrivacyChecked.value) {
errors.value.isPrivacyChecked = "개인정보 동의를 체크해주세요.";
}
return result.success && isPrivacyChecked.value;
};
const submitForm = async () => {
isSubmitted.value = true;
if (!validateForm()) return;
try {
const res = await serviceInquiry(formData);
if (res) router.push("/complete");
} catch (error) {
console.error(error);
}
};
검증 메서드로 parse 대신 safeParse를 선택한 데에는 명확한 이유가 있습니다. 보통 parse는 검증에 실패하면 즉시 예외(Exception)를 던지기 때문에 전체 로직을 try-catch로 감싸야 합니다. 하지만 사용자가 값을 잘못 입력하는 건 프로그램의 오류가 아니라 언제든 일어날 수 있는 정상적인 흐름입니다.
그래서 저는 예외를 발생시키지 않고 결과값만 깔끔하게 반환해 주는 safeParse를 사용했습니다. 성공 여부에 따라 { success, data } 또는 { success, error } 형태의 객체를 돌려받으니, 별도의 예외 처리 없이도 조건문만으로 훨씬 자연스럽게 로직을 짤 수 있었습니다.
에러 메시지를 화면에 뿌려줄 때는 result.error.format()을 사용했는데, Zod의 복잡한 에러 객체를 필드별로 예쁘게 정리된 구조로 바꿔주었습니다. 덕분에 errors.clubName.\_errors[0] 처럼 직관적인 방식으로 각 필드의 첫 번째 에러 메시지에 접근해 화면에 바로 보여줄 수 있었습니다.
<p class="error-msg" v-if="errors.clubName">
{{ errors.clubName?._errors[0] }}
</p>
마지막으로 개인정보 동의 체크박스는 Zod 스키마 외부에 두어 별도로 관리했습니다. 스키마 검증 결과와 이 체크박스의 상태를 최종적으로 합쳐서, 폼 전체의 제출 가능 여부를 결정하는 방식으로 마무리했습니다.
실시간 유효성 검사
실시간 유효성 검사를 테스트하다 보니, 사용자 입장에서 불편할 수 있는 두 가지 상황이 눈에 띄었습니다.
첫째는 페이지에 처음 들어오자마자 아직 아무것도 입력하지 않았는데 빈 칸 에러부터 떠 있는 상황이었습니다. 사용자 입장에서는 시작부터 지적받는 느낌이라 당황스러울 수밖에 없습니다.
둘째는 제출에 실패한 뒤 에러 내용에 맞춰 값을 수정했는데도, 메시지가 즉시 업데이트되지 않고 여전히 오류가 있다고 떠 있는 상황이었습니다.
이 두 가지 문제를 해결하기 위해 isSubmitted라는 상태 값을 활용했습니다. 우선 첫 제출 버튼을 누르기 전까지는 에러 메시지를 노출하지 않도록 막아두었습니다. 그러다 제출을 한 번이라도 시도한 이후부터는 값이 바뀔 때마다 실시간으로 재검증을 수행하게 했습니다. 이렇게 하니 처음엔 깔끔하게 시작하고, 수정할 때는 즉각적인 피드백을 주는 가장 자연스러운 흐름을 만들 수 있었습니다.
watch(
formData,
() => {
if (isSubmitted.value) {
validateForm();
}
},
{ deep: true },
);
마무리
결국 Zod가 하는 일은 각 필드의 값을 정해진 규칙에 따라 검사하고, 실패했을 때 그 이유를 차곡차곡 모아주는 것이었습니다.
원리는 생각보다 단순했지만, 그 과정에서 데이터 검증이 어떤 논리로 흐르는지에 대해 좀 더 이해할 수 있었습니다. 단순히 편리한 도구를 도입하는 것을 넘어 왜 이 도구가 필요했는지와 어떻게 동작하는지에 대해서 오랜 시간 고민해 볼 수 있던 과정이었습니다.