왜 폼 검증을 브라우저에만 맡기면 안 되는가

회원가입, 견적 문의, 주문서, 관리자 입력 화면처럼 사용자가 값을 제출하는 화면은 겉으로는 단순해 보이지만 운영 장애가 자주 생기는 영역입니다. 필수값 누락, 잘못된 이메일, 휴대폰 번호 형식 오류, 너무 긴 메모, 자동완성 실패, 모바일 키보드 불편함이 모두 이 지점에서 발생합니다. HTML에는 required, minlength, maxlength, pattern, type, inputmode, autocomplete 같은 기본 도구가 이미 들어 있습니다. 이 기능을 잘 쓰면 자바스크립트를 많이 쓰지 않아도 사용자가 잘못 입력한 값을 빠르게 고칠 수 있고, 서버로 불필요한 요청이 넘어가는 양도 줄일 수 있습니다.

다만 브라우저 검증은 사용자 경험을 좋게 만드는 1차 방어선일 뿐 최종 보안 장치가 아닙니다. 개발자 도구, 스크립트, API 클라이언트로 요청을 직접 보내면 브라우저 검증은 쉽게 우회됩니다. 따라서 화면에서는 HTML 검증으로 즉시 피드백을 주고, 서버에서는 같은 규칙을 다시 검증하며, 데이터베이스에는 길이와 null 제약을 별도로 두는 식으로 계층을 나누어야 합니다. 운영에서 안정적인 폼은 한 곳에 모든 책임을 몰아넣지 않습니다.

입력 목적에 맞는 타입과 자동완성부터 정리하기

가장 먼저 해야 할 일은 input type을 정확히 지정하는 것입니다. 이메일에는 type="email", 전화번호에는 type="tel", 숫자에는 상황에 따라 type="number" 또는 type="text"와 inputmode="numeric"을 선택합니다. 금액이나 사업자번호처럼 앞자리 0이 의미 있고 하이픈이 들어갈 수 있는 값은 숫자 계산 대상이 아니므로 type="number"보다 type="text"가 더 안전합니다. 모바일에서 숫자 키패드만 띄우고 싶다면 inputmode를 함께 쓰면 됩니다.

  • 이메일: type="email"과 autocomplete="email"을 사용합니다.
  • 휴대폰: type="tel", inputmode="tel", autocomplete="tel"을 사용합니다.
  • 이름: autocomplete="name"을 지정해 브라우저 자동완성을 돕습니다.
  • 회사명: autocomplete="organization"을 사용하면 기업 고객 폼에서 입력 부담이 줄어듭니다.
  • 우편번호: inputmode="numeric"과 maxlength를 같이 두되 서버에서 자릿수를 다시 확인합니다.

autocomplete는 보안상 꺼야 한다고 오해하는 경우가 많지만, 이름, 이메일, 전화번호, 주소 같은 일반 정보는 정확한 토큰을 주는 편이 사용성에 유리합니다. 반대로 일회성 인증번호, 관리자 임시 토큰, 민감한 내부 값은 autocomplete="one-time-code" 또는 상황에 맞는 정책을 따로 정해야 합니다. 브라우저가 어떤 값을 채울지 알 수 없도록 무작정 off를 남발하면 사용자는 매번 같은 정보를 반복 입력하게 되고 이탈률이 올라갑니다.

실전 예제: 문의 폼 기본 구조

다음 예제는 회사 홈페이지의 문의 폼에서 바로 응용할 수 있는 기본 패턴입니다. HTML 기본 검증을 사용하고, 설명 문구는 aria-describedby로 연결하며, 제출 시 자바스크립트에서 reportValidity를 호출해 브라우저 기본 오류 UI를 활용합니다. 실제 서비스에서는 이 규칙과 동일한 내용을 서버 DTO, validator, 데이터베이스 컬럼 길이에 다시 반영해야 합니다.

<form id="contactForm" action="/api/inquiries" method="post" novalidate>
  <label for="name">담당자 이름</label>
  <input id="name" name="name" autocomplete="name" required minlength="2" maxlength="40" />

  <label for="email">이메일</label>
  <input id="email" name="email" type="email" autocomplete="email" required maxlength="120" />

  <label for="phone">연락처</label>
  <input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel"
         pattern="^0[0-9]{1,2}-?[0-9]{3,4}-?[0-9]{4}$" required />
  <p id="phoneHelp">예: 010-1234-5678</p>

  <label for="message">문의 내용</label>
  <textarea id="message" name="message" required minlength="20" maxlength="2000"></textarea>

  <button type="submit">문의 보내기</button>
</form>

<script>
  const form = document.querySelector('#contactForm');

  form.addEventListener('submit', async (event) => {
    event.preventDefault();

    if (!form.reportValidity()) {
      return;
    }

    const data = Object.fromEntries(new FormData(form));
    const response = await fetch(form.action, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });

    if (!response.ok) {
      alert('전송에 실패했습니다. 입력값을 확인한 뒤 다시 시도해 주세요.');
    }
  });
