jQuery는 DOM 선택/조작, 이벤트 처리, Ajax를 간결하게 제공하는 JavaScript 라이브러리다. 이 글은 실무에서 폼 입력값을 빠르게 수집하고 JSON 객체로 변환하는 데 유용한 메서드(.val(), .prop(), .attr(), .serialize(), .serializeArray())를 중심으로 정리한다.

이 글을 읽기 전에 알아 둘 HTML · 자바스크립트 기초

예제 코드는 HTML 화면 하나와 그 화면을 다루는 자바스크립트로 되어 있다. 자바만 알면 처음 보는 것이 많아서, 글 전체에 반복해서 나오는 것을 여기서 한 번에 정리한다. jQuery를 쓰려면 HTML 페이지에서 jQuery 파일을 먼저 불러와야 하는데, 이 글은 그 준비가 끝난 화면을 전제로 한다.

폼과 HTML 요소는 화면의 입력 칸이다

  • HTML 태그 하나(<input ... />)가 요소 하나다. <input type="text" name="username" value="taegyu" />에서 type · name · value는 HTML 속성이다. type은 칸의 종류(글자 · 숫자 · 체크박스 · 라디오 · 파일 등), name은 값을 보낼 때 붙는 이름표, value는 칸에 든 값이다
  • checked(처음부터 체크됨), selected(처음부터 선택됨), disabled(사용자가 건드릴 수 없고 제출에서도 빠짐), multiple(여러 개 고르기), required(비어 있으면 제출이 안 됨), placeholder(빈 칸에 보이는 안내 글자), accept(파일 칸에서 고를 수 있는 파일 종류)도 속성이다. <!-- ... -->는 HTML 주석이다. <select>의 size="5"는 접힌 목록 대신 다섯 줄이 보이는 목록 상자로 그리라는 뜻이다
  • 예제에 나오는 다른 태그 — <label>(글자와 입력 칸을 묶는다. 안에 칸을 넣으면 글자를 눌러도 그 칸이 눌린다), <div>(여러 요소를 묶는 상자), <span>(글 속의 작은 칸), <h3>(제목), <hr/>(가로줄), <ul> · <li>(목록과 그 한 줄), <option>(<select> 안의 선택지 하나), <a href="/home">(누르면 href에 적힌 주소로 가는 링크)
  • 폼(<form>)은 입력 칸 묶음이다. <button type="submit">을 누르면 submit 이벤트가 일어나고, 그대로 두면 브라우저가 폼 값을 서버로 직접 제출한다. <button type="button">은 눌러도 제출하지 않는 버튼이다. 이 글의 예제는 대부분 e.preventDefault()로 이 기본 제출을 막고, jQuery로 값을 모아 직접 보낸다
  • <style> 안의 글과 style="..."은 화면 모양을 정하는 CSS다. 이 글의 CSS는 색 · 숨김 같은 모양만 정하므로 건너뛰어도 흐름을 따라갈 수 있다. class="email-row"의 클래스는 CSS가 쓰는 이름표이고 자바 클래스와 상관없다. .addClass() · .removeClass()는 이 이름표를 붙였다 뗀다

$() 안의 문자열은 요소를 고르는 선택자다

$("...")에 넣는 문자열은 CSS 선택자다. 이 글에 나오는 모양은 이것뿐이다.

  • #userForm — id="userForm"인 요소
  • .email-row — class="email-row"인 요소
  • input, select, form — 그 태그인 요소 전부
  • [name=username] — name 속성이 username인 요소. input[name=username]처럼 태그와 붙이면 「name이 username인 input」이다. 값에 [] 같은 기호가 들어 있으면 [name='emails[]']처럼 값을 따옴표로 감싼다. [multiple]처럼 값 없이 이름만 쓰면 「그 속성이 붙어 있는 요소」다
  • :checked — 체크 · 선택된 것만, :input — 입력 요소(input · textarea · select · button) 전부, :checkbox — 체크박스만
  • "#a, #b" — 쉼표는 「둘 중 어느 것이든」이다
  • "#draftForm :input" — 공백은 「앞 요소 안에 든」이다

$()가 돌려주는 것은 jQuery 객체다

  • $는 jQuery 함수의 짧은 이름이다. $("선택자")는 고른 요소들을 담은 jQuery 객체를 돌려준다. .length가 고른 개수이고 못 찾으면 0이다. [0]은 첫 번째 원래 요소(DOM 요소)를 꺼낸다
  • $(this) · $(el)은 원래 요소를 jQuery 객체로 감싸 .val() 같은 jQuery 메서드를 쓰게 만든다. 감싸지 않은 원래 요소는 el.value처럼 브라우저가 주는 속성을 바로 읽는다
  • $(`<div>...</div>`)처럼 <로 시작하는 HTML 문자열을 넣으면 고르는 대신 새 요소를 만든다. .append(새 요소)가 그것을 화면의 어떤 요소 안 끝에 붙인다. .append('<option>...</option>')처럼 HTML 문자열을 바로 넘겨도 된다. .empty()는 반대로 그 요소 안의 것을 전부 지운다
  • .find(선택자)는 고른 요소 안쪽에서 선택자에 맞는 요소를 찾는다. .closest(선택자)는 반대로 자기 자신부터 바깥쪽으로 올라가며 맞는 첫 요소를 찾는다
  • $.each() · $.ajax()처럼 $. 뒤에 붙는 것은 고른 요소와 상관없는 도구 함수다. $("form").serialize()처럼 $(...) 뒤에 붙는 것은 고른 요소에 대해 동작한다
  • $(document).ready(함수)는 브라우저가 HTML을 다 읽어 DOM이 준비된 뒤에 그 함수를 실행한다. $(window)는 브라우저 창 자체다
  • .text("글")은 글을 글자 그대로 넣고, .html("<li>...</li>")는 HTML로 해석해 요소로 넣는다. .css("color", "green")은 그 요소의 CSS 값 하나를 바꾼다

.on()은 이벤트가 일어났을 때 실행할 함수를 건다

  • $("form").on("submit", function (e) { ... })는 「이 폼에서 submit 이벤트가 일어나면 이 함수를 실행하라」다. 이렇게 거는 것을 바인딩이라고 한다. e는 무슨 일이 일어났는지 담은 이벤트 객체이고, e.target은 이벤트가 실제로 일어난 요소다
  • 이 글에 나오는 이벤트는 submit(폼 제출), click(클릭), input(칸의 값이 바뀔 때마다), change(체크박스를 바꾸거나, 글자 칸이면 값을 바꾸고 칸을 떠날 때), blur(칸에서 커서가 빠져나갈 때), beforeunload(페이지를 떠나려 할 때)다. "input blur"처럼 공백으로 이으면 둘 다에 같은 함수를 건다
  • 같은 요소 · 같은 이벤트에 함수를 여러 번 걸면 건 순서대로 전부 실행된다
  • .on("click", ".remove-email", 함수)처럼 가운데에 선택자를 하나 더 넣으면 이벤트 위임이다

자바와 다른 자바스크립트 문법

  • setTimeout(함수, 300) — 300밀리초 뒤에 함수를 실행하도록 예약하고, 그 예약의 번호를 돌려준다. 그 번호를 clearTimeout(번호)에 넘기면 예약이 취소된다. 번호가 아직 없거나(undefined) 이미 끝난 예약이면 clearTimeout은 아무 일도 하지 않는다
  • const · let — 변수를 만든다. 자료형은 적지 않는다. const는 다시 넣을 수 없고 let은 다시 넣을 수 있다
  • function formToJSON($form) { ... } — 함수 선언이다. 반환 타입과 매개변수 타입을 적지 않는다. $form처럼 $로 시작하는 이름은 「jQuery 객체를 담은 변수」라는 관례일 뿐이다
  • {} · { name: "a" } — 클래스 없이 그 자리에서 객체를 만드는 객체 리터럴이다. { url, method }는 { url: url, method: method }의 줄임이다. []는 빈 배열이고 .push(v)는 배열 끝에 v를 더한다
  • 배열 메서드 — arr.forEach(함수)는 요소를 하나씩 함수에 넘긴다(자바의 향상된 for 문과 같은 일). arr.map(함수)는 요소마다 함수를 불러 그 결과로 새 배열을 만든다. arr.filter(함수)는 함수가 true를 돌려준 요소만 남긴 새 배열을 만든다. 예를 들어 [1, 2, 3].map(v => v * 2)는 [2, 4, 6], ["a", ""].filter(v => v !== "")는 ["a"]다
  • === · !== — 타입까지 같은지 보는 일치 연산자다
  • undefined — 「아직 값이 없음」이다. 객체에 없는 속성을 읽으면 에러 대신 undefined가 나온다. null은 「없다」고 일부러 넣는 값이고, null === undefined는 false다
  • if (errors.length), !value — 자바스크립트는 조건 자리에 숫자 · 문자열도 넣는다. 0 · 빈 문자열 · null · undefined는 거짓으로 친다(참 같은 값 · 거짓 같은 값). a || b는 a가 거짓 같은 값이면 b를 쓰는 기본값 표현이고, a && b는 a가 참 같은 값일 때만 b를 본다
  • `진행률: ${percent}%` — 백틱 문자열 안에 값을 끼워 넣는 템플릿 리터럴이다
  • function (message, duration = 3000) — 인자를 안 넘기면 3000이 들어가는 기본값 매개변수다
  • NaN — 「숫자가 아님」을 뜻하는 숫자 값이다. parseFloat("abc")가 NaN이다. isNaN(n)은 NaN인지, Number.isFinite(n)은 정상적인 숫자인지 묻는다
  • JSON.stringify(객체)는 객체를 JSON 문자열로, JSON.parse(문자열)은 그 반대로 바꾼다
  • console.log(...)는 브라우저 콘솔에 찍는다(자바의 System.out.println). console.warn · console.error도 같은 곳에 찍되 경고 · 오류로 표시한다. alert("글")은 확인 버튼 하나짜리 알림 창을, confirm("글")은 확인 · 취소 창을 띄우고 확인이면 true, 취소면 false를 돌려준다
  • /^\d+$/ — 슬래시로 감싼 것은 정규식이다. ^는 문자열 시작, $는 끝, \d는 숫자 한 글자, +는 「앞의 것이 하나 이상」이다. 그래서 /^\d+$/는 「처음부터 끝까지 숫자만」이고, .test(v)는 v가 그 모양이면 true를 돌려준다

