Spring Boot

Spring Boot 요청/응답 바인딩(@RequestBody, @ModelAttribute) 실전 가이드

IT Lab 2026. 3. 10. 10:00

Spring Boot 3에서 폼/쿼리/JSON 바인딩 차이와 Jackson 설정 포인트, null 처리 전략을 실전 코드로 정리합니다.

도입 (문제 상황)

API를 만들다 보면 “같은 DTO인데 왜 어떤 요청은 바인딩이 되고, 어떤 요청은 400이 나지?” 같은 상황을 자주 만나게 됩니다. 특히 폼 전송/쿼리스트링은 잘 되는데 JSON은 갑자기 실패하거나, null 처리 때문에 업데이트 API가 의도치 않게 값을 지워버리는 일도 생깁니다. 이번 글에서는 Spring Boot에서 요청/응답 바인딩을 실전 관점으로 정리해 봅니다.

핵심 개념: Spring Boot 바인딩이 갈리는 지점 (@RequestBody vs @ModelAttribute)

RequestBody와 ModelAttribute 바인딩 흐름 비교 다이어그램

Spring MVC에서 바인딩은 크게 두 갈래로 나뉩니다.

  • @ModelAttribute 계열: 요청 파라미터(query string), 폼 데이터(application/x-www-form-urlencoded, multipart/form-data)를 이름 기준으로 객체 필드에 채웁니다. 내부적으로 WebDataBinder가 동작합니다.
  • @RequestBody 계열: 요청 본문(body)을 메시지 컨버터(HttpMessageConverter) 로 읽습니다. JSON이면 보통 Jackson(MappingJackson2HttpMessageConverter)이 DTO로 역직렬화합니다.

이 차이가 중요한 이유는 다음과 같습니다.

  1. 실패 지점이 다릅니다.
  • @ModelAttribute: 타입 변환 실패(예: "abc" → int)는 바인딩 에러로 쌓이고, 컨트롤러 진입 후 BindingResult/예외 처리로 이어집니다.
  • @RequestBody: JSON 파싱/역직렬화 단계에서 실패하면 컨트롤러 진입 전에 400(Bad Request)로 떨어지기 쉽습니다.
  1. null의 의미가 달라집니다.
  • @ModelAttribute는 “파라미터가 아예 없으면” 보통 해당 필드는 null(또는 primitive면 기본값)로 남습니다.
  • @RequestBody는 “필드가 JSON에 없으면 null”, “필드가 있고 null이면 null”인데, 이 둘을 구분해야 PATCH/부분 업데이트에서 안전합니다.
  1. 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)과 에러 응답 표준화