사실 평소엔 <form> 태그를 잘 안 썼다. 로그인이든 검색이든 그냥 <div>로 감싸고 버튼에 이벤트 걸어서 JS로 값 긁어 보내는 식이었고, 동작은 잘 되니까 굳이 form을 써야 할 이유를 못 느꼈다.
그러다 최근에 레거시 페이지 리뉴얼 작업을 하면서 <form> 태그를 엄청 많이 보게 됐다. 처음엔 "요즘도 이렇게 form을 많이 쓰나?" 싶었는데, 찬찬히 뜯어보니 form이 엔터키 제출, 한글 IME 처리, 브라우저 자동완성, 스크린리더 인식 같은 걸 전부 공짜로 해결하고 있었다. 그동안 내가 div로 만들고 JS로 일일이 막고 있던 것들이다.
이 문서는 그때 정리해둔 내용.
required, type, pattern 속성FormData로 전체 값 일괄 수집 가능<form action="/order/submit" method="post">
<input type="text" name="buyer_name" required>
<button type="submit">주문하기</button>
</form>
| 속성 | 설명 |
|---|---|
action | 제출될 URL |
method | get (검색/필터) / post (결제/가입) |
enctype | 파일 업로드 시 multipart/form-data |
autocomplete | on/off — 카드번호 등은 off 권장 |
<form action="/goods/search" method="get" role="search">
<label for="keyword" class="sr-only">상품 검색</label>
<input type="search" id="keyword" name="keyword"
placeholder="브랜드, 상품명 입력" required>
<button type="submit">검색</button>
</form>
type="search"는 모바일에서 X 버튼 자동 제공<form action="/member/login" method="post">
<label for="user_id">아이디</label>
<input type="text" id="user_id" name="user_id"
autocomplete="username" required>
<label for="user_pw">비밀번호</label>
<input type="password" id="user_pw" name="user_pw"
autocomplete="current-password" required>
<label>
<input type="checkbox" name="remember" value="Y">
로그인 상태 유지
</label>
<button type="submit">로그인</button>
</form>
autocomplete="current-password" 있어야 브라우저/비밀번호 매니저가 인식new-password 사용<form action="/member/join" method="post">
<input type="email" name="email" required
placeholder="이메일">
<input type="password" name="password"
minlength="8" maxlength="20"
pattern="(?=.*[A-Za-z])(?=.*\d).{8,}"
title="영문+숫자 포함 8자 이상" required>
<input type="tel" name="phone"
pattern="010-?\d{4}-?\d{4}"
placeholder="010-0000-0000" required>
<input type="date" name="birth" max="2010-12-31">
<button type="submit">가입하기</button>
</form>
type="email" — 브라우저가 @ 포함 여부 자동 검증pattern — 정규식 검증title — 검증 실패 시 툴팁 메시지<form action="/cart/update" method="post">
<input type="hidden" name="cart_id" value="12345">
<label for="qty">수량</label>
<input type="number" id="qty" name="qty"
min="1" max="99" value="1" required>
<button type="submit">변경</button>
</form>
type="number" + min/max — 음수/초과 입력 차단type="hidden"<form action="/order/pay" method="post" id="orderForm">
<fieldset>
<legend>배송지 정보</legend>
<input type="text" name="receiver" placeholder="받는 분"
required autocomplete="name">
<input type="tel" name="receiver_phone"
placeholder="연락처" required autocomplete="tel">
<input type="text" name="zipcode" readonly required
autocomplete="postal-code">
<button type="button" onclick="openZipcodePopup()">우편번호 찾기</button>
<input type="text" name="addr1" readonly required
autocomplete="address-line1">
<input type="text" name="addr2" placeholder="상세주소"
autocomplete="address-line2">
<textarea name="delivery_memo" maxlength="50"
placeholder="배송 요청사항"></textarea>
</fieldset>
<fieldset>
<legend>결제수단</legend>
<label><input type="radio" name="pay_method" value="card" checked> 신용카드</label>
<label><input type="radio" name="pay_method" value="vbank"> 무통장입금</label>
<label><input type="radio" name="pay_method" value="kakao"> 카카오페이</label>
</fieldset>
<label>
<input type="checkbox" name="agree" required>
주문 내용 확인 및 결제 동의 (필수)
</label>
<button type="submit">결제하기</button>
</form>
<fieldset> + <legend> — 접근성에서 섹션 그룹핑autocomplete 속성 — 브라우저 자동완성 지원 (결제 컨버전 향상)<form action="/review/write" method="post" enctype="multipart/form-data">
<input type="hidden" name="goods_no" value="98765">
<select name="rating" required>
<option value="">별점 선택</option>
<option value="5">★★★★★</option>
<option value="4">★★★★</option>
<option value="3">★★★</option>
</select>
<textarea name="content" minlength="10" maxlength="1000"
required placeholder="10자 이상 작성"></textarea>
<input type="file" name="photos[]"
accept="image/*" multiple>
<button type="submit">리뷰 등록</button>
</form>
enctype="multipart/form-data" 필수accept="image/*" — 모바일에서 카메라/갤러리 바로 열림name="photos[]" — 다중 파일을 배열로 서버 수신요즘은 form을 써도 실제 제출은 Ajax로 처리하는 경우가 많다. 페이지 새로고침이 일어나면 SPA 흐름이 깨지기 때문이다. 그래서 "form의 기본 동작은 쓰되, 실제 전송만 JS가 가로채는" 방식이 자주 나온다. 엔터키 제출이나 접근성 같은 form의 이점은 그대로 가져가면서 제출 로직만 커스텀하는 패턴이다.
<form id="searchForm" action="/goods/search" method="get">
<input type="search" name="keyword" required>
<button type="submit">검색</button>
</form>
<script>
document.getElementById('searchForm').addEventListener('submit', function(e) {
e.preventDefault(); // 기본 제출 막기
const formData = new FormData(this);
const params = new URLSearchParams(formData);
fetch('/goods/search?' + params)
.then(res => res.json())
.then(data => renderResults(data));
});
</script>
FormData(this) — 폼 안의 모든 input 값을 한 번에 수집action이 있어서 fallback 작동form.addEventListener('submit', function(e) {
const price = this.querySelector('[name=min_price]').value;
if (Number(price) < 1000) {
e.preventDefault();
alert('최소 금액은 1,000원입니다.');
return;
}
// 통과하면 그대로 제출됨
});
form 안의 <button>은 기본값이 type="submit". 제출하면 안 되는 버튼(우편번호 찾기, 중복확인 등)은 반드시 type="button" 명시.
<button type="button" onclick="checkDuplicate()">중복확인</button>
<button type="submit">가입하기</button>
<form> 안에 <form> 넣으면 브라우저가 바깥 form 무시함.
단일 input만 있는 form에서 엔터치면 자동 제출됨. 원치 않으면:
<form onsubmit="return false;">
textarea에서 한글 조합 중 엔터 → 줄바꿈인지 제출인지 헷갈림. compositionstart/compositionend 이벤트로 구분 필요.
let isComposing = false;
textarea.addEventListener('compositionstart', () => isComposing = true);
textarea.addEventListener('compositionend', () => isComposing = false);
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !isComposing && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
| type | 용도 | 모바일 키패드 |
|---|---|---|
text | 일반 텍스트 | 기본 |
search | 검색어 | 기본 + X버튼 |
email | 이메일 | @ 포함 |
tel | 전화번호 | 숫자 패드 |
number | 수량, 금액 | 숫자 패드 |
password | 비밀번호 | 기본 (마스킹) |
date | 생년월일 | 날짜 피커 |
url | 홈페이지 주소 | .com 포함 |
file | 파일 업로드 | 카메라/갤러리 |
hidden | 숨김값 (상품번호 등) | - |
레거시 페이지를 리뉴얼하면서 느낀 건, 예전 개발자들이 <form>을 쓴 게 구식이라서가 아니라 그게 가장 안정적인 방식이라서였다는 점이다. 엔터키, 자동완성, 접근성, IME… 내가 div로 만들고 JS로 하나씩 막아가며 해결했던 문제들을 form은 태그 하나로 해결하고 있었다.
익숙하지 않다고 피하지 말고, 상황에 맞게 form을 쓸 수 있어야겠다는 생각이 들었다. 이 문서가 과거의 나 같은 사람한테 도움이 되면 좋겠다.