기본 명령어

명령어 설명 예제
$() CSS 선택자로 DOM을 선택 $("#formId")
.val() 입력/선택 요소의 값 읽기/설정 $("input[name=username]").val()
.prop() checked/disabled처럼 요소가 지금 가지고 있는 참/거짓 상태(DOM 속성)를 읽기/설정 $("#agree").prop("checked", true)
.attr() HTML 속성 읽기/설정 $("a").attr("href", "/home")
:checked 체크된 라디오/체크박스/옵션 선택 $("input[name=role]:checked")
.serialize() 폼 데이터를 URL 인코딩 문자열(username=taegyu&age=29)로 직렬화 $("form").serialize()
.serializeArray() 폼 name-value 배열로 직렬화 $("form").serializeArray()
$.each() 배열/객체/jQuery 객체 순회 $.each(arr, (i, v) => { ... })
$.map() 배열/컬렉션 변환 $.map(arr, v => v * 2)
.on() 이벤트 바인딩/위임 $("form").on("submit", fn)
$.ajax() Ajax 요청 $.ajax({ url, method, data })

표에 나온 (i, v) => { ... }처럼 화살표(=>)를 쓴 코드는 자바스크립트의 화살표 함수다. 자바의 람다식((i, v) -> { ... })과 거의 같은 역할을 하는 익명 함수이고, $.each(arr, (i, v) => { ... })는 “arr를 하나씩 돌면서, 그 자리(인덱스) i와 값 v를 받아 { } 안의 코드를 실행하라”는 뜻이다. 아래 예제 코드에서도 이 화살표 함수가 계속 나온다.

사용 예제

<!-- 테스트 폼 -->
<form id="userForm">
  <input type="text" name="username" value="taegyu" />
  <input type="number" name="age" value="29" />

  <!-- 체크박스 그룹 (다중값) -->
  <label><input type="checkbox" name="skills" value="js" checked /> JS</label>
  <label><input type="checkbox" name="skills" value="java" /> Java</label>
  <label><input type="checkbox" name="skills" value="sql" checked /> SQL</label>

  <!-- 라디오 그룹 (단일값) -->
  <label><input type="radio" name="role" value="admin" /> Admin</label>
  <label><input type="radio" name="role" value="user" checked /> User</label>

  <!-- select (single/multiple) -->
  <select name="dept">
    <option value="dev" selected>Dev</option>
    <option value="ops">Ops</option>
  </select>

  <select name="tags" multiple>
    <option value="A" selected>A</option>
    <option value="B">B</option>
    <option value="C" selected>C</option>
  </select>

  <textarea name="memo"> hello world </textarea>

  <!-- 파일 입력은 serialize에 포함되지 않음 -->
  <input type="file" name="avatar" />

  <!-- disabled 필드는 serialize에 포함되지 않음 -->
  <input type="text" name="hiddenInfo" value="secret" disabled />
</form>
// 1) serializeArray로 name-value 배열 → JSON 객체 만들기
function formToJSON($form) {
  const obj = {};
  $form.serializeArray().forEach(({ name, value }) => {
    const v = typeof value === 'string' ? value.trim() : value;
    if (obj[name] !== undefined) {
      if (!Array.isArray(obj[name])) obj[name] = [obj[name]];
      obj[name].push(v);
    } else {
      obj[name] = v;
    }
  });
  return obj;
}

const data1 = formToJSON($("#userForm"));
console.log("data1", data1);
// 출력 예시:
// {
//   username: "taegyu",
//   age: "29",
//   skills: ["js", "sql"],
//   role: "user",
//   dept: "dev",
//   tags: ["A", "C"],
//   memo: "hello world"
// }
// age가 숫자 29가 아니라 문자열 "29"인 이유: 입력 칸의 값은 type="number"라도
// 늘 문자열이다. 숫자로 바꾸는 일은 아래 3) coerceTypes가 한다

// 2) 선택자 기반 수집: 체크박스/라디오/셀렉트 세부 제어
const data2 = {
  username: $("input[name=username]").val()?.trim() ?? null,
  age: $("input[name=age]").val(),
  skills: $("input[name=skills]:checked").map((_, el) => el.value).get(),
  role: $("input[name=role]:checked").val() ?? null,
  dept: $("select[name=dept]").val(),
  tags: $("select[name=tags]").val() || [], // multiple: 선택 없으면 jQuery 3.0부터 빈 배열, 그 전엔 null
  memo: $("textarea[name=memo]").val()?.trim() ?? null,
};
console.log("data2", data2);

한 줄씩 따라가기

  • $form.serializeArray().forEach(({ name, value }) => {...}) — .serializeArray()가 돌려주는 배열의 각 항목은 {name: "username", value: "taegyu"} 같은 객체다. 매개변수 자리의 { name, value }는 구조 분해(destructuring)라는 문법으로, 그 객체 안의 name과 value 값을 바로 꺼내 같은 이름의 변수로 쓰겠다는 뜻이다. 풀어 쓰면 (item) => { const name = item.name; const value = item.value; ... }와 같다.
  • typeof value === 'string' — typeof는 어떤 값이 어떤 자료형인지(문자열인지, 숫자인지 등) 문자열로 알려주는 연산자다. “지금 이 값이 문자열이면 공백을 지워라”를 판단하는 데 쓴다. .serializeArray()는 체크박스가 여러 개 체크돼도 항목을 하나씩 따로(값은 문자열로) 돌려주므로, 이 자리의 value는 사실상 늘 문자열이다. 이 검사는 문자열이 아닌 값이 들어와도 .trim()에서 에러가 나지 않게 하는 안전장치다.
  • obj[name], obj[name] = [obj[name]] — 자바에서 배열 인덱스에 쓰는 대괄호 []를 자바스크립트는 객체의 속성에 접근할 때도 쓴다. obj[name]은 obj.username처럼 점으로 접근하는 것과 같은데, name이 "username"처럼 변수에 담긴 문자열일 때는 점 표기를 쓸 수 없어 대괄호 표기를 쓴다. 같은 이름의 체크박스가 여러 번 나오면(obj[name] !== undefined), 기존 값을 배열로 감싸([obj[name]]) 새 값을 추가로 담는다.
  • $("input[name=username]").val()?.trim() ?? null — ?.(옵셔널 체이닝)은 그 앞의 값이 null이나 undefined면 뒤의 호출을 하지 않고 그대로 undefined를 돌려주는 연산자다. .val()이 값을 못 찾아 undefined를 돌려줘도 .trim()을 호출하다 에러가 나지 않게 막아 준다. ??(널 병합)는 왼쪽 값이 null이나 undefined일 때만 오른쪽 값을 대신 쓰는 연산자로, 빈 문자열("")처럼 값이 있긴 하지만 비어 있는 경우는 그대로 둔다.
  • $("input[name=skills]:checked").map((_, el) => el.value).get() — jQuery의 .map()은 배열의 .map()처럼 각 요소를 변환하지만, 변환한 결과를 jQuery 객체로 돌려준다는 점이 다르다. 뒤에 .get()을 붙여야 비로소 평범한 자바스크립트 배열로 바뀐다. (_, el) => el.value에서 첫 번째 매개변수 _는 “여기서는 쓰지 않는 값(인덱스)”이라는 관례적인 이름이고, 실제로 쓰는 건 두 번째 매개변수 el(체크된 요소)이다.

선택자 기반 수집 집중 정리 (가독성 우선)

