05.1 검증, 대체 언제 해야 잘한 걸까요?

폼(form, 입력 양식)을 만들 때 검증(validation, 값이 규칙에 맞는지 확인) 자체는 다들 넣어요. 문제는 언제 검증하느냐예요. 같은 검증 규칙이라도 타이밍만 바꾸면 사용자가 느끼는 인상이 확 달라져요.


브라우저에서 검증을 걸 수 있는 타이밍은 크게 세 군데예요. 칸에 한 글자 칠 때마다 도는 input(입력 이벤트), 칸에서 포커스가 빠져나갈 때 도는 blur(포커스 아웃), 그리고 등록 버튼을 누르는 submit(제출)이죠.


이 셋은 성격이 완전히 달라요. input은 즉각적이지만 성가시기 쉽고, submit은 놓친 게 없지만 사용자는 다 채우고 나서야 틀린 걸 알아요. 그 사이 균형을 잡아주는 게 blur고요. 오늘은 이 셋을 어떻게 조합해야 안 짜증나는 폼이 되는지 볼게요.

05.2 매 글자마다 빨간 에러, 정말 별로죠?

제일 흔한 실수부터 짚을게요. 검증을 input에 걸면 사용자가 이메일 칸에 두 글자 쳤을 때 벌써 빨간 글씨로 이메일 형식이 아니에요가 떠요. 아직 다 안 쳤는데 화면은 벌써 틀렸다고 다그치는 거예요.


상황을 그려볼게요. 사용자는 hong까지 쳤을 뿐인데 화면엔 벌써 에러가 떠 있어요.


<input type="email" id="email">
<p id="email-err"></p>

const email = document.querySelector('#email');
const errBox = document.querySelector('#email-err');

// 이렇게 하면 안 돼요
email.addEventListener('input', () => {
const ok = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.value);
errBox.textContent = ok ? '' : '이메일 형식이 아니에요';
});


동작을 보면 h 치는 순간 에러, hong@ 까지 가도 여전히 에러, 마지막에 [email protected] 이 완성돼야 겨우 사라져요. 다 맞게 치는 중인데도 내내 빨간 글씨를 보는 거죠. 실제로 이렇게 매 입력마다 검증하면 blur 검증에 비해 완료율이 9~16%까지 낮게 나온다는 데이터도 있어요. 성가시니까 도중에 나가버리는 거예요.

05.3 그럼 기본은 blur로 잡을까요?

가장 무난한 기본값은 blur 시점 검증이에요. 사용자가 그 칸을 다 채우고 다음 칸으로 넘어가려는 순간, 그때 딱 한 번 봐주는 거죠. 아직 입력 중인 사람을 방해하지 않으면서, 넘어가기 전에 틀린 건 잡아줘요.


상황: 이메일 칸을 채우고 비밀번호 칸으로 마우스나 탭(Tab, 다음 칸 이동 키)을 눌러 넘어가는 순간에만 검증하고 싶어요.


email.addEventListener('blur', () => {
const v = email.value.trim();
const ok = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v);
errBox.textContent = ok ? '' : '이메일 형식이 아니에요';
});


동작을 보면 이제 사용자가 [email protected] 을 다 치는 동안엔 화면이 조용해요. 그러다 칸을 벗어나는 순간에 한 번 검사하죠. 제대로 채웠으면 아무 일도 없고, 비워두거나 형식이 틀렸을 때만 에러가 떠요. 훨씬 덜 다그치는 느낌이죠. value.trim()으로 앞뒤 공백을 걷어낸 뒤 검사한 것도 포인트예요. 실수로 스페이스가 끼어들어간 값을 통과시키면 나중에 골치 아프거든요.

05.4 한 번 틀린 칸은 어떻게 해줘야 친절할까요?

blur만으로도 훌륭한데, 한 가지 아쉬운 게 있어요. 이미 에러가 뜬 칸이에요. 사용자가 지금 고치는 중인데, blur만 걸려 있으면 다 고치고 칸을 또 벗어나야 에러가 사라져요. 고치는 동안엔 계속 빨간 글씨가 남죠.


그래서 실무에서 많이 쓰는 게 하이브리드(hybrid, 두 방식을 섞음)예요. 아직 안 건드린 칸은 조용히 두다가 blur에서 처음 검증하고, 에러가 한 번 뜬 뒤부터는 input으로 전환해 고치는 즉시 에러를 지워줘요. 다그치지도 않고, 고치면 바로 반응해주는 셈이죠.


