Vuelidate로 Vue 폼 검증하기: 내장·커스텀 검증기, 비동기 검증, 에러 메시지, 중첩 객체
이 글의 핵심
폼 검증을 컴포넌트마다 if 문으로 작성하면 규칙이 흩어지고 에러를 보여 주는 방식도 제각각이 됩니다. Vuelidate는 규칙을 폼 상태 옆에 선언하고 $error·$errors로 필드별 결과를 꺼내 쓰게 해 줍니다. 비동기 검증을 붙일 때의 주의점과, 실제 폼에 적용할 때 놓치기 쉬운 부분을 구현 체크리스트로 정리했습니다.
이 글의 핵심
Vuelidate로 Vue 폼 검증을 구현하는 글입니다. Built-in Validators, Custom Validators, Async Validation을 예제로 정리했습니다. 본문은 Composition API용 Vuelidate 2(@vuelidate/core) 기준입니다.
실무에서 마주치는 문제들
검증 로직이 중복돼요
if (!email.includes('@')) 같은 검사를 제출 핸들러마다 쓰면, 같은 이메일 규칙이 회원가입·프로필 수정·초대 폼에 조금씩 다르게 복사됩니다. Vuelidate는 검증기를 함수로 만들어 두고 규칙 객체에 조합하는 방식이라, 한 번 만든 strongPassword 같은 규칙을 어느 폼에서든 가져다 쓸 수 있습니다.
에러 메시지가 일관적이지 않아요
검증 결과를 직접 관리하면 에러 상태를 담는 변수 이름, 에러를 지우는 시점, 메시지 문구가 폼마다 달라집니다. Vuelidate는 모든 필드에 $error, $errors, $pending, $dirty 같은 같은 모양의 상태를 붙여 주므로, 에러 표시 컴포넌트 하나를 만들어 모든 폼에서 재사용할 수 있습니다.
비동기 검증이 필요해요
“이미 가입된 이메일인지” 같은 서버 확인은 요청 중 상태, 응답 순서, 실패 처리를 함께 다뤄야 합니다. Vuelidate는 비동기 검증기를 일반 검증기와 같은 자리에 두고, 검증 중에는 $pending으로 상태를 알려줍니다.
Vuelidate의 특징은 폼 데이터와 검증 규칙을 분리한다는 점입니다. 데이터는 평범한 reactive 객체로 두고, 규칙은 같은 모양의 객체로 따로 선언합니다. 입력 컴포넌트를 라이브러리 전용 컴포넌트로 바꿀 필요가 없어 기존 폼에 붙이기 쉬운 반면, 필드 등록이나 입력 컴포넌트 연동 기능은 없어서 에러 표시 UI는 직접 만들어야 합니다. 폼 상태까지 관리해 주는 도구가 필요하다면 VeeValidate 같은 대안도 함께 비교해 볼 만합니다.
Vuelidate가 폼 검증을 다루는 방식
Vuelidate는 Vue 폼 검증 라이브러리입니다. 주요 장점:
- Model 기반: 반응형 검증
- Composition API: Vue 3 친화적
- TypeScript: 완벽한 지원
- Async Validation: 비동기 검증
- 커스터마이징: 자유로운 확장
설치와 useVuelidate 기본 검증
설치
npm install @vuelidate/core @vuelidate/validators
기본 검증
<script setup lang="ts">
import { reactive, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, email, minLength } from '@vuelidate/validators';
const state = reactive({
email: '',
password: '',
});
const rules = computed(() => ({
email: { required, email },
password: { required, minLength: minLength(8) },
}));
const v$ = useVuelidate(rules, state);
const handleSubmit = async () => {
const isValid = await v$.value.$validate();
if (!isValid) {
return;
}
console.log('Valid data:', state);
};
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="state.email" />
<span v-if="v$.email.$error">
{{ v$.email.$errors[0].$message }}
</span>
<input v-model="state.password" type="password" />
<span v-if="v$.password.$error">
{{ v$.password.$errors[0].$message }}
</span>
<button type="submit">Submit</button>
</form>
</template>
useVuelidate(rules, state)는 규칙과 상태를 받아 v$라는 검증 객체를 만듭니다. v$는 state와 같은 모양으로 필드마다 결과를 가지고 있고, 상태가 바뀌면 반응형으로 다시 계산됩니다. rules를 computed로 감싼 이유는 규칙 안에서 다른 상태 값을 참조할 때(예: 비밀번호 확인) 그 값이 바뀌면 규칙도 다시 만들어지게 하기 위해서입니다. 다른 상태를 참조하지 않는 규칙이라면 일반 객체로 둬도 됩니다.
$error와 $invalid의 차이를 알아야 에러가 뜨는 시점을 제어할 수 있습니다. $invalid는 규칙을 통과하지 못했는지만 나타내므로 빈 폼에서는 처음부터 true입니다. $error는 $invalid이면서 $dirty(사용자가 건드렸거나 검증이 요청됨)일 때만 true라서, 페이지를 열자마자 빨간 에러가 가득 뜨는 것을 막아 줍니다. $validate()는 모든 필드를 $dirty로 만들고 비동기 검증까지 기다린 뒤 결과를 돌려주므로, 제출 버튼을 누르면 그때 모든 에러가 한 번에 나타납니다. 입력하는 즉시 검증하고 싶다면 v-model 대신 @blur="v$.email.$touch()"처럼 원하는 시점에 $touch()를 호출합니다. useVuelidate(rules, state, { $autoDirty: true }) 옵션을 쓰면 값이 바뀌는 순간 자동으로 $dirty가 됩니다.
required·email·minLength 같은 내장 validator
import {
required,
email,
minLength,
maxLength,
minValue,
maxValue,
between,
alpha,
alphaNum,
numeric,
url,
sameAs,
} from '@vuelidate/validators';
const rules = {
email: { required, email },
password: { required, minLength: minLength(8) },
age: { required, between: between(18, 120) },
username: { required, alphaNum, minLength: minLength(3) },
website: { url },
confirmPassword: { required, sameAs: sameAs(computed(() => state.password)) },
};
내장 검증기는 두 종류입니다. required, email처럼 그대로 쓰는 것과, minLength(8), between(18, 120)처럼 인자를 받아 검증기를 만들어 주는 함수입니다. 후자를 minLength처럼 괄호 없이 넣으면 검증기가 아니라 검증기를 만드는 함수가 규칙으로 들어가 이상하게 동작하므로 주의합니다.
대부분의 내장 검증기는 빈 값을 통과시킨다는 점이 처음 쓸 때 헷갈리는 부분입니다. email이나 url만 걸어 두면 빈 문자열은 유효한 것으로 처리되어, “선택 입력이지만 입력한다면 형식이 맞아야 한다”는 규칙이 됩니다. 필수 입력이라면 required를 함께 써야 합니다. between과 minValue는 숫자 비교인데 <input>의 값은 문자열이므로, 숫자 필드라면 v-model.number로 받아야 기대대로 비교됩니다.
sameAs는 비교 대상을 반응형 값으로 받아야 합니다. 여기서는 computed(() => state.password)로 넘겼는데, 이 rules가 일반 객체라서 state.password를 그냥 넘기면 규칙을 만드는 순간의 값(빈 문자열)에 고정되어 비밀번호를 입력해도 비교 대상이 바뀌지 않습니다. 아래 회원가입 폼 예제처럼 rules 전체를 computed로 감싸면 sameAs(state.password)로 써도 규칙이 매번 새로 만들어지므로 동작합니다.
커스텀 validator 작성
import { helpers } from '@vuelidate/validators';
const strongPassword = helpers.withMessage(
'Password must contain uppercase, lowercase, and number',
(value: string) => {
return /[A-Z]/.test(value) && /[a-z]/.test(value) && /[0-9]/.test(value);
}
);
const rules = {
password: { required, strongPassword },
};
커스텀 검증기는 값을 받아 true나 false를 돌려주는 평범한 함수이고, helpers.withMessage로 감싸면 에러 메시지가 붙습니다. 메시지 없이 함수만 넣어도 동작하지만 $message가 비어 있어 화면에 아무것도 표시되지 않습니다. 여러 폼에서 쓰는 검증기는 validators.ts 같은 파일로 모아 두면 앞에서 말한 중복 문제가 해결됩니다.
이 검증기는 빈 문자열에서 false를 돌려주므로, 내장 검증기와 달리 빈 값을 통과시키지 않습니다. required와 함께 쓰면 빈 입력에서 에러가 두 개(required, strongPassword) 생기고, $errors[0]이 어느 쪽이 될지는 규칙 순서에 따릅니다. 내장 검증기와 동작을 맞추려면 (value) => !helpers.req(value) || /[A-Z]/.test(value) && ...처럼 helpers.req로 “값이 있을 때만 검사”하게 만드는 것이 관례입니다. 메시지에 조건값을 넣고 싶다면 helpers.withParams({ min: 8 }, fn)으로 매개변수를 붙이고, 메시지 함수에서 $params.min으로 꺼내 쓸 수 있습니다.
비동기 검증 (중복 확인 등)
import { helpers } from '@vuelidate/validators';
const uniqueEmail = helpers.withAsync(async (value: string) => {
if (!value) return true;
const response = await fetch(`/api/check-email?email=${value}`);
const { exists } = await response.json();
return !exists;
});
const rules = {
email: {
required,
email,
uniqueEmail: helpers.withMessage('Email already taken', uniqueEmail),
},
};
helpers.withAsync로 감싸야 Vuelidate가 이 검증기를 비동기로 인식하고 Promise를 기다립니다. 감싸지 않고 async 함수를 그대로 넣으면 반환된 Promise 객체 자체가 참 같은 값으로 취급되어 항상 통과합니다. 에러 없이 조용히 검증이 무력화되는 경우라 반드시 기억해 둘 부분입니다. 검증이 진행 중일 때는 v$.email.$pending이 true가 되므로 “확인 중…” 표시를 띄우거나 제출 버튼을 잠글 수 있습니다.
실무에서 이 코드를 그대로 쓰기 전에 고칠 점이 몇 가지 있습니다. URL에 이메일을 그대로 넣으면 +나 &가 포함된 주소에서 쿼리가 깨지므로 encodeURIComponent(value)로 인코딩해야 합니다. 서버가 500을 돌려주거나 네트워크가 끊기면 response.json()에서 예외가 나서 검증 자체가 실패하므로, 에러를 잡아 “확인할 수 없음”을 어떻게 처리할지 정해야 합니다. 그리고 검증 결과는 반응형으로 계산되므로 기본 설정에서는 값이 바뀔 때마다 요청이 나가, 타이핑 중 글자마다 서버를 호출하게 됩니다. useVuelidate(rules, state, { $lazy: true })로 필드가 $dirty가 된 뒤에만 검증하게 하거나, 디바운스를 적용하거나, 형식 검증(email)을 통과한 뒤에만 요청하게 만드는 것이 좋습니다. 중복 확인은 결국 가입 요청 시점에 서버가 다시 검사해야 하므로, 이 검증은 사용자 편의를 위한 1차 안내라고 생각하는 편이 맞습니다.
에러 메시지 커스터마이징과 다국어
커스텀 메시지
import { required, email } from '@vuelidate/validators';
import { helpers } from '@vuelidate/validators';
const rules = {
email: {
required: helpers.withMessage('이메일을 입력하세요', required),
email: helpers.withMessage('유효한 이메일을 입력하세요', email),
},
};
내장 검증기의 기본 메시지는 영어(Value is required)라서 한국어 서비스라면 대부분 이렇게 덮어써야 합니다. 메시지 자리에는 문자열 대신 함수를 넣을 수도 있어서 helpers.withMessage(({ $params }) => `${$params.min}자 이상 입력하세요`, minLength(8))처럼 검증기의 매개변수를 메시지에 반영할 수 있습니다. 같은 메시지를 폼마다 다시 쓰지 않으려면, 한국어 메시지를 붙인 검증기 모음을 한 파일에서 export해 두는 것이 가장 간단한 방법입니다.
다국어
import { createI18nMessage, required, email } from '@vuelidate/validators';
import { i18n } from './i18n'; // vue-i18n 인스턴스
// vue-i18n 메시지 예: ko.validations.required = '{property}을(를) 입력하세요'
const withI18nMessage = createI18nMessage({ t: i18n.global.t.bind(i18n) });
const rules = {
email: {
required: withI18nMessage(required),
email: withI18nMessage(email),
},
};
Vuelidate는 번역 기능을 직접 갖고 있지 않고, createI18nMessage에 vue-i18n 같은 번역 라이브러리의 t 함수를 넘겨 연결합니다. 이렇게 만든 withI18nMessage로 검증기를 감싸면, 에러가 날 때 validations.required 같은 키로 번역 문구를 찾고 {property}(필드 이름)나 검증기 매개변수를 끼워 넣습니다. 메시지 키 경로는 messagePath 옵션으로 바꿀 수 있습니다. 언어를 바꾸면 메시지도 반응형으로 다시 계산되므로, 사용자가 언어를 전환해도 이미 표시된 에러 문구가 새 언어로 바뀝니다. {property}에는 기본적으로 email 같은 필드 키가 들어가 사용자에게 어색하게 보이므로, 필드 이름도 번역 키로 관리하는 것이 좋습니다.
중첩 객체 검증
<script setup lang="ts">
import { reactive } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, email } from '@vuelidate/validators';
const state = reactive({
user: {
name: '',
email: '',
},
address: {
street: '',
city: '',
},
});
const rules = {
user: {
name: { required },
email: { required, email },
},
address: {
street: { required },
city: { required },
},
};
const v$ = useVuelidate(rules, state);
</script>
<template>
<input v-model="state.user.name" />
<span v-if="v$.user.name.$error">{{ v$.user.name.$errors[0].$message }}</span>
<input v-model="state.user.email" />
<span v-if="v$.user.email.$error">{{ v$.user.email.$errors[0].$message }}</span>
</template>
규칙 객체를 상태와 같은 모양으로 중첩하면 v$.user.name처럼 같은 경로로 결과에 접근할 수 있습니다. 상위 객체 v$.user도 자체 $invalid와 $errors를 가지며 하위 필드의 결과를 모두 합친 값이라, “개인 정보” 섹션 전체가 유효한지 확인해 단계별 폼의 다음 단계 버튼을 제어하는 데 쓸 수 있습니다.
폼이 커져서 섹션별로 자식 컴포넌트로 나눌 때는 방식이 달라집니다. 자식 컴포넌트에서 각자 useVuelidate(childRules, childState)를 호출하면 그 결과가 가장 가까운 부모의 v$에 자동으로 수집되어, 부모가 v$.value.$validate()를 호출하면 자식들의 검증까지 함께 실행됩니다. 편리하지만 의도하지 않은 수집이 생기기도 합니다. 같은 부모 아래 있는 별개의 폼 컴포넌트(예: 모달 안의 다른 폼)까지 모여 부모 폼의 제출이 막히는 경우가 있어, 그럴 때는 $scope 옵션으로 수집 범위를 나누거나 $stopPropagation: true로 전파를 막아야 합니다. 배열 필드(주소 여러 개 등)는 규칙 객체로 표현하기 어려워 helpers.forEach를 쓰거나 항목마다 자식 컴포넌트로 나누는 것이 일반적입니다.
예제: 회원가입 폼
<script setup lang="ts">
import { reactive, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, email, minLength, sameAs, helpers } from '@vuelidate/validators';
const state = reactive({
email: '',
password: '',
confirmPassword: '',
name: '',
agreeToTerms: false,
});
const strongPassword = helpers.withMessage(
'Password must contain uppercase, lowercase, and number',
(value: string) => /[A-Z]/.test(value) && /[a-z]/.test(value) && /[0-9]/.test(value)
);
const rules = computed(() => ({
email: { required, email },
password: { required, minLength: minLength(8), strongPassword },
confirmPassword: { required, sameAs: sameAs(state.password) },
name: { required, minLength: minLength(2) },
agreeToTerms: {
checked: helpers.withMessage('You must agree to terms', (value: boolean) => value === true),
},
}));
const v$ = useVuelidate(rules, state);
const handleSubmit = async () => {
const isValid = await v$.value.$validate();
if (!isValid) {
return;
}
console.log('Submitting:', state);
};
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<input v-model="state.email" placeholder="Email" />
<span v-if="v$.email.$error" class="error">
{{ v$.email.$errors[0].$message }}
</span>
</div>
<div>
<input v-model="state.password" type="password" placeholder="Password" />
<span v-if="v$.password.$error" class="error">
{{ v$.password.$errors[0].$message }}
</span>
</div>
<div>
<input v-model="state.confirmPassword" type="password" placeholder="Confirm Password" />
<span v-if="v$.confirmPassword.$error" class="error">
{{ v$.confirmPassword.$errors[0].$message }}
</span>
</div>
<div>
<input v-model="state.name" placeholder="Name" />
<span v-if="v$.name.$error" class="error">
{{ v$.name.$errors[0].$message }}
</span>
</div>
<div>
<label>
<input v-model="state.agreeToTerms" type="checkbox" />
I agree to terms
</label>
<span v-if="v$.agreeToTerms.$error" class="error">
{{ v$.agreeToTerms.$errors[0].$message }}
</span>
</div>
<button type="submit" :disabled="v$.$invalid">Sign Up</button>
</form>
</template>
앞의 내용이 모두 들어간 예제입니다. rules를 computed로 감쌌기 때문에 sameAs(state.password)가 비밀번호가 바뀔 때마다 새 값으로 다시 만들어지고, 체크박스는 value === true를 확인하는 커스텀 검증기로 약관 동의를 강제합니다. 체크박스에 required를 쓰지 않은 이유는 required가 false를 “값이 있음”으로 보기 때문에 체크하지 않아도 통과하기 때문입니다. 불리언 필드에 required를 걸었다가 동의 없이 가입되는 버그가 생기는 경우가 꽤 흔합니다.
마지막 줄의 :disabled="v$.$invalid"는 UX 측면에서 다시 생각해 볼 부분입니다. $invalid는 빈 폼에서 처음부터 true이므로 버튼이 계속 비활성화되어 있고, 사용자는 제출을 눌러 볼 수 없으니 $validate()가 호출되지 않아 무엇이 틀렸는지 에러 메시지도 볼 수 없습니다. 버튼 비활성화만 보고 원인을 찾아 헤매게 되므로, 버튼은 항상 활성화해 두고 제출 시 $validate()로 모든 에러를 보여 주거나, v$.$pending(비동기 검증 중)일 때만 비활성화하는 편이 사용자에게 친절합니다. 또 검증을 통과해도 state에는 confirmPassword와 agreeToTerms가 들어 있으므로, 서버에 보낼 때는 필요한 필드만 골라 보내는 것이 좋습니다. 제출 후 폼을 초기화할 때는 상태를 비우고 v$.value.$reset()을 호출해야 $dirty가 풀려 빈 폼에 에러가 뜨지 않습니다.
Vuelidate 요약
- Vuelidate: Vue 폼 검증
- Model 기반: 반응형 검증
- Composition API: Vue 3 친화적
- Built-in Validators: 다양한 검증
- Custom Validators: 자유로운 확장
- Async Validation: 비동기 검증
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. Vue 2에서도 사용할 수 있나요?
A. Vuelidate 2는 Vue 3와 Vue 2.7(Composition API 내장), 그리고 @vue/composition-api 플러그인을 쓴 Vue 2.6을 지원합니다. Options API의 validations 옵션을 쓰던 Vuelidate 0.x 코드와는 import 경로와 API가 달라 마이그레이션이 필요합니다.
Q. Zod와 함께 사용할 수 있나요?
A. 공식 변환 기능은 없지만, schema.safeParse(value).success를 반환하는 커스텀 검증기를 만들어 연결할 수 있습니다. 다만 규칙이 Zod와 Vuelidate 두 곳에 나뉘면 관리가 어려워지므로, 서버와 스키마를 공유해야 한다면 Zod 기반 검증을 기본으로 하는 도구(예: VeeValidate의 Zod 연동)를 검토하는 것도 방법입니다.
Q. 성능은 어떤가요?
A. 검증 결과가 반응형 computed로 계산되어 값이 바뀐 필드의 규칙만 다시 실행되므로 일반적인 폼에서는 문제가 없습니다. 비동기 검증이 입력마다 실행되는 설정이라면 네트워크 요청 수가 병목이 되기 쉬우니 실행 시점을 조절해야 합니다.
Q. 에러 표시를 공통 컴포넌트로 만들 수 있나요?
A. 모든 필드 결과가 같은 모양이라 <FieldError :field="v$.email" />처럼 필드 검증 객체를 받아 $error일 때 $errors[0].$message를 보여 주는 컴포넌트 하나로 모든 폼의 에러 표시를 통일할 수 있습니다.