폼 검증은 브라우저 경고창으로 끝나지 않는다

회원가입, 견적 요청, 결제 주소처럼 사용자가 데이터를 입력하는 화면에서 검증은 데이터 품질과 보안의 첫 관문이다. 자바스크립트만으로 빈값을 검사하면 화면은 동작해 보이지만, 사용자가 개발자 도구로 요청을 조작하거나 오래된 브라우저·자동화 도구를 사용했을 때 규칙이 쉽게 우회된다. 반대로 서버만 검증하면 사용자는 제출 뒤에야 오류를 알게 되어 입력 경험이 나빠진다. 실무에서는 HTML의 기본 제약 검증으로 빠른 피드백을 제공하고, JavaScript로 오류 표현을 보완하며, 서버에서 같은 규칙을 최종적으로 검증하는 세 층을 함께 둔다.

HTML 제약 검증을 먼저 설계하기

HTML의 required, type, minlength, maxlength, pattern, min, max 속성은 브라우저가 이해하는 선언형 규칙이다. 이 규칙은 submit 시 자동으로 적용되며, CSS의 :invalid와도 연결된다. 이메일 입력칸에는 type=email을, 숫자 범위에는 type=number와 min·max를 사용한다. 전화번호처럼 형식이 정해진 텍스트는 type=tel과 pattern을 조합할 수 있다. pattern은 전체 값이 정규식에 맞아야 하므로 시작과 끝 앵커를 중복해 쓰지 않아도 된다.

<form id="signup-form" action="/api/signup" method="post" novalidate>
  <label for="email">업무용 이메일</label>
  <input id="email" name="email" type="email"
         autocomplete="email" required maxlength="254"
         aria-describedby="email-error">
  <p id="email-error" class="field-error" aria-live="polite"></p>
  <label for="company-size">직원 수</label>
  <input id="company-size" name="companySize" type="number"
         min="1" max="100000" required>
  <button type="submit">등록</button>
</form>

예시에서 novalidate를 쓴 이유는 검증 자체를 끄기 위해서가 아니라, 브라우저마다 다른 기본 말풍선 대신 일관된 오류 UI를 구현하기 위해서다. novalidate를 사용하지 않는 단순한 폼이라면 브라우저 기본 검증도 충분히 유용하다. 다만 novalidate를 넣었다면 JavaScript에서 checkValidity()와 reportValidity()를 호출하거나 invalid 이벤트를 처리해, HTML 속성에 선언한 규칙을 반드시 실행해야 한다.

오류 메시지는 입력칸 가까이에, 포커스는 첫 오류로

색상만 빨갛게 바꾸면 스크린 리더 사용자나 색각 이상 사용자는 오류를 알기 어렵다. 오류 텍스트를 해당 입력칸과 aria-describedby로 연결하고, 제출 후 첫 번째 잘못된 항목으로 포커스를 이동시키면 키보드 사용자도 즉시 수정할 수 있다. aria-live=polite 영역은 오류 내용이 바뀌었음을 보조 기술에 알린다. 오류를 요약하는 영역을 폼 위쪽에 추가할 때도, 각 필드 옆의 구체적인 메시지는 남겨 두는 편이 좋다.

const form = document.querySelector('#signup-form');

form.addEventListener('submit', async (event) => {
  event.preventDefault();
  document.querySelectorAll('.field-error').forEach((el) => el.textContent = '');

  if (!form.checkValidity()) {
    const firstInvalid = form.querySelector(':invalid');
    const error = document.querySelector('#' + firstInvalid.id + '-error');
    if (error) error.textContent = firstInvalid.validationMessage;
    firstInvalid.focus();
    return;
  }

  const response = await fetch(form.action, {
    method: 'POST',
    body: new FormData(form),
    headers: { Accept: 'application/json' }
  });

  if (!response.ok) {
    const body = await response.json().catch(() => ({}));
    console.error(body);
  }
});

validationMessage는 브라우저 언어에 따라 달라진다. 서비스 문구를 통일해야 하면 validity.valueMissing, validity.typeMismatch, validity.patternMismatch 같은 상태를 확인해 직접 한국어 메시지를 매핑한다. 다만 입력할 때마다 큰 오류를 반복해서 읽어 주면 방해가 될 수 있으므로, blur 시점이나 제출 시점에 안내하고 성공 상태는 조용히 처리하는 것이 안정적이다.

클라이언트 검증은 보안 경계가 아니다

HTML 속성, JavaScript, 숨겨진 입력값은 모두 사용자가 변경할 수 있다. 서버는 요청 본문을 신뢰하지 말고 필수 여부, 길이, 형식, 권한, 허용 가능한 값 목록을 다시 검증해야 한다. 데이터베이스 제약 조건도 마지막 안전망으로 유지한다. 예를 들어 이메일 중복은 화면에서 미리 확인하더라도, 서버의 고유 인덱스와 충돌 처리 없이는 동시 요청에서 중복이 생길 수 있다. SQL을 문자열로 이어 붙이지 말고 매개변수 바인딩을 사용해 입력값을 쿼리 구조와 분리한다.

-- PostgreSQL: 애플리케이션 검증과 별개로 저장 규칙을 강제한다.
CREATE TABLE member (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email varchar(254) NOT NULL,
  company_size integer NOT NULL CHECK (company_size BETWEEN 1 AND 100000),
  CONSTRAINT member_email_unique UNIQUE (email)
);

배포 전 점검 체크리스트

  • 각 입력값에 label과 고유한 name을 연결했는지 확인한다.
  • required, 길이, 범위, 형식 규칙을 HTML 속성으로 먼저 선언한다.
  • 오류 메시지가 색상 외의 텍스트로 제공되고 aria-describedby로 연결되는지 확인한다.
  • 제출 실패 시 첫 오류 필드로 포커스가 이동하는지 키보드로 시험한다.
  • JavaScript를 끄거나 요청을 직접 보내도 서버가 동일한 규칙을 검증하는지 확인한다.
  • 데이터베이스의 NOT NULL, CHECK, UNIQUE 제약으로 핵심 불변 조건을 보호한다.

좋은 폼 검증은 사용자를 막는 장치가 아니라 올바른 입력을 빠르게 완성시키는 안내 체계다. HTML 제약 검증을 기준으로 UI와 서버 규칙을 정렬하면 화면별 예외 처리와 운영 오류를 함께 줄일 수 있다.