Spring Boot 3에서 폼/쿼리/JSON 바인딩 차이와 Jackson 설정 포인트, null 처리 전략을 실전 코드로 정리합니다.
도입 (문제 상황)
API를 만들다 보면 “같은 DTO인데 왜 어떤 요청은 바인딩이 되고, 어떤 요청은 400이 나지?” 같은 상황을 자주 만나게 됩니다. 특히 폼 전송/쿼리스트링은 잘 되는데 JSON은 갑자기 실패하거나, null 처리 때문에 업데이트 API가 의도치 않게 값을 지워버리는 일도 생깁니다. 이번 글에서는 Spring Boot에서 요청/응답 바인딩을 실전 관점으로 정리해 봅니다.
핵심 개념: Spring Boot 바인딩이 갈리는 지점 (@RequestBody vs @ModelAttribute)

Spring MVC에서 바인딩은 크게 두 갈래로 나뉩니다.
- @ModelAttribute 계열: 요청 파라미터(query string), 폼 데이터(
application/x-www-form-urlencoded,multipart/form-data)를 이름 기준으로 객체 필드에 채웁니다. 내부적으로WebDataBinder가 동작합니다. - @RequestBody 계열: 요청 본문(body)을 메시지 컨버터(HttpMessageConverter) 로 읽습니다. JSON이면 보통 Jackson(
MappingJackson2HttpMessageConverter)이 DTO로 역직렬화합니다.
이 차이가 중요한 이유는 다음과 같습니다.
- 실패 지점이 다릅니다.
@ModelAttribute: 타입 변환 실패(예: "abc" → int)는 바인딩 에러로 쌓이고, 컨트롤러 진입 후BindingResult/예외 처리로 이어집니다.@RequestBody: JSON 파싱/역직렬화 단계에서 실패하면 컨트롤러 진입 전에 400(Bad Request)로 떨어지기 쉽습니다.
- null의 의미가 달라집니다.
@ModelAttribute는 “파라미터가 아예 없으면” 보통 해당 필드는 null(또는 primitive면 기본값)로 남습니다.@RequestBody는 “필드가 JSON에 없으면 null”, “필드가 있고 null이면 null”인데, 이 둘을 구분해야 PATCH/부분 업데이트에서 안전합니다.
- Jackson 설정이 API의 계약을 바꿉니다.
예를 들어 응답에서 null 필드를 숨길지(NON_NULL), 날짜 포맷을 통일할지, unknown field를 허용할지 같은 설정은 클라이언트와의 호환성에 직접 영향을 줍니다.
한눈에 비교: 폼/쿼리 vs JSON 바인딩
| 구분 | 주 사용 어노테이션 | 입력 형태 | 동작 메커니즘 | 자주 나는 이슈 |
|---|---|---|---|---|
| 쿼리스트링/폼 | @ModelAttribute (또는 생략) |
?name=... / x-www-form-urlencoded |
DataBinder 기반 필드 매핑 | 타입 변환 오류, 파라미터 누락 시 기본값 문제 |
| 멀티파트 폼 | @ModelAttribute + MultipartFile |
multipart/form-data |
DataBinder + MultipartResolver | 파일/필드 혼합 시 DTO 설계 난이도 |
| JSON 본문 | @RequestBody |
application/json |
HttpMessageConverter(Jackson) | JSON 파싱 오류, unknown field, null 처리/부분 업데이트 |
코드 예제: 폼/쿼리/JSON 바인딩 + Jackson 설정 + null 처리 전략
아래 예제는 그대로 복사해서 실행할 수 있는 Spring Boot 3.x 프로젝트입니다.
(패키지명은 편하신 대로 바꾸셔도 됩니다.)
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.2'
id 'io.spring.dependency-management' version '1.1.6'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
application.yml (Jackson 핵심 설정 포인트)
spring:
jackson:
# 응답에서 null 필드를 숨기고 싶을 때 (API 응답을 더 깔끔하게)
default-property-inclusion: non_null
# 알 수 없는 필드가 들어오면 실패시키는 옵션
# 운영에서는 "엄격 모드"가 계약 위반을 빨리 잡아줘서 유리한 경우가 많습니다.
deserialization:
fail-on-unknown-properties: true
# 날짜/시간 직렬화 기본 동작(ISO-8601) 유지
serialization:
write-dates-as-timestamps: false
요청 바인딩 데모 컨트롤러
package com.example.binding;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonSetter;
import com.fasterxml.jackson.annotation.Nulls;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Optional;
@RestController
@RequestMapping("/api")
public class BindingController {
// 1) 쿼리스트링/폼 바인딩: @ModelAttribute
// 예: GET /api/search?keyword=spring&page=1
@GetMapping("/search")
public SearchRequest search(@ModelAttribute SearchRequest request) {
return request;
}
// 예: POST /api/form (Content-Type: application/x-www-form-urlencoded)
// body: name=kim&age=20
@PostMapping(path = "/form", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public UserFormRequest form(UserFormRequest request) { // @ModelAttribute 생략 가능
return request;
}
// 2) JSON 바인딩: @RequestBody
// 예: POST /api/users (Content-Type: application/json)
@PostMapping("/users")
public CreateUserRequest create(@RequestBody CreateUserRequest request) {
return request;
}
// 3) 멀티파트: DTO + 파일을 섞을 때는 @ModelAttribute가 자연스럽습니다.
@PostMapping(path = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadRequest upload(@ModelAttribute UploadRequest request) {
// 파일은 request.file()로 접근
return request;
}
// 4) 부분 업데이트(PATCH)에서 null 처리 전략 예시
// - "필드가 아예 없다" = 변경하지 않음
// - "필드가 null이다" = 정책에 따라 (허용 시) null로 변경
@PatchMapping("/users/{id}")
public String patch(@PathVariable long id, @RequestBody UpdateUserRequest request) {
// 실무에서는 여기서 서비스 레이어로 내려가 병합(merge) 로직을 수행합니다.
// 예시에서는 의미만 보여주기 위해 문자열로 반환합니다.
return "id=" + id +
", name=" + request.name().map(v -> "SET(" + v + ")").orElse("NO_CHANGE") +
", nickname=" + request.nickname().map(v -> "SET(" + v + ")").orElse("NO_CHANGE");
}
// ===== DTOs =====
public record SearchRequest(
String keyword,
Integer page
) {}
public record UserFormRequest(
String name,
Integer age
) {}
public record CreateUserRequest(
String email,
String name
) {}
public record UploadRequest(
String title,
MultipartFile file
) {}
/**
* PATCH용 DTO에서 "필드 부재"와 "null"을 구분하고 싶을 때 Optional을 활용하는 패턴입니다.
* - JSON에 필드가 없으면 Optional.empty()
* - JSON에 필드가 있고 값이 있으면 Optional.of(value)
* - JSON에 필드가 있고 null이면: 아래 @JsonSetter로 Optional.empty()로 들어오게(=정책) 만들 수 있습니다.
*
* 주의: "null을 명시적으로 보내서 값을 지우는 기능"이 필요하면 이 정책을 바꾸거나 별도 표현이 필요합니다.
*/
public record UpdateUserRequest(
@JsonSetter(nulls = Nulls.AS_EMPTY)
Optional<String> name,
@JsonSetter(nulls = Nulls.AS_EMPTY)
Optional<String> nickname
) {}
}
실행/테스트용 요청 예시
- 쿼리 바인딩
curl "http://localhost:8080/api/search?keyword=spring&page=1"
- 폼 바인딩
curl -X POST "http://localhost:8080/api/form" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "name=kim&age=20"
- JSON 바인딩
curl -X POST "http://localhost:8080/api/users" \
-H "Content-Type: application/json" \
-d '{"email":"a@b.com","name":"kim"}'
- PATCH에서 “필드 없음” vs “null” 확인
# name만 변경
curl -X PATCH "http://localhost:8080/api/users/1" \
-H "Content-Type: application/json" \
-d '{"name":"new-name"}'
# name을 null로 보냄 (현재 예제 정책에서는 Optional.empty() 처리 = NO_CHANGE로 간주)
curl -X PATCH "http://localhost:8080/api/users/1" \
-H "Content-Type: application/json" \
-d '{"name":null}'
flowchart TD
A["HTTP Request"] --> B["HandlerMapping"]
B --> C["HandlerAdapter"]
C --> D{"Binding Type"}
D -->| "@ModelAttribute" | E["WebDataBinder\n(query/form -> object)"]
D -->| "@RequestBody" | F["HttpMessageConverter\n(Jackson JSON -> object)"]
E --> G["Controller Method"]
F --> G["Controller Method"]
G --> H["Response (Jackson serialize)"]
요청 데이터가 어디에서 어떻게 객체로 변환되는지(바인딩 지점)를 보여주는 흐름도입니다.
실무 팁
💡 실무에서는: PATCH/부분 업데이트에서 null 정책을 먼저 문서로 고정해 두세요
- “필드 미포함 = 변경 없음”은 대부분의 API가 기대하는 동작입니다.
- “null 전송 = 값 삭제”를 허용할지 여부는 민감합니다(특히 개인정보/필수값). 허용한다면 별도 엔드포인트(예: /nickname:clear) 나 명시적 연산 모델(JSON Patch) 을 고려해 보세요.
- Optional을 쓰는 방식은 간단하지만, “null을 삭제로 해석”해야 하는 요구가 생기면 DTO/정책을 다시 손봐야 합니다.
💡 실무에서는: Jackson의
fail-on-unknown-properties를 환경별로 다르게 운영하기도 합니다
- 클라이언트가 여러 버전으로 섞여 들어오는 환경에서는 unknown field를 무조건 400으로 만들면 장애처럼 보일 수 있습니다.
- 반대로 내부 B2B/사내 API라면 엄격 모드가 계약 위반을 빨리 잡아줘서 유지보수성이 좋아집니다.
- 타협안으로는 로그/모니터링으로 unknown field를 탐지하고, 일정 기간 후 엄격 모드로 전환하는 전략이 현실적입니다.
핵심 요약
@ModelAttribute는 쿼리/폼,@RequestBody는 JSON 본문을 컨버터(Jackson)로 바인딩합니다.- Jackson 설정은 “API 계약”에 해당하므로 null/unknown field 정책을 명확히 정해야 합니다.
- 부분 업데이트에서는 “필드 부재 vs null”을 구분하는 전략(Optional 등)을 먼저 정해 두는 게 안전합니다.
다음 글: #12 검증(Validation)과 에러 응답 표준화