요청 파라미터 바인딩
게시판 검색·등록 폼을 RequestParam과 ModelAttribute로 바인딩하고 기본값·변환 오류·과다 바인딩을 다룹니다.
쿼리와 URL 인코딩 폼은 Servlet 파라미터 맵으로 들어오지만 컨트롤러가 매번 문자열을 꺼내 숫자와 날짜로 바꿀 필요는 없습니다.
단일 값은 @RequestParam, 관련 필드 묶음은 @ModelAttribute로 받고 변환·검증 실패를 일관되게 처리합니다.
RequestParam 규칙
검색 엔드포인트의 페이지와 크기에 기본값을 두고 태그는 여러 값을 받습니다.
package board.web;
import java.time.LocalDate;
import java.util.List;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/posts")
class PostSearchController {
private final PostSearch search;
PostSearchController(PostSearch search) {
this.search = search;
}
@GetMapping
PostPage find(
@RequestParam(required = false)
String title,
@RequestParam(defaultValue = "0")
int page,
@RequestParam(defaultValue = "20")
int size,
@RequestParam(defaultValue = "")
List<String> tag,
@RequestParam(required = false)
@DateTimeFormat(iso =
DateTimeFormat.ISO.DATE)
LocalDate from) {
return search.find(new FindPostsQuery(
title, page, size, tag, from));
}
}defaultValue를 지정하면 파라미터가 없거나 빈 값일 때 기본값이 적용될 수 있습니다.
“없음”과 “빈 문자열”을 업무상 구분해야 하면 defaultValue로 합치지 말고 null 허용 여부와 정규화 정책을 명시합니다.
?tag=http&tag=spring은 List<String>으로 받습니다.
쉼표 문자열 하나를 자동으로 같은 의미라 가정하지 않고 클라이언트 계약을 테스트합니다.
페이지가 abc이면 int 변환이 컨트롤러 호출 전에 실패해 400이 됩니다.
페이지가 -1이거나 크기가 1000인 것은 형식은 맞지만 범위 규칙이므로 검증이나 쿼리 생성자에서 거부합니다.
ModelAttribute 폼 묶음
HTML 폼 등록은 URL 인코딩 파라미터를 폼 객체에 바인딩합니다.
package board.web;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import org.springframework.format.annotation.DateTimeFormat;
public final class CreatePostForm {
@NotBlank
private String title;
@NotBlank
@Size(max = 720)
private String content;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
private LocalDate publishedOn;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public LocalDate getPublishedOn() {
return publishedOn;
}
public void setPublishedOn(LocalDate publishedOn) {
this.publishedOn = publishedOn;
}
CreatePostCommand toCommand() {
return new CreatePostCommand(
title, content, publishedOn);
}
}변경 가능한 폼 객체는 웹 바인딩 어댑터에 한정하고 도메인 객체로 쓰지 않습니다.
바인더가 세터를 호출한 뒤 검증하고 애플리케이션 명령으로 변환합니다.
도메인 불변식은 폼 애노테이션만 믿지 않고 도메인과 애플리케이션에서도 지킵니다.
BindingResult 오류 폼
package board.web;
import jakarta.validation.Valid;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
class PostFormPageController {
private final PostService service;
PostFormPageController(PostService service) {
this.service = service;
}
@PostMapping("/posts")
Object register(
@Valid @ModelAttribute("form")
CreatePostForm form,
BindingResult binding) {
if (binding.hasErrors()) {
return "post-form";
}
var saved = service.register(
form.toCommand());
return ResponseEntity
.status(HttpStatus.SEE_OTHER)
.location(URI.create(
"/posts/" + saved.id()))
.build();
}
}BindingResult는 검증 대상 파라미터 바로 뒤에 둡니다.
오류가 있으면 입력값과 필드 오류가 모델에 남아 뷰가 같은 요청에서 폼을 다시 렌더링합니다.
성공하면 303 PRG로 이동합니다.
HTML 폼 컨트롤러가 뷰 이름과 ResponseEntity를 함께 반환해 Object가 된 예제는 동작을 설명하기 위한 최소 형태입니다.
실제 코드에서는 성공·실패 결과 규칙을 더 명확히 나누거나 리다이렉트 뷰 타입을 사용해 반환 타입을 읽기 좋게 유지합니다.
바인딩 결과 검증
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;
class QueryAndFormBindingTest {
@Test
void query_parameter를_typed_query로_바인딩한다() {
var observed =
new AtomicReference<FindPostsQuery>();
PostSearch search = query -> {
observed.set(query);
return PostPage.empty();
};
var client = RestTestClient
.bindToController(
new PostSearchController(search))
.build();
client.get()
.uri("/api/posts"
+ "?title=HTTP"
+ "&tag=network&tag=spring"
+ "&from=2026-07-01")
.exchange()
.expectStatus().isOk();
assertThat(observed.get())
.isEqualTo(new FindPostsQuery(
"HTTP",
0,
20,
List.of("network", "spring"),
LocalDate.of(2026, 7, 1)));
}
@Test
void 숫자_변환_실패는_controller_호출_전_400이다() {
var invocations = new AtomicInteger();
PostSearch search = query -> {
invocations.incrementAndGet();
return PostPage.empty();
};
var client = RestTestClient
.bindToController(
new PostSearchController(search))
.build();
client.get()
.uri("/api/posts?page=not-a-number")
.exchange()
.expectStatus().isBadRequest();
assertThat(invocations.get()).isZero();
}
}QueryAndFormBindingTest
> query_parameter를_typed_query로_바인딩한다() PASSED
QueryAndFormBindingTest
> 숫자_변환_실패는_controller_호출_전_400이다() PASSED
controller invocations on conversion failure = 0
BUILD SUCCESSFUL오버포스팅 방지
엔티티에 admin, ownerId, version 세터가 있는데 요청 파라미터를 엔티티에 바로 바인딩하면 공격자가 폼에 숨은 필드를 추가할 수 있습니다.
웹 전용 폼에는 클라이언트가 수정할 필드만 둡니다.
추가 방어가 필요하면 @InitBinder로 허용 필드를 제한합니다.
@InitBinder("form")
void configureFormBinder(WebDataBinder binder) {
binder.setAllowedFields(
"title", "content", "publishedOn");
}알 수 없는 필드를 무시할지 바인딩 오류로 거부할지도 결정합니다.
보안에 민감한 명령은 조용히 무시하기보다 잘못된 클라이언트 요청을 알리는 편이 낫습니다.
도메인 엔티티를 바인딩 대상으로 사용하지 않는 것이 가장 중요한 경계입니다.
쿼리 객체와 API 경계
검색 파라미터가 10개를 넘으면 스칼라 파라미터 목록보다 불변 쿼리 폼을 @ModelAttribute로 받을 수 있습니다.
생성자 바인딩과 파라미터 이름 메타데이터를 실제 빌드에서 검증합니다.
선택적 필터 정규화와 페이지 크기 제한은 쿼리 객체 생성자에 둘 수 있습니다.
문서화 도구와 클라이언트가 어떤 쿼리 파라미터가 있는지 알 수 있도록 필드 이름과 형식을 안정적으로 유지합니다.
일반 맵으로 모두 받으면 오타와 미지원 파라미터를 놓칩니다.
연습 문제
게시글 검색을 PostSearchForm 하나로 묶고 제목, 여러 태그, from·to, 페이지, 크기를 생성자 또는 속성 바인딩하세요.
from이 to보다 늦거나 크기가 100을 넘으면 400, 알 수 없는 ownerId 필드는 바인딩 오류가 되어야 합니다.
해설 보기
웹 폼은 원시 null 허용 값을 받고 toQuery()에서 필드 간 규칙을 검사해 불변 FindPostsQuery를 만듭니다.
또는 클래스 수준 검증 제약 조건을 사용할 수 있습니다.
FindPostsQuery toQuery() {
if (from != null && to != null
&& from.isAfter(to)) {
throw new IllegalArgumentException(
"from must not be after to");
}
if (size < 1 || size > 100) {
throw new IllegalArgumentException(
"size must be 1..100");
}
return new FindPostsQuery(
title, tags, from, to, page, size);
}테스트는 유효한 쿼리 객체, 변환 실패, 필드 간 실패, 알 수 없는 민감한 필드를 각각 분리합니다.
바인딩 성공 뒤 리포지토리 SQL까지 한 테스트에 포함하지 않습니다.
다음 문서에서는 파라미터 맵이 아니라 요청 본문 전체를 @RequestBody와 메시지 컨버터가 JSON DTO로 바꾸는 과정과 파싱·검증·업무 오류를 분리합니다.