시냅스

구현하며 이해하는 Spring MVC 본문

Java, Spring

구현하며 이해하는 Spring MVC

ted k 2023. 4. 16. 22:11

Spring MVC

Spring MVC 는 웹 애플리케이션 개발을 쉽게하기 위해 지원하는 모듈입니다.

 

Model-View-Controller 패턴을 기반으로 FrontController 패턴을 구현한

DispatcherServlet 을 활용하며 매번 Servlet 을 생성하거나 소멸시켜도 되지 않게 합니다.

 

흔히 Spring 이라고 하면, Spring MVC 를 떠올리게 되는 경우가 많습니다.

Spring 은 전체적인 프레임워크 자체를 말하고,

Spring MVC 는 웹 애플리케이션을 위한 모듈이라고 지칭하는 것이 옳은 표현일 것입니다.

 

그렇다면 Spring MVC 의 정체성을 결정하는 것에는 무엇이 있을까요?

 

 

구성요소

  1. DispatcherServlet
    • 모든 클라이언트의 요청을 받아 적절한 컨트롤러로 분배한다.
  2. HanlderMappping
    • Mapping 된 Uri 인지 저장시켜 둔 map
  3. HandlerAdapter
    • Controller 마다 구현 spec 이 다를 수 있으므로, 공통된 처리를 하게 도와주는 기능을 수행
    • 핸들러 메서드의 인자를 처리하고, 컨트롤러 메서드에 전달하기 위해 ArgumentResolver를 사용
    • 컨트롤러 메서드를 실행하고, 반환 값을 처리하기 위해 ReturnValueHandler를 사용
    • ModelAndView 객체를 생성하여 처리 결과를 뷰에 전달
  4. Handler(Controller)
    • 클라이언트의 요청을 처리하고 응답하는 객체
  5. ModelAndView
    • 클라이언트에 전달할 모델과 사용할 뷰를 포함한다.
    • ViewResolver 가 렌더링 시에 사용한다.
  6. ViewResolver
    • 컨트롤러가 반환한 뷰 이름을 실제 뷰로 해석하고 렌더링하는 역할을 한다.
  7. ArgumentResolver
    • 컨트롤러의 매개변수를 해석하고 처리한다.
    • 매개변수를 처리할 때 메세지 컨버터를 사용한다.
  8. ReturnValueHandler
    • 반환 값을 처리한다.
    • 반환값을 처리할 때 메세지 컨버터를 사용한다.
  9. MessageConverter
    • 클라이언트와 서버 간에 교환되는 데이터를 변환하는 역할을 한다.
    • 주로 HTTP request body 데이터를 java 객체로 변환하거나 java 객체를 HTTP response body 로 변환한다.
    • ArgumentResolver, ReturnValueResolver 와 밀접한 관계를 갖는다.

 

작동 원리를 정리하자면, 요청이 DispatcherServlet 으로 들어오게 되면

HandlerMapping 을 통해 처리할 수 있는 URI 인지 확인한 다음,

HandlerAdapter 로 Handler 를 실행하고, 

실행하기 전 ArgumentResolver 를 통해 Argument 들을 생성해서 주입합니다.

다만, converting 이 필요할 경우 MessageConveter 를 이용합니다.

비즈니스 로직이 끝나고 return 을 할 때 ReturnValueHandler 를 사용하는데,

View 가 return 될 경우 ViewResolver 를, 문자열이 반환될 경우 MessageConverter를 사용합니다.

 

여전히 모호하신가요?

그렇다면 아래에서 구현하며 확인하겠습니다!

 

 

구현

개괄적인 모습을 확인하기 위해 간단히 구현하겠습니다.

  • HandlerMapping
  • DispatcherServlet
  • MessageConverter

위의 3개의 모습을 구현하며 확인하겠습니다.

 

 

Handler (Controller)

package com.example.messageconverter.controller;

import java.util.Map;

public interface Controller {
    Map<String, String> process(Map<String, String> map);
}
  • Interface Controller
    • Handler 를 Interface 로 만드는 이유는, 추상화를 통해 동일한 동작을 하게 하기 위함입니다.
    • 아래에서 DispatcherServlet 이 실행하는 모습을 보며 확인해보겠습니다.

 

@Retention(RetentionPolicy.RUNTIME)
public @interface MyRestController {
    String value() default "";
}