입력 유형 선택자/패턴 설명 예제
텍스트 $("input[name=username]").val()?.trim() ?? null 좌우 공백 제거, 비어있으면 null 사용자명 수집
숫자 parseFloat($("input[name=age]").val() || "") || null 숫자 변환 실패 시 null 나이 수집
체크박스 그룹 $("input[name=skills]:checked").map((_, el) => el.value).get() 체크된 값만 배열로 수집 스킬 배열
라디오 그룹 $("input[name=role]:checked").val() ?? null 선택된 단일 값 수집 역할 수집
셀렉트(단일) $("select[name=dept]").val() 선택된 옵션 값 부서 수집
셀렉트(다중) $("select[name=tags]").val() || [] 선택된 옵션 배열, 없으면 빈 배열 태그 배열
텍스트영역 $("textarea[name=memo]").val()?.trim() ?? null 공백 제거 메모 수집
파일 $("input[name=avatar]")[0]?.files?.[0] ?? null 첫 파일 객체 취득 아바타 파일
비활성 필드 const $el = $("[name=hiddenInfo]"); $el.prop("disabled") ? null : $el.val() disabled이면 제외 숨김정보

표에서 처음 나온 것 둘만 짚는다.

  • 숫자 행 — parseFloat(문자열)은 문자열을 숫자로 바꾸고, 바꿀 수 없으면 NaN을 돌려준다. NaN은 거짓 같은 값이라 뒤의 || null이 null을 대신 넣는다. 앞의 || ""는 .val()이 undefined(칸을 못 찾음)일 때 빈 문자열을 대신 넘기는 장치다
  • 파일 행 — [0]으로 jQuery 객체에서 원래 <input> 요소를 꺼내고, 그 요소의 .files(사용자가 고른 파일 목록)에서 첫 파일을 꺼낸다. ?.[0]은 ?.의 대괄호판이다. 앞의 값이 null이나 undefined면 꺼내지 않고 undefined를 돌려준다

유틸리티: 선언형 필드 매핑으로 수집

「선언형」은 수집하는 순서와 방법을 코드로 늘어놓는 대신, 「필드 이름 → 그 값을 읽는 함수」 표를 객체 하나로 적어 두는 방식이다. 필드가 늘면 표에 한 줄만 더하면 된다.

// 선언형 매핑으로 각 필드의 수집 로직을 명확히
const fieldGetters = {
  username: () => $("input[name=username]").val()?.trim() ?? null,
  age: () => {
    const v = $("input[name=age]").val();
    const n = v === undefined ? NaN : parseFloat(v);
    return Number.isFinite(n) ? n : null;
  },
  skills: () => $("input[name=skills]:checked").map((_, el) => el.value).get(),
  role: () => $("input[name=role]:checked").val() ?? null,
  dept: () => $("select[name=dept]").val(),
  tags: () => $("select[name=tags]").val() || [],
  memo: () => $("textarea[name=memo]").val()?.trim() ?? null,
  avatar: () => $("input[name=avatar]")[0]?.files?.[0] ?? null,
};

function collectBySelectors(getters) {
  const entries = Object.entries(getters).map(([key, fn]) => [key, fn()]);
  return Object.fromEntries(entries);
}

const dataSelectors = collectBySelectors(fieldGetters);
console.log("dataSelectors", dataSelectors);
  • username: () => ... — 값 자리에 화살표 함수를 넣었다. 이 객체는 값을 들고 있지 않고 「값을 읽는 방법」을 들고 있다. 실제로 읽는 건 fn()으로 부를 때다
  • Object.entries(getters) — 객체를 [["username", 함수], ["age", 함수], ...]처럼 [이름, 값] 쌍의 배열로 바꾼다
  • .map(([key, fn]) => [key, fn()]) — 쌍 하나를 받아 대괄호 구조 분해로 key와 fn을 꺼내고, 함수를 실제로 불러 [이름, 읽은 값] 쌍으로 바꾼다
  • Object.fromEntries(entries) — Object.entries의 반대다. 쌍의 배열을 다시 { username: "taegyu", age: 29, ... } 객체로 만든다

필수값 검증과 에러 핸들링 패턴

function validateRequired(obj, requiredKeys) {
  const errors = [];
  requiredKeys.forEach(k => {
    const v = obj[k];
    const emptyArray = Array.isArray(v) && v.length === 0;
    const emptyString = typeof v === 'string' && v.trim() === '';
    if (v === null || v === undefined || emptyArray || emptyString) errors.push(k);
  });
  return errors;
}

const requiredErrors = validateRequired(dataSelectors, ["username", "role", "dept"]);
if (requiredErrors.length) {
  console.warn("필수값 누락:", requiredErrors);
}

팁

  • .val() 반환 타입: select[multiple]에서 .val()은 배열을 반환, 단일 select는 문자열.
  • 라디오/체크박스: :checked를 반드시 사용하여 선택된 요소만 대상으로 값 추출.
  • 파일: 직렬화에 포함되지 않으므로 업로드는 FormData 사용(아래 4번 코드).
  • disabled: 비활성화된 필드는 브라우저가 폼을 서버로 제출할 때도 빠지므로, 직접 수집할 때도 제외 처리.
// 3) 값 타입 보정: 숫자/불리언으로 변환
function coerceTypes(obj) {
  const out = {};
  $.each(obj, (key, value) => {
    const coerce = v => {
      if (v === null || v === undefined) return null;
      if (v === "") return null; // 빈 문자열 → null
      if (/^\d+$/.test(v)) return Number(v); // 정수
      if (/^\d+\.\d+$/.test(v)) return Number(v); // 실수
      if (v === "true" || v === true) return true;
      if (v === "false" || v === false) return false;
      return v;
    };
    if (Array.isArray(value)) out[key] = value.map(coerce);
    else out[key] = coerce(value);
  });
  return out;
}

const data3 = coerceTypes(data1);
console.log("data3", data3);
// 출력: data1에서 age만 바뀐다
// { username: "taegyu", age: 29, skills: ["js", "sql"], role: "user",
//   dept: "dev", tags: ["A", "C"], memo: "hello world" }

// 4) 파일 포함 제출: FormData 사용 (serialize는 파일을 포함하지 않음)
$("#userForm").on("submit", function (e) {
  e.preventDefault();
  const fd = new FormData(this); // disabled 제외, 파일 포함
  // 필요 시 JSON과 FormData를 병합해 전송
  // json은 아래 주석 처리된 headers 줄에서만 쓰는 예시 값이다
  const json = formToJSON($(this));

  $.ajax({
    url: "/api/users",
    method: "POST",
    data: fd,
    processData: false, // FormData 처리
    contentType: false,
    // 필요하면 폼 값을 JSON으로 만들어 요청 헤더에 덧붙여 보낼 수도 있다
    // headers: { "X-JSON": JSON.stringify(json) },
    success: resp => console.log("OK", resp),
    error: err => console.error("ERR", err)
  });
});

한 줄씩 따라가기

  • /^\d+$/.test(v), /^\d+\.\d+$/.test(v) — 앞은 「숫자만」, 뒤는 「숫자 + 점(\.) + 숫자」다. 둘 중 하나에 맞으면 Number(v)로 숫자로 바꾼다
  • e.preventDefault(); — 브라우저가 폼을 직접 제출하는 기본 동작을 막는다. 막지 않으면 아래 $.ajax가 보내기도 전에 브라우저가 폼을 제출해 버린다
  • const fd = new FormData(this); — this는 지금 제출된 <form> 요소를 가리킨다. FormData는 그 폼 안의 모든 입력값(파일 포함)을 하나로 묶어 서버에 보낼 수 있게 만든 객체로, 문자열만 다루는 .serialize()와 달리 <input type="file">의 파일 내용까지 그대로 담는다.
  • $.ajax({ url: ..., method: "POST", data: fd, ... }) — HTTP 요청을 보내는 jQuery 함수다. url은 요청을 보낼 서버 주소다. "/api/users"처럼 /로 시작하고 https://...가 없으면 「지금 이 페이지를 준 서버의 그 경로」라는 뜻이다(/api/는 서버가 정한 경로 이름일 뿐이다). method는 “POST”(서버에 새 데이터를 보낸다는 뜻의 방식), data는 그 요청에 실어 보낼 내용이다. 서버는 이 요청을 받아 처리한 뒤 성공인지 실패인지를 응답으로 돌려준다.
  • processData: false — jQuery는 평소 data에 넣은 객체를 자동으로 URL 형식의 문자열로 바꿔서 보내는데, data가 이미 FormData(파일 포함)일 때는 그렇게 바꾸면 안 되므로 이 자동 변환을 꺼 두는 옵션이다.
  • contentType: false — jQuery가 자동으로 설정하는 요청의 “이 내용이 어떤 형식이다”라는 정보(Content-Type 헤더)도 끄는 옵션이다. FormData는 값 여러 개를 구분 문자열(경계, boundary)로 칸칸이 나눠 담는 multipart/form-data 형식으로 보내는데, 그 구분 문자열을 Content-Type에 같이 적어야 서버가 칸을 나눌 수 있다. 브라우저가 이 값을 알아서 채워 주므로, jQuery가 기본값(application/x-www-form-urlencoded)으로 덮어쓰지 않게 막아야 한다.
  • success: resp => ..., error: err => ... — 응답이 돌아온 뒤 jQuery가 부르는 함수다. 요청이 성공하면 success가 서버가 보낸 내용을 받고, 실패하면 error가 불린다. $.ajax() 자체는 응답을 기다리지 않고 바로 끝난다.

