기본 콘텐츠로 건너뛰기

스프링 MVC에서 @RequestBody와 @ModelAttribute를 어떻게 구분해 써야 할까

스프링 MVC에서 @RequestBody와 @ModelAttribute를 어떻게 구분해 써야 할까

빠른 답

  • JSON 본문을 DTO로 받는다면 대부분 @RequestBody를 사용합니다.
  • 쿼리스트링, 폼 전송, multipart/form-data를 객체로 묶을 때는 @ModelAttribute가 더 자연스럽습니다.
  • 둘의 차이는 문법보다 Content-Type, 바인딩 대상, 내부 변환기 흐름에서 갈립니다.
  • 파일 업로드와 일반 필드를 함께 받는 요청은 @ModelAttribute 쪽이 실무에서 더 자주 쓰입니다.

빠른 답

  • application/json 본문을 DTO로 받는 요청이라면 보통 @RequestBody를 사용합니다.
  • 쿼리스트링, application/x-www-form-urlencoded, multipart/form-data를 객체로 묶을 때는 @ModelAttribute가 더 자연스럽습니다.
  • 둘의 차이는 문법보다 데이터를 어디서 읽는지, 그리고 어떤 바인딩 경로를 타는지에 있습니다.
  • 파일 업로드가 섞인 요청은 대부분 @ModelAttribute로 처리하고, JSON 파트가 따로 필요할 때만 @RequestPart를 함께 검토합니다.

왜 둘이 자주 헷갈릴까

@RequestBody@ModelAttribute는 둘 다 컨트롤러에서 "요청 데이터를 DTO로 받는다"는 점만 보면 비슷해 보입니다. 그래서 처음에는 애너테이션 취향 차이처럼 느껴지기 쉽습니다. 하지만 실제로는 출발점이 완전히 다릅니다.

@RequestBody는 HTTP 요청의 body 전체를 읽어서 객체로 변환합니다. 반면 @ModelAttribute는 쿼리 파라미터, 폼 필드, 멀티파트 파트를 이름 기준으로 찾아 객체의 필드에 채워 넣습니다. 결과가 같은 DTO라고 해도, 값을 모으는 방식 자체가 다르기 때문에 맞는 Content-Type도 달라집니다.

실무에서는 이 차이를 먼저 Content-Type으로 판단하면 실수가 줄어듭니다.

  • application/json이면 대부분 @RequestBody
  • application/x-www-form-urlencoded면 대부분 @ModelAttribute
  • multipart/form-data면 대부분 @ModelAttribute
  • 검색 조건처럼 URL 뒤에 붙는 쿼리스트링도 @ModelAttribute 또는 @RequestParam

즉 핵심은 "DTO를 만든다"가 아니라 "어디에 담긴 값을 어떤 방식으로 읽어 DTO를 만들 것인가"입니다.

요청은 어디서 읽히는가

스프링 MVC는 요청 데이터를 한 군데에서만 읽지 않습니다. 같은 사용자 입력이어도 HTTP 요청 안에서 위치가 다를 수 있습니다.

예를 들어 아래 네 가지는 모두 자주 보는 입력 형태입니다.

  • /members?page=1&size=20 같은 쿼리스트링
  • 브라우저 폼 전송인 application/x-www-form-urlencoded
  • 파일 업로드가 포함된 multipart/form-data
  • API 간 통신에서 흔한 application/json

이 중 JSON은 body 전체를 읽어서 객체 구조로 역직렬화해야 합니다. 그래서 @RequestBody가 맞습니다. 반대로 쿼리 파라미터와 폼 데이터는 대부분 key=value 형태이므로, 이름이 같은 필드에 값을 주입하는 @ModelAttribute 방식이 더 자연스럽습니다.

여기서 하나 더 기억할 점이 있습니다. 스프링 MVC는 복합 객체 타입의 파라미터에 대해 @ModelAttribute를 암묵적으로 적용하는 경우가 있습니다. 그래서 JSON 요청을 보내면서 @RequestBody를 빼먹으면, 스프링은 body를 JSON으로 읽지 않고 "파라미터 바인딩 대상"처럼 해석하려고 듭니다. 그 결과 DTO가 비어 있거나 일부 필드만 들어오는 식의 헷갈리는 문제가 생깁니다.

내부 동작은 어떻게 다를까

@RequestBody는 본문을 메시지 단위로 읽습니다. 스프링 MVC 내부에서는 HttpMessageConverter가 동작하고, JSON이라면 보통 Jackson 기반 컨버터가 선택됩니다. 이때 JSON 문자열이 DTO로 역직렬화됩니다.

이 방식은 다음과 같은 경우에 강합니다.

  • 중첩 객체가 있는 JSON
  • 배열, 리스트, 맵 같은 구조화된 데이터
  • REST API의 POST, PUT, PATCH 본문

반면 @ModelAttribute는 본문 전체를 JSON처럼 해석하지 않습니다. 요청 파라미터와 폼 필드를 가져와서 필드명 기준으로 객체에 바인딩합니다. 내부적으로는 데이터 바인더와 타입 변환기가 동작하고, 필요하면 문자열을 숫자나 날짜 타입으로 변환합니다.

이 차이 때문에 같은 DTO라도 어떤 요청에는 잘 맞고, 어떤 요청에는 전혀 맞지 않을 수 있습니다.

또 자주 오해하는 부분이 생성자 문제입니다. "@RequestBody는 기본 생성자가 꼭 필요하다"라고 단순화해서 외우기 쉬운데, 정확히는 Jackson이 해당 타입을 만들 수 있어야 합니다. 기본 생성자와 세터를 쓸 수도 있고, 생성자 기반 역직렬화도 가능합니다. 그래서 record도 충분히 잘 동작합니다.

@RequestBody가 맞는 상황과 코드 예시

JSON API를 만든다면 대부분 @RequestBody부터 떠올리면 됩니다. 특히 프런트엔드, 모바일 앱, 다른 서버와 JSON으로 통신하는 경우라면 거의 표준적인 선택입니다.

아래 예시는 회원 가입 요청을 JSON 본문으로 받는 전형적인 패턴입니다.

package com.example.member;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record SignupRequest(
    @NotBlank String name,
    @Email String email,
    @NotBlank String password
) {
}

이 DTO를 받는 컨트롤러는 다음처럼 작성할 수 있습니다.

package com.example.member;

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/members")
public class MemberController {

    @PostMapping
    public ResponseEntity<String> signup(@Valid @RequestBody SignupRequest request) {
        return ResponseEntity.ok("created: " + request.email());
    }
}

이 구조의 장점은 명확합니다. 요청 body 전체가 하나의 JSON 문서이므로, 중첩 객체나 컬렉션도 자연스럽게 다룰 수 있습니다. 예를 들어 주문 생성 API처럼 상품 목록, 배송지, 결제 정보가 한꺼번에 오는 경우에는 @ModelAttribute보다 @RequestBody가 훨씬 읽기 쉽고 안정적입니다.

반대로 단순한 검색 폼이나 파일 업로드처럼 "필드 몇 개를 이름 기준으로 받는 요청"에 같은 방식을 억지로 쓰면 오히려 불편해집니다.

@ModelAttribute가 맞는 상황과 코드 예시

@ModelAttribute는 브라우저 폼 전송과 잘 맞습니다. 검색 조건, 관리자 화면 입력, 프로필 수정, 게시글 작성처럼 key-value 형태의 데이터를 받는 경우에 특히 유용합니다. 파일 업로드까지 섞이면 더더욱 그렇습니다.

아래는 닉네임, 소개글, 프로필 이미지를 함께 받는 폼 DTO입니다.

package com.example.profile;

import jakarta.validation.constraints.NotBlank;
import org.springframework.web.multipart.MultipartFile;

public class ProfileForm {

    @NotBlank
    private String nickname;

    private String bio;

    private MultipartFile profileImage;

    public String getNickname() {
        return nickname;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public String getBio() {
        return bio;
    }

    public void setBio(String bio) {
        this.bio = bio;
    }

    public MultipartFile getProfileImage() {
        return profileImage;
    }

    public void setProfileImage(MultipartFile profileImage) {
        this.profileImage = profileImage;
    }
}

컨트롤러는 다음처럼 구성할 수 있습니다.

package com.example.profile;

import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/profiles")
public class ProfileController {

    @PostMapping("/form")
    public ResponseEntity<String> updateProfile(
        @Valid @ModelAttribute ProfileForm form,
        BindingResult bindingResult
    ) {
        if (bindingResult.hasErrors()) {
            return ResponseEntity.badRequest().body("invalid form");
        }

        String filename = form.getProfileImage() != null
            ? form.getProfileImage().getOriginalFilename()
            : "no-file";

        return ResponseEntity.ok(form.getNickname() + ", file=" + filename);
    }
}

이 예시는 실무에서 자주 보는 형태입니다. 일반 문자열 필드와 파일을 하나의 객체로 묶어 처리할 수 있고, 폼 전송 방식과도 잘 맞습니다. 특히 브라우저 기반 관리자 페이지를 만드는 경우 @ModelAttribute는 거의 기본값에 가깝습니다.

참고로 @ModelAttribute는 메서드 파라미터에 붙이지 않아도 복합 타입이면 암묵적으로 적용될 수 있습니다. 다만 코드 의도를 분명히 하려면 명시적으로 적는 편이 낫습니다.

설정까지 함께 봐야 하는 이유

파일 업로드가 들어가면 컨트롤러 시그니처만 맞춘다고 끝나지 않습니다. 스프링 부트의 멀티파트 설정도 함께 확인해야 합니다. 업로드 크기 제한이 너무 작거나 비활성화되어 있으면, 바인딩 이전에 요청 자체가 실패할 수 있습니다.

다음은 application.yml 예시입니다.

spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 30MB

이 설정이 중요한 이유는, 현장에서 업로드 실패를 DTO 바인딩 문제로 착각하는 일이 많기 때문입니다. 실제로는 컨트롤러까지 요청이 도달하기 전에 크기 제한에 걸려 예외가 발생했을 수 있습니다. 따라서 파일 업로드 이슈를 볼 때는 DTO, 컨트롤러, Content-Type뿐 아니라 설정도 같이 확인해야 합니다.

또 하나 자주 나오는 질문이 있습니다. "JSON 본문과 파일을 한 번에 받고 싶은데 @RequestBodyMultipartFile을 같이 쓰면 되나?" 대부분의 경우 답은 단순하지 않습니다. 파일이 포함되면 요청은 대개 multipart/form-data가 되며, 이때는 @ModelAttribute 또는 @RequestPart를 고려해야 합니다. JSON 문서 하나와 파일 하나를 각각 별도 파트로 보내는 구조라면 @RequestPart가 더 적합할 수 있습니다.

curl로 보면 차이가 더 분명해진다

문서로 읽을 때보다 실제 요청 예시를 보면 차이가 훨씬 선명합니다. 아래 세 요청은 모두 "회원 또는 프로필 정보를 전송한다"는 점에서는 비슷하지만, 서버가 읽는 방식은 다릅니다.

먼저 JSON 요청입니다. 이 경우는 @RequestBody가 자연스럽습니다.

curl -X POST http://localhost:8080/api/members \
  -H "Content-Type: application/json" \
  -d '{
    "name": "seji",
    "email": "seji@example.com",
    "password": "secret123"
  }'

다음은 폼 URL 인코딩 방식입니다. 이 경우는 @ModelAttribute가 잘 맞습니다.

curl -X POST http://localhost:8080/profiles/form \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "nickname=seji&bio=Spring MVC 연습 중"

마지막은 파일 업로드가 포함된 멀티파트 요청입니다.

curl -X POST http://localhost:8080/profiles/form \
  -F "nickname=seji" \
  -F "bio=프로필 이미지 업로드 테스트" \
  -F "profileImage=@/tmp/avatar.png"

여기서 중요한 것은 문법이 아니라 요청 형식입니다. 첫 번째는 본문 전체를 JSON으로 읽어야 하고, 두 번째와 세 번째는 필드 단위로 분해해서 바인딩해야 합니다. 그래서 같은 DTO 바인딩이라도 선택 기준이 달라집니다.

검증과 예외 처리에서 주의할 점

둘 다 @Valid를 붙일 수 있지만, 검증이 일어나는 맥락과 예외 처리 방식은 조금 다르게 체감됩니다.

@RequestBody는 대개 JSON 역직렬화가 먼저 일어난 뒤 검증이 수행됩니다. 형식 자체가 잘못되었거나 필수 필드가 비어 있으면 MethodArgumentNotValidException 같은 예외가 발생하고, 보통 전역 예외 처리기로 넘겨서 응답 형식을 맞춥니다.

반면 @ModelAttributeBindingResult를 바로 뒤에 받아서 컨트롤러 안에서 오류를 확인하는 패턴이 많이 쓰입니다. 폼 화면을 다시 보여주거나 필드별 에러 메시지를 내려야 할 때 이 흐름이 특히 편합니다.

또한 바인딩 실패 원인을 정확히 구분하는 습관이 중요합니다.

  • JSON을 보냈는데 DTO가 비어 있다면 @RequestBody 누락을 먼저 의심합니다.
  • 폼 전송인데 415나 컨버터 관련 오류가 난다면 @RequestBody를 잘못 붙였는지 봅니다.
  • 파일 업로드가 안 되면 멀티파트 설정과 요청 크기 제한을 함께 확인합니다.
  • 검증이 안 먹는다면 @Valid 위치와 예외 처리 방식, BindingResult 사용 여부를 같이 봅니다.

이 정도만 구분해도 디버깅 시간이 크게 줄어듭니다.

실무에서는 어떤 기준으로 고르면 될까

실무 판단 기준은 생각보다 단순합니다.

  • JSON API다: @RequestBody
  • 검색 조건이나 폼 전송이다: @ModelAttribute
  • 파일 업로드가 있다: 우선 @ModelAttribute
  • 멀티파트 안에 JSON 파트를 분리해 정교하게 받고 싶다: @RequestPart 검토

결국 둘의 차이는 "DTO를 받는다"가 아니라 "HTTP 요청을 어떤 방식으로 해석하느냐"입니다. @RequestBody는 body 전체를 문서처럼 읽고, @ModelAttribute는 요청에 흩어진 필드들을 이름 기준으로 묶습니다. 이 기준만 명확하면 대부분의 컨트롤러 설계는 흔들리지 않습니다.

한 줄로 줄이면 이렇게 정리할 수 있습니다. JSON 본문이면 @RequestBody, 쿼리 파라미터와 폼 데이터면 @ModelAttribute, 파일이 섞이면 먼저 @ModelAttribute를 떠올리면 됩니다.

원문 참고

https://www.maeil-mail.kr/question/14

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...