JavaScript DOM 폼 만들기: 입력 검증과 접근성 처리

2026.09.05·수정 2026.09.12·약 15분·작성: 해비·블로그 소개

이 글에서 정리하는 내용

querySelector로 폼 요소를 선택하고 submit의 기본 동작을 막은 뒤, 입력 검증·오류 연결·포커스 복구를 중심으로, 목록 추가·완료·삭제까지 접근 가능한 폼 예제로 구현합니다.

먼저 읽기: JavaScript 함수와 객체, import export로 모듈 나누기

폼 요소를 querySelector로 선택하기

JavaScript DOM 폼 실습은 HTML 요소를 JavaScript 값으로 가져오는 것부터 시작합니다. document.querySelector("#todo-form")는 CSS 선택자와 처음 일치하는 요소를 반환합니다. id는 한 문서에서 고유하게 쓰고, 선택 결과가 null일 수 있으므로 script를 본문 요소 뒤에 두어 HTML이 만들어진 다음 실행합니다.

폼, 입력, 오류 문단, 목록을 각각 한 번 선택해 변수에 저장합니다. 이후 같은 요소가 필요할 때 문서를 다시 검색하지 않고 변수를 사용합니다. 브라우저 개발자 도구 콘솔에서 변수를 확인하면 실제 HTML 요소가 연결되었는지 알 수 있습니다.

문서의 일부 영역을 돋보기로 찾는 모습으로 DOM 요소 선택을 비유한 삽화
문서의 일부 영역을 돋보기로 찾는 모습으로 DOM 요소 선택을 비유한 삽화입니다. 실제 동작은 예제 코드에서 확인합니다.

submit 기본 동작을 막고 입력 검증하기

폼에서 추가 버튼을 누르거나 입력 중 Enter를 누르면 submit 이벤트가 발생합니다. 브라우저는 원래 폼을 제출하며 페이지를 이동하거나 새로 고칠 수 있습니다. 이 실습은 서버 제출이 목적이 아니므로 이벤트 객체의 preventDefault()를 가장 먼저 호출합니다.

input.value는 사용자가 입력한 문자열이고 trim()은 앞뒤 공백을 제거합니다. 정리한 값이 두 글자보다 짧으면 오류 문구를 보여주고 return으로 함수를 끝냅니다. HTML의 maxlength="40"은 일반 입력 길이를 제한하며, JavaScript에서도 2~40 범위를 검사합니다. length와 maxlength는 UTF-16 코드 단위 기준이라 이모지 등은 눈에 보이는 한 글자와 길이가 다를 수 있습니다. 화면 검사는 사용자 안내일 뿐 서버 보안 검증을 대신하지 않습니다.

입력값을 안전하게 목록에 추가하기

검증을 통과하면 createElement로 li, span, 두 버튼을 만듭니다. 사용자 입력은 innerHTML이 아니라 textContent에 넣습니다. 그러면 입력한 꺾쇠 문자가 HTML 요소로 해석되지 않고 화면의 문자로 표시됩니다.

append로 자식 요소를 순서대로 붙인 뒤 완성한 li를 ul에 추가합니다. 추가가 끝나면 입력값을 비우고 다시 focus를 돌려 연속 입력이 가능하게 합니다. DOM 생성, 속성 지정, 문서 삽입의 순서로 읽으면 코드가 길어져도 역할을 놓치지 않습니다.

빈 종이를 정리함에 넣는 모습으로 입력을 확인한 뒤 목록에 추가하는 과정을 비유한 삽화
빈 종이를 정리함에 넣는 모습으로 입력을 확인한 뒤 목록에 추가하는 과정을 비유한 삽화입니다. 실제 동작은 예제 코드에서 확인합니다.

완료·삭제와 오류 뒤 포커스 관리

이 수업은 생성한 버튼에 직접 click 리스너를 붙입니다. 각 함수는 자신이 만든 item과 입력 문구를 기억하므로 다른 항목을 잘못 지우지 않습니다. 버튼 문구를 조건식으로 읽지 않고 완료 버튼은 완료 동작만, 삭제 버튼은 삭제 동작만 실행합니다. 동적 항목이 많아졌을 때 사용하는 이벤트 위임과 데이터 배열 중심 설계는 다음 투두리스트 수업에서 다룹니다.

완료는 클래스와 aria-pressed를 함께 바꿉니다. 토글 버튼의 이름은 “완료”로 고정하고 눌린 상태만 true/false로 표현합니다. 삭제 뒤 사라진 버튼에 포커스를 남기지 않고 입력으로 돌립니다. 추가·완료·삭제 결과는 role="status" 영역에서 알립니다.

입력의 label은 이름, aria-describedby는 길이 안내와 오류 설명을 연결합니다. 검증 실패 때만 aria-invalid=”true”를 지정하고, 오류 문구와 포커스를 입력에 돌립니다. 사용자가 수정하기 시작하면 이전 오류 표시를 지우며 제출할 때 다시 검증합니다. novalidate는 브라우저 기본 오류 팝업을 생략하고 이 예제의 일관된 오류 안내를 실행하도록 합니다. 서버가 있는 앱에서는 서버에서도 검증해야 합니다.

submit 리스너의 event는 제출 동작을 나타냅니다. preventDefault() 뒤 검증에 실패하면 return으로 종료하므로 목록은 바뀌지 않습니다. 성공하면 오류를 지우고 DOM을 만들고 입력을 비웁니다. 사용자 문자열은 textContent에만 대입하여 HTML로 해석하지 않습니다. 완료와 삭제 버튼의 type=”button”은 의도치 않은 제출을 방지합니다.

완성 코드 실행과 확인 절차

다음 파일을 같은 폴더에 저장합니다. 구조와 스타일, 검증 함수, DOM 이벤트를 분리하므로 오류가 어느 층에서 발생했는지 찾을 수 있습니다. logic.js를 app.js보다 먼저 연결합니다.

index.html

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

전체 코드

app.js

const form = document.querySelector('#todo-form');
const input = document.querySelector('#todo-input');
const error = document.querySelector('#error');
const list = document.querySelector('#todo-list');
const status = document.querySelector('#status');
function clearError() {
  error.textContent = '';
  input.removeAttribute('aria-invalid');
}
input.addEventListener('input', clearError);
form.addEventListener('submit', (event) => {
  event.preventDefault();
  const result = validateTodo(input.value);
  if (result.error) {
    error.textContent = result.error;
    input.setAttribute('aria-invalid', 'true');
    input.focus();
    return;
  }
  clearError();
  const item = document.createElement('li');
  const label = document.createElement('span');
  const complete = document.createElement('button');
  const remove = document.createElement('button');
  label.textContent = result.text;
  complete.type = remove.type = 'button';
  complete.textContent = '완료';
  complete.setAttribute('aria-pressed', 'false');
  complete.setAttribute('aria-label', `${result.text} 완료`);
  remove.textContent = '삭제';
  remove.setAttribute('aria-label', `${result.text} 삭제`);
  complete.addEventListener('click', () => {
    const done = item.classList.toggle('done');
    complete.setAttribute('aria-pressed', String(done));
    status.textContent = `${result.text}: ${done ? '완료' : '미완료'}`;
  });
  remove.addEventListener('click', () => {
    item.remove();
    status.textContent = `${result.text} 삭제. 남은 항목 ${list.children.length}개`;
    input.focus();
  });
  item.append(label, complete, remove);
  list.append(item);
  input.value = '';
  status.textContent = `${result.text} 추가. 전체 ${list.children.length}개`;
  input.focus();
});

checks.cjs

const assert = require('node:assert/strict');
const { validateTodo } = require('./logic.js');
assert.ok(validateTodo('   ').error);
assert.ok(validateTodo('가').error);
assert.equal(validateTodo('  우유 사기  ').text, '우유 사기');
assert.equal(validateTodo('가'.repeat(40)).error, '');
assert.ok(validateTodo('가'.repeat(41)).error);
assert.equal(validateTodo('<b>문자</b>').error, '');
console.log('6 validation assertions passed');

index.html

<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>입력 검증과 접근 가능한 폼</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main>
      <h1>입력 검증과 접근 가능한 폼</h1>
      <form id="todo-form" novalidate>
        <label for="todo-input">할 일</label>
        <p id="hint">앞뒤 공백을 뺀 길이 2~40(UTF-16 코드 단위)로 입력하세요.</p>
        <input
          id="todo-input"
          name="todo"
          maxlength="40"
          required
          aria-describedby="hint error"
          autocomplete="off"
        />
        <button type="submit">추가</button>
        <p id="error" role="alert"></p>
      </form>
      <p id="status" role="status"></p>
      <ul id="todo-list"></ul>
    </main>
    <script src="logic.js"></script>
    <script src="app.js"></script>
  </body>
</html>

logic.js

function validateTodo(raw) {
  const text = raw.trim();
  return {
    text,
    error:
      text.length < 2 || text.length > 40
        ? '앞뒤 공백을 뺀 길이를 2~40으로 입력해 주세요.'
        : '',
  };
}
if (typeof module !== 'undefined') module.exports = { validateTodo };

style.css

* {
  box-sizing: border-box;
}
body {
  margin: 0;
  padding: 2rem 1rem;
  color: #222;
  background: #fff;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
main {
  max-width: 48rem;
  margin: auto;
}
form,
section {
  margin-block: 1.5rem;
}
input,
select,
button {
  font: inherit;
  padding: 0.5rem;
  border: 1px solid #777;
  color: #222;
  background: #fff;
}
button {
  cursor: pointer;
}
:focus-visible {
  outline: 3px solid #333;
  outline-offset: 3px;
}
label {
  display: block;
}
li {
  margin-block: 0.7rem;
  overflow-wrap: anywhere;
}
li button {
  margin-inline-start: 0.5rem;
}
.done span {
  text-decoration: line-through;
}
pre {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  padding: 1rem;
  background: #eee;
}
[aria-invalid='true'] {
  border: 2px solid #222;
}

index.html 전체 코드 보기

style.css

style.css 전체 코드 보기

logic.js

logic.js 전체 코드 보기

app.js

app.js 전체 코드 보기

선택 API는 MDN querySelector 문서, submit 이벤트는 MDN submit 이벤트 문서, 기본 동작 취소는 MDN preventDefault 문서에서 확인할 수 있습니다. 로컬 자동 DOM 실행 도구가 없는 환경에서는 문법·요소 연결을 검사하고 위 브라우저 절차를 별도로 수행해야 합니다.

오류 상태는 MDN aria-invalid에서 확인할 수 있습니다. 다음 상태 중심 투두리스트에서는 화면 대신 배열을 상태의 기준으로 삼습니다.

실습 파일 내려받기

HTML·CSS·JavaScript 실습 ZIP 다운로드

index.html을 브라우저에서 엽니다. 별도 패키지 설치는 없습니다.

완료 기준

  1. 공백·한 글자 제출: 오류와 aria-invalid가 생기고 항목은 늘지 않습니다.
  2. 40 길이는 추가되고 개발자 도구에서 input.value를 41 길이로 대입한 뒤 제출하면 거절됩니다.
  3. 입력 수정: 이전 오류가 지워집니다. Enter로 우유 사기 추가: 입력이 비워지고 포커스가 돌아옵니다.
  4. 문자 입력: 태그가 아닌 문자열로 보입니다.
  5. 키보드 Tab/Space로 완료: 취소선과 aria-pressed가 함께 바뀝니다. 재선택하면 복원됩니다.
  6. 삭제: 항목이 사라지고 입력으로 포커스가 돌아옵니다. 새로고침하면 목록이 비워집니다.

이 글이 도움이 되었나요?

조회 중

JavaScript 학습 순서

필수 13개 · 전체 14개

읽음 기록 관리

전체 과정 목차 (14개)
  1. 필수 학습 · JavaScript 조건문과 반복문: 변수 값의 흐름부터 추적하기
  2. 필수 학습 · JavaScript 함수와 객체, import export로 모듈 나누기
  3. 필수 학습 · JavaScript 배열 메서드: map filter forEach reduce 차이
  4. 필수 학습 · JavaScript 객체 참조와 불변 갱신: 중첩 객체를 안전하게 바꾸기
  5. 필수 학습 · JavaScript reduce 사용법: 배열 누적 계산을 이해하는 기준
  6. 필수 학습 · JavaScript DOM 폼 만들기: 입력 검증과 접근성 처리 현재 글
  7. 필수 학습 · JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기
  8. 필수 학습 · JavaScript URLSearchParams 사용법: URL 파라미터 읽고 수정하기
  9. 필수 학습 · JavaScript Date UTC KST 차이: 시간대 변환 기준 잡기
  10. 필수 학습 · JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기
  11. 필수 학습 · JavaScript fetch 오류 처리: Promise부터 404까지
  12. 필수 학습 · JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기
  13. 필수 학습 · JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어
  14. 선택 참고 · GSAP이 처음일 때 기초 사용법: 설치부터 기본 애니메이션까지

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기