고급 활용

  • 다중 값 처리: 동일한 name을 가진 체크박스/멀티 셀렉트는 배열로 수집한다.
  • 비활성/숨김 필드: .serialize()/.serializeArray()는 disabled 필드와 체크되지 않은 체크박스/라디오를 제외한다.
  • 속성 제어: 선택/활성화 등 상태 값은 .attr()가 아닌 .prop()을 사용한다. HTML에 적힌 checked 속성은 처음 상태일 뿐이라, 사용자가 체크를 풀어도 .attr("checked")는 계속 "checked"를 돌려준다. 지금 체크돼 있는지는 클릭할 때마다 바뀌는 .prop("checked")(true/false)만 알려 준다.
  • 네임 규칙: user[name] 같은 브래킷 표기 네임은 기본 직렬화가 { user: { name: ... } } 같은 중첩 객체가 아니라 "user[name]"이라는 이름 하나로 나온다(평면 구조). 그래서 받는 쪽이 대괄호를 어떻게 읽을지 정해 둬야 한다. 브라우저 쪽에서 직접 읽는 방법이 아래 3번 예제다.
  • 성능: 이벤트 위임(.on('click', '.class', handler))과 구체적 선택자를 사용해 불필요한 DOM 탐색(맞는 요소를 찾으려고 DOM 나무를 뒤지는 일)을 줄인다.

커스텀 직렬화(트림/타입 변환 포함)

function formToJSONStrict($form) {
  const obj = formToJSON($form);
  return coerceTypes(obj); // 위 예제의 타입 보정 재사용
}

const strict = formToJSONStrict($("#userForm"));
console.log(strict);

실무 활용 예시

1. 파일 업로드 + JSON 데이터 함께 전송

FormData를 사용하여 파일과 JSON 데이터를 함께 전송하는 패턴이다.

<form id="profileForm">
  <input type="text" name="username" placeholder="사용자명" />
  <input type="email" name="email" placeholder="이메일" />
  
  <!-- 단일 파일 -->
  <label>프로필 사진:
    <input type="file" name="avatar" accept="image/*" />
  </label>
  
  <!-- 다중 파일 -->
  <label>첨부 문서:
    <input type="file" name="documents" multiple accept=".pdf,.doc,.docx" />
  </label>
  
  <button type="submit">제출</button>
  <span id="progress"></span> <!-- 업로드 진행률을 글자로 보여줄 자리 -->
</form>
$("#profileForm").on("submit", function(e) {
  e.preventDefault();
  
  const $form = $(this);
  const formData = new FormData(this); // 모든 필드 포함 (파일 포함)
  
  // 추가 JSON 데이터를 FormData에 추가
  const metadata = {
    timestamp: new Date().toISOString(),
    source: "web",
    version: "1.0"
  };
  formData.append("metadata", JSON.stringify(metadata));
  
  // JSON을 별도 Blob으로도 추가 (예시라 문자열 방식과 Blob 방식을 둘 다 넣었다.
  // 실제로는 서버가 읽는 방식에 맞춰 하나만 쓴다)
  const jsonBlob = new Blob([JSON.stringify(formToJSON($form))], {
    type: "application/json"
  });
  formData.append("formData", jsonBlob);
  
  $.ajax({
    url: "/api/profile",
    method: "POST",
    data: formData,
    processData: false, // jQuery가 데이터를 처리하지 않도록
    contentType: false, // jQuery가 Content-Type을 설정하지 않도록
    xhr: function() {
      const xhr = new window.XMLHttpRequest();
      // 업로드 진행률 표시
      xhr.upload.addEventListener("progress", function(e) {
        if (e.lengthComputable) {
          const percent = Math.round((e.loaded / e.total) * 100);
          console.log(`업로드 진행률: ${percent}%`);
          $("#progress").text(`${percent}%`);
        }
      });
      return xhr;
    },
    success: function(response) {
      console.log("업로드 성공:", response);
      alert("프로필이 업데이트되었습니다!");
    },
    error: function(xhr, status, error) {
      console.error("업로드 실패:", error);
      alert("업로드 중 오류가 발생했습니다.");
    }
  });
});

// 여러 파일 정보 수집하기
function getFileInfo(inputName) {
  const files = $(`input[name="${inputName}"]`)[0]?.files || [];
  return Array.from(files).map(file => ({
    name: file.name,
    size: file.size,
    type: file.type,
    lastModified: new Date(file.lastModified)
  }));
}

// 사용자가 파일을 고른 뒤에 불러야 의미가 있다(고르기 전에는 빈 배열이 나온다)
const avatarInfo = getFileInfo("avatar");
const documentsInfo = getFileInfo("documents");
console.log("아바타:", avatarInfo);
console.log("문서들:", documentsInfo);

한 줄씩 따라가기

  • new Date().toISOString() — 지금 시각을 2026-01-19T03:00:00.000Z처럼 국제 표준(ISO 8601) 모양의 문자열로 바꾼다. 끝의 Z는 한국 시간이 아니라 세계 표준시(UTC)라는 표시다
  • formData.append("metadata", JSON.stringify(metadata)) — append(이름, 값)은 FormData에 칸 하나를 더한다. 객체는 그대로 못 넣으므로 JSON.stringify로 문자열로 바꿔 넣는다
  • const jsonBlob = new Blob([JSON.stringify(formToJSON($form))], {type: "application/json"}); — Blob은 텍스트나 바이너리 데이터를 파일처럼 다룰 수 있게 묶은 객체다. 여기서는 JSON 문자열을 하나의 “파일 비슷한 덩어리”로 만들어, formData.append("formData", jsonBlob)로 FormData 안에 파일처럼 끼워 넣을 수 있게 한다. type: "application/json"은 이 덩어리가 JSON이라는 MIME 타입 이름표다.
  • xhr: function() { const xhr = new window.XMLHttpRequest(); ... return xhr; } — jQuery의 $.ajax()는 내부적으로 XMLHttpRequest(브라우저가 서버와 통신할 때 쓰는 객체)를 만들어 요청을 보낸다. 이 옵션은 그 내부 객체를 직접 꺼내서, 업로드가 진행되는 도중에 발생하는 progress 이벤트(지금까지 몇 퍼센트 전송됐는지 알려주는 이벤트)를 추가로 연결하기 위한 것이다. 보통의 $.ajax() 호출에서는 이 옵션을 쓸 일이 없고, 업로드 진행률처럼 세밀한 제어가 필요할 때만 쓴다. $("#progress").text(...)는 위 HTML의 <span id="progress">에 퍼센트를 글자로 넣는다.
  • xhr.upload.addEventListener("progress", function(e) {...}) — addEventListener는 jQuery 없이 브라우저가 직접 주는 이벤트 걸기 메서드로, .on()과 같은 일을 한다(이벤트 리스너). xhr.upload는 요청 중 「올려 보내는 쪽」만 따로 가리키는 객체라, 여기에 건 progress는 업로드 진행을 알려 준다. e.loaded는 지금까지 보낸 바이트 수, e.total은 전체 바이트 수, e.lengthComputable은 전체 크기를 알 수 있어서 비율 계산이 되는지(true/false)다
  • file.size는 파일 크기(바이트), file.lastModified는 파일을 마지막으로 고친 시각을 1970년 1월 1일 0시부터 센 밀리초 숫자로 준다. new Date(그 숫자)가 그 시각의 날짜 객체를 만든다
  • error: function(xhr, status, error) — 실패했을 때 jQuery가 넘기는 세 값이다. 차례로 요청 객체(응답 내용을 꺼낼 수 있다), 실패 종류를 적은 짧은 문자열, HTTP 상태 코드에 붙은 설명 글(예: "Not Found")이다
  • Array.from(files) — .files는 배열처럼 번호로 꺼낼 수는 있지만 진짜 배열이 아니라 .map이 없다. Array.from이 평범한 배열로 바꿔 준다
  • file => ({ name: file.name, ... }) — 화살표 함수가 객체를 돌려줄 때는 중괄호를 괄호로 한 번 감싼다. 감싸지 않으면 { }를 함수 본문으로 읽어 버린다

2. 동적 폼 필드 추가/제거

사용자가 필요에 따라 입력 필드를 추가하거나 제거할 수 있는 패턴이다.

