Skip to content
This page has been auto-translated and may contain errors.View in English

폼 유효성 검사

docs.scrimba.com

은 사람들에게서 입력을 받는데, 사람들은 필드를 빈칸으로 남겨두거나 이메일을 잘못 입력하거나 숫자가 들어가야 할 곳에 글자를 넣기도 합니다. 그런 데이터가 손질 없이 그대로 여러분의 코드에 도달하게 두어서는 안 됩니다. 무언가가 제출되기 전에 브라우저가 그중 많은 부분을 대신 검사해줄 수 있지만, 그러려면 각 필드가 어떤 값을 기대하는지 미리 설명해줘야 합니다. 그 설명은 다름 아닌 HTML 안에 바로 적어 넣는 것이고, 이번 장에서는 그것을 어떻게 작성하는지 다룹니다.

필수 필드와 입력 타입

가장 적은 노력으로 추가할 수 있는 두 가지 검사는, 필드를 반드시 채워야 한다고 지정하는 것과 그 안에 어떤 종류의 값이 들어가야 하는지 지정하는 것입니다.

input에 **required**라는 단어를 추가하면, 그 필드에 무언가 값이 채워지기 전까지 브라우저가 폼 제출을 막아줍니다. 이를 위해 코드를 따로 작성할 필요가 없습니다. 이 속성 자체가 바로 지시문입니다.

html
<input type="text" name="full_name" required>

나머지 절반은 type 속성입니다. 이 속성은 값이 어떤 형태여야 하는지 브라우저에 알려주고, 브라우저는 그것을 대신 검사해줍니다. type="email"을 사용하면 브라우저가 값이 이메일 주소처럼 생겼는지 확인합니다. type="number"를 사용하면 숫자만 받아들입니다. 날짜용 타입도 있고, 웹 주소용 타입도 있고, 그 외에도 여러 가지가 있습니다.

병원 접수 서류를 떠올려 보세요. 어떤 칸에는 "필수 기입"이라고 표시되어 있고, 날짜 칸에는 이미 일, 월, 년을 적어 넣을 작은 칸이 나뉘어 있습니다. 종이가 아무것도 적기 전부터 어디에 무엇을 써야 하는지 미리 알려주는 것입니다.

required는 **불리언 속성**입니다. 존재 자체가 규칙을 켜는 것이고, 별도의 값이 필요 없습니다. required가 붙은 컨트롤은 비어 있는 동안 폼 제출을 막습니다.

type 속성은 두 가지 역할을 합니다. 브라우저가 렌더링할 컨트롤(날짜 선택기, 숫자 스피너 등)을 정하고, 동시에 값에 대한 내장 제약을 설정합니다.

  • type="email"@와 도메인 형태의 꼬리를 요구합니다.
  • type="url"은 절대 웹 주소로 해석되는 값을 요구합니다.
  • type="number"는 숫자 입력만 허용하며 min, max, step을 쓸 수 있게 해줍니다.
  • type="tel"은 값 자체를 제약하지는 않지만, 모바일에서 전화 키패드를 띄워줍니다.

다른 무엇보다 먼저 구체적인 타입을 사용하는 것부터 고려하세요. 가장 적은 코드로 가장 많은 검사를 얻을 수 있고, 터치 기기에서 나타나는 화면 키보드도 더 나아집니다.

requiredtype은 플랫폼이 **제약 검증(constraint validation)**이라고 부르는 것의 첫 두 단계입니다. 이는 마크업에 선언한 규칙에 따라 컨트롤의 값을 검사하는 브라우저의 내장 시스템입니다. required는 "값 누락" 규칙을 설정하고, type은 그 타입에 연결된 형식 규칙을 설정합니다.

type에 관해 실무에서 알아둘 점이 두 가지 있습니다. 첫째, type="email"은 완전한 이메일 명세에 맞춰 검증하지 않습니다. 브라우저는 의도적으로 느슨한 패턴(문자열 다음에 @, 그다음 점으로 구분된 도메인)을 사용하는데, 더 엄격한 검사를 하면 실제로는 전달 가능한 주소를 거부하게 되기 때문입니다. 이를 형태 검사일 뿐 주소가 실제로 존재한다는 증거로 여기지는 마세요. 둘째, 알려지지 않았거나 지원되지 않는 typetype="text"로 대체 처리되므로, 오래된 브라우저에서도 필드가 아예 쓸 수 없게 되지는 않고 추가 검사 기능만 사라집니다. 이런 우아한 대체 동작 덕분에 호환성 표를 일일이 확인하지 않고도 새로운 타입을 도입할 수 있습니다.