</script>

novalidate를 언제 써야 하는가

예제의 form에는 novalidate가 들어 있습니다. 이것은 검증을 하지 않겠다는 뜻이 아니라 브라우저가 submit 이벤트 전에 자동으로 막는 동작을 끄고, 개발자가 원하는 시점에 reportValidity를 호출하겠다는 의미입니다. 이렇게 하면 로딩 상태, 중복 제출 방지, 서버 오류 표시, 분석 이벤트 기록을 한 흐름에서 제어하기 쉽습니다. 단순 정적 폼이라면 novalidate 없이 브라우저 기본 동작만 써도 충분하지만, 비동기 제출이 들어가는 서비스 폼에서는 명시적으로 흐름을 잡는 편이 운영에 유리합니다.

중요한 점은 checkValidity와 reportValidity의 차이입니다. checkValidity는 유효한지 여부만 boolean으로 반환하고, reportValidity는 사용자에게 브라우저 기본 오류 메시지를 표시합니다. 커스텀 오류 메시지가 필요하다면 setCustomValidity를 사용할 수 있지만, 값을 수정한 뒤에는 반드시 빈 문자열로 되돌려야 합니다. 그렇지 않으면 사용자가 올바른 값을 입력해도 이전 오류가 계속 남아 제출이 막힐 수 있습니다.

pattern을 과하게 쓰지 않기

pattern은 강력하지만 남용하면 유지보수가 어려워집니다. 이메일은 type="email"에 맡기고, 휴대폰이나 우편번호처럼 서비스 정책상 형식이 분명한 값에만 제한적으로 쓰는 편이 좋습니다. 특히 이름, 회사명, 주소처럼 국가와 언어에 따라 형태가 다양한 값은 정규식으로 너무 좁게 막지 않는 것이 안전합니다. 한글, 영문, 숫자만 허용한다는 규칙은 겉으로는 깔끔해 보이지만 실제 고객의 법인명, 괄호, 가운데점, 하이픈, 외국인 이름을 막아 문의를 잃을 수 있습니다.

길이 제한도 화면과 서버가 같은 기준을 가져야 합니다. textarea maxlength가 2000인데 서버 컬럼이 1000자라면 사용자는 화면에서 정상 제출했다고 느끼지만 저장 단계에서 오류를 만납니다. 반대로 서버는 2000자를 받는데 화면이 500자로 막으면 필요한 정보를 받을 수 없습니다. 폼을 만들 때는 입력 필드, API 스키마, 데이터베이스 컬럼, 관리자 목록 표시 폭을 함께 확인해야 합니다.

접근성과 오류 표시

검증 메시지는 색상만으로 전달하지 않아야 합니다. 빨간 테두리는 보조 신호로만 사용하고, 오류 문구는 텍스트로 노출해야 합니다. 오류 문구는 해당 입력과 aria-describedby로 연결하고, 제출 실패 후에는 첫 번째 오류 필드로 포커스를 이동시키면 키보드 사용자도 문제를 빠르게 찾을 수 있습니다. 브라우저 기본 reportValidity는 접근성 처리가 어느 정도 되어 있으므로, 특별한 디자인 요구가 없다면 먼저 기본 기능을 쓰고 부족한 부분만 보완하는 접근이 좋습니다.

  • label의 for와 input의 id를 반드시 맞춥니다.
  • placeholder를 label 대신 쓰지 않습니다.
  • 필수 항목은 required와 시각적 표시를 함께 둡니다.
  • 오류 메시지는 입력칸 바로 가까이에 배치합니다.
  • 서버 오류도 사용자가 수정할 수 있는 문장으로 바꿔 보여줍니다.

운영 체크리스트

HTML 폼 검증은 작은 속성 몇 개로 끝나는 작업이 아니라 입력 데이터의 전체 생애주기를 정리하는 일입니다. 화면에서는 타입, 자동완성, 필수값, 길이, 패턴을 정하고, 서버에서는 같은 규칙을 신뢰 가능한 검증으로 반복하며, 저장소에서는 null과 길이 제약으로 마지막 방어선을 둡니다. 배포 전에는 모바일 브라우저에서 키보드가 의도대로 뜨는지, 자동완성이 불편하게 동작하지 않는지, 잘못된 값과 너무 긴 값이 서버에서 어떻게 응답되는지 확인해야 합니다.

마지막으로 폼별로 입력 규칙 문서를 남겨 두면 유지보수가 쉬워집니다. 필드명, 목적, 최대 길이, 허용 형식, 서버 검증 위치, 데이터베이스 컬럼을 한 표로 관리하면 화면 개편이나 API 변경 때 누락을 줄일 수 있습니다. 좋은 폼 검증의 기준은 사용자를 많이 혼내는 것이 아니라, 올바른 값을 가장 적은 마찰로 제출하게 만드는 것입니다.