<form id="dynamicForm">
  <div id="emailContainer">
    <h3>이메일 주소</h3>
    <div class="email-row">
      <input type="email" name="emails[]" placeholder="이메일 입력" />
      <button type="button" class="remove-email">삭제</button>
    </div>
  </div>
  <button type="button" id="addEmail">이메일 추가</button>
  
  <hr/>
  
  <div id="phoneContainer">
    <h3>전화번호</h3>
    <div class="phone-row">
      <select name="phoneTypes[]">
        <option value="mobile">휴대폰</option>
        <option value="home">집</option>
        <option value="work">회사</option>
      </select>
      <input type="tel" name="phoneNumbers[]" placeholder="전화번호" />
      <button type="button" class="remove-phone">삭제</button>
    </div>
  </div>
  <button type="button" id="addPhone">전화번호 추가</button>
  
  <button type="submit">제출</button>
</form>
// 이메일 필드 추가
$("#addEmail").on("click", function() {
  const $row = $(`
    <div class="email-row">
      <input type="email" name="emails[]" placeholder="이메일 입력" />
      <button type="button" class="remove-email">삭제</button>
    </div>
  `);
  $("#emailContainer").append($row);
});

// 이메일 필드 삭제 (이벤트 위임)
$("#emailContainer").on("click", ".remove-email", function() {
  const $container = $("#emailContainer");
  if ($container.find(".email-row").length > 1) {
    $(this).closest(".email-row").remove();
  } else {
    alert("최소 1개의 이메일은 필요합니다.");
  }
});

// 전화번호 필드 추가
$("#addPhone").on("click", function() {
  const $row = $(`
    <div class="phone-row">
      <select name="phoneTypes[]">
        <option value="mobile">휴대폰</option>
        <option value="home">집</option>
        <option value="work">회사</option>
      </select>
      <input type="tel" name="phoneNumbers[]" placeholder="전화번호" />
      <button type="button" class="remove-phone">삭제</button>
    </div>
  `);
  $("#phoneContainer").append($row);
});

// 전화번호 필드 삭제
$("#phoneContainer").on("click", ".remove-phone", function() {
  const $container = $("#phoneContainer");
  if ($container.find(".phone-row").length > 1) {
    $(this).closest(".phone-row").remove();
  } else {
    alert("최소 1개의 전화번호는 필요합니다.");
  }
});

// 동적 필드 데이터 수집
$("#dynamicForm").on("submit", function(e) {
  e.preventDefault();
  
  const emails = $("input[name='emails[]']")
    .map((_, el) => $(el).val().trim())
    .get()
    .filter(v => v !== "");
  
  const phones = $(".phone-row").map((_, row) => ({
    type: $(row).find("select[name='phoneTypes[]']").val(),
    number: $(row).find("input[name='phoneNumbers[]']").val().trim()
  })).get().filter(p => p.number !== "");
  
  const data = { emails, phones };
  console.log("수집된 데이터:", data);
  
  // Ajax 전송
  $.ajax({
    url: "/api/contacts",
    method: "POST",
    contentType: "application/json",
    data: JSON.stringify(data),
    success: resp => console.log("저장 성공:", resp),
    error: err => console.error("저장 실패:", err)
  });
});

한 줄씩 따라가기

  • name="emails[]" — HTML 입장에서 []는 이름 글자의 일부일 뿐이다. 「같은 이름의 칸이 여러 개이니 배열로 읽어라」라는 표시로 붙이고, 그 표시를 읽는 것은 받는 쪽이다(아래 3번 예제가 브라우저에서 직접 읽는 방법이다)
  • $(`<div class="email-row">...</div>`) → $("#emailContainer").append($row) — HTML 문자열로 새 줄을 만들고, 이메일 상자 안 맨 끝에 붙인다
  • $("#emailContainer").on("click", ".remove-email", function() {...}) — 이벤트 위임이다. 삭제 버튼은 「이메일 추가」를 누를 때마다 새로 생기므로, 버튼마다 함수를 걸면 나중에 생긴 버튼에는 함수가 없다. 그래서 처음부터 있는 바깥 상자에 한 번만 걸고, 눌린 것이 .remove-email일 때만 실행되게 한다. 이때 함수 안의 this는 바깥 상자가 아니라 눌린 삭제 버튼이다
  • $(this).closest(".email-row").remove() — .closest(선택자)는 자기 자신부터 바깥쪽으로 올라가며 선택자에 맞는 첫 요소를 찾는다. 삭제 버튼을 감싼 그 줄(.email-row)을 찾아 화면에서 지운다
  • .map((_, el) => $(el).val().trim()) — 앞의 체크박스 예제는 el.value를 바로 읽었고 여기서는 $(el).val()로 감싸 읽는다. 글자 칸에서는 둘 다 같은 값을 준다. jQuery 메서드로 통일하려고 감쌌을 뿐이다
  • contentType: "application/json", data: JSON.stringify(data) — 이번에는 파일이 없으므로 FormData 대신 객체를 JSON 문자열로 바꿔 보내고, 본문이 JSON이라고 Content-Type에 적는다. 파일이 있으면 앞의 FormData 방식, 값만 있으면 이 JSON 방식을 쓴다

3. 중첩된 객체 구조로 변환

user[profile][name] 같은 브래킷 네이밍을 중첩 객체로 파싱한다.

<form id="nestedForm">
  <input type="text" name="user[profile][name]" value="홍길동" />
  <input type="email" name="user[profile][email]" value="hong@example.com" />
  <input type="number" name="user[profile][age]" value="30" />
  
  <input type="text" name="user[address][city]" value="서울" />
  <input type="text" name="user[address][zipcode]" value="12345" />
  
  <input type="text" name="items[0][name]" value="사과" />
  <input type="number" name="items[0][quantity]" value="5" />
  
  <input type="text" name="items[1][name]" value="바나나" />
  <input type="number" name="items[1][quantity]" value="3" />
  
  <input type="text" name="tags[]" value="fruit" />
  <input type="text" name="tags[]" value="healthy" />
</form>
function parseNestedForm($form) {
  const result = {};
  
  $form.serializeArray().forEach(({ name, value }) => {
    // "user[profile][name]" → ["user", "profile", "name"]
    const keys = name.replace(/\]/g, '').split('[');
    let current = result;
    
    keys.forEach((key, index) => {
      const isLast = index === keys.length - 1;
      const isArrayKey = key === '';
      
      if (isArrayKey) {
        // 배열 케이스: tags[]
        const parentKey = keys[index - 1];
        if (!Array.isArray(current[parentKey])) {
          current[parentKey] = [];
        }
        current[parentKey].push(value);
      } else if (isLast) {
        // 마지막 키: 값 할당
        const numValue = parseFloat(value);
        current[key] = isNaN(numValue) ? value : numValue;
      } else {
        // 중간 키: 객체 또는 배열 생성
        const nextKey = keys[index + 1];
        // 다음 키가 ''(tags[] 꼴)면 여기서 객체를 만들지 않는다.
        // 다음 차례의 배열 케이스가 지금 위치(current)에 배열을 만든다
        if (nextKey === '') return;
        const isNextNumeric = /^\d+$/.test(nextKey);
        
        if (!current[key]) {
          current[key] = isNextNumeric ? [] : {};
        }
        current = current[key];
      }
    });
  });
  
  return result;
}

const nested = parseNestedForm($("#nestedForm"));
console.log("중첩 객체:", nested);
/*
{
  user: {
    profile: { name: "홍길동", email: "hong@example.com", age: 30 },
    address: { city: "서울", zipcode: 12345 }
  },
  items: [
    { name: "사과", quantity: 5 },
    { name: "바나나", quantity: 3 }
  ],
  tags: ["fruit", "healthy"]
}
*/