또한 타입은 화면 키보드에도 영향을 주며, 관련 속성인 inputmode(검증 방식은 바꾸지 않고 어떤 키보드 배열을 보여줄지 알려주는 힌트)를 통해 그 키보드를 독립적으로 조정할 수 있습니다. 타입을 올바르게 정하는 일은 검증 문제이기도 하지만 접근성과 사용성의 문제이기도 합니다.

두 속성을 함께 사용한 작은 가입 폼을 살펴보겠습니다.

html
<form>
  <label>
    Email address
    <input type="email" name="email" required>
  </label>
  <label>
    Website
    <input type="url" name="website">
  </label>
  <button>Sign up</button>
</form>

이메일 필드는 반드시 채워야 하고 이메일처럼 보여야 합니다. 웹사이트 필드는 선택 사항이지만, 무언가 입력한다면 웹 주소처럼 보여야 합니다. 버튼을 누르는 순간 브라우저가 두 조건을 모두 검사합니다.

Juno필수 필드와 입력 타입 짧은 두 단어가 많은 일을 해냅니다. 필드에 required를 붙이면 브라우저가 빈칸을 통과시켜주지 않습니다. typeemail이나 number처럼 지정하면 값이 올바른 형태인지 검사해줍니다. 필드에 맞는 타입을 고르면 검사는 공짜로 따라옵니다.
Juno필수 필드와 입력 타입required는 불리언 속성입니다. 있거나 없거나 둘 중 하나이고, 값이 필요 없습니다. type은 컨트롤을 정하는 동시에 형식 규칙도 설정하므로, type="email"@와 도메인을 요구합니다. 언제나 가장 구체적인 타입부터 시작하세요. 가장 적은 마크업으로 가장 많은 검사를 얻고, 모바일 키보드도 더 나아집니다.
Juno필수 필드와 입력 타입 이 둘은 제약 검증, 즉 브라우저 내장 규칙 검사기의 첫 두 단계입니다. 기억해둘 점: type="email"은 느슨한 형태 검사일 뿐 실제 주소가 존재한다는 증거는 아니고, 지원되지 않는 타입은 오류를 내지 않고 조용히 text로 대체됩니다. 타입을 고르는 일은 사람들이 받게 될 화면 키보드를 결정하므로 접근성에 관한 선택이기도 합니다.

제약 속성

"채워졌는지"와 "타입이 맞는지"를 넘어서, 종종 한계값도 필요합니다. 최소 이만큼 길이여야 한다거나, 숫자가 저 값보다 크지 않아야 한다거나, 정해진 형식의 코드여야 한다는 식으로요. 몇 가지 속성이 이를 다룹니다.

필드가 받아들일 값에 한계를 정할 수 있습니다. 시작하기 좋은 두 가지는 이렇습니다.

html
<input type="text" name="username" maxlength="20">
<input type="number" name="quantity" min="1" max="10">

maxlength="20"은 필드에 스무 글자가 채워지면 더 이상 입력을 받지 않습니다. minmax는 허용되는 최소, 최대 숫자를 정합니다. 브라우저가 이 범위를 지키도록 강제하므로, 여러분이 직접 검사할 필요가 없습니다.

제약 속성의 종류는 적고, 각각 하나의 규칙에 대응됩니다.

  • minlength / maxlength는 텍스트 값이 가질 수 있는 최소, 최대 글자 수를 정합니다.
  • min / max는 숫자와 날짜의 최소, 최대 값을 정합니다.
  • step은 숫자에 허용되는 증가 단위를 정합니다. 예를 들어 step="5"는 0, 5, 10은 받아들이고 3은 거부합니다.
  • pattern정규 표현식(허용되는 텍스트의 정확한 형태를 표현하는 압축된 표기법)을 담고 있으며, 값이 그것과 전체적으로 일치해야 합니다.
html
<input type="text" name="username" minlength="3" maxlength="20" required>
<input type="number" name="quantity" min="1" max="10" step="1">
<input type="text" name="pin" pattern="[0-9]{4}" title="Four digits">

patterntitle과 함께 쓰세요. 일부 브라우저는 그 텍스트를 오류 메시지에 그대로 보여주고, 눈으로 보는 사용자에게는 그 필드가 무엇을 기대하는지 알려줍니다. CSS에서는 :valid:invalid를 이용해 컨트롤이 현재 제약을 통과했는지에 따라 스타일을 다르게 줄 수 있습니다.

각 제약 속성은 브라우저가 컨트롤에 대해 표시할 수 있는 플래그 하나에 대응되며, 이는 JavaScript에서 검증 상태를 읽을 때 중요해집니다. min을 어기면 "range underflow"가, maxlength를 초과하면 "too long"이, pattern이 맞지 않으면 "pattern mismatch"가 발생하는 식입니다. 속성은 선언이고, 플래그는 그 결과가 보고되는 방식입니다.

사람들이 자주 헷갈리는 부분이 두 가지 있습니다. pattern은 암묵적으로 앵커링되어 있습니다. 즉 표현식은 값의 시작부터 끝까지 전체를 감싸는 것처럼 값 전체와 일치해야 하므로, pattern="[0-9]{4}"는 "숫자 네 개가 포함됨"이 아니라 정확히 숫자 네 자리를 의미합니다. 그리고 step은 부동소수점 연산을 이용해 기준값(있으면 min, 없으면 0)을 기준으로 계산되므로, step="0.1"은 이진수가 소수를 표현하는 방식 때문에 여러분이 통과할 것으로 예상한 값을 거부할 수도 있습니다. 정밀도가 중요할 때는 명시적으로 min을 기준값으로 설정해두세요.

:valid:invalid 가상 클래스(요소의 위치가 아니라 상태에 따라 매칭되는 CSS 선택자)는 유용하지만 다소 투박합니다. 비어 있는 required 필드는 화면에 처음 그려지는 순간부터 :invalid에 매칭되므로, 이를 빨갛게 스타일링하면 사용자가 아무것도 입력하지 않았는데도 오류로 맞이하게 됩니다. 해결책은 :user-invalid입니다. 이는 사용자가 필드와 상호작용한 뒤에만 매칭되므로, 피드백이 실제로 도움이 될 때 나타납니다.

가장 많은 혼란을 줄여주는 규칙은 이것입니다. pattern은 값의 일부가 아니라 값 전체와 일치해야 합니다.

html
<input
  type="text"
  name="product_code"
  pattern="[A-Z]{2}-[0-9]{3}"
  title="Two letters, a hyphen, three digits, e.g. AB-123"
  required
>
Juno제약 속성 필드가 필수이고 타입도 정해졌다면, 이제 한계를 더할 수 있습니다. maxlength는 입력할 수 있는 글자 수를 제한하고, minmax는 숫자의 범위를 정합니다. 브라우저가 자동으로 이 범위 안에 사람들을 붙잡아 두므로, 여러분이 따로 검사할 일이 크게 줄어듭니다.
Juno제약 속성 종류는 적습니다. 텍스트에는 minlength/maxlength, 숫자와 날짜에는 min/max, 증가 단위에는 step, 정확한 형태에는 pattern을 씁니다. pattern에는 title을 함께 붙여 메시지가 의미를 갖도록 하세요. 그리고 pattern은 값 전체와 일치해야 하므로 [0-9]{4}는 전체 네 자리 숫자를 뜻하지, 어딘가에 네 자리 숫자가 들어 있다는 뜻이 아닙니다.
Juno제약 속성 각 속성은 나중에 읽을 수 있는 유효성 플래그 하나에 대응되므로, 마크업과 보고 내용이 서로 맞아떨어집니다. 주의할 점 두 가지: pattern은 처음부터 끝까지 앵커링되어 있고, step은 기준값에서 부동소수점 계산을 하므로 정밀도가 중요할 때는 명시적으로 min을 설정하세요. 스타일은 :invalid가 아니라 :user-invalid로 주세요. 그렇지 않으면 아무도 입력하지 않은 필드까지 빨갛게 칠하게 됩니다.

브라우저 기본 유효성 피드백

규칙을 선언하는 것은 이야기의 절반에 불과합니다. 나머지 절반은 값이 규칙을 어겼을 때 사람들이 실제로 무엇을 보게 되는가입니다.

누군가 제출 버튼을 눌렀을 때 필드에 문제가 있으면, 브라우저는 제출을 막고 문제가 있는 첫 번째 필드 옆에 작은 메시지를 보여준 뒤 그 필드로 커서를 옮겨줍니다. 이 메시지를 여러분이 직접 만들 필요는 없습니다. 브라우저가 방문자의 언어로 알아서 작성해서 자동으로 보여줍니다.

그래서 required가 붙은 이메일 필드를 비워두면 "이 입력란을 작성하세요"와 비슷한 메시지가 나타나고, 고치기 전까지 폼은 전송되지 않습니다.

유효성 검사는 제출 시점에 실행됩니다. 브라우저는 컨트롤을 순서대로 살펴보다가 유효하지 않은 첫 번째 요소를 찾아 포커스를 주고, 문제를 설명하는 작은 메시지 풍선을 보여줍니다. 모두 통과하면 폼은 평소대로 제출됩니다.

CSS로 스타일을 조금은 조절할 수 있습니다. :required, :valid, :invalid, :in-range로 필드를 시각적으로 표시할 수 있습니다. 쉽게 다시 꾸밀 수 없는 것은 메시지 풍선 자체입니다. 그 모양은 여러분 것이 아니라 브라우저의 것입니다. novalidate 속성으로 폼 전체에서 이 시스템 자체를 끌 수도 있는데, 검증을 전부 JavaScript로 처리하려는 경우에 유용합니다.

css
input:user-invalid {
  border-color: #c0392b;
}
input:user-valid {
  border-color: #2d7a3f;
}

브라우저 기본 피드백은 편리하고 거의 공짜지만, 제품에 실제로 적용하기 전에 미리 알아두어야 할 한계가 있습니다.

메시지 풍선은 일시적인 요소입니다. 제출 시 나타났다가 다음 상호작용에서 사라지며, 선택하거나 스타일링할 수 있는 문서의 일부도 아닙니다. 스크린 리더의 지원도 브라우저마다 제각각이라, 풍선 하나만으로는 화면을 볼 수 없는 사람에게 오류를 안정적으로 알려주기 어렵습니다. 텍스트 또한 여러분 페이지의 lang이 아니라 브라우저의 언어로 지역화되므로, 영어로 작성된 폼이라도 프랑스어로 설정된 브라우저를 쓰는 방문자에게는 프랑스어 오류 메시지가 나타날 수 있습니다. 사용자 입장에서는 맞는 동작이지만 개발자 입장에서는 놀랄 만한 일입니다.

제출하지 않고도 같은 흐름을 직접 실행할 수 있습니다. reportValidity()는 검사를 실행하고 필요할 때 풍선을 보여줍니다. 하지만 간단한 프로토타입을 넘어서는 경우라면, 접근성을 고려한 방식은 브라우저 기본 풍선을 (novalidate로) 억제하고 페이지 안에 필드와 연결된 여러분만의 오류 텍스트를 직접 렌더링하는 것입니다. 그러면 눈에 보이고, 스타일을 줄 수 있고, 안정적으로 낭독될 수 있습니다. 다음 절에서 그 방법을 다룹니다.

Juno브라우저 기본 유효성 피드백 지금까지 다룬 속성들이 결국 이런 결과를 줍니다. 잘못된 필드로 제출을 누르면 브라우저가 작은 메시지를 띄우고, 해당 필드로 안내하고, 전송을 거부합니다. 그 텍스트는 여러분이 쓴 게 아니라 브라우저가 방문자의 언어로 알아서 쓴 것입니다. 많은 폼에서는 이 정도 피드백이면 충분합니다.
Juno브라우저 기본 유효성 피드백 검사는 제출 시점에 실행됩니다. 문제가 있는 첫 필드가 포커스와 메시지 풍선을 받고, 통과할 때까지 폼은 붙잡혀 있습니다. :user-valid:user-invalid로 필드는 스타일링할 수 있지만, 풍선 자체의 디자인은 브라우저의 몫입니다. JavaScript로 직접 처리하려면 novalidate로 전체 기능을 꺼두세요.
Juno브라우저 기본 유효성 피드백 풍선은 일시적이고 스타일을 줄 수 없으며, 스크린 리더 지원도 일정하지 않고, 텍스트는 여러분 페이지의 언어가 아니라 브라우저의 언어를 따릅니다. 프로토타입에는 괜찮지만 실제 제품에는 부족합니다. 접근성이 중요하다면 novalidate를 추가하고 여러분만의 오류 텍스트를 페이지 안에 렌더링해서 제대로 보이고, 스타일을 줄 수 있고, 낭독될 수 있게 하세요.

여전히 JavaScript가 필요한 경우

브라우저 기본 검증은 필드 하나를 자기 자신의 규칙에 비추어 검사합니다. 실제로 필요한 많은 검사는 그런 형태에 맞지 않고, 그럴 때 JavaScript가 등장합니다.

브라우저가 스스로 검사할 수 없는 것들이 있습니다. 비밀번호 두 필드가 서로 같은지, 아이디를 이미 다른 사람이 쓰고 있는지, 할인 코드가 실제로 유효한지 같은 것들입니다. 이런 것들은 필드 하나의 형태에 관한 문제가 아니므로 그에 맞는 속성이 없고, 이런 검사는 JavaScript로 처리해야 합니다.

이는 HTML의 약점이 아닙니다. 내장 검사가 흔한 경우를 코드 한 줄 없이 처리해주고, JavaScript가 나머지를 맡는 것입니다.

빈틈이 생기는 지점은 필드 하나 이상에 걸쳐 있거나, 페이지가 아직 갖고 있지 않은 정보에 의존하는 검사입니다. 흔한 예로 비밀번호 두 개가 같은지 확인하는 경우가 있습니다. JavaScript로 두 값을 비교한 뒤, setCustomValidity를 통해 그 결과를 브라우저 기본 시스템에 다시 넘겨줍니다. 이 메서드는 컨트롤에 사용자 지정 오류 메시지를 설정합니다(빈 문자열이면 "이 값은 유효함"을 의미합니다).

html
<input type="password" id="password" name="password" required>
<input type="password" id="confirm" name="confirm" required>

비교 자체는 <script> 안에서 실행됩니다.

js
const password = document.querySelector("#password")
const confirm = document.querySelector("#confirm")

confirm.addEventListener("input", () => {
  // 빈 문자열은 오류를 지우고 필드를 유효한 것으로 표시함
  const message = confirm.value === password.value ? "" : "Passwords do not match"
  confirm.setCustomValidity(message)
})

이제 이 필드는 다른 필드와 마찬가지로 브라우저 기본 검증에 참여하며, 비밀번호가 다를 때 제출을 막고 여러분이 지정한 메시지를 보여줍니다. 아이디 중복 확인처럼 서버가 필요한 검사도 같은 형태를 따르되, 네트워크 요청이 돌아온 뒤에 메시지를 설정합니다.

JavaScript는 제약 검증 API(Constraint Validation API, 브라우저가 폼 컨트롤에 대한 유효성을 읽고 설정할 수 있도록 노출하는 속성과 메서드의 집합)를 통해 브라우저 기본 검증에 접근합니다. 가장 자주 쓰는 요소는 다음과 같습니다.

  • setCustomValidity(message)는 컨트롤에 사용자 지정 오류 문자열을 설정합니다. 빈 문자열이 아니면 필드를 유효하지 않은 상태로 표시하고 그 문자열이 메시지가 되며, 빈 문자열이면 사용자 지정 오류를 지웁니다.
  • validity는 읽기 전용 ValidityState 객체입니다. 실패 가능성마다 불리언 값이 하나씩 있고(valueMissing, typeMismatch, patternMismatch, rangeOverflow, tooLong, customError 등), 전체 결과를 나타내는 valid도 있습니다. 필드가 실패했다는 사실만이 아니라 실패했는지 알아내려면 이것을 읽으세요.
  • checkValidity()는 아무것도 표시하지 않고 true 또는 false만 반환합니다. reportValidity()는 같은 검사를 하면서 브라우저 기본 풍선도 함께 보여줍니다.

접근성을 고려한 오류 표시에서는 색상만으로는 절대 충분하지 않습니다. 빨간 테두리는 스크린 리더 사용자나 빨간색과 초록색을 구분하지 못하는 사람에게는 아무런 의미가 없습니다. 오류 텍스트를 해당 필드와 연결해서 보조 기술이 둘을 함께 읽어주도록 하세요. input에 aria-describedby를 오류 요소의 id로 설정하고(스크린 리더에게 "이 텍스트를 이 필드의 설명으로 읽어라"라고 알려줍니다), 필드가 실패한 동안에는 aria-invalid="true"를 설정하세요(이는 그 필드가 오류 상태임을 알려줍니다).

html
<input
  type="text"
  id="username"
  name="username"
  aria-describedby="username-error"
  aria-invalid="true"
  required
>
<p id="username-error" role="alert">That username is already taken.</p>

role="alert"는 메시지가 나타나는 즉시 스크린 리더가 그것을 읽어주도록 만듭니다. 접근성 장에서는 컨트롤과 설명을 연결하는 방법을 더 깊이 다룹니다.

이제 트레이드오프가 남습니다. 브라우저 기본 검증은 코드가 더 적고, 플랫폼과 일관되며, 여러분의 스크립트가 로드되기 전에도 동작하지만, 그 피드백은 스타일링하거나 낭독하게 만들기가 어렵습니다. 사용자 지정 검증은 코드도 많고 책임도 크지만, 문구, 시점, 접근성을 완전히 제어할 수 있습니다. 실제 제품에서 쓰는 폼 대부분은 두 가지를 함께 씁니다. 기본 속성을 기준선으로 두고, 그 속성이 손댈 수 없는 검사와 메시지 부분에 JavaScript를 얹는 방식입니다.

무엇을 선택하든, 절대 바뀌지 않는 규칙이 하나 있습니다. 클라이언트 검증은 편의일 뿐, 절대 보장이 아닙니다. 이 장에서 설명한 모든 것은 방문자의 브라우저 안에서 실행되며, 누구든 그것을 끄거나 페이지를 수정하거나 폼을 건드리지 않고 서버로 요청을 곧바로 보낼 수 있습니다. 서버는 마치 브라우저 검사가 전혀 일어나지 않은 것처럼, 받은 모든 값을 다시 검증해야 합니다. 브라우저의 검사는 사용자를 위해 왕복을 줄여주는 빠르고 친절한 첫 번째 관문으로만 여기고, 실제로 여러분의 데이터를 지키는 검사는 서버 쪽이라고 생각하세요.

Juno여전히 JavaScript가 필요한 경우 내장 검사는 한 번에 필드 하나만 다루므로, "이 두 비밀번호가 같은가"처럼 필드를 서로 비교하거나 "이 아이디가 비어 있는가"처럼 서버 확인이 필요한 경우에는 JavaScript가 필요합니다. 이는 자연스러운 일입니다. HTML이 흔한 경우를 공짜로 처리해주고, JavaScript는 HTML이 스스로 알 수 없는 나머지를 맡습니다.
Juno여전히 JavaScript가 필요한 경우 검사가 두 필드에 걸쳐 있거나 서버가 필요할 때는 JavaScript로 계산한 뒤 setCustomValidity로 결과를 되돌려주세요. 실패시키려면 메시지 문자열을, 지우려면 빈 문자열을 넘깁니다. 그러면 그 필드도 다른 필드처럼 브라우저 기본 검증에 참여하게 됩니다. 비밀번호 일치 확인과 아이디 중복 확인이 대표적인 두 예시입니다.
Juno여전히 JavaScript가 필요한 경우 제약 검증 API가 그 통로입니다. 메시지를 설정하려면 setCustomValidity, 필드가 왜 실패했는지 읽으려면 validity, 검사를 실행하려면 checkValidityreportValidity를 씁니다. 접근성 있는 오류 표시를 위해서는 aria-describedbyaria-invalid로 텍스트를 필드와 연결하고, 색상 하나에만 의존하지 마세요. 그리고 절대 빠뜨릴 수 없는 것: 여기서 다룬 모든 검사는 브라우저에서 실행되므로, 서버가 그 전부를 다시 검증해야 합니다.