아래에서 살펴볼 HandlerMapping 을 사용하며 Mapping 의 기준이 될 애노테이션을 정의합니다.

저희가 만든 MyRestController 는 RestController 를 대체합니다.

물론, RestController 를 사용해도 무방하지만 하나라도 더 구현해 보는 것에 의의를 둡니다.

 

package com.example.messageconverter.controller;

import java.util.Map;

@MyRestController("/request-body")
public class RequestHttpBodyController implements Controller{
    @Override
    public Map<String, String> process(Map<String, String> map) {
        return map;
    }
}
  • RequestHttpBodyController
    • HTTP Body 처리를 위한 컨트롤러 입니다.
    • 그저 들어온 Map 을 반환하기만 하는 역할을 합니다.

 

package com.example.messageconverter.controller;

import java.util.Map;

@MyRestController("/request-param")
public class RequestParameterController implements Controller {
    @Override
    public Map<String, String> process(Map<String, String> map) {
        return map;
    }
}
  • RequestParamterController
    • Query Parameter 를 처리하기 위한 컨트롤러 입니다.
    • 마찬가지로 들어온 map 을 반환하기만 합니다.

 

DispatcherServlet 이 실행시킬 Controller 들을 우선적으로 정의하였습니다.

단순히 하기 위해서 query paramter 나 Http Body 로 들어온 데이터들을 Map 으로 파싱해서 보내줄 것이고,

return 또한 Map 으로 보내면 아래에서 message conveter 가 JSON 으로 response 하게 할 것입니다.

 

 

HanlderMappping

Map<String, Controller> mappingMap = new HashMap<>();

@PostConstruct
void initMappingMap() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
    String base = getBasePackage(); // 프로젝트의 base package 를 확인합니다.
    Reflections reflections = new Reflections(base);
    // reflections 라이브러리를 사용해서 제가 만든 MyRestController 애노테이션이 붙어있는 class 만 가져옵니다.
    Set<Class<?>> annotatedClass = reflections.getTypesAnnotatedWith(MyRestController.class);

    for (Class<?> clazz : annotatedClass) {
        MyRestController myRestController = clazz.getAnnotation(MyRestController.class);
        // mapping map 에 추가합니다.
        mappingMap.put(myRestController.value(), (Controller) clazz.getDeclaredConstructor().newInstance());
    }
}

private String getBasePackage() {
    return this.getClass().getPackage().getName();
}

방금 위에서 구현한 컨트롤러들을 map 에 넣어두었습니다.

Reflections 라이브러리를 사용하여 MyRestController 를 사용하는 모든 class 를 가져오고,

class 를 reflection 을 사용하여 mapping 된 uri 정보와 class 를 인스턴스화 하여 map 에 넣어줍니다.

따라서 클라이언트가 해당 uri 로 요청하게 된다면, 맞춰 controller 가 반환될 것입니다.

 

 

DispatcherServlet

@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    String requestURI = request.getRequestURI();
    Controller controller = mappingMap.get(requestURI);
    if (ObjectUtils.isEmpty(controller)) {
        response.sendError(HttpServletResponse.SC_NOT_FOUND, "fail");
        return;
    }

    if (request.getMethod().equals("GET")) {
    	// ..
        Map<String, String> responseMap = controller.process(map);
		// ..
    } else {
    	// ..
        Map<String, String> responseMap = controller.process(bodyMap);
		// ..
    }
	// ..
}

클라이언트의 요청이 들어오면 URI 를 확인하여 컨트롤러를 꺼내오게 합니다.

만약 없다면 404와 함께 fail 이라는 메세지를 보내줍니다.

존재하는 컨트롤러는 같은 interface 를 implement 하고 있으므로 동일한 process 메서드로 동작하게 됩니다.

(물론 실제 Spring 에서는 애노테이션을 확인해서 Dynamic proxy 로 동작할 것입니다.)

그렇기 때문에 DispatcherServlet은 조건에 따라, 위처럼 GET인지 POST인지, 단순히 실행하기만 하면 됩니다.

물론 실제 Spring MVC 에서는 훨씬 복잡하게 동작할 것입니다.

 

 

MessageConverter