한 줄씩 따라가기

  • name.replace(/\]/g, '').split('[') — /\]/g는 정규식이다. \]는 닫는 대괄호(]) 글자 자체를 뜻하고(그냥 ]라고만 쓰면 다른 의미로 해석될 수 있어 \를 붙여 “이 글자 그대로”임을 표시한다), 뒤의 g는 “문자열 전체에서 찾을 수 있는 만큼 전부 바꾸라”는 옵션이다. 그래서 .replace(/\]/g, '')는 "user[profile][name]"에서 ]를 전부 지워 "user[profile[name"로 만들고, 이어지는 .split('[')가 남은 [를 기준으로 쪼개 ["user", "profile", "name"] 배열을 만든다. "tags[]"는 같은 과정을 거쳐 ["tags", ""]가 된다. 끝의 빈 문자열 ''이 “여기는 배열에 이어 붙이는 자리”라는 표시다.
  • if (nextKey === '') return; — forEach 안의 return은 함수 전체를 끝내는 게 아니라 이번 키만 건너뛰고 다음 키로 넘어간다(자바 for 문의 continue와 같다). 이 줄이 없으면 "tags" 차례에 tags: {} 객체를 먼저 만들고 그 안으로 들어가 버려서, 결과가 tags: { tags: ["fruit", "healthy"] }처럼 한 겹 더 감싸진다. 브라우저 없이 자바스크립트를 실행하는 Node.js로 이 PC에서 돌려 확인했다.
  • let current = result; … current = current[key]; — 자바스크립트 객체 변수도 자바처럼 객체 자체가 아니라 객체를 가리키는 참조를 담는다. current는 처음에 result를 가리키다가, 키 하나를 지날 때마다 그 안쪽 객체를 가리키도록 한 칸씩 내려간다. 그래서 current[key] = 값으로 넣으면 result 안의 그 자리가 채워진다
  • current[key] = isNextNumeric ? [] : {}; — items[0][name]처럼 다음 키가 숫자면 배열을, 아니면 객체를 만든다. 배열에 current["0"] = {...}처럼 문자열 "0"으로 넣어도 자바스크립트는 숫자 0번 칸으로 받아들여서 items[0]이 채워진다(node로 확인했다)
  • parseFloat(value) + isNaN(...) — 숫자로 읽히는 값은 숫자로 바꾼다. 그래서 age: 30, quantity: 5처럼 숫자가 되지만, 우편번호 "12345"도 12345 숫자가 된다. 앞자리 0이 있는 우편번호("01234")라면 1234가 되어 값이 망가지므로, 실제로 쓸 때는 숫자로 바꿀 필드를 따로 정하는 편이 안전하다.

4. 실시간 유효성 검증

입력하는 동안 실시간으로 에러 메시지를 표시한다.

<style>
.field-error { border-color: red; }
.error-message { color: red; font-size: 0.9em; display: none; }
.field-success { border-color: green; }
</style>

<form id="validationForm">
  <div class="form-group">
    <label>사용자명 (3-15자):</label>
    <input type="text" name="username" id="username" />
    <span class="error-message" id="username-error"></span>
  </div>
  
  <div class="form-group">
    <label>이메일:</label>
    <input type="email" name="email" id="email" />
    <span class="error-message" id="email-error"></span>
  </div>
  
  <div class="form-group">
    <label>비밀번호 (최소 8자, 영문+숫자):</label>
    <input type="password" name="password" id="password" />
    <span class="error-message" id="password-error"></span>
  </div>
  
  <div class="form-group">
    <label>비밀번호 확인:</label>
    <input type="password" name="passwordConfirm" id="passwordConfirm" />
    <span class="error-message" id="passwordConfirm-error"></span>
  </div>
  
  <button type="submit">회원가입</button>
</form>
// 검증 규칙 정의
const validationRules = {
  username: {
    required: true,
    minLength: 3,
    maxLength: 15,
    pattern: /^[a-zA-Z0-9_]+$/,
    messages: {
      required: "사용자명은 필수입니다.",
      minLength: "사용자명은 최소 3자 이상이어야 합니다.",
      maxLength: "사용자명은 15자를 초과할 수 없습니다.",
      pattern: "사용자명은 영문, 숫자, 언더스코어만 가능합니다."
    }
  },
  email: {
    required: true,
    pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
    messages: {
      required: "이메일은 필수입니다.",
      pattern: "유효한 이메일 주소를 입력하세요."
    }
  },
  password: {
    required: true,
    minLength: 8,
    pattern: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]+$/,
    messages: {
      required: "비밀번호는 필수입니다.",
      minLength: "비밀번호는 최소 8자 이상이어야 합니다.",
      pattern: "비밀번호는 영문과 숫자를 포함해야 합니다."
    }
  },
  passwordConfirm: {
    required: true,
    matchField: "password",
    messages: {
      required: "비밀번호 확인은 필수입니다.",
      matchField: "비밀번호가 일치하지 않습니다."
    }
  }
};

// 필드 검증 함수
function validateField(fieldName, value) {
  const rules = validationRules[fieldName];
  if (!rules) return { valid: true };
  
  // required 체크
  if (rules.required && (!value || value.trim() === "")) {
    return { valid: false, message: rules.messages.required };
  }
  
  // minLength 체크
  if (rules.minLength && value.length < rules.minLength) {
    return { valid: false, message: rules.messages.minLength };
  }
  
  // maxLength 체크
  if (rules.maxLength && value.length > rules.maxLength) {
    return { valid: false, message: rules.messages.maxLength };
  }
  
  // pattern 체크
  if (rules.pattern && !rules.pattern.test(value)) {
    return { valid: false, message: rules.messages.pattern };
  }
  
  // matchField 체크
  if (rules.matchField) {
    const matchValue = $(`#${rules.matchField}`).val();
    if (value !== matchValue) {
      return { valid: false, message: rules.messages.matchField };
    }
  }
  
  return { valid: true };
}

// 실시간 검증 바인딩
Object.keys(validationRules).forEach(fieldName => {
  $(`#${fieldName}`).on("input blur", function() {
    const value = $(this).val();
    const result = validateField(fieldName, value);
    const $error = $(`#${fieldName}-error`);
    
    if (result.valid) {
      $(this).removeClass("field-error").addClass("field-success");
      $error.text("").hide();
    } else {
      $(this).removeClass("field-success").addClass("field-error");
      $error.text(result.message).show();
    }
  });
});

// 폼 제출 시 전체 검증
$("#validationForm").on("submit", function(e) {
  e.preventDefault();
  
  let isValid = true;
  const errors = [];
  
  Object.keys(validationRules).forEach(fieldName => {
    const value = $(`#${fieldName}`).val();
    const result = validateField(fieldName, value);
    
    if (!result.valid) {
      isValid = false;
      errors.push({ field: fieldName, message: result.message });
      $(`#${fieldName}`).addClass("field-error");
      $(`#${fieldName}-error`).text(result.message).show();
    }
  });
  
  if (isValid) {
    console.log("검증 통과!");
    const data = formToJSON($(this));
    // Ajax 전송
    $.ajax({
      url: "/api/register",
      method: "POST",
      contentType: "application/json",
      data: JSON.stringify(data),
      success: resp => {
        console.log("회원가입 성공:", resp);
        alert("회원가입이 완료되었습니다!");
      },
      error: err => {
        console.error("회원가입 실패:", err);
        alert("회원가입 중 오류가 발생했습니다.");
      }
    });
  } else {
    console.log("검증 실패:", errors);
    alert("입력값을 확인해주세요.");
  }
});

validationRules의 pattern 값들은 모두 정규식이다. /^[a-zA-Z0-9_]+$/는 “처음부터 끝까지 영문자·숫자·언더스코어만으로 이루어져 있는가”를 검사하고, /^[^\s@]+@[^\s@]+\.[^\s@]+$/는 “공백·@ 없는 글자들 + @ + 공백·@ 없는 글자들 + . + 공백·@ 없는 글자들” 모양인지, 즉 이메일 형식과 비슷한지를 검사한다. rules.pattern.test(value)는 그 정규식이 value와 들어맞는지를 참/거짓으로 돌려주는 메서드다.

비밀번호 정규식 /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@$!%*#?&]+$/의 (?=...)는 「뒤에 이런 것이 있는지 미리 보기만 하고 넘어가지는 않는다」는 표시다. (?=.*[A-Za-z])는 「어딘가에 영문자가 하나는 있다」, (?=.*\d)는 「어딘가에 숫자가 하나는 있다」이고, 나머지 [A-Za-z\d@$!%*#?&]+는 「영문 · 숫자 · 이 특수문자들만으로 이루어졌다」다. 그래서 abcd1234는 통과하고 abcdefgh와 12345678은 떨어진다(node로 돌려 확인했다).

  • Object.keys(validationRules) — 객체의 이름들만 ["username", "email", "password", "passwordConfirm"] 배열로 꺼낸다. 이름마다 같은 id의 칸을 찾아 함수를 건다
  • rules.minLength && value.length < rules.minLength — rules.minLength가 없으면(undefined) 거짓 같은 값이라 뒤를 보지 않는다. 그 규칙이 정해진 필드만 길이를 검사한다
  • .on("input blur", ...) — 글자를 칠 때마다(input), 그리고 칸을 떠날 때(blur) 검사한다

5. 자동완성/검색 필터링

Ajax로 서버에서 데이터를 가져와서 select 옵션을 동적으로 생성하고 검색 필터링을 적용한다.

<form id="searchForm">
  <div class="form-group">
    <label>도시 검색:</label>
    <input type="text" id="citySearch" placeholder="도시명 입력..." />
    <select id="citySelect" name="city" size="5">
      <option value="">도시를 검색하세요</option>
    </select>
  </div>
  
  <div class="form-group">
    <label>사용자 자동완성:</label>
    <input type="text" id="userAutocomplete" name="username" placeholder="사용자명 입력..." />
    <ul id="suggestions" style="display:none; border: 1px solid #ccc;"></ul>
  </div>
  
  <button type="submit">검색</button>
</form>
// 도시 데이터 (실제로는 Ajax로 가져옴)
let cities = [];

// 서버에서 도시 목록 가져오기
function loadCities() {
  $.ajax({
    url: "/api/cities",
    method: "GET",
    success: function(data) {
      cities = data; // [{id: 1, name: "서울"}, {id: 2, name: "부산"}, ...]
      populateCitySelect(cities);
    },
    error: function(err) {
      console.error("도시 로딩 실패:", err);
    }
  });
}

