폼 전송의 세 가지 방식: urlencoded, multipart, JSON의 차이와 선택 기준

HTML <form>의 submit 버튼을 누르면 데이터가 서버로 갑니다. 간단해 보이지만, "어떤 형식으로 보내느냐"에 따라 서버가 받는 데이터의 모양이 완전히 달라집니다.
텍스트만 보낼 때, 파일을 함께 보낼 때, JavaScript로 API를 호출할 때 — 상황에 따라 각각 다른 인코딩 방식을 씁니다. application/x-www-form-urlencoded, multipart/form-data, application/json 세 가지가 어떻게 다른지, 언제 뭘 써야 하는지 정리해 보겠습니다.
먼저 전체 그림
┌─────────────────────────┐ │ 브라우저 / 클라이언트 │ └────────┬────────────────┘ │ ┌──────────────────┼──────────────────┐ │ │ │ ┌────────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐ │ form submit │ │ form submit │ │ fetch / axios │ │ (기본) │ │ (파일 포함) │ │ (API 호출) │ └────────┬────────┘ └──────┬──────┘ └────────┬────────┘ │ │ │ ┌────────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐ │ x-www-form- │ │ multipart/ │ │ application/ │ │ urlencoded │ │ form-data │ │ json │ └────────┬────────┘ └──────┬──────┘ └────────┬────────┘ │ │ │ └──────────────────┼──────────────────┘ │ ┌────────▼────────┐ │ 서버 │ └─────────────────┘
세 방식의 차이를 한 줄로 요약하면 이렇습니다:
| 방식 | Content-Type | 데이터 형태 | 파일 전송 | 주 사용처 |
|---|---|---|---|---|
| urlencoded | application/x-www-form-urlencoded | key=value&key=value | 불가 | 로그인, 간단한 폼 |
| multipart | multipart/form-data | 경계값으로 구분된 파트 | 가능 | 파일 업로드 |
| JSON | application/json | {"key": "value"} | 불가* | SPA, REST API |
*JSON에서 파일을 보내려면 Base64로 인코딩해야 하는데, 크기가 33% 늘어나서 실무에서는 거의 안 씁니다.
같은 데이터를 세 가지 방식으로 보내면 HTTP 요청 본문의 모습이 이렇게 달라집니다.

urlencoded는 모든 데이터를 key=value&key=value 한 줄로 직렬화하고, multipart는 boundary 구분자로 각 필드를 독립된 파트로 나누며, JSON은 중괄호로 감싼 구조화된 포맷을 사용합니다. 특히 multipart만 바이너리 파일을 그대로 담을 수 있고, JSON은 중첩 객체를 자연스럽게 표현할 수 있다는 점이 핵심 차이입니다.
1. application/x-www-form-urlencoded
HTML <form>의 기본 인코딩 방식입니다. enctype을 따로 지정하지 않으면 이게 적용됩니다.
HTML 폼
<form action="/api/login" method="POST"> <input type="text" name="username" value="kang" /> <input type="password" name="password" value="1234" /> <button type="submit">로그인</button> </form>
submit을 누르면 브라우저가 보내는 HTTP 요청은 이렇습니다:
POST /api/login HTTP/1.1 Content-Type: application/x-www-form-urlencoded username=kang&password=1234
인코딩 규칙
이름에서 알 수 있듯이, URL 인코딩 규칙을 따릅니다.
- 키와 값을
=로 연결 - 각 쌍을
&로 연결 - 특수문자는 퍼센트 인코딩 (
공백→+또는%20,&→%26) - 한글도 인코딩됨 (
강건우→%EA%B0%95%EA%B1%B4%EC%9A%B0)
실제로 한글 이름을 보내면 이렇게 됩니다:
name=%EA%B0%95%EA%B1%B4%EC%9A%B0&email=kang%40example.com
사람이 읽기 어렵죠. 하지만 브라우저와 서버가 알아서 디코딩하니까 신경 쓸 필요는 없습니다.
서버에서 받기
Spring Boot:
@PostMapping("/api/login") public ResponseEntity<String> login( @RequestParam String username, @RequestParam String password) { // username = "kang", password = "1234" return ResponseEntity.ok("로그인 성공"); }
Spring은 @RequestParam으로 urlencoded 데이터를 자동 파싱합니다. @RequestBody가 아닙니다.
Express (Node.js):
app.use(express.urlencoded({ extended: true })); app.post('/api/login', (req, res) => { console.log(req.body.username); // "kang" console.log(req.body.password); // "1234" });
한계
urlencoded는 모든 데이터를 텍스트로 직렬화합니다. 파일의 바이너리 데이터를 담을 수 없고, 중첩된 객체 구조도 표현하기 어렵습니다.
// 이런 중첩 구조는 표현이 불편합니다 address[city]=Seoul&address[zip]=06000&tags[0]=dev&tags[1]=java
가능은 하지만 서버마다 파싱 규칙이 다릅니다. PHP는 이걸 잘 처리하지만, 다른 서버 프레임워크는 못 알아듣는 경우가 있거든요.
2. multipart/form-data
파일을 보내야 하면 이 방식을 써야 합니다. enctype="multipart/form-data"를 명시합니다.
HTML 폼
<form action="/api/profile" method="POST" enctype="multipart/form-data"> <input type="text" name="name" value="강건우" /> <input type="file" name="avatar" /> <button type="submit">저장</button> </form>
HTTP 요청의 실제 모습
submit을 누르면 브라우저가 보내는 요청이 흥미롭습니다:
POST /api/profile HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="name" 강건우 ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="avatar"; filename="profile.jpg" Content-Type: image/jpeg (바이너리 데이터...) ------WebKitFormBoundary7MA4YWxkTrZu0gW--
urlencoded와 완전히 다른 구조입니다. 핵심은 boundary(경계값)로 각 필드를 분리한다는 겁니다.
전체 구조: ┌──────────────────────────────────────┐ │ --boundary │ │ Content-Disposition: name="name" │ │ │ │ 강건우 │ ├──────────────────────────────────────┤ │ --boundary │ │ Content-Disposition: name="avatar" │ │ Content-Type: image/jpeg │ │ │ │ (바이너리 이미지 데이터) │ ├──────────────────────────────────────┤ │ --boundary-- (종료 표시) │ └──────────────────────────────────────┘
각 파트는 독립적인 헤더를 가집니다. 파일 파트에는 filename과 Content-Type이 들어가고, 텍스트 파트는 값만 들어갑니다. 이 구조 덕분에 텍스트와 바이너리를 하나의 요청에 섞어 보낼 수 있습니다.
boundary는 뭔가요?
boundary는 각 파트를 구분하는 구분자입니다. 브라우저가 자동으로 생성하며, 데이터 안에 절대 등장하지 않을 문자열을 사용합니다. Content-Type 헤더에 boundary 값이 명시되어 있어야 서버가 어디서 자르는지 알 수 있습니다.
직접 만들 일은 거의 없지만, curl이나 Postman으로 테스트할 때 가끔 만나게 됩니다.
서버에서 받기
Spring Boot:
@PostMapping("/api/profile") public ResponseEntity<String> updateProfile( @RequestParam("name") String name, @RequestPart("avatar") MultipartFile avatar) { // name = "강건우" // avatar.getOriginalFilename() = "profile.jpg" // avatar.getSize() = 파일 크기 (bytes) return ResponseEntity.ok("프로필 업데이트 완료"); }
파일은 @RequestPart나 @RequestParam으로 MultipartFile 타입으로 받습니다.
Express (Node.js) + multer:
const multer = require('multer'); const upload = multer({ dest: 'uploads/' }); app.post('/api/profile', upload.single('avatar'), (req, res) => { console.log(req.body.name); // "강건우" console.log(req.file.filename); // 저장된 파일명 });
Express에서는 multer 같은 미들웨어가 필요합니다. express.json()이나 express.urlencoded()로는 multipart를 파싱하지 못하거든요.
주의할 점
multipart는 boundary와 헤더 때문에 오버헤드가 있습니다. 파일 없이 텍스트만 보내는 상황에서는 urlencoded보다 용량이 큽니다. 파일이 없으면 굳이 multipart를 쓸 이유가 없습니다.
3. application/json
SPA(Single Page Application)에서 API를 호출할 때 가장 많이 쓰는 방식입니다. <form> submit이 아니라 JavaScript의 fetch나 axios로 직접 보냅니다.
HTML form submit과의 차이
여기서 짚고 넘어가야 할 게 있습니다.
<form> submit vs fetch / axios ─────────────── ────────────── 브라우저가 페이지 이동 페이지 이동 없음 Content-Type 자동 설정 Content-Type 직접 지정 urlencoded 또는 multipart JSON, urlencoded, multipart 등 자유 응답이 오면 새 페이지 렌더링 응답을 JavaScript로 처리
form submit은 브라우저의 기본 동작입니다. 요청을 보내면 페이지가 새로고침됩니다. 반면 fetch/axios는 JavaScript에서 비동기로 요청을 보내고, 페이지 이동 없이 응답을 처리합니다.
요즘 대부분의 웹 서비스가 SPA거나 SPA에 가까운 구조이기 때문에, form submit 대신 fetch/axios로 API를 호출하는 게 일반적입니다.
JavaScript에서 JSON 보내기
// fetch const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ name: '강건우', email: 'kang@example.com', role: 'STUDENT', settings: { theme: 'dark', notifications: { email: true, push: false, }, }, }), }); const data = await response.json();
// axios const { data } = await axios.post('/api/users', { name: '강건우', email: 'kang@example.com', role: 'STUDENT', settings: { theme: 'dark', notifications: { email: true, push: false, }, }, });
axios는 Content-Type: application/json을 자동으로 설정하고, 객체를 자동으로 JSON.stringify 해줍니다. fetch보다 코드가 짧아지는 이유입니다.
HTTP 요청
POST /api/users HTTP/1.1 Content-Type: application/json { "name": "강건우", "email": "kang@example.com", "role": "STUDENT", "settings": { "theme": "dark", "notifications": { "email": true, "push": false } } }
JSON의 강점은 중첩 구조를 자연스럽게 표현할 수 있다는 점입니다. urlencoded로는 settings[notifications][email]=true처럼 어색하게 써야 하는 걸, JSON으로는 그냥 객체 그대로 보내면 됩니다.
서버에서 받기
Spring Boot:
@PostMapping("/api/users") public ResponseEntity<UserResponse> createUser( @RequestBody CreateUserRequest request) { // request.getName() = "강건우" // request.getSettings().getTheme() = "dark" return ResponseEntity.created(uri).body(response); }
JSON은 @RequestBody로 받습니다. Spring이 Jackson 라이브러리를 써서 JSON을 Java 객체로 자동 변환하죠.
Express (Node.js):
app.use(express.json()); app.post('/api/users', (req, res) => { console.log(req.body.name); // "강건우" console.log(req.body.settings.theme); // "dark" console.log(req.body.settings.notifications); // { email: true, push: false } });
JSON으로 파일을 보낼 수 있나요?
기술적으로 가능하긴 합니다. Base64로 인코딩하면 됩니다.
{ "name": "강건우", "avatar": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." }
하지만 Base64 인코딩은 원본 대비 약 33% 용량이 증가합니다. 1MB 이미지가 1.33MB가 되는 거죠. 서버에서 디코딩하는 비용도 있고, JSON 파서가 거대한 문자열을 처리해야 하니 메모리도 많이 씁니다.
그래서 실무에서는 이렇게 합니다:
- 파일은
multipart/form-data로 먼저 업로드 - 서버가 업로드된 파일의 URL을 반환
- 나머지 데이터는 JSON으로 보내면서 파일 URL을 포함
1단계: POST /api/files (multipart) → { "url": "https://cdn.example.com/abc.jpg" } 2단계: POST /api/users (JSON) → { "name": "강건우", "avatar": "https://cdn.example.com/abc.jpg" }
다음 그림은 이 2단계 분리 패턴의 전체 흐름을 보여줍니다.

1단계에서 파일을 multipart로 업로드하면 서버가 CDN에 파일을 저장하고 URL을 반환합니다. 2단계에서는 반환받은 파일 URL을 포함하여 나머지 데이터를 JSON으로 전송합니다. JSON에 Base64로 파일을 직접 넣는 것보다 네트워크 효율이 좋고, 서버 구현도 단순해지기 때문에 최근 대부분의 웹 서비스에서 이 패턴을 사용합니다.
form submit vs API 호출: 실무에서의 차이
같은 "회원가입" 기능이라도 form submit과 API 호출은 동작이 다릅니다.
form submit 방식
<form action="/signup" method="POST"> <input name="name" value="강건우" /> <input name="email" value="kang@example.com" /> <button type="submit">가입</button> </form>
사용자 → [submit 클릭] → 브라우저가 POST 요청 전송 → 서버가 처리 → 서버가 새 HTML 페이지 반환 (또는 리다이렉트) → 브라우저가 페이지 전체를 새로 렌더링
특징:
- 페이지가 새로고침됩니다
- 서버가 HTML을 반환해야 합니다 (또는
302 Redirect) - 에러가 나면 에러 페이지로 이동하거나 폼을 다시 보여줘야 합니다
- 새로고침 시 "양식을 다시 제출하시겠습니까?" 경고가 뜹니다
API 호출 방식
<form onSubmit={handleSubmit}> <input value={name} onChange={e => setName(e.target.value)} /> <input value={email} onChange={e => setEmail(e.target.value)} /> <button type="submit">가입</button> </form>
const handleSubmit = async (e) => { e.preventDefault(); // form의 기본 submit 동작을 막음 try { const res = await fetch('/api/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email }), }); if (res.ok) { alert('가입 완료!'); router.push('/welcome'); } else { const error = await res.json(); setErrorMessage(error.message); } } catch (err) { setErrorMessage('네트워크 오류가 발생했습니다'); } };
사용자 → [submit 클릭] → e.preventDefault()로 기본 동작 차단 → JavaScript가 fetch로 요청 전송 → 서버가 JSON 응답 반환 → JavaScript가 응답을 처리 → 페이지 이동 없이 UI 업데이트
특징:
- 페이지가 새로고침되지 않습니다
- 서버는 JSON만 반환하면 됩니다
- 에러 처리를 클라이언트에서 세밀하게 할 수 있습니다
- 로딩 상태, 유효성 검사 등 UX가 훨씬 자유롭습니다
e.preventDefault()가 핵심입니다
React나 Vue 같은 SPA에서 <form>을 쓸 때 e.preventDefault()를 안 붙이면, 브라우저가 기본 form submit을 실행해서 페이지가 새로고침됩니다. JavaScript로 API를 호출하려면 반드시 기본 동작을 막아야 합니다.
이 설정을 누락하여 페이지가 새로고침되는 이유를 묻는 질문이 Stack Overflow에 정말 많습니다.
세 가지를 섞어 쓰는 경우
실무에서는 하나만 쓰는 게 아니라 상황에 따라 섞어 씁니다.
프로필 수정 (파일 + 텍스트): JavaScript에서 multipart 보내기
const handleSubmit = async (e) => { e.preventDefault(); const formData = new FormData(); formData.append('name', name); formData.append('email', email); if (avatarFile) { formData.append('avatar', avatarFile); } const res = await fetch('/api/profile', { method: 'PUT', // Content-Type을 직접 설정하지 않음! // fetch가 boundary를 포함해서 자동 설정 body: formData, }); };
여기서 중요한 점이 하나 있습니다. Content-Type을 직접 설정하면 안 됩니다.
// 이러면 안 됩니다! headers: { 'Content-Type': 'multipart/form-data' }
직접 설정하면 boundary 값이 빠져서 서버가 파싱을 못 합니다. FormData를 body에 넣으면 fetch가 알아서 Content-Type: multipart/form-data; boundary=...를 설정합니다.
이건 초보자들이 자주 실수하는 부분이거든요. "Content-Type을 꼭 설정해야 한다"고 배웠는데 multipart에서는 오히려 설정하면 안 되니까요.
axios에서 FormData 보내기
const formData = new FormData(); formData.append('name', name); formData.append('avatar', avatarFile); // axios도 FormData를 감지하면 Content-Type을 자동 설정 await axios.put('/api/profile', formData);
axios도 마찬가지로, body가 FormData 인스턴스이면 Content-Type을 자동으로 multipart로 설정합니다.
Spring Boot에서 세 가지를 한 번에 비교
같은 데이터를 세 가지 방식으로 받을 때 서버 코드가 어떻게 달라지는지 비교해 보죠.
// 1. urlencoded: @RequestParam @PostMapping(path = "/v1", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) public String fromUrlencoded( @RequestParam String name, @RequestParam String email) { return "urlencoded: " + name; } // 2. multipart: @RequestParam + @RequestPart @PostMapping(path = "/v2", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public String fromMultipart( @RequestParam String name, @RequestPart MultipartFile avatar) { return "multipart: " + name + ", file: " + avatar.getOriginalFilename(); } // 3. JSON: @RequestBody @PostMapping(path = "/v3", consumes = MediaType.APPLICATION_JSON_VALUE) public String fromJson( @RequestBody CreateRequest request) { return "json: " + request.getName(); }
핵심 차이:
- urlencoded →
@RequestParam - multipart →
@RequestParam+@RequestPart(MultipartFile) - JSON →
@RequestBody
이걸 헷갈리면 415 Unsupported Media Type 에러를 만납니다. Content-Type과 서버의 파싱 방식이 안 맞으면 서버가 요청을 아예 거부합니다.
자주 발생하는 에러와 원인
| 에러 | 원인 | 해결 |
|---|---|---|
| 415 Unsupported Media Type | Content-Type과 서버 파싱 불일치 | consumes 확인 |
| 400 Required parameter missing | urlencoded에서 @RequestBody 사용 | @RequestParam으로 변경 |
| multipart boundary missing | Content-Type 직접 설정 | 헤더에서 Content-Type 제거 |
| JSON parse error | urlencoded 데이터를 JSON으로 파싱 시도 | Content-Type 확인 |
| 파일이 null | name 속성 불일치 | input name과 서버 파라미터명 확인 |
가장 흔한 실수는 "form에서 보냈는데 서버에서 @RequestBody로 받으려는 것"입니다. form submit은 기본적으로 urlencoded를 보내는데, @RequestBody는 JSON을 기대하거든요.
어떤 상황에서 뭘 써야 하나
파일 업로드가 있나? ─── Yes ──→ multipart/form-data │ No │ SPA / API 호출인가? ─── Yes ──→ application/json │ No │ 전통적 form submit인가? ──→ application/x-www-form-urlencoded
좀 더 구체적으로 정리하면:
| 상황 | 추천 방식 |
|---|---|
| 로그인 (ID/PW만) | urlencoded 또는 JSON |
| 회원가입 (프로필 사진 포함) | multipart |
| 게시글 작성 (텍스트만) | JSON |
| 게시글 작성 (첨부파일 포함) | 파일은 multipart로 먼저 업로드, 글은 JSON |
| 설정 변경 | JSON |
| OAuth 토큰 요청 | urlencoded (OAuth2 스펙이 요구) |
| 결제 API | JSON |
| 레거시 시스템 연동 | urlencoded (호환성) |
OAuth2는 왜 urlencoded를 쓸까?
재미있는 점은 OAuth2 토큰 요청은 반드시 application/x-www-form-urlencoded를 써야 한다는 겁니다. RFC 6749에서 명시하고 있습니다.
POST /oauth/token HTTP/1.1 Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=abc123&redirect_uri=https://example.com/callback
OAuth2 스펙이 만들어진 2012년에는 JSON이 아직 표준으로 자리 잡기 전이었고, 가장 범용적인 urlencoded를 택한 겁니다. 지금은 대부분 JSON을 쓰지만, OAuth만은 호환성 때문에 urlencoded를 유지하고 있습니다.
마무리
결국 이겁니다:
- urlencoded: 가장 오래된 방식. 단순하고 호환성 좋음. 파일은 못 보냄
- multipart: 파일이 있으면 이것. boundary로 데이터를 파트 단위로 구분
- JSON: 현대 웹 API의 표준. 중첩 구조 표현이 자연스러움. 파일은 별도 처리
그리고 form submit과 API 호출의 차이:
- form submit: 브라우저가 알아서 요청을 보내고 페이지를 새로고침
- fetch/axios: JavaScript가 비동기로 보내고, 페이지 이동 없이 응답 처리
Content-Type을 잘못 설정해서 415 에러를 만나거나, multipart에서 Content-Type을 직접 넣어서 boundary가 깨지는 건 누구나 한 번쯤 겪는 일입니다. 한 번 겪고 나면 잊을 일은 없으니, 이런 시행착오도 나름 가치가 있습니다.




댓글
댓글을 작성하려면 이 필요합니다.