스프링 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이면 대부분@RequestBodyapplication/x-www-form-urlencoded면 대부분@ModelAttributemultipart/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 본문과 파일을 한 번에 받고 싶은데 @RequestBody와 MultipartFile을 같이 쓰면 되나?" 대부분의 경우 답은 단순하지 않습니다. 파일이 포함되면 요청은 대개 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 같은 예외가 발생하고, 보통 전역 예외 처리기로 넘겨서 응답 형식을 맞춥니다.
반면 @ModelAttribute는 BindingResult를 바로 뒤에 받아서 컨트롤러 안에서 오류를 확인하는 패턴이 많이 쓰입니다. 폼 화면을 다시 보여주거나 필드별 에러 메시지를 내려야 할 때 이 흐름이 특히 편합니다.
또한 바인딩 실패 원인을 정확히 구분하는 습관이 중요합니다.
- 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
댓글
댓글 쓰기