// select에 옵션 채우기
function populateCitySelect(cityList) {
  const $select = $("#citySelect");
  $select.empty().append('<option value="">선택하세요</option>');
  
  cityList.forEach(city => {
    $select.append(`<option value="${city.id}">${city.name}</option>`);
  });
}

// 도시 검색 필터링
$("#citySearch").on("input", function() {
  const query = $(this).val().toLowerCase().trim();
  
  if (query === "") {
    populateCitySelect(cities);
    return;
  }
  
  const filtered = cities.filter(city => 
    city.name.toLowerCase().includes(query)
  );
  
  populateCitySelect(filtered);
});

// 페이지 로드 시 도시 목록 가져오기
$(document).ready(function() {
  loadCities();
});

// 사용자 자동완성 (디바운스 적용)
let autocompleteTimer;

$("#userAutocomplete").on("input", function() {
  const query = $(this).val().trim();
  
  clearTimeout(autocompleteTimer);
  
  if (query.length < 2) {
    $("#suggestions").hide();
    return;
  }
  
  // 디바운스: 300ms 후 검색
  autocompleteTimer = setTimeout(() => {
    $.ajax({
      url: "/api/users/search",
      method: "GET",
      data: { q: query },
      success: function(users) {
        const $suggestions = $("#suggestions");
        $suggestions.empty();
        
        if (users.length === 0) {
          $suggestions.html('<li style="padding: 5px;">결과 없음</li>').show();
          return;
        }
        
        users.forEach(user => {
          const $li = $(`<li style="padding: 5px; cursor: pointer;">${user.username} (${user.email})</li>`);
          $li.on("click", function() {
            $("#userAutocomplete").val(user.username);
            $suggestions.hide();
          });
          $suggestions.append($li);
        });
        
        $suggestions.show();
      },
      error: function(err) {
        console.error("자동완성 실패:", err);
      }
    });
  }, 300);
});

// 외부 클릭 시 자동완성 숨기기
$(document).on("click", function(e) {
  if (!$(e.target).closest("#userAutocomplete, #suggestions").length) {
    $("#suggestions").hide();
  }
});

한 줄씩 따라가기

  • clearTimeout(autocompleteTimer); ... autocompleteTimer = setTimeout(() => { ... }, 300); — 이 부분이 디바운스를 구현한 코드다. setTimeout(함수, 300)은 “300밀리초 뒤에 이 함수를 실행해라”고 예약하는 자바스크립트 기본 함수다. 글자를 입력할 때마다 먼저 clearTimeout으로 직전에 예약해 둔 실행을 취소하고 새로 예약하기 때문에, 타이핑을 멈추고 300ms가 지나야만 실제로 서버에 요청이 나간다. 이렇게 하지 않으면 한 글자 입력할 때마다 서버에 요청이 나가 낭비가 심해진다.
  • method: "GET", data: { q: query } — GET은 서버에서 데이터를 가져오기만 하는 HTTP 메서드다. GET 요청에는 본문이 없어서, jQuery가 data를 주소 뒤에 붙여 /api/users/search?q=입력값처럼 보낸다
  • success: function(data) { cities = data; ... } — 서버가 JSON으로 응답하면 jQuery가 응답의 Content-Type을 보고 형식을 짐작해 자바스크립트 배열 · 객체로 바꿔 넘겨준다. 형식을 확실히 하려면 dataType: "json"을 적는다
  • $suggestions.html('<li ...>결과 없음</li>') — .html()은 문자열을 HTML로 해석해 <li> 요소를 만들어 넣는다
  • !$(e.target).closest("#userAutocomplete, #suggestions").length — 클릭된 요소(e.target)에서 바깥으로 올라가며 입력 칸이나 제안 목록을 찾는다. 하나도 못 찾으면 length가 0이고 !0은 true다. 즉 「입력 칸과 목록 바깥을 클릭했으면」 목록을 숨긴다

6. 폼 상태 관리

로딩 중 상태 표시, 버튼 비활성화, 제출 후 폼 리셋 및 성공 메시지를 관리한다.

