자바스크립트 실전 #4 폼 다루기

#3 fetch와 비동기 UI 에서 데이터를 받아 화면에 반영하는 패턴을 봤습니다. 이번엔 반대 방향으로 — 사용자 입력을 받아 검증하고 서버로 보내는 단계를 다룹니다.

HTML 빌트인 검증부터 #

자바스크립트로 검증을 직접 짜기 전에, HTML 5 빌트인 검증부터 활용하세요.

HTML 빌트인 검증
<form id="signup">
  <input name="email" type="email" required>
  <input name="password" type="password" minlength="8" required>
  <input name="age" type="number" min="14" max="120">
  <input name="username" pattern="[a-z0-9_]{3,20}" required>
  <button type="submit">가입</button>
</form>

이 자체로 — 빈 필드 막기, 이메일 형식 검사, 길이/범위 검사, 정규식 패턴까지 모두 작동합니다. 폼 제출 시 브라우저가 자동으로 검사해서 첫 invalid 필드에 에러 풍선까지 보여줍니다.

빌트인이 다 되는데 자바스크립트로 다시 만드는 건 보통 낭비입니다. 빌트인을 기본으로 두고, 자바스크립트는 보강으로 들어갑니다.

빌트인 검증 활용 — Constraint Validation API #

자바스크립트에서 빌트인 검증을 활용하는 도구.

ValidityState
const input = document.querySelector('input[name="email"]');

input.validity.valid;          // 모두 통과?
input.validity.valueMissing;    // required 위반
input.validity.typeMismatch;    // type=email 형식 위반
input.validity.tooShort;        // minlength 위반
input.validity.patternMismatch; // pattern 위반
// ... 등

input.validationMessage;        // 브라우저가 보여줄 메시지

폼 단위로:

form 단위
form.checkValidity();    // 폼 전체 valid 한가
form.reportValidity();   // 검사하고 invalid 면 풍선 표시

이 API를 쓰면 빌트인 검증을 그대로 활용하면서 — 우리만의 UI 메시지를 출력할 수 있습니다.

커스텀 에러 메시지 #

기본 메시지가 어색하면 (영어/너무 짧음/번역 부족) 우리가 정해줄 수 있습니다.

setCustomValidity
input.addEventListener('input', () => {
  if (input.value && !/^[a-z]+$/.test(input.value)) {
    input.setCustomValidity('소문자만 가능합니다');
  } else {
    input.setCustomValidity('');   // 비워야 valid 로 간주
  }
});

setCustomValidity('빈 문자열이 아닌 메시지')가 있으면 입력은 invalid로 간주됩니다. 검사를 풀려면 빈 문자열로 다시 설정.

검증 + 표시 — 직접 하는 패턴 #

빌트인 풍선이 마음에 안 들면 우리가 직접 출력. 자주 쓰는 모양:

HTML
<form id="signup" novalidate>
  <label>
    이메일
    <input name="email" type="email" required>
    <span class="error" data-for="email"></span>
  </label>

  <label>
    비밀번호
    <input name="password" type="password" minlength="8" required>
    <span class="error" data-for="password"></span>
  </label>

  <button type="submit">가입</button>
</form>

novalidate 어트리뷰트가 핵심 — 브라우저 자동 검증을 끕니다. 우리가 모두 처리합니다.

검증 + 표시
function showError(name, message) {
  form.querySelector(`[data-for="${name}"]`).textContent = message;
}

function clearErrors() {
  form.querySelectorAll('.error').forEach((el) => (el.textContent = ''));
}

form.addEventListener('submit', (e) => {
  e.preventDefault();
  clearErrors();

  let hasError = false;
  for (const field of form.elements) {
    if (!field.name) continue;
    if (!field.checkValidity()) {
      showError(field.name, field.validationMessage);
      hasError = true;
    }
  }

  if (hasError) return;

  const formData = new FormData(form);
  submit(Object.fromEntries(formData));
});

빌트인 검증을 자바스크립트가 활용하면서, 메시지는 우리 마크업에 출력. 이 방식이 가장 흔하고 견고합니다.

FormData — 폼 입력을 한 번에 모으기 #

리액트 시리즈 에서 본 도구가 바닐라에서도 똑같이 동작합니다.

FormData
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  if (!form.checkValidity()) return form.reportValidity();

  const formData = new FormData(form);

  // 단일 필드
  formData.get('email');           // 'me@example.com'

  // 모든 키-값
  for (const [key, value] of formData) {
    console.log(key, value);
  }

  // 객체로 변환
  const data = Object.fromEntries(formData);
  // { email: '...', password: '...', ... }

  await fetch('/api/signup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
});

new FormData(form)에 폼을 통째로 넘기면 모든 입력을 자동 수집합니다. name 속성이 있는 필드만 포함됩니다.

FormData 그대로 보내기 — multipart #

JSON으로 변환하지 않고 FormData를 그대로 fetch 본문으로 보낼 수도 있습니다. 파일 업로드가 있을 때 이쪽이 자연스럽습니다.

multipart 본문
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  await fetch('/api/upload', {
    method: 'POST',
    body: new FormData(form),
    // Content-Type 직접 적지 말 것 — 브라우저가 boundary 포함 자동 설정
  });
});

중급 #6 fetch API 에서 본 패턴.

체크박스 / 다중 선택 다루기 #

여러 값 입력
<input type="checkbox" name="tags" value="js">
<input type="checkbox" name="tags" value="ts">
<input type="checkbox" name="tags" value="react">