상황: 칸마다 이 규칙을 붙이고 싶어서 재사용 함수로 뽑았어요.


function attachValidation(input, errBox, validate) {
let touched = false; // 한 번이라도 검증했나
const run = () => {
const msg = validate(input.value);
input.setAttribute('aria-invalid', msg ? 'true' : 'false');
errBox.textContent = msg; // 통과면 빈 문자열
};
input.addEventListener('blur', () => { touched = true; run(); });
input.addEventListener('input', () => { if (touched) run(); });
}


동작을 보면 처음 입력 중일 땐 touchedfalse라 input 리스너가 아무 일도 안 해요. 칸을 벗어나 blur가 한 번 돌면 touchedtrue가 되고, 그다음부터는 고칠 때마다 검증이 돌아서 바로잡는 순간 에러가 사라져요. validate에 칸에 맞는 검사 함수만 넘기면 칸이 몇 개든 이 함수 하나로 다 붙일 수 있어요.

05.5 아이디 중복확인은 왜 디바운스가 필요할까요?

지금까지는 브라우저 혼자 판단하는 검증이었어요. 그런데 아이디 중복확인은 달라요. 이 아이디가 이미 있는지는 브라우저가 알 수 없어요. 서버에 물어봐야 알죠. 그래서 fetch(패치, 서버 요청 함수)로 확인 요청을 보내야 해요.


여기서 순진하게 input마다 요청을 보내면 큰일 나요. honggildong 열한 글자를 치면 글자 수만큼 요청이 날아가요. 열한 번이죠. 서버는 부하를 받고, 화면은 응답이 올 때마다 깜빡여요.


그래서 디바운스(debounce, 입력이 멈출 때까지 기다렸다 한 번만 실행)를 써요. 손을 멈추고 400~600밀리초(millisecond, 1000분의 1초)쯤 지나면 그때 딱 한 번 요청을 보내는 거죠. 여기에 하나 더, 새 입력이 들어오면 이전 요청은 취소해요. 안 그러면 늦게 도착한 낡은 응답이 최신 결과를 덮어쓸 수 있거든요. 취소는 AbortController(어보트 컨트롤러, 요청 취소용 객체)로 해요.


상황: 아이디 칸에 디바운스와 요청 취소를 함께 걸었어요.


function debounceAsync(fn, delay = 500) {
let timer, controller;
return (...args) => {
clearTimeout(timer); // 대기 중이던 예약 취소
if (controller) controller.abort(); // 진행 중이던 요청 취소
timer = setTimeout(() => {
controller = new AbortController();
fn(...args, controller.signal);
}, delay);
};
}

const checkId = debounceAsync(async (name, signal) => {
const res = await fetch('/api/check?u=' + encodeURIComponent(name), { signal });
const data = await res.json();
idErr.textContent = data.available ? '' : '이미 쓰는 아이디예요';
});

idInput.addEventListener('input', () => checkId(idInput.value));


동작을 보면 사용자가 honggildong을 빠르게 쳐도 서버 요청은 손을 멈춘 뒤 딱 한 번만 나가요. 중간에 다시 치면 예약된 요청은 clearTimeout으로 취소되고, 이미 날아간 요청은 controller.abort()로 끊어요. signal을 fetch에 넘겼기 때문에 브라우저가 그 요청을 실제로 중단하죠. 서버 부하도 줄고 결과가 뒤섞일 걱정도 없어요.

05.6 오늘 배운 실시간 검증, 이렇게 정리돼요

타이밍이 UX를 가른다는 게 오늘의 핵심이었어요. 마지막으로 손에 남을 규칙만 짚을게요.


첫째, 매 글자마다 검증하는 input 단독은 피해요. 다 치기도 전에 다그치니까요. 둘째, 기본은 blur로 잡아요. 칸을 벗어날 때 한 번 봐주는 게 가장 무난해요. 셋째, 한 번 에러가 뜬 칸만 input으로 전환해서, 고치는 즉시 에러가 사라지게 해줘요. 넷째, 서버에 물어보는 검증은 디바운스와 요청 취소를 꼭 같이 걸어요.


규칙이 복잡해 보여도 결국 한 문장이에요. 사용자가 입력하는 동안엔 조용히, 넘어갈 땐 확실히, 고칠 땐 즉시 반응하기. 빈 HTML 파일에 이메일 칸 하나 만들어 blur 검증부터 붙여보세요. 한 번 겪어보면 왜 input 단독이 별로인지 몸으로 알게 돼요.