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를 사용한다.
공식 문서 링크
.val(): https://api.jquery.com/val/.prop(): https://api.jquery.com/prop/.attr(): https://api.jquery.com/attr/:checkedselector: https://api.jquery.com/checked-selector/.serialize(): https://api.jquery.com/serialize/.serializeArray(): https://api.jquery.com/serializeArray/$.each(): https://api.jquery.com/jQuery.each/$.map(): https://api.jquery.com/jQuery.map/.on(): https://api.jquery.com/on/$.ajax(): https://api.jquery.com/jQuery.ajax/
자신만의 철학을 만들어가는 중입니다.
댓글남기기