웹 개발을 하면서 폼(form) 유효성 검증은 거의 매일 마주치는 작업이다. 많은 개발자들은 HTML5의 required, type="email" 같은 기본 속성에만 의존하거나, 제출 시점에만 검증을 하는 경향이 있다. 하지만 현대적인 UX는 사용자가 입력하는 순간 실시간으로 피드백을 주고, 잘못된 데이터가 서버에 도달하지 않도록 막는 것을 기대한다. 이번에는 JavaScript로 동적 폼 유효성 검증을 어떻게 설계하고, 실제 프로젝트에서 어떻게 적용하는지 완벽하게 정리해서 소개하겠다.
폼 유효성 검증은 크게 3가지로 나뉜다. 첫째, 필드 수준 검증(field-level)은 각 입력 필드마다 즉시 확인하는 방식이고, 둘째, 폼 수준 검증(form-level)은 모든 필드를 종합적으로 확인한 후 제출을 허용하거나 막는 방식이다. 셋째, 서버 수준 검증(server-side)은 클라이언트에서 무조건 한 번 더 검증해야 한다는 철칙이다. 대부분의 개발자들은 클라이언트 검증만으로 충분하다고 생각하지만, 악의적인 사용자는 브라우저 개발자 도구로 JavaScript를 비활성화하거나 직접 API를 호출할 수 있다.
HTML5의 required, type="email", pattern 같은 속성들은 브라우저의 내장 검증만 제공한다. 이는 네이티브하고 빠르지만, 사용자 경험 제어가 제한적이다. 예를 들어, 이메일 형식이 잘못되면 브라우저는 기본 알림을 띄우는데, 이를 커스텀할 수 없다. 또한 비동기 검증(예: 이메일 중복 확인)은 불가능하다. 따라서 JavaScript로 정교한 검증 로직을 직접 구현해야 한다.
입력 필드에 input, change, blur 이벤트를 리스닝해서 사용자가 입력하는 순간 검증 결과를 피드백하는 방식이다. 이렇게 하면 사용자는 제출 전에 이미 문제를 알 수 있다.
const validationRules = {
username: {
validate: (value) => value.length >= 3 && value.length <= 20,
message: '사용자명은 3자 이상 20자 이하여야 합니다'
},
email: {
validate: (value) => /^[^s@]+@[^s@]+.[^s@]+$/.test(value),
message: '올바른 이메일 주소를 입력하세요'
},
password: {
validate: (value) => /^(?=.*[a-z])(?=.*[A-Z])(?=.*d)(?=.*[@$!%*?&])[A-Za-zd@$!%*?&]{8,}$/.test(value),
message: '비밀번호는 최소 8자, 대문자, 소문자, 숫자, 특수문자를 포함해야 합니다'
},
phone: {
validate: (value) => /^01[0-9]-d{3,4}-d{4}$/.test(value),
message: '휴대폰 번호 형식: 010-1234-5678'
}
};
function validateField(fieldName, value) {
const rule = validationRules[fieldName];
if (!rule) return null;
const isValid = rule.validate(value);
return {
isValid: isValid,
message: isValid ? '' : rule.message
};
}
function setupFieldValidation(form) {
const fields = form.querySelectorAll('[data-validate]');
fields.forEach(field => {
const fieldName = field.name;
const errorDisplay = form.querySelector(`[data-error="${fieldName}"]`);
// 실시간 검증 (입력할 때마다)
field.addEventListener('input', function() {
const result = validateField(fieldName, this.value);
if (result) {
if (result.isValid) {
field.classList.remove('is-invalid');
field.classList.add('is-valid');
if (errorDisplay) errorDisplay.textContent = '';
} else {
field.classList.add('is-invalid');
field.classList.remove('is-valid');
if (errorDisplay) errorDisplay.textContent = result.message;
}
}
});
// 포커스를 잃었을 때 한 번 더 확인
field.addEventListener('blur', function() {
const result = validateField(fieldName, this.value);
if (result && !result.isValid) {
field.classList.add('is-invalid');
if (errorDisplay) errorDisplay.textContent = result.message;
}
});
});
}
// 사용 예제
const form = document.getElementById('myForm');
setupFieldValidation(form);
// 이렇게 하면 안 된다!
function validateEmail(email) {
return email.includes('@'); // 이건 불충분함
}
// 문제: 'a@' 같은 형식도 통과됨
개별 필드 검증이 끝났으면, 사용자가 제출 버튼을 클릭했을 때 전체 폼의 모든 필드를 한 번에 검증하고, 하나라도 실패하면 제출을 막아야 한다.
function validateAllFields(form) {
const fields = form.querySelectorAll('[data-validate]');
let isFormValid = true;
fields.forEach(field => {
const result = validateField(field.name, field.value);
if (result && !result.isValid) {
field.classList.add('is-invalid');
const errorDisplay = form.querySelector(`[data-error="${field.name}"]`);
if (errorDisplay) errorDisplay.textContent = result.message;
isFormValid = false;
} else if (result) {
field.classList.remove('is-invalid');
field.classList.add('is-valid');
}
});
return isFormValid;
}
function setupFormSubmission(form) {
form.addEventListener('submit', function(e) {
e.preventDefault(); // 기본 제출 동작 차단
if (validateAllFields(form)) {
// 모든 검증이 통과했을 때만 실제 전송
console.log('폼이 유효합니다. 서버로 전송합니다.');
// 여기서 fetch나 axios로 서버에 데이터 전송
submitFormToServer(form);
} else {
console.log('폼에 오류가 있습니다. 수정해주세요.');
// 첫 번째 오류 필드에 포커스
const firstInvalid = form.querySelector('.is-invalid');
if (firstInvalid) firstInvalid.focus();
}
});
}
async function submitFormToServer(form) {
const formData = new FormData(form);
const data = Object.fromEntries(formData);
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
alert('회원가입이 완료되었습니다!');
window.location.href = '/login';
} else {
alert('서버 오류가 발생했습니다.');
}
} catch (error) {
console.error('전송 오류:', error);
alert('네트워크 오류가 발생했습니다.');
}
}
// 초기화
const form = document.getElementById('myForm');
setupFieldValidation(form);
setupFormSubmission(form);
이메일이나 사용자명 같은 필드는 서버에 이미 존재하는지 확인해야 할 때가 있다. 이런 경우 비동기 검증이 필요하다. 사용자가 입력을 마친 후 서버에 요청하고, 응답을 받아서 검증 결과를 표시한다.
// Debounce 함수: 과도한 요청 방지
function debounce(func, delay) {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
// 서버에 중복 여부 확인
async function checkEmailExists(email) {
try {
const response = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`);
const data = await response.json();
return data.exists; // true/false
} catch (error) {
console.error('중복 확인 오류:', error);
return false;
}
}
const asyncValidationRules = {
email: {
sync: (value) => /^[^s@]+@[^s@]+.[^s@]+$/.test(value),
async: async (value) => {
const exists = await checkEmailExists(value);
return !exists; // 존재하지 않으면 유효
},
message: '이미 가입된 이메일입니다',
asyncMessage: '사용 가능한 이메일입니다'
}
};
function setupAsyncFieldValidation(form) {
const emailField = form.querySelector('[name="email"]');
const errorDisplay = form.querySelector('[data-error="email"]');
// Debounce로 감싼 비동기 검증
const debouncedAsyncValidate = debounce(async function(value) {
if (!value) return;
const rule = asyncValidationRules.email;
// 먼저 기본 형식 검증
if (!rule.sync(value)) {
emailField.classList.add('is-invalid');
errorDisplay.textContent = '올바른 이메일 주소를 입력하세요';
return;
}
// 로딩 상태 표시
emailField.classList.add('is-checking');
errorDisplay.textContent = '확인 중...';
// 비동기 검증 수행
const isAvailable = await rule.async(value);
emailField.classList.remove('is-checking');
if (isAvailable) {
emailField.classList.remove('is-invalid');
emailField.classList.add('is-valid');
errorDisplay.textContent = rule.asyncMessage;
} else {
emailField.classList.add('is-invalid');
errorDisplay.textContent = rule.message;
}
}, 600); // 600ms 딜레이
emailField.addEventListener('input', function() {
debouncedAsyncValidate(this.value);
});
}
const form = document.getElementById('myForm');
setupAsyncFieldValidation(form);
✗ 잘못된 생각: "JavaScript로 검증했으니 서버에서는 생략해도 된다"
✓ 올바른 방식: 서버에서도 반드시 동일한 검증을 다시 수행하자
// 클라이언트
if (validateEmail(email)) {
// 서버로 전송
}
// 서버 (PHP 예시)
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['error' => '유효하지 않은 이메일입니다'];
}
✗ 잘못된 것: RFC 5322를 완벽히 따르는 정규식(매우 복잡함)
✓ 올바른 것: 실무에서 충분한 수준의 단순한 정규식
// 과도함
const complexEmail = /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[x01-x08x0bx0cx0e-x1fx21x23-x5bx5d-x7f]|\[x01-x09x0bx0cx0e-x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])).){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[x01-x08x0bx0cx0e-x1fx21-x5ax53-x7f]|\[x01-x09x0bx0cx0e-x7f])+)])$/i;
// 충분함
const simpleEmail = /^[^s@]+@[^s@]+.[^s@]+$/;
사용자가 빠르게 입력을 수정하면 여러 개의 비동기 요청이 동시에 발생할 수 있다. debounce를 반드시 사용하거나, 최신 요청의 응답만 처리하도록 해야 한다.
let latestRequestId = 0;
async function checkWithRaceConditionPrevention(email) {
const requestId = ++latestRequestId; // 요청 ID 증가
const response = await fetch(`/api/check-email?email=${encodeURIComponent(email)}`);
// 더 최신의 요청이 있으면 무시
if (requestId !== latestRequestId) return;
const data = await response.json();
return data.exists;
}
// HTML
<form id="registerForm">
<input type="text" name="username" data-validate placeholder="사용자명" />
<span data-error="username" class="error-message"></span>
<input type="email" name="email" data-validate placeholder="이메일" />
<span data-error="email" class="error-message"></span>
<input type="password" name="password" data-validate placeholder="비밀번호" />
<span data-error="password" class="error-message"></span>
<button type="submit">가입하기</button>
</form>
// CSS
<style>
.error-message {
color: #dc3545;
font-size: 0.875rem;
display: block;
margin-top: 0.25rem;
}
input.is-valid {
border-color: #28a745 !important;
background-color: #f0fff4;
}
input.is-invalid {
border-color: #dc3545 !important;
background-color: #fff5f5;
}
input.is-checking {
border-color: #ffc107 !important;
}
</style>
// JavaScript - 모든 것을 함께
const form = document.getElementById('registerForm');
setupFieldValidation(form);
setupAsyncFieldValidation(form);
setupFormSubmission(form);
JavaScript 동적 폼 유효성 검증은 사용자 경험을 크게 향상시키는 핵심 기능이다. 실시간 피드백, 비동기 검증, 제출 방지라는 3가지 층이 모여서 견고하고 친절한 폼 처리를 만든다는 점을 잊지 말자. 이 글의 코드를 참고해서 다음 프로젝트에 바로 적용하면, 사용자들이 더 쾌적하게 폼을 작성하고, 서버 부하도 줄일 수 있을 것이다.