<select name="category" multiple>
  <option value="news">뉴스</option>
  <option value="tip"></option>
</select>
여러 값 받기
const formData = new FormData(form);

formData.get('tags');         // 첫 번째 값만 ('js')
formData.getAll('tags');      // ['js', 'ts'] — 선택된 모두
formData.getAll('category');  // 다중 선택의 모든 값

get은 첫 값, getAll은 배열로 모두. 체크박스 그룹은 항상 getAll을 써야 안전합니다.

Object.fromEntries(formData)는 같은 키 여러 값이 있으면 마지막만 남으니, 다중 값이 있는 폼에서는 직접 처리해야 합니다.

다중 값 객체로 변환
const data = {};
for (const key of new Set(formData.keys())) {
  const all = formData.getAll(key);
  data[key] = all.length > 1 ? all : all[0];
}

자주 쓰는 검증 패턴들 #

1) 두 비밀번호 일치 확인 #

빌트인 검증으로는 못 잡는 경우.

비밀번호 일치
const password = form.elements.password;
const confirm = form.elements.passwordConfirm;

function syncPasswordMatch() {
  if (password.value !== confirm.value) {
    confirm.setCustomValidity('비밀번호가 일치하지 않습니다');
  } else {
    confirm.setCustomValidity('');
  }
}

password.addEventListener('input', syncPasswordMatch);
confirm.addEventListener('input', syncPasswordMatch);

2) 비동기 검증 — 사용자명 중복 확인 #

서버에 물어봐야 알 수 있는 검증.

비동기 검증
const username = form.elements.username;

const checkUsername = debounce(async (value) => {
  if (!value) return;

  const res = await fetch(`/api/check-username?u=${value}`);
  const { available } = await res.json();

  username.setCustomValidity(available ? '' : '이미 사용 중인 이름입니다');
}, 400);

username.addEventListener('input', (e) => checkUsername(e.target.value));

#3 fetch와 비동기 UI 의 디바운스 패턴이 그대로 들어갑니다.

제출 중 — 중복 제출 방지 #

제출 중 disabled
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  if (!form.checkValidity()) return form.reportValidity();

  const submitBtn = form.querySelector('button[type="submit"]');
  submitBtn.disabled = true;

  try {
    const data = Object.fromEntries(new FormData(form));
    await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    showSuccess('가입 완료');
    form.reset();
  } catch (err) {
    showError(err.message);
  } finally {
    submitBtn.disabled = false;
  }
});

finally가 핵심 — 성공/실패 무관하게 버튼을 다시 활성화합니다. 사용자가 같은 폼을 두 번 제출하는 사고를 막습니다.

폼 리셋과 초기값 #

폼 도구
form.reset();              // 모든 필드를 초기값으로
input.defaultValue;        // 초기 value (HTML 에 적힌 값)
input.value;                // 현재 값

reset<input value="...">처럼 HTML에 적힌 초기값으로 되돌려요. 자바스크립트로 동적으로 채운 값은 초기값이 아니에요 — 명시적으로 비워주려면 input.value = ''.

change vs input — 어떤 이벤트를 들을까 #

이벤트 차이
input    매 키 입력 / 매 변화 — 실시간
change   포커스 잃을 때 / select 변경 시 — 변경 완료

실시간 검증은 input. “입력이 끝났을 때” 무거운 작업은 change 또는 디바운스된 input 사용.

blur 시점 검증 #

blur 에 검증
input.addEventListener('blur', () => {
  if (!input.checkValidity()) {
    input.classList.add('invalid');
    showError(input.name, input.validationMessage);
  } else {
    input.classList.remove('invalid');
    showError(input.name, '');
  }
});

타이핑 중에는 빨간 줄을 안 보여주고, 포커스가 나갈 때만 검사하는 패턴. 사용자에게 덜 거슬려요.

접근성 — 폼은 특히 중요 #

접근성 좋은 폼
<label for="email">이메일</label>
<input id="email" name="email" type="email" required
       aria-describedby="email-error">
<span id="email-error" class="error" role="alert"></span>

세 가지 핵심:

  1. <label for="..."> 또는 <label> 안에 input을 두기
  2. aria-describedby로 에러 영역과 연결
  3. 에러 영역에 role="alert" 또는 aria-live="polite"로 변경 시 스크린 리더가 읽음

빌트인 메시지는 자동으로 접근성을 가지지만, 우리가 직접 출력하는 에러는 위 셋업이 필요합니다.

마무리 #

이번 글에서 정리한 내용:

  • HTML 빌트인 검증부터 활용 (required, type, pattern, min/max)
  • ValidityState API로 자바스크립트에서 활용
  • setCustomValidity로 커스텀 메시지
  • novalidate + 직접 처리 패턴이 가장 흔함
  • FormData로 폼 입력 한 번에 수집
  • 파일 포함 시 FormData 그대로 보내기 (Content-Type 자동)
  • getAll로 다중 값 (체크박스 그룹, multi select)
  • 비밀번호 일치, 비동기 사용자명 검증 같은 보강 패턴
  • 제출 중 disabled + finally로 복구
  • 접근성 — label, aria-describedby, role="alert"

다음 글(#5 로컬 스토리지와 가벼운 상태 관리)에서는 데이터를 브라우저에 저장하는 도구들과, 라이브러리 없이 화면 상태를 깔끔하게 관리하는 패턴을 다룹니다.

X