빌드노트
Spring @RequestParam에 빈 값을 보내면 기본값이 들어간다
Spring MVC의 @RequestParam 기본값은 누락과 빈 문자열을 합친다. MockMvc에서 String·Integer·int의 여섯 입력과 두 커스텀 바인더를 비교하고, 누락만 허용하려면 원래 문자열이 남아 있을 때 검증해야 하는 이유를 설명한다.
@RequestParam(defaultValue = "10")인 파라미터에 value=를 보내면 Spring MVC는 빈 문자열 대신 기본값을 넣는다. 기본값은 파라미터가 아예 없을 때만 쓰는 값이 아니다. 누락만 기본값으로 처리하고 빈 입력은 거절해야 한다면, defaultValue를 빼고 String으로 받아 구분해야 한다.
공백 한 칸은 빈 문자열과도 다르다. 별도 MockMvc 실험에서 Integer 인자는 공백을 기본값 10으로 받았지만, 같은 선언의 타입을 int로 바꾸자 400이 나왔다. 따라서 호출자가 보낸 값뿐 아니라 컨트롤러의 인자 타입과 바인더도 함께 봐야 한다.
빈 문자열까지 기본값이 되는 이유
Spring 6.2.19의 RequestParam API (새 탭에서 열림)는 누락과 빈 값에 defaultValue를 적용하고, 기본값을 지정하면 required도 사실상 false로 취급한다고 설명한다. required = true를 더 적어서 빈 값을 거절하는 방식으로 바꿀 수는 없다.
이 글의 대상은 Spring MVC의 Servlet 요청 파라미터다. 쿼리 파라미터와 폼 입력이 여기에 해당한다. JSON 본문의 필드와는 바인딩 경로가 다르다. 문자열이 아닌 인자에 타입 변환이 적용된다는 점도 MVC 문서 (새 탭에서 열림)에서 확인할 수 있다.
합성 컨트롤러에 다음 세 인자 선언을 각각 두었다. 문자열의 기본값은 fallback, 숫자의 기본값은 10이다.
@RequestParam(name = "value", defaultValue = "fallback") String value
@RequestParam(name = "value", defaultValue = "10") Integer value
@RequestParam(name = "value", defaultValue = "10") int value
Spring Framework 6.2.19, OpenJDK 17.0.2, Maven 3.9.16에서 standaloneSetup으로 실행했다. 애플리케이션 설정이나 커스텀 바인더를 넣지 않은 결과는 다음과 같다. 각 칸은 상태 / 메서드가 받은 값이며, 따옴표는 공백을 보이게 하려고 붙였다.
입력 String Integer int
누락 200 / "fallback" 200 / 10 200 / 10
"" 200 / "fallback" 200 / 10 200 / 10
" " 200 / " " 200 / 10 400 / 진입 안 함
"42" 200 / "42" 200 / 42 200 / 42
"oops" 200 / "oops" 400 / 진입 안 함 400 / 진입 안 함
" 42 " 200 / " 42 " 200 / 42 200 / 42
누락과 ""는 세 선언 모두 기본값으로 합쳐졌다. 반면 문자열 인자의 " "는 길이 1인 채로 남았다. 숫자의 "oops"는 기본값으로 구제되지 않았다. 두 숫자 타입 모두 MethodArgumentTypeMismatchException이 발생했고 컨트롤러 메서드는 호출되지 않았다. 이 실험의 400 응답 본문은 비어 있었다.
공백에서 Integer와 int가 갈린 지점
숫자 변환기 하나만 호출해서 MVC 응답을 예측하면 이 차이를 놓칠 수 있다. 별도로 DefaultFormattingConversionService에 공백 한 칸을 Integer로 변환하게 하자 ConversionFailedException이 발생했다. 그런데 같은 변환 서비스를 넣은 WebDataBinder는 Integer 변환에서 null을 반환했고, int 변환에서는 TypeMismatchException을 던졌다.
이 차이는 해당 버전의 구현과도 맞는다. TypeConverterDelegate (새 탭에서 열림)는 변환 서비스가 실패하면 기본 PropertyEditor로 다시 변환할 수 있다. 기본 편집기 등록 (새 탭에서 열림)에서 Integer는 빈 입력을 허용하고 int는 허용하지 않는다. 이때 CustomNumberEditor (새 탭에서 열림)의 빈 입력 허용은 공백만 있는 문자열도 null로 처리한다.
그다음 요청 인자 해석기 (새 탭에서 열림)는 변환 결과가 null이면 기본값을 다시 적용한다. 그래서 이 설정의 Integer 공백 입력은 최종적으로 10이 된다. int 쪽은 변환 예외가 발생하므로 그 단계까지 가지 못한다. API 문서의 “빈 값”을 모든 타입에서 공백까지 같은 뜻으로 처리한다는 약속으로 읽어서는 안 된다.
바인더가 있으면 같은 입력도 다시 확인한다
문자열 트리밍은 기본 관찰에 포함되지 않았다. 별도 컨트롤러에 아래 등록을 추가하자 String의 " "가 fallback으로 바뀌었고, " 42 "도 "42"가 됐다.
@InitBinder
public void trim(WebDataBinder binder) {
binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
}
StringTrimmerEditor(true) (새 탭에서 열림)는 문자열 앞뒤를 자르고, 남은 값이 비면 null로 만든다. 위 등록은 String 타입용이다. 같은 실험에서 숫자 인자의 공백 결과는 그대로 Integer는 200과 10, int는 400이었다. 문자열 트리머가 숫자 변환 전처리까지 해 준다고 가정하면 안 된다.
또 다른 컨트롤러에서는 Integer용 숫자 편집기를 아래처럼 등록했다. 마지막 false는 빈 입력을 허용하지 않는 설정이다.
binder.registerCustomEditor(Integer.class, new CustomNumberEditor(Integer.class, false));
이번에는 Integer의 공백 입력도 400이 됐다. 하지만 value=는 여전히 200과 10이었다. 길이가 0인 문자열은 이 숫자 편집기를 거치기 전에 defaultValue로 교체되기 때문이다. 숫자 편집기를 엄격하게 바꿔도 defaultValue가 먼저 합쳐 버린 누락과 빈 문자열은 복원되지 않는다. 이 두 바인더 실험은 각각 따로 실행했다.
누락만 허용하려면 문자열로 받아 정책을 적는다
누락과 빈 문자열을 모두 “기본 설정을 사용하라”는 요청으로 취급한다면 defaultValue를 유지할 수 있다. 다만 공백을 허용할지, 호출 오류로 볼지는 별도 계약으로 정해야 한다. 앞의 관찰처럼 숫자 인자 타입 하나가 그 계약을 바꿀 수도 있다.
누락은 10, 빈 문자열·공백·잘못된 숫자는 400이어야 한다면 원래 문자열이 남아 있을 때 판단한다. 아래는 관찰용 기록만 뺀 실험의 처리 코드다. 숫자 앞뒤의 공백은 허용하기로 명시했다.
@GetMapping("/explicit")
public String explicit(@RequestParam(name = "value", required = false) String raw) {
return Integer.toString(parseValue(raw));
}
static int parseValue(String raw) {
if (raw == null) return 10;
if (raw.isBlank()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "value must not be blank");
try {
return Integer.parseInt(raw.trim());
} catch (NumberFormatException error) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "value must be an integer", error);
}
}
이 경로도 같은 여섯 입력으로 실행했다. 누락은 200과 10, ""·" "·"oops"는 400, "42"와 " 42 "는 200과 42였다. 이번 400은 메서드에 원문이 들어온 뒤 직접 던진 ResponseStatusException이다. 앞의 숫자 자동 변환 실패와 발생 위치가 다르다.
이 방식을 쓸 때는 해당 컨트롤러에 적용되는 @InitBinder와 @ControllerAdvice도 확인해야 한다. 전역 StringTrimmerEditor(true)가 있으면 빈 입력이 null로 바뀌어 누락과 다시 합쳐질 수 있다. 이 코드의 관찰 조건은 문자열을 미리 바꾸는 바인더가 없는 설정이다. parseInt가 표현할 수 있는 정수 범위만 검사하므로, 페이지 크기처럼 양수나 상한이 필요한 값은 파싱 다음에 그 범위도 검사해야 한다.
내 API에서 확인할 지점
먼저 문제가 난 컨트롤러의 defaultValue와 인자 타입을 확인한다. 기존 API 테스트에서 누락·빈 문자열·공백 한 칸·정상 숫자·잘못된 숫자를 각각 새 요청으로 보내고, 응답 상태와 메서드가 받은 값을 함께 본다. 숫자 앞뒤 공백을 허용한다면 " 42 "도 비교한다. 400이면 컨트롤러에 진입하기 전 변환에 실패했는지, 진입한 뒤 직접 검증에서 거절했는지 구분한다.
위 결과와 다르면 해당 컨트롤러에 적용되는 @InitBinder, @ControllerAdvice, 변환 서비스를 대조한다. 특히 누락만 기본값으로 처리하는 코드에서는 빈 문자열과 공백이 parseValue에 도착할 때까지 보존되는지 확인해야 한다.
여기서 측정한 것은 합성 컨트롤러와 MockMvc의 바인딩·상태·응답이다. MockMvc는 실행 중인 서버 없이 MVC 처리를 검사한다 (새 탭에서 열림). 실제 HTTP 통신, URL 디코딩, 애플리케이션의 공통 오류 응답 형식은 확인하지 않았다. 입력은 파라미터 맵에 직접 넣었으며 공백 실험은 ASCII 공백 한 칸이었다. 다른 Spring 버전, 중복 파라미터, 다른 종류의 공백에 같은 결과를 보장하지 않는다.