@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
   // ...
    String parsedJson = "";
    if (request.getMethod().equals("GET")) {
        // 쿼리 파라미터 처리
        Map<String, String> map = createParamMap(request);
        Map<String, String> responseMap = controller.process(map);
        parsedJson = mapToJson(responseMap);
    } else {
        // http body(json) 처리
        Map<String, String> bodyMap = rawToMap(readRequestBody(request, parsedJson));
        Map<String, String> responseMap = controller.process(bodyMap);
        parsedJson = mapToJson(responseMap);
    }

    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    out.println(parsedJson);
    out.flush();
}

MessageConverter 는 실제 Spring 에서는 ArgumentResolver 로 매개변수를 보내주거나,

ReturnValueHandler 로 응답을 보낼 때 필요에 의해 사용합니다.

 

그러므로 위 예제에서 MessageConverter 에 해당하는 부분은 createParamMap 으로 Query parameter 를 파싱하거나

Http Body 를 읽는 readRequestBody 를 사용하고 Map 으로 파싱하거나 

결과물로 나온 responseMap 을 다시 json 으로 변환하여 out.println 으로 write 하는 부분이라고 할 수 있겠습니다.

 

 

아래에서 Spring MVC가 실제로 사용하는 ReturnValueHandler 와 MessageConverter 를 확인해보겠습니다.

// DispatcherServlet
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    // ...
    HandlerMethod handlerMethod = ...; // 적절한 핸들러 메서드를 찾습니다.
    // ...
    ModelAndView mv = null;
    // 핸들러 메서드를 실행합니다.
    Object handlerResult = handlerMethod.invokeAndHandle(webRequest, mavContainer, providedArgs);
    // 반환 값을 처리합니다.
    returnValueHandlers.handleReturnValue(handlerResult, handlerMethod.getReturnType(), mavContainer, webRequest);
    // ...
}

// RequestResponseBodyMethodProcessor를 생성할 때 필요한 HttpMessageConverter 목록을 받아옵니다.
public RequestResponseBodyMethodProcessor(List<HttpMessageConverter<?>> messageConverters) {
    this.messageConverters = messageConverters;
}

@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
    // ...
    MediaType contentType = ...; // 적절한 컨텐츠 타입을 결정합니다.
    ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
    // messageConverters를 사용하여 적절한 컨버터를 찾아 반환 값을 변환합니다.
    writeWithMessageConverters(returnValue, returnType, webRequest, outputMessage, contentType);
    // ...
}

protected <T> void writeWithMessageConverters(T value, MethodParameter returnType, NativeWebRequest webRequest, ServletServerHttpResponse outputMessage, MediaType contentType) throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
    Class<?> valueType = getReturnValueType(value, returnType);
    // 적절한 HttpMessageConverter를 찾습니다.
    HttpMessageConverter<T> messageConverter = (HttpMessageConverter<T>) getMessageConverter(valueType, contentType);
    // 컨버터를 사용하여 반환 값을 변환하고 응답 본문으로 작성합니다.
    messageConverter.write(value, contentType, outputMessage);
}

doDispatch 로 DispatcherServlet 이 Handler 를 실행하며 returnValueHandler 의 handleReturnValue 를 호출합니다.

RequestResponseBodyMethodProcessor 는

@ResponseBody 를 처리하는 데 사용되는 ReturnValueHandler의 구현체이자, ArgumentResolver의 구현체입니다.

실제로 handleReturnValue 를 수행하는 구현체입니다.

 

이처럼 저희가 구현한 코드와 비슷한 과정을 거친다는 것을 알 수 있습니다.

물론 저희의 코드는 조악하지만, 구현한 코드들을 통해 개괄적으로 동작하는 모습을 상상할 수 있겠습니다.

 

 

전체 코드

package com.example.messageconverter;

import com.example.messageconverter.controller.Controller;
import com.example.messageconverter.controller.MyRestController;
import com.example.messageconverter.controller.RequestHttpBodyController;
import com.example.messageconverter.controller.RequestParameterController;
import org.reflections.Reflections;
import org.springframework.http.MediaType;
import org.springframework.util.ObjectUtils;
import org.springframework.util.ReflectionUtils;

import javax.annotation.PostConstruct;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.InvocationTargetException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

@WebServlet(name = "MessageConverterController", urlPatterns = "/*")
public class MessageConverterDispatcherServlet extends HttpServlet {
    Map<String, Controller> mappingMap = new HashMap<>();

    @PostConstruct
    void initMappingMap() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        String base = getBasePackage(); // 프로젝트의 base package 를 확인합니다.
        Reflections reflections = new Reflections(base);
        // reflection 라이브러리를 사용해서 제가 만든 MyRestController 애노테이션이 붙어있는 class 만 가져옵니다.
        Set<Class<?>> annotatedClass = reflections.getTypesAnnotatedWith(MyRestController.class);

        for (Class<?> clazz : annotatedClass) {
            MyRestController myRestController = clazz.getAnnotation(MyRestController.class);
            // mapping map 에 추가합니다.
            mappingMap.put(myRestController.value(), (Controller) clazz.getDeclaredConstructor().newInstance());
        }
    }

    private String getBasePackage() {
        return this.getClass().getPackage().getName();
    }

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String requestURI = request.getRequestURI();
        Controller controller = mappingMap.get(requestURI);
        ServletOutputStream out = response.getOutputStream();
        if (ObjectUtils.isEmpty(controller)) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "fail");
            return;
        }

        String parsedJson = "";
        if (request.getMethod().equals("GET")) {
            // 쿼리 파라미터 처리
            Map<String, String> map = createParamMap(request);
            Map<String, String> responseMap = controller.process(map);
            parsedJson = mapToJson(responseMap);
        } else {
            // http body(json) 처리
            Map<String, String> bodyMap = rawToMap(readRequestBody(request, parsedJson));
            Map<String, String> responseMap = controller.process(bodyMap);
            parsedJson = mapToJson(responseMap);
        }

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        out.println(parsedJson);
        out.flush();
    }

    // request 로 들어온 json 을 map 으로 parsing 합니다.
    private Map<String, String> rawToMap(String rawJson) {
        Map<String, String> temp = new HashMap<>();
        rawJson = replaceToWhiteSpace(rawJson, "{");
        rawJson = replaceToWhiteSpace(rawJson, "}");

        String[] split = rawJson.split(",");
        for (int i = 0; i < split.length; i++) {
            String[] innerSplit = split[i].split(":");
            innerSplit[0] = replaceToWhiteSpace(innerSplit[0].trim(), "\"");
            innerSplit[1] = replaceToWhiteSpace(innerSplit[1].trim(), "\"");
            temp.put(innerSplit[0], innerSplit[1]);
        }
        return temp;
    }

    // request 로부터 inputstream 을 얻어 body 에 있는 데이터를 읽습니다.
    private static String readRequestBody(HttpServletRequest request, String parsedJson) throws IOException {
        StringBuilder temp = new StringBuilder();
        // request 로부터 소켓의 fd 를 바로 엽니다. -> 서블릿컨테이너가 헤더는 처리하였으므로 본문 데이터만 포함합니다.
        BufferedReader br = new BufferedReader(new InputStreamReader(request.getInputStream(), StandardCharsets.UTF_8));
        String requestLine;
        while ((requestLine = br.readLine()) != null) {
            temp.append(requestLine);
        }
        return temp.toString();
    }

    private static String replaceToWhiteSpace(String rawJson, String target) {
        return rawJson.replace(target, "");
    }

    private static Map<String, String> createParamMap(HttpServletRequest request) {
        Map<String, String> paramMap = new HashMap<>();
        request.getParameterNames().asIterator()
                .forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName)));
        return paramMap;
    }

    private String mapToJson(Map<String, String> map) {
        StringBuilder json = new StringBuilder();
        json.append("{");

        int count = 0;
        for (Map.Entry<String, String> key : map.entrySet()) {
            json.append(key.getKey());
            json.append(" : ");
            json.append(key.getValue());
            if (++count < map.size()) json.append(", ");
        }
        json.append("}");
        return json.toString();
    }
}

 

https://github.com/taesukang-dev/my-own-spring-mvc

 

GitHub - taesukang-dev/my-own-spring-mvc: Spring mvc 가 제공하는 기능을 이해하기 위한 repo 입니다.

Spring mvc 가 제공하는 기능을 이해하기 위한 repo 입니다. Contribute to taesukang-dev/my-own-spring-mvc development by creating an account on GitHub.

github.com

 

끝!

Comments