2026년의 폼 UX: 네이티브 HTML 검증(Validation)만으로도 충분하다
요약
외부 라이브러리 없이 네이티브 HTML 검증 API를 활용하여 폼 UX를 개선하고 번들 크기를 줄이는 방법을 소개합니다. :user-valid 및 :user-invalid 의사 클래스를 통해 사용자 경험을 해치지 않는 세련된 에러 피드백 구현이 가능합니다.
핵심 포인트
- Constraint Validation API를 활용해 12KB 규모의 라이브러리 제거
- :user-valid/invalid 사용으로 성급한 에러 노이즈 방지
- setCustomValidity()를 통한 커스텀 검증 로직 구현
- accent-color를 활용한 간결한 스타일링 및 SVG 의존성 제거
-
네이티브 HTML을 사용하여 3개의 서비스 전반에서 12 KB 규모의 검증 라이브러리 제거
-
:user-valid 및 :user-invalid는 상호작용 후에만 발생하여, 성급한 에러 노이즈를 제거
-
accent-color 하나로 체크박스와 라디오 버튼 스타일을 한 줄로 처리, SVG 해킹 불필요
-
폼 관련 커스텀 엘리먼트(Form-associated custom elements)를 통해 웹 컴포넌트가 네이티브 검증에 참여 가능
지난달에 12 KB 크기의 폼 검증 라이브러리를 삭제했는데, 폼이 더 나빠지기는커녕 오히려 좋아졌습니다. 이제 세 개의 RAXXO 서비스는 네이티브 HTML 검증만으로 작동하며, 에러가 발생하는 타이밍도 기존에 대체했던 JavaScript 버전보다 더 세련되게 느껴집니다. 이를 가능하게 만든 5가지 패턴을 소개합니다.
제약 조건 검증 API (Constraint Validation API)가 핵심 역할을 수행합니다
모든 input 엘리먼트는 이미 유효성 객체(validity object)를 가지고 있습니다. 이를 별도로 임포트하거나 설정할 필요가 없습니다. 지금 이 순간에도 input.validity에 위치하여 valueMissing, typeMismatch, patternMismatch, tooLong, tooShort, rangeUnderflow, rangeOverflow, stepMismatch라는 8가지 개별 상태를 추적하고 있습니다. 브라우저는 매 키 입력마다 이를 무료로 계산합니다.
required 속성은 valueMissing을 처리합니다. type="email"은 typeMismatch를 처리합니다. pattern은 정규 표현식(regex)을 사용하여 patternMismatch를 처리합니다. 어떤 라이브러리도 이를 파싱하지 않습니다. HTML 파서가 이를 수행합니다.
정규 표현식으로 표현할 수 없는 할인 코드 형식을 확인하는 것과 같이 커스텀 로직이 필요한 경우에는 setCustomValidity()를 호출합니다. 문자열을 전달하면 해당 메시지와 함께 필드가 유효하지 않은 상태(invalid)가 됩니다. 빈 문자열을 전달하면 필드는 다시 유효한(valid) 상태가 됩니다. 이것이 커스텀 규칙을 위한 API의 전부입니다.
input.addEventListener('input', () => {
const ok = /^RAXXO-\d{4}$/.test(input.value);
...
폼 자체의 checkValidity() 메서드는 모든 필드를 순회하며 true 또는 false를 반환합니다. reportValidity() 메서드도 동일한 역할을 수행하지만, 첫 번째 오류가 있는 필드에 네이티브 에러 버블(error bubble)을 표시하는 기능까지 포함합니다. 단 두 번의 메서드 호출이 60줄에 달하던 제출 핸들러(submit handler)를 대체했습니다. 제가 왜 이처럼 의존성(dependencies)을 계속해서 제거하는지에 대한 전체적인 논거를 알고 싶다면, Why I Delete Libraries First를 참조하세요. 이 패턴은 제가 다루는 모든 영역에서 반복됩니다.
:user-valid와 :user-invalid가 나의 최악의 UX 문제를 해결했다
네이티브 검증(validation)에 대한 고전적인 불만은 타이밍 문제입니다. 일반적인 :invalid 의사 클래스(pseudo-class)는 페이지가 로드되는 즉시 비어 있는 필수(required) 필드와 일치합니다. 따라서 방문자가 단 한 글자도 입력하기 전에 모든 폼이 빨간색으로 변하게 됩니다. 이는 사용자에게 적대적인 경험이며, 대부분의 사람들이 처음에 라이브러리를 찾았던 이유이기도 합니다. 라이브러리는 필드별로 "입력 완료(touched)" 상태를 추적하여, 사용자가 상호작용을 마친 후에만 에러가 나타나도록 했습니다.
이제 브라우저가 그 역할을 수행합니다. :user-invalid는 사용자가 필드와 상호작용한 후, 잘못된 상태로 필드를 벗어났을 때만 일치합니다. :user-valid는 그 반대입니다. 추적해야 할 "입력 완료(touched)" 상태도 없고, blur 리스너(blur listeners)도 필요 없습니다. 브라우저가 상호작용 이력을 관리하기 때문입니다.
input:user-invalid {
border-color: #d4183d;
...
이 단 한 번의 변화로 기존 라이브러리가 수행하던 작업 중 가장 큰 부분을 제거할 수 있었습니다. "입력 완료(touched)" 상태를 관리하는 메커니즘이 라이브러리 코드의 약 3분의 1을 차지했습니다. 적용하기 전에 지원 테이블을 확인했습니다. :user-valid와 :user-invalid는 현재 모든 브라우저 엔진에 탑재되어 있으며, 폴백(fallback) 방식도 매끄럽습니다. 오래된 브라우저는 제출(submit) 전까지 초록색이나 빨간색을 표시하지 않을 뿐이며, 이는 수용 가능한 수준입니다.
위에서 언급한 에러 텍스트 트릭은 짚고 넘어갈 가치가 있습니다. 저는 각 input 뒤에 숨겨진 .error-text span을 배치하고, 인접 형제 결합자(sibling combinator)를 사용하여 필드가 :user-invalid 상태일 때만 이를 드러냅니다. 시점은 브라우저가 결정합니다. 저는 단지 어떻게 보일지만 결정할 뿐입니다. 이제 단 하나의 에러 메시지를 보여주거나 숨기기 위해 실행되는 JavaScript는 더 이상 없습니다.
결제 화면(checkout surface)에서 이 점이 가장 중요했습니다. 방문자는 8개의 필드를 채웁니다. 기존 시스템에서는 잘못된 렌더링(re-render)이 발생하면 8개 필드가 모두 빨간색으로 깜빡일 수 있었습니다. :user-invalid를 사용하면, 사용자가 실제로 해당 필드를 건드리고 다음으로 넘어갈 때까지 필드는 중립적인 상태를 유지합니다. 폼이 더 차분하게 느껴집니다. 저는 이 차분함을 만들기 위해 코드 한 줄도 작성하지 않았습니다. 오히려 시끄럽게 만들던 코드들을 삭제했을 뿐입니다. 더 적게 배포하고 더 많은 것을 얻는 것에 대한 더 넓은 관점은, The Subtraction Habit에서 저를 이 단계로 이끈 사고방식을 다루고 있습니다.
accent-color와 :has()가 나의 커스텀 컨트롤 CSS를 없애버렸다
체크박스(checkbox)와 라디오 버튼(radio button)은 사람들이 전체 폼 키트(form kits)를 구축하게 만들었던 주된 이유였습니다. 네이티브 요소들은 보기 흉하고 스타일을 입히기 어려웠기에, 모든 라이브러리는 숨겨진 입력창(hidden inputs)과 가상 요소(pseudo-elements)를 포함한 자체적인 SVG 기반 대체 요소를 배포했습니다. 그 대체 코드들은 부채(liability)입니다. 키보드 포커스(keyboard focus)를 망가뜨리고, 스크린 리더(screen reader)를 혼란스럽게 하며, 무게를 더합니다.
accent-color는 단 한 줄로 저에게 이 문제를 끝내주었습니다.
:root {
accent-color: #6c3df4;
...
}
이 코드는 페이지의 모든 체크박스, 라디오 버튼, 범위 슬라이더(range slider), 프로그레스 바(progress bar)를 제 브랜드의 보라색에 맞춰 색상을 변경합니다. 컨트롤은 네이티브 상태를 유지합니다. 실제 요소를 교체하지 않았기 때문에 키보드 탐색(keyboard navigation)이 작동합니다. 여전히 실제 입력창(input)이기 때문에 스크린 리더가 이를 정확하게 안내합니다. 저는 세 개의 화면에 걸쳐 약 200줄의 SVG 체크박스 마크업과 CSS를 삭제했고, 이를 단 한 줄의 선언과 다른 색조가 필요한 경우를 위한 폼별 오버라이드(override)로 대체했습니다.
그다음 :has()가 레이아웃 로직을 정리해주었습니다. 예전에는 체크박스가 체크되었을 때 라벨 래퍼(label wrapper)의 클래스를 토글(toggle)하곤 했는데, 이는 변경 리스너(change listener)와 classList.toggle이 필요함을 의미했습니다. 이제 CSS가 상태를 직접 읽습니다.
label:has(input:checked) {
background: #f3effe;
...
}
부모 요소가 자식의 체크 상태에 반응하며, 이 과정에서 JavaScript는 전혀 사용되지 않습니다. 두 번째 규칙은 배송 옵션이 선택되었을 때만 버튼을 활성화하는데, 이는 순수하게 CSS만으로 구현됩니다. 저는 업스케일링을 위해 Magnific과 같은 도구에 의존하는 제품 비주얼 작업과 동일한 흐름으로 이를 테스트해 보았으며, 폼(Form)은 주변 이미지만큼이나 선명하게 느껴졌습니다.
결합된 효과는 다음과 같습니다. 기존에 폼 라이브러리(Form library) 사용을 정당화했던 시각적 커스텀 기능들이 이제 단 네 줄의 CSS 선언으로 해결됩니다. 컨트롤을 위한 accent-color, 반응형 상태를 위한 :has(), 에러를 위한 :user-invalid, 그리고 메시지를 위한 형제 결합자(Sibling combinator)가 그것입니다. 저는 제가 우회 방법을 가져오느라 바쁜 동안 플랫폼이 해당 기능을 출시하는 것을 계속 목격하곤 합니다. 프레임워크 없이 UI를 구축하는 것에 대한 더 깊은 맥락이 궁금하시다면, Vanilla UI That Scales에서 더 자세히 다루었습니다.
Form-Associated Custom Elements가 마지막 간극을 메우다
네이티브 검증(Native validation)이 할 수 없었던 한 가지가 있었고, 그것이 제가 기존 라이브러리를 조금이나마 남겨두었던 이유였습니다. 저에게는 웹 컴포넌트(Web component)로 구축된 5성급 컨트롤인 커스텀 별점 위젯이 있습니다. 이것은 실제 입력(Input) 요소가 아닙니다. 따라서 폼의 유효성(Validity)에 참여할 수 없었습니다. required 속성을 가질 수도 없었고, checkValidity()에 나타날 수도 없었습니다. 시스템 외부에 존재했던 것입니다.
Form-associated custom elements가 이 문제를 해결했습니다. static formAssociated = true를 설정하고 ElementInternals 객체를 가져옴으로써, 웹 컴포넌트는 일급 폼 참여자(First-class form participant)가 됩니다. 값을 제출하고, 제약 조건 검증(Constraint validation)에 참여합니다. 스스로를 메시지와 함께 유효하지 않음(Invalid)으로 표시할 수 있으며, 그러면 폼은 네이티브 입력 요소와 정확히 똑같이 제출을 거부합니다.
class StarRating extends HTMLElement {
static formAssociated = true;
...
setFormValue 호출은 제출 시 평점(rating)을 폼 데이터(form data)에 넣습니다. setValidity 호출은 모든 네이티브 입력(native input)이 사용하는 것과 동일한 유효성 검사 메커니즘(validity machinery)에 이를 연결합니다. 이제 form.checkValidity()는 제가 만든 커스텀 위젯(custom widget)을 포함합니다. :user-invalid 스타일링도 위젯에 적용됩니다. 별점을 선택하지 않으면 제출이 차단됩니다. 이전에는 제 위젯을 JavaScript 유효성 검사 라이브러리에 붙이기 위해 브릿지 코드(bridge code)를 사용했지만, 이제 위젯이 브라우저와 직접 통신하므로 해당 코드를 삭제했습니다.
이것으로 마지막 12 KB를 덜어낼 수 있었습니다. 이 라이브러리가 지금까지 살아남을 수 있었던 이유는 순전히 이 위젯 하나 때문이었으며, ElementInternals가 이를 지원하게 되자 전체 의존성(dependency)이 존재할 이유가 사라졌습니다. 임포트(import)를 제거하고, 생각할 수 있는 모든 실패 케이스를 통해 세 개의 폼을 테스트한 뒤 배포했습니다. 아무것도 망가지지 않았습니다.
만약 커스텀 컨트롤(custom controls)을 만들면서 네이티브 유효성 검사(native validation)에 참여할 수 없다고 가정한다면, 다시 확인해 보십시오. 이 기능은 현재 모든 엔진에 탑재되어 있습니다. 동일한 원리가 제가 Claude Blueprint에서 문서화한 아키텍처를 이끌고 있습니다. 그곳에서 저는 전체 스택을 혼자서도 유지 관리할 수 있을 만큼 가볍게 유지하는 방법을 설명합니다. 글루 코드(glue code)가 적을수록 부패할 요소도 줄어듭니다.
결론 (Bottom Line)
2026년의 네이티브 HTML 유효성 검사(Native HTML validation)는 마지못해 받아들이는 불완전한 해결책이 아닙니다. 그것이 더 나은 해결책입니다. 제약 조건 유효성 검사 API(Constraint Validation API)가 규칙을 처리합니다. :user-valid와 :user-invalid는 라이브러리가 존재했던 유일한 실질적 이유였던 타이밍(timing) 문제를 처리합니다. accent-color와 :has()는 실제 컨트롤을 대체하지 않고도 스타일링을 처리합니다. 폼 연관 커스텀 엘리먼트(Form-associated custom elements)는 웹 컴포넌트(web components)를 동일한 시스템으로 끌어들입니다. 다섯 가지 패턴, 제로 의존성(zero dependencies), 세 가지 영역에 걸쳐 12 KB 경량화.
브라우저가 이미 올바르게 제공하고 있는 컨트롤을 재발명하는 것을 멈췄기 때문에, 폼은 더 빠르게 로드되고, 유지 관리가 쉬우며, 접근성(accessibility)도 더 좋아졌습니다. 플랫폼이 작업을 흡수했기 때문에, 저는 이제 코드를 더 적게 작성하고, 남겨둔 코드는 더 많은 일을 수행합니다.
만약 당신이 여전히 검증 라이브러리(validation library)를 임포트(import)하고 있다면, 당신의 폼(form)을 열고 그것을 삭제해 보세요. 속성(attributes)들을 네이티브(native) 방식으로 다시 연결하고, 네 가지 CSS 규칙을 추가한 뒤 실제로 무엇이 깨지는지 확인해 보십시오. 저의 경우, 거의 아무것도 깨지지 않았습니다. 제가 이 정도의 적은 오버헤드(overhead)로 어떻게 1인 스튜디오를 운영하며 제품을 출시하고 있는지에 대한 전체적인 그림을 알고 싶다면, Claude Blueprint에서 전체 접근 방식을 처음부터 끝까지 살펴볼 수 있습니다.
이 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면, 귀하에게 추가 비용 없이 저에게 소정의 수수료가 지급될 수 있습니다. (광고)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기