Spring Framework 2.5의 Annotation based Controller의 메서드 파라미터에서 주의점
Spring framework 버전 2.5에서는 어노테이션(Annotation)을 이용하여 URL과 웹 요청 처리 핸들러(handler)를 매핑할 수 있게 되었다. DefaultAnnotationHandlerMapping과 AnnotationHandlerAdapter 클래스로 이것이 가능해진 것인데, 이를 이용하면 핸들러 매핑을 편리하게 할 수 있을 뿐 아니라 웹 요청 파라미터(Request Parameter)에 대한 처리도 좀더 고수준으로 처리할 수 있게 된다. 즉 어노테이션을 이용하여 폼 커맨드(form command) 객체를 바인드(bind)하거나 원하는 웹 요청 파라미터를 추출하여 직접 핸들러 메서드의 파라미터로 넘길 수 있게 된 것이다. 이로써 웹 요청 처리 핸들러 메서드를 서블릿 API에 의존하지 않는 형태로 작성할 수 있게 되었다.
웹 요청 파라미터로 id 값을 받아서 그 값을 보여주는 기능을 구현하려면 이전에는 MultiActionController를 써서 다음과 같이 구현했다.
public ModelAndView read(HttpServletRequest request, HttpServletResponse response) throws Exception {
int id = Integer.parseInt(request.getParameter("id"));
PrintWriter out = response.getWriter();
out.println("value: id=" + id);
}
그러나 이제는 다음과 같이 처리할 수 있게 되었다.
@RequestMapping("/read")
public void read(@RequestParam("id") int id, Writer out) throws Exception {
out.write("value: id=" + id);
}
핸들러 메서드의 파라미터로는 HttpServletRequest, HttpServletResponse, HttpSession 같은 서블릿 API의 객체, 폼 커맨드 객체, Map, Model 등의 모델 객체, InputStream, Reader, OutputStream, Writer 등의 IO 객체가 순서에 상관없이 올 수 있다. 확실히 이전 MultiActionController에 비하면 상당히 편해진 셈이다. 그외 일반적인 데이타 타입(정수 또는 문자열 등)은 @RequestParam 어노테이션으로 메서드의 파라미터를 표시하면 그 @RequestParam 어노테이션의 value와 같은 이름의 웹 요청 파라미터를 추출해서 메서드에 넘겨준다.
그런데 여기서 문제가 되는 것은, 특별한 경우 @RequestParam 어노테이션으로 표시하지 않아도 메서드 파라미터와 같은 이름의 웹 요청 파라미터의 값을 넘겨 줄 수 있다는 것이다. 즉 다음과 같은 일이 가능한 것이다.
@RequestMapping("/read")
public String read(int id, Writer out) throws Exception {
out.write("value: id=" + id);
}
많은 경우 - 특히 이클립스에서 실행해 보는 경우 - 이 기능은 잘 동작한다. 매우 편리한 기능인데 "와~ 스프링 킹왕짱!" 하고 감탄 한 번 한 다음 잘 써먹어도 되는지는 좀 더 따져봐야 한다. 왜냐하면 이 기능이 잘 동작한다면 다음 세 가지 물음에 대하여 어떤 식으로든 답이 있어야 하기 때문이다.
첫째로 그럴꺼면 왜 @RequestParam 어노테이션이 있냐는 것이다. 두 기능은 중복되며 어노테이션을 안 사용하는 게 훨씬 편하다. 중복된 기능이 그것도 더 불편한 방식이 존재하는 이유는 무얼까? 둘째로 왜 이러한 기능이 참조 문서나 API 문서에는 언급되어 있지 않느냐는 거다. 세째로는 과연 자바에서 이게 정상적으로 가능하냐는 거다. 자바의 reflection 기능을 생각해 보면 의문이 들 수밖에 없다. 자바 reflection에서는 메서드 파라미터의 갯수나 타입은 알 수 있지만 이름은 알 수 없기 때문이다. 이 물음에 답하기 위해 spring의 소스를 한번 뒤져 보았다.
Spring MVC 에서는 DispatcherServlet이 모든 웹 요청을 받아서 HandlerMapping 빈을 이용하여 요청에 해당하는 핸들러를 찾고 HandlerAdapter 빈을 이용하여 실제 요청을 처리한다. 어노테이션을 이용한 웹 요청 처리에는 DefaultAnnotationHandlerMapping 과 AnnotationHandlerAdpter 클래스가 관련되어 있다.
웹 요청은 AnnotationMethodHandlerAdpter 클래스의 handle 메서드가 처리하는데 이 메서드는 다시 invokeHandlerMethod를 호출한다. 이 메서드는 MethodResolver 빈을 이용하여 핸들러의 해당 메서드를 찾은 다음 이 메서드를 ServletHandlerMethodInvoker(HandlerMethodInvoker의 서브 클래스) 객체로 감싼 다음 이 객체의 invokeHandlerMethod 메서드를 호출한다. invokeHandlerMethod 메서드에서는 핸들러 메서드의 파라미터 타입 목록 정보를 이용하여 웹 요청 객체(HttpServletRequest)로부터 파라미터들을 생성하여 이를 파라미터로 핸들러 메서드를 호출한다. 파라미터들을 생성하는 메서드가 resolveHandlerArgument 메서드인데 여기에 비밀(?)이 숨겨져 있다.
이 메서드는 핸들러 메서드의 파라미터 타입 배열을 얻은 다음 배열 크기만큼 루프를 돈다. 루프 안에서 각 파라미터마다 처리를 하는데 처리 루틴은 다음과 같다.
146: MethodParameter methodParam = new MethodParameter(handlerMethod, i);
....
152: Object[] paramAnns = methodParam.getParameterAnnotations();
154: for (int j = 0; j < paramAnns.length; j++) {
Object paramAnn = paramAnns[j];
if (RequestParam.class.isInstance(paramAnn)) {
RequestParam requestParam = (RequestParam) paramAnn;
paramName = requestParam.value();
paramRequired = requestParam.required();
break;
}
else if (ModelAttribute.class.isInstance(paramAnn)) {
ModelAttribute attr = (ModelAttribute) paramAnn;
attrName = attr.value();
}
}
....
Class paramType = methodParam.getParameterType();
174: if (paramName == null && attrName == null) {
175: Object argValue = resolveCommonArgument(methodParam, webRequest);
if (argValue != WebArgumentResolver.UNRESOLVED) {
args[i] = argValue;
}
else {
if (Model.class.isAssignableFrom(paramType) || Map.class.isAssignableFrom(paramType)) {
args[i] = implicitModel;
....
190: else if (BeanUtils.isSimpleProperty(paramType)) {
paramName = "";
}
193: else {
attrName = "";
}
}
}
199: if (paramName != null) {
args[i] = resolveRequestParam(paramName, paramRequired, methodParam, webRequest, handler);
}
202: else if (attrName != null) {
WebDataBinder binder = resolveModelAttribute(attrName, methodParam, implicitModel, webRequest, handler);
....
152 줄에서 파라미터에 표시되어 있는 어노테이션을 얻는다. 그 후 154줄에서 RequestParam 또는 ModelAttribute 어노테이션인지 알아내서 각각 paramName 또는 attrName 변수에 이름을 설정한다. 변수에 값이 설정되어 있지 않은 경우(174줄) resolveCommonArgument 메서드로 처리를 하여 파라미터 객체를 얻는다. 여기서 HttpServletRequest, HttpServletResponse, InputStream/OutputStream, HttpSession, Reader/Writer, Locale, Map, Model, SessionStatus, Errors 타입일 때 처리를 하는 것이다. 그리고 paramName 변수에 값이 설정되어 있으면(199줄) resolveRequestParam 메서드를 호출하여 RequestParameter 처리를 하고 attrName 변수에 값이 설정되어 있으면 (202줄) resolveModelAttribute 메서드를 호출하여 command 객체 바인딩을 한다.
파 라미터에 어노테이션이 표시되어 있지 않고 그 타입이 미리 처리할 수 있는 경우(HttpSerlvetRequest 등등일 경우)가 아니라면 190줄, 193줄에서 처리하게 된다. 만약 파라미터의 타입이 단순 데이타 타입이면 paramName 변수를 빈이면 attrName 변수에 빈 문자열로 값을 설정한다. 그렇게 되어 이후 199, 202줄에서 처리되게 되는 것이다. 즉 만약 핸들러 메서드 형식이
public String read(int id) throws Exception { .... }
과 같이 되어 있다면 190번 줄을 거쳐 199번으로 넘어가 resolveRequestParam으로 처리가 넘어가서 파라미터 값을 얻게 되는 것이다. 그렇다면 resolveRequestParam 메서드는 어떻게 되어 있을까? resolveRequestParam 메서드의 첫부분은 다음과 같다.
309: Class paramType = methodParam.getParameterType();
if ("".equals(paramName)) {
311: paramName = methodParam.getParameterName();
if (paramName == null) {
throw new IllegalStateException("No parameter specified for @RequestParam argument of type [" +
paramType.getName() + "], and no parameter name information found in class file either.");
}
}
Object paramValue = null;
if (webRequest.getNativeRequest() instanceof MultipartRequest) {
paramValue = ((MultipartRequest) webRequest.getNativeRequest()).getFile(paramName);
}
if (paramValue == null) {
322: String[] paramValues = webRequest.getParameterValues(paramName);
if (paramValues != null) {
paramValue = (paramValues.length == 1 ? paramValues[0] : paramValues);
}
}
paramName 변수는 이 메서드의 인자로 RequestParam 어노테이션을 사용한 경우 이 어노테이션의 value() 값이며, 어노테이션이 표시되어 있지 않은 경우(즉 문제가 되는 경우)는 빈 문자열("")이다. paramName 변수 값이 빈 문자열이면 311줄이 실행된다. 여기서는 methodParam 객체의 getParameterName 메서드를 호출하여 파라미터의 이름을 얻어온다. 여기서 이름을 제대로 얻어오면 (위의 경우 "id") 이어지는 처리에서 제대로 웹 요청 인자값을 추출할 수 있게 된다. 그러면 methodParam 객체는 어떤 객체일까? 이 객체는 MethodParameter 클래스의 인스턴스이며 SpringFramework API 문서에서 getParamenterName 메서드는 다음과 같이 설명하고 있다.
Return the name of the method/construct parameter
Returns: the parameter name (may be null if no parameter name metadata is contained in the class file or no ParameterNameDiscoverer has been set to begin with)
그리고 ParameterNameDiscoverer 클래스의 설명을 보면 다음과 같이 되어 있다.
Parameter name discovery is not always possible, but various strategies are available to try, such as looking for debug information that may have been emitted at compile time, and looking for argname annotation values optionally accompanying AspectJ annotated methods.
즉 getParameterName()는 메서드 파라미터의 이름을 컴파일할 때 컴파일러가 생성하는 디버그 정보나 어노테이션 등을 이용하여 얻어내는 것이다. 만약 클래스 파일로 컴파일할 때 디버그 정보를 포함시키지 않았다면 이 메서드는 null을 반환하게 된다.
정리하면 핸들러 메서드의 파라미터 이름으로 웹 요청에서 값을 추출할 수 있는 기능은 컴파일할 때 디버그 정보를 포함시켜 컴파일했을 경우에만 동작한다는 거다. 만약 디버그 옵션을 끄고 컴파일한다면 제대로 동작하지 않는 것이다.
이를 테스트해보기 위하여 간단히 실험을 해봤다. 값을 받아 단순히 출력하는 controller를 다음과 같이 작성하였다.
@Controller
public class ExampleController {
@RequestMapping("/index")
public void index(Writer out, int id) throws Exception {
out.write("value id=" + id);
}
}
이를 일반적인 이클립스 환경에서 실행하면 잘 실행된다.

그러나 이클립스의 빌드 설정을 다음과 같이 바꿔서 파라미터 이름에 대한 디버그 정보를 포함하지 않고 컴파일한 후 실행하면 에러가 발생한다.

정리하면 웹 요청 핸들러 메서드에서 웹 요청에서 값을 추출하여 메서드 파라미터로 받으려면 반드시 RequestParam 어노테이션을 사용하는 것이 좋다. 메서드 파라미터와 같은 이름의 웹 요청 파라미터를 추출할 수 있는 경우도 있지만 이는 언제나 그런 것은 아니기 때문에 주의해야 한다. 무심코 사용했다가는 개발 환경과 테스트 환경에서는 잘 동작하다가도(빌드시 디버그 옵션을 켜 놓았기 때문에) 실 환경에서 에러가 발생하게 되는 경우(실 환경을 위해 빌드할 경우 보통 디버그 정보를 끄고 컴파일한다)가 생긴다.
이 경우도 그렇지만 자바로 개발할 때에는 항상 Specification 문서와 참조 매뉴얼, API 문서 등을 확인하여 규약대로 기능을 구현해야 한다. 해보니까 동작하더라는 식으로 개발하여 구현에 의존하게 되면 환경이 바뀌면서 다르게 동작하여 찾기 힘든 버그가 발생하게 된다. 또한 자바의 경우 많은 라이브러리들이 오픈 소스이므로 미진한 사항은 항상 소스를 참조하여 해결하는 게 좋을 것 같다.