<style>
.loading { opacity: 0.5; pointer-events: none; }
.spinner { display: inline-block; width: 20px; height: 20px; 
           border: 3px solid #f3f3f3; border-top: 3px solid #3498db;
           border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.success-message { color: green; padding: 10px; background: #d4edda; 
                  border: 1px solid #c3e6cb; border-radius: 4px; display: none; }
</style>

<form id="stateForm">
  <input type="text" name="title" placeholder="제목" required />
  <textarea name="content" placeholder="내용" required></textarea>
  
  <button type="submit" id="submitBtn">
    <span class="btn-text">제출</span>
    <span class="spinner" style="display:none;"></span>
  </button>
  
  <button type="button" id="resetBtn">초기화</button>
  
  <div class="success-message" id="successMsg"></div>
</form>
// 폼 상태 관리 클래스
class FormStateManager {
  constructor(formId) {
    this.$form = $(`#${formId}`);
    this.$submitBtn = this.$form.find('[type="submit"]');
    this.$spinner = this.$submitBtn.find('.spinner');
    this.$btnText = this.$submitBtn.find('.btn-text');
  }
  
  // 로딩 시작
  startLoading() {
    this.$form.addClass('loading');
    this.$submitBtn.prop('disabled', true);
    this.$btnText.text('처리중...');
    this.$spinner.show();
  }
  
  // 로딩 종료
  stopLoading() {
    this.$form.removeClass('loading');
    this.$submitBtn.prop('disabled', false);
    this.$btnText.text('제출');
    this.$spinner.hide();
  }
  
  // 성공 메시지 표시
  showSuccess(message, duration = 3000) {
    const $msg = $('#successMsg');
    $msg.text(message).fadeIn();
    setTimeout(() => $msg.fadeOut(), duration);
  }
  
  // 폼 리셋
  reset() {
    this.$form[0].reset();
  }
  
  // 필드 비활성화
  disableFields(fieldNames) {
    fieldNames.forEach(name => {
      this.$form.find(`[name="${name}"]`).prop('disabled', true);
    });
  }
  
  // 필드 활성화
  enableFields(fieldNames) {
    fieldNames.forEach(name => {
      this.$form.find(`[name="${name}"]`).prop('disabled', false);
    });
  }
}

const formManager = new FormStateManager('stateForm');

$("#stateForm").on("submit", function(e) {
  e.preventDefault();
  
  formManager.startLoading();
  
  const data = formToJSON($(this));
  
  $.ajax({
    url: "/api/posts",
    method: "POST",
    contentType: "application/json",
    data: JSON.stringify(data),
    success: function(response) {
      console.log("성공:", response);
      formManager.showSuccess("게시글이 등록되었습니다!");
      formManager.reset();
    },
    error: function(xhr, status, error) {
      console.error("실패:", error);
      alert("오류가 발생했습니다: " + (xhr.responseJSON?.message || error));
    },
    complete: function() {
      formManager.stopLoading();
    }
  });
});

// 초기화 버튼
$("#resetBtn").on("click", function() {
  if (confirm("입력한 내용을 모두 지우시겠습니까?")) {
    formManager.reset();
  }
});

한 줄씩 따라가기

  • class FormStateManager { constructor(formId) { this.$form = ... } } — 자바의 생성자는 필드를 클래스 본문에 미리 선언해 두고 생성자 안에서는 값만 채우지만, 자바스크립트 클래스는 필드를 미리 선언하지 않고 생성자 안에서 this.필드이름 = 값을 처음 쓰는 순간 그 필드가 만들어진다. 그래서 this.$form = $(...)처럼 constructor 안에 this.로 시작하는 줄들이 바로 이 클래스가 가지는 필드 목록이다. $로 시작하는 이름($form, $submitBtn)은 “이 변수는 jQuery 객체를 담고 있다”는 걸 나타내는 흔한 관례일 뿐, 특별한 문법은 아니다.
  • this.$form[0].reset() — [0]으로 jQuery 객체에서 원래 <form> 요소를 꺼내고, 브라우저가 폼 요소에 주는 reset()을 부른다. 모든 칸이 페이지를 처음 열었을 때의 값으로 돌아간다
  • error: function(xhr, status, error) { ... xhr.responseJSON?.message ... } — 서버가 실패 응답을 JSON으로 보냈다면 jQuery가 그것을 객체로 바꿔 xhr.responseJSON에 넣어 둔다. 거기에 message가 있으면 그것을, 없으면 error(상태 설명 글)를 보여준다
  • complete: function() { ... } — 성공이든 실패든 success나 error가 끝난 뒤에 항상 불린다. 그래서 「로딩 표시 끄기」처럼 결과와 상관없이 해야 하는 일을 여기 둔다
  • .fadeIn() · .fadeOut() · .show() · .hide() — 요소를 서서히 나타내고 · 서서히 숨기고 · 바로 보이고 · 바로 숨긴다

7. 로컬스토리지 연동

작성 중인 폼 데이터를 자동 저장하고 페이지 재방문 시 복원한다.

<form id="draftForm">
  <input type="text" name="title" placeholder="제목" />
  <textarea name="content" placeholder="내용을 입력하세요..." rows="5"></textarea>
  <input type="email" name="email" placeholder="이메일" />
  
  <div>
    <button type="submit">제출</button>
    <button type="button" id="clearDraft">임시저장 삭제</button>
    <span id="autoSaveStatus"></span>
  </div>
</form>
// 로컬스토리지 키
const DRAFT_KEY = "form_draft_data";
const DRAFT_TIMESTAMP_KEY = "form_draft_timestamp";

// 폼 데이터 저장
function saveDraft($form) {
  const data = formToJSON($form);
  const timestamp = new Date().toISOString();
  
  try {
    localStorage.setItem(DRAFT_KEY, JSON.stringify(data));
    localStorage.setItem(DRAFT_TIMESTAMP_KEY, timestamp);
    
    $("#autoSaveStatus").text("✓ 자동저장됨").css("color", "green");
    setTimeout(() => $("#autoSaveStatus").text(""), 2000);
    
    return true;
  } catch (e) {
    console.error("저장 실패:", e);
    return false;
  }
}

// 폼 데이터 불러오기
function loadDraft($form) {
  try {
    const savedData = localStorage.getItem(DRAFT_KEY);
    const timestamp = localStorage.getItem(DRAFT_TIMESTAMP_KEY);
    
    if (!savedData) return null;
    
    const data = JSON.parse(savedData);
    const savedDate = new Date(timestamp);
    
    // 저장된 데이터를 폼에 채우기
    Object.entries(data).forEach(([name, value]) => {
      const $field = $form.find(`[name="${name}"]`);
      
      if ($field.length) {
        if ($field.is(':checkbox') || $field.is(':radio')) {
          if (Array.isArray(value)) {
            value.forEach(v => {
              $field.filter(`[value="${v}"]`).prop('checked', true);
            });
          } else {
            $field.filter(`[value="${value}"]`).prop('checked', true);
          }
        } else {
          $field.val(value);
        }
      }
    });
    
    return { data, timestamp: savedDate };
  } catch (e) {
    console.error("불러오기 실패:", e);
    return null;
  }
}

// 임시저장 삭제
function clearDraft() {
  localStorage.removeItem(DRAFT_KEY);
  localStorage.removeItem(DRAFT_TIMESTAMP_KEY);
  $("#autoSaveStatus").text("임시저장 삭제됨").css("color", "orange");
  setTimeout(() => $("#autoSaveStatus").text(""), 2000);
}

// 페이지 로드 시 임시저장 복원
$(document).ready(function() {
  const $form = $("#draftForm");
  const draft = loadDraft($form);
  
  if (draft) {
    const timeAgo = Math.floor((new Date() - draft.timestamp) / 1000 / 60);
    const message = timeAgo < 1 
      ? "방금 전 임시저장된 내용을 불러왔습니다."
      : `${timeAgo}분 전 임시저장된 내용을 불러왔습니다.`;
    
    if (confirm(message + "\n계속 작성하시겠습니까?")) {
      $("#autoSaveStatus").text("✓ 임시저장 복원됨").css("color", "blue");
    } else {
      clearDraft();
      $form[0].reset();
    }
  }
});

// 자동저장 (디바운스 적용)
let autoSaveTimer;

$("#draftForm :input").on("input change", function() {
  clearTimeout(autoSaveTimer);
  
  autoSaveTimer = setTimeout(() => {
    saveDraft($("#draftForm"));
  }, 1000); // 1초 후 저장
});

// 폼 제출 시 임시저장 삭제
$("#draftForm").on("submit", function(e) {
  e.preventDefault();
  
  const data = formToJSON($(this));
  
  $.ajax({
    url: "/api/submit",
    method: "POST",
    contentType: "application/json",
    data: JSON.stringify(data),
    success: function(response) {
      console.log("제출 성공:", response);
      clearDraft(); // 제출 성공 시 임시저장 삭제
      $("#draftForm")[0].reset();
      alert("제출이 완료되었습니다!");
    },
    error: function(err) {
      console.error("제출 실패:", err);
      alert("제출 중 오류가 발생했습니다.");
    }
  });
});

// 임시저장 삭제 버튼
$("#clearDraft").on("click", function() {
  if (confirm("임시저장된 내용을 삭제하시겠습니까?")) {
    clearDraft();
    $("#draftForm")[0].reset();
  }
});

// 페이지 이탈 시 경고 (변경사항이 있을 경우)
let formModified = false;

$("#draftForm :input").on("input change", function() {
  formModified = true;
});

$("#draftForm").on("submit", function() {
  formModified = false;
});

$(window).on("beforeunload", function(e) {
  if (formModified) {
    const message = "작성 중인 내용이 있습니다. 페이지를 떠나시겠습니까?";
    e.returnValue = message;
    return message;
  }
});

한 줄씩 따라가기

  • localStorage.setItem(DRAFT_KEY, JSON.stringify(data)) / localStorage.getItem(DRAFT_KEY) — localStorage는 브라우저가 사이트마다 마련해 둔 저장 공간이고, 이 두 줄은 폼 값을 JSON 문자열로 바꿔 거기에 넣고 다시 꺼낸다. 서버로 전송되지 않고 그 브라우저 안에만 남기 때문에, 탭을 닫았다 다시 열어도 작성하던 내용이 그대로 남아 있다. setItem은 저장, getItem은 꺼내기, removeItem은 지우기다. setItem은 저장 공간이 가득 찼을 때 등에 예외를 던지므로 try/catch로 감쌌다. 저장할 때 JSON.stringify로 문자열로 바꾸고, 꺼낼 때 JSON.parse로 다시 객체로 되돌린다.
  • $field.is(':checkbox') — .is(선택자)는 고른 요소가 그 선택자에 맞으면 true를 돌려준다. 체크박스 · 라디오는 값을 넣는 대신 맞는 칸을 체크해야 해서 따로 처리한다
  • $field.filter(`[value="${v}"]`) — jQuery의 .filter(선택자)는 고른 요소 중 선택자에 맞는 것만 남긴다. 앞에서 본 배열의 .filter(함수)와 이름은 같지만, 이쪽은 jQuery 객체의 메서드이고 여기서는 선택자 문자열을 넘겼다
  • (new Date() - draft.timestamp) / 1000 / 60 — 날짜끼리 빼면 두 시각의 차이가 밀리초(1/1000초) 숫자로 나온다. 1000으로 나눠 초, 다시 60으로 나눠 분으로 바꾸고 Math.floor로 소수점을 버린다
  • $("#draftForm :input").on("input change", ...)가 두 번 — 자동저장용과 formModified 표시용으로 같은 칸에 함수를 따로 걸었다. 같은 요소에 건 함수는 건 순서대로 둘 다 실행된다. 폼의 submit에 건 두 함수도 마찬가지다
  • $(window).on("beforeunload", function(e) { ... }) — 사용자가 페이지를 닫거나 다른 주소로 이동하려는 바로 그 순간에 실행되는 이벤트다. e.returnValue = message에 문자열을 넣으면 브라우저가 “정말 나가시겠습니까?”라는 확인 창을 띄운다. 다만 요즘 브라우저는 message에 적은 글을 보여주지 않고 브라우저가 정한 문구를 띄운다. 문자열은 「창을 띄워라」는 신호로만 쓰인다. return message도 옛 브라우저를 위한 같은 신호인데, MDN은 이 방식이 onbeforeunload 속성으로 건 함수에서만 듣는다고 적는다. jQuery .on()으로 건 함수에서 이 return이 어떻게 처리되는지는 확인하지 않았다. MDN이 지금 권하는 방법은 e.preventDefault()다. formModified가 true일 때만(폼을 수정한 적이 있을 때만) 이 확인 창을 띄우도록 앞에서 입력 이벤트마다 formModified = true로 표시해 둔 것이다.

자주 사용하는 패턴

  • 체크박스 배열: $("input[name=skills]:checked").map((_, el) => el.value).get()
  • 라디오 단일값: $("input[name=role]:checked").val() ?? null
  • 멀티 셀렉트: $("select[name=tags]").val() || []
  • 토글 상태: $("#agree").prop("checked", !$("#agree").prop("checked"))
  • 제출 중 비활성화: $("button[type=submit]").prop("disabled", true) → 완료 후 false

팁 & 주의사항

  • 주의 — .prop() vs .attr(): 체크 상태, 비활성화 등은 .prop('checked' | 'disabled')로 관리한다. .attr()는 HTML 속성 문자열을 다룬다.
  • 주의 — 직렬화 제한: 파일 입력(input[type=file]), disabled 필드, 체크되지 않은 라디오/체크박스는 .serialize()/.serializeArray()에 포함되지 않는다. 파일은 FormData를 사용한다.
  • 권장 — 이벤트 위임: 동적 요소가 많은 경우 .on('event', 'selector', handler)로 위임 처리하면 성능과 유지보수에 유리하다.
  • 권장 — JSON 파싱: $.parseJSON은 폐지되었다. JSON.parse를 사용한다.

공식 문서 링크



자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기