이 글에서 정리하는 내용
querySelector로 폼 요소를 선택하고 submit의 기본 동작을 막은 뒤, 입력 검증·오류 연결·포커스 복구를 중심으로, 목록 추가·완료·삭제까지 접근 가능한 폼 예제로 구현합니다.
먼저 읽기: JavaScript 함수와 객체, import export로 모듈 나누기
폼 요소를 querySelector로 선택하기
JavaScript DOM 폼 실습은 HTML 요소를 JavaScript 값으로 가져오는 것부터 시작합니다. document.querySelector("#todo-form")는 CSS 선택자와 처음 일치하는 요소를 반환합니다. id는 한 문서에서 고유하게 쓰고, 선택 결과가 null일 수 있으므로 script를 본문 요소 뒤에 두어 HTML이 만들어진 다음 실행합니다.
폼, 입력, 오류 문단, 목록을 각각 한 번 선택해 변수에 저장합니다. 이후 같은 요소가 필요할 때 문서를 다시 검색하지 않고 변수를 사용합니다. 브라우저 개발자 도구 콘솔에서 변수를 확인하면 실제 HTML 요소가 연결되었는지 알 수 있습니다.

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;
}
style.css
logic.js
app.js
선택 API는 MDN querySelector 문서, submit 이벤트는 MDN submit 이벤트 문서, 기본 동작 취소는 MDN preventDefault 문서에서 확인할 수 있습니다. 로컬 자동 DOM 실행 도구가 없는 환경에서는 문법·요소 연결을 검사하고 위 브라우저 절차를 별도로 수행해야 합니다.
오류 상태는 MDN aria-invalid에서 확인할 수 있습니다. 다음 상태 중심 투두리스트에서는 화면 대신 배열을 상태의 기준으로 삼습니다.
실습 파일 내려받기
HTML·CSS·JavaScript 실습 ZIP 다운로드
index.html을 브라우저에서 엽니다. 별도 패키지 설치는 없습니다.
완료 기준
- 공백·한 글자 제출: 오류와 aria-invalid가 생기고 항목은 늘지 않습니다.
- 40 길이는 추가되고 개발자 도구에서 input.value를 41 길이로 대입한 뒤 제출하면 거절됩니다.
- 입력 수정: 이전 오류가 지워집니다. Enter로 우유 사기 추가: 입력이 비워지고 포커스가 돌아옵니다.
- 문자 입력: 태그가 아닌 문자열로 보입니다.
- 키보드 Tab/Space로 완료: 취소선과 aria-pressed가 함께 바뀝니다. 재선택하면 복원됩니다.
- 삭제: 항목이 사라지고 입력으로 포커스가 돌아옵니다. 새로고침하면 목록이 비워집니다.
이 글이 도움이 되었나요?
JavaScript 학습 순서
필수 13개 · 전체 14개
읽음 기록 관리
전체 과정 목차 (14개)
- 필수 학습 · JavaScript 조건문과 반복문: 변수 값의 흐름부터 추적하기
- 필수 학습 · JavaScript 함수와 객체, import export로 모듈 나누기
- 필수 학습 · JavaScript 배열 메서드: map filter forEach reduce 차이
- 필수 학습 · JavaScript 객체 참조와 불변 갱신: 중첩 객체를 안전하게 바꾸기
- 필수 학습 · JavaScript reduce 사용법: 배열 누적 계산을 이해하는 기준
- 필수 학습 · JavaScript DOM 폼 만들기: 입력 검증과 접근성 처리 현재 글
- 필수 학습 · JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기
- 필수 학습 · JavaScript URLSearchParams 사용법: URL 파라미터 읽고 수정하기
- 필수 학습 · JavaScript Date UTC KST 차이: 시간대 변환 기준 잡기
- 필수 학습 · JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기
- 필수 학습 · JavaScript fetch 오류 처리: Promise부터 404까지
- 필수 학습 · JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기
- 필수 학습 · JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어
- 선택 참고 · GSAP이 처음일 때 기초 사용법: 설치부터 기본 애니메이션까지
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.