Spring Boot에서 잘못된 타입으로 파라미터를 보내면 어떻게 될까?

목적

스프링 부트에서 잘못된 타입으로 파라미터를 보내는 예외 상황에 어떤 예외가 발생하는 지 확인해보고 이를 검증하는 코드를 만들어본다!

컨트롤러 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/api/equipments")
public class EquipmentController {

private final EquipmentService equipmentService;

public EquipmentController(final EquipmentService equipmentService) {
this.equipmentService = equipmentService;
}

@GetMapping("/{id}")
public EquipmentDetailResponse getEquipment(@PathVariable final Long id) {
return equipmentService.findById(id);
}
}

테스트 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@WebMvcTest(controllers = {EquipmentController.class})
class EquipmentControllerTest {

@Autowired
private MockMvc mockMvc;

@Test
@DisplayName("id가 문자열이면 400을 응답한다.")
void getEquipment_400_idIsNotNumeric() throws Exception {
// given
String invalidId = "hi";

// when, then
mockMvc.perform(get("/api/equipments/" + invalidId))
.andExpect(status().isBadRequest());
}
}

위 테스트 코드는 PathVariable이 Long이어야 하는데 문자열이 전달된 경우이다.
이 테스트 코드를 실행하고 DispatcherServletdoDispatch에 break point를 걸고 디버깅을 해보자.

디버깅하면서 찾아보기

디버깅을 하다보면 InvocableHandlerMethod (컨트롤러에서 요청을 처리하는 메서드)라는 객체에서 invokeForRequest라는 메서드를 호출하면서 인자를 Object 배열로 변환한다.
이 변환 로직을 각 리졸버에게 위임한다. 리졸버는 Object로 변환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class InvocableHandlerMethod extends HandlerMethod {
// 생략
@Nullable
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {

Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
if (logger.isTraceEnabled()) {
logger.trace("Arguments: " + Arrays.toString(args));
}
return doInvoke(args);
}

// 생략...

protected Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {

MethodParameter[] parameters = getMethodParameters();
if (ObjectUtils.isEmpty(parameters)) {
return EMPTY_ARGS;
}

Object[] args = new Object[parameters.length];
for (int i = 0; i < parameters.length; i++) {
MethodParameter parameter = parameters[i];
parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
args[i] = findProvidedArgument(parameter, providedArgs);
if (args[i] != null) {
continue;
}
if (!this.resolvers.supportsParameter(parameter)) {
throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver"));
}
try {
args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
}
catch (Exception ex) {
// Leave stack trace for later, exception may actually be resolved and handled...
if (logger.isDebugEnabled()) {
String exMsg = ex.getMessage();
if (exMsg != null && !exMsg.contains(parameter.getExecutable().toGenericString())) {
logger.debug(formatArgumentError(parameter, exMsg));
}
}
throw ex;
}
}
return args;
}

Object로 변환된 인자는 DataBinder가 적절한 타입으로 바꿔준다. 이 과정에서 ConversionException이 발생하고

1
2
3
4
5
6
7
8
9
10
11
public class DataBinder implements PropertyEditorRegistry, TypeConverter {
// 생략...
@Override
@Nullable
public <T> T convertIfNecessary(@Nullable Object value, @Nullable Class<T> requiredType,
@Nullable MethodParameter methodParam) throws TypeMismatchException {

return getTypeConverter().convertIfNecessary(value, requiredType, methodParam);
}
// 생략...
}

DataBinder에서 시작한 형 변환 로직 중 형 변환에 실패하면 ConversionException이 발생하고 이 예외는 TypeMismatchException이 대신 던져진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public abstract class TypeConverterSupport extends PropertyEditorRegistrySupport implements TypeConverter {
// 생략...
@Nullable
@Override
public <T> T convertIfNecessary(@Nullable Object value, @Nullable Class<T> requiredType,
@Nullable TypeDescriptor typeDescriptor) throws TypeMismatchException {

Assert.state(this.typeConverterDelegate != null, "No TypeConverterDelegate");
try {
return this.typeConverterDelegate.convertIfNecessary(null, null, value, requiredType, typeDescriptor);
}
catch (ConverterNotFoundException | IllegalStateException ex) {
throw new ConversionNotSupportedException(value, requiredType, ex);
}
catch (ConversionException | IllegalArgumentException ex) {
throw new TypeMismatchException(value, requiredType, ex);
}
}
// 생략..
}

그리고 TypeMismatchExceptionAbstarctNamedValueMethodArgumentResolver에서 MethodArgumentTypeMismatchException으로 바뀌어 던져지게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public abstract class AbstractNamedValueMethodArgumentResolver implements HandlerMethodArgumentResolver {

// 생략
@Override
@Nullable
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

NamedValueInfo namedValueInfo = getNamedValueInfo(parameter);
MethodParameter nestedParameter = parameter.nestedIfOptional();

Object resolvedName = resolveEmbeddedValuesAndExpressions(namedValueInfo.name);
if (resolvedName == null) {
throw new IllegalArgumentException(
"Specified name must not resolve to null: [" + namedValueInfo.name + "]");
}

Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
if (arg == null) {
if (namedValueInfo.defaultValue != null) {
arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue);
}
else if (namedValueInfo.required && !nestedParameter.isOptional()) {
handleMissingValue(namedValueInfo.name, nestedParameter, webRequest);
}
arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType());
}
else if ("".equals(arg) && namedValueInfo.defaultValue != null) {
arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue);
}

if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, null, namedValueInfo.name);
try {
arg = binder.convertIfNecessary(arg, parameter.getParameterType(), parameter);
}
catch (ConversionNotSupportedException ex) {
throw new MethodArgumentConversionNotSupportedException(arg, ex.getRequiredType(),
namedValueInfo.name, parameter, ex.getCause());
}
catch (TypeMismatchException ex) {
throw new MethodArgumentTypeMismatchException(arg, ex.getRequiredType(),
namedValueInfo.name, parameter, ex.getCause());
}
// Check for null value after conversion of incoming argument value
if (arg == null && namedValueInfo.defaultValue == null &&
namedValueInfo.required && !nestedParameter.isOptional()) {
handleMissingValueAfterConversion(namedValueInfo.name, nestedParameter, webRequest);
}
}

handleResolvedValue(arg, namedValueInfo.name, parameter, mavContainer, webRequest);

return arg;
}
// 생략
}

결론만 말하자면 타입이 맞지 않으면 MethodArgumentTypeMismatchException이 발생한다.

예외 처리 코드

1
2
3
4
5
6
7
8
9
10
@RestControllerAdvice
public class GlobalExceptionAdvice {

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<String> handleMethodArgumentTypeMismatch(final MethodArgumentTypeMismatchException e) {
return ResponseEntity.badRequest()
.body(String.format("%s이 잘못된 타입으로 입력됐습니다. 입력값 : %s",
e.getParameter().getParameter().getName(), e.getValue()));
}
}

@RestControllerAdvice로 예외를 처리해주면 된다. 파라미터의 이름과 입력값을 가져와서 에러 메시지를 출력하는 코드이다.

Share