div로 다 되는데 왜 form을 써야 할까?

👉🏼 KIM·2026년 4월 14일

들어가며

사실 평소엔 <form> 태그를 잘 안 썼다. 로그인이든 검색이든 그냥 <div>로 감싸고 버튼에 이벤트 걸어서 JS로 값 긁어 보내는 식이었고, 동작은 잘 되니까 굳이 form을 써야 할 이유를 못 느꼈다.

그러다 최근에 레거시 페이지 리뉴얼 작업을 하면서 <form> 태그를 엄청 많이 보게 됐다. 처음엔 "요즘도 이렇게 form을 많이 쓰나?" 싶었는데, 찬찬히 뜯어보니 form이 엔터키 제출, 한글 IME 처리, 브라우저 자동완성, 스크린리더 인식 같은 걸 전부 공짜로 해결하고 있었다. 그동안 내가 div로 만들고 JS로 일일이 막고 있던 것들이다.

이 문서는 그때 정리해둔 내용.


1. form을 쓰는 이유

  • 엔터키 자동 제출 — input 포커스 상태에서 엔터 치면 submit 발생
  • 스크린리더 접근성 — "폼" 랜드마크로 인식됨
  • 브라우저 기본 검증required, type, pattern 속성
  • 한글 IME 처리 — 조합 중 엔터와 제출용 엔터를 브라우저가 구분
  • FormData로 전체 값 일괄 수집 가능

2. 기본 구조

<form action="/order/submit" method="post">
    <input type="text" name="buyer_name" required>
    <button type="submit">주문하기</button>
</form>
속성설명
action제출될 URL
methodget (검색/필터) / post (결제/가입)
enctype파일 업로드 시 multipart/form-data
autocompleteon/off — 카드번호 등은 off 권장

3. 실전 예제

3-1. 상품 검색 (GET)

<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>
  • GET 쓰면 URL에 쿼리스트링 남아서 즐겨찾기/공유 가능
  • type="search"는 모바일에서 X 버튼 자동 제공

3-2. 로그인

<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 사용

3-3. 회원가입 (검증 속성 활용)

<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 — 검증 실패 시 툴팁 메시지

3-4. 장바구니 수량 변경

<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 — 음수/초과 입력 차단
  • 상품 ID 같은 값은 type="hidden"

3-5. 주문/결제 (배송지)

<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 속성 — 브라우저 자동완성 지원 (결제 컨버전 향상)

3-6. 상품 이미지 업로드 (리뷰 작성)

<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[]" — 다중 파일을 배열로 서버 수신

4. JS와 함께 쓰는 패턴

요즘은 form을 써도 실제 제출은 Ajax로 처리하는 경우가 많다. 페이지 새로고침이 일어나면 SPA 흐름이 깨지기 때문이다. 그래서 "form의 기본 동작은 쓰되, 실제 전송만 JS가 가로채는" 방식이 자주 나온다. 엔터키 제출이나 접근성 같은 form의 이점은 그대로 가져가면서 제출 로직만 커스텀하는 패턴이다.

4-1. 제출 가로채서 Ajax로 보내기

<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 값을 한 번에 수집
  • JS가 안 되는 환경에서도 action이 있어서 fallback 작동

4-2. 제출 전 커스텀 검증

form.addEventListener('submit', function(e) {
    const price = this.querySelector('[name=min_price]').value;

    if (Number(price) < 1000) {
        e.preventDefault();
        alert('최소 금액은 1,000원입니다.');
        return;
    }
    // 통과하면 그대로 제출됨
});

5. 주의사항

버튼 type 명시

form 안의 <button>기본값이 type="submit". 제출하면 안 되는 버튼(우편번호 찾기, 중복확인 등)은 반드시 type="button" 명시.

<button type="button" onclick="checkDuplicate()">중복확인</button>
<button type="submit">가입하기</button>

form 중첩 금지

<form> 안에 <form> 넣으면 브라우저가 바깥 form 무시함.

엔터키 의도치 않은 제출 방지

단일 input만 있는 form에서 엔터치면 자동 제출됨. 원치 않으면:

<form onsubmit="return false;">

한글 IME 이슈

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();
    }
});

6. input type 치트시트 (자주 쓰는 것)

type용도모바일 키패드
text일반 텍스트기본
search검색어기본 + X버튼
email이메일@ 포함
tel전화번호숫자 패드
number수량, 금액숫자 패드
password비밀번호기본 (마스킹)
date생년월일날짜 피커
url홈페이지 주소.com 포함
file파일 업로드카메라/갤러리
hidden숨김값 (상품번호 등)-

마무리

레거시 페이지를 리뉴얼하면서 느낀 건, 예전 개발자들이 <form>을 쓴 게 구식이라서가 아니라 그게 가장 안정적인 방식이라서였다는 점이다. 엔터키, 자동완성, 접근성, IME… 내가 div로 만들고 JS로 하나씩 막아가며 해결했던 문제들을 form은 태그 하나로 해결하고 있었다.

익숙하지 않다고 피하지 말고, 상황에 맞게 form을 쓸 수 있어야겠다는 생각이 들었다. 이 문서가 과거의 나 같은 사람한테 도움이 되면 좋겠다.

profile
프론트는 순항중 ¿¿

0개의 댓글