Spring MVC에서 ObjectMapper로 json 포맷의 request body를 DTO 객체로 역직렬화 하는 방법은 크게 두 가지이다.

디폴트 생성자가 있을 경우,

이 경우는 다음과 같은 순서를 따라 역직렬화를 수행한다. 역직렬화의 시작점은 BeanDeserializer이다.

  1. BeanDeserializer.deserializeFromObject()에서 디폴트 생성자를 이용한 리플렉션 기법(Constructor.newInstance(Object[] args)으로 DTO 객체를 생성한다.
  2. FieldProperty.deserializeAndSet(...)에서 리플렉션으로 얻어온 Field에 JSON 포맷에서 얻은 값을 set한다. 여기서의 Field는 DTO 객체의 멤버 필드의 메타 정보를 포함한 객체이다.

디폴트 생성자가 있을 경우의 결론은 디폴트 생성자를 이용해서 Java의 리플렉션을 이용해 Field에 값을 세팅한다.

 

디폴트 생성자가 없을 경우,

문제는 디폴트 생성자가 없을 경우이다.

이 경우는 IntelliJ에서 Gradle 빌드 옵션(IntelliJ > Settings > Build, Execution, Deployment > Build Tools > Gradle)을 Gradle(default)로 설정하느냐 IntelliJ로 설정하느냐에 따라 다르다.

여기서의 핵심은 PropertyBasedCreator 객체가 null이냐 그렇지 않느냐이다.

 

Gradle 빌드 옵션을 Gradle(default)로 설정할 경우,

디폴트 생성자가 없어도 argument가 있는 Constructor 정보를 이용한 리플렉션 기법을 통해 JSON에서 DTO 객체로 역직렬화가 된다. 

PropertyBasedCreator 객체가 null이 아니다.

 

PropertyBasedCreator가 null이 아니므로, BeanDeserializer.deserializeFromObjectUsingNonDefault() 에서 시작해 내부적으로 더 들어 가면,

PropertyBasedCreator가 StdValueInstantiator를 이용해 역시 리플렉션 기법으로 argument가 있는 Constructor로 객체를 생성한다.

argument가 있다라는 것은 DTO 클래스의 필드에 생성자 주입을 통해 바로 값이 세팅된다라는 의미와 같다.

 

Gradle 빌드 옵션을 IntelliJ로 설정할 경우,

디폴트 생성자가 없으면 JSON에서 DTO 객체로 역직렬화가 되지 않는다.

PropertyBasedCreator 객체가 null이다.

PropertyBasedCreator가 null 이므로, 디폴트 생성자나 argument가 있는 생성자를 이용해 리플렉션으로 DTO 클래스의 객체를 생성할 수 없다.

 

PropertyBasedCreator에 대해서는 아래와 같이 설명하고 있다.

Object that is used to collect arguments for non-default creator (non-default-constructor, or argument-taking factory method) before creator can be called. Since ordering of JSON properties is not guaranteed, this may require buffering of values other than ones being passed to creator.

즉, 디폴트 생성자가 아닌 생성자의 argument를 수집하는데 사용한다고 설명이 나와 있음.

 

결론

  • Gradle 빌드 옵션이 Gradle(default)일 경우, 디폴트 생성자가 없어도 리플렉션을 통해 argument가 있는 Constructor 정보로 DTO 객체를 생성할 수 있다. 즉, 어떤 경우도 역직렬화가 가능하다.
  • Gradle 빌드 옵션을, IntelliJ로 설정하면 PropertyBasedCreator 객체가 생성되지 않으므로, 디폴트 생성자가 반드시 있어야지만 역직렬화가 가능하다.
  • Gradle 빌드 옵션을 바꾸지 말자.

Spring MVC 기반의 웹 애플리케이션을 개발하다보면 Interceptor나 AOP Advice 등에서 request body를 사용해야하는 경우가 종종 있을 수 있습니다.

reqeust 전송 시, header에 추가되 전송되는 JWT의 claims에 포함된 memberId와 request body에 포함된 memberId가 동일한지를 검증하는 예를 들어보겠습니다.

@Slf4j
@Component
@Aspect
public class MemberVerifyAdvice extends VerifyAdvice {
    private final ObjectMapper objectMapper;
    public MemberVerifyAdvice(JwtTokenizer jwtTokenizer, ObjectMapper objectMapper) {
        super(jwtTokenizer);
        this.objectMapper = objectMapper;
    }

    @Pointcut(
            "execution(* com.itvillage.member.controller.MemberController.postQnaQuestionOfMember(..)) || " +
                    "execution(* com.itvillage.qna.controller.QnaQuestionController.postAnswerOfQuestion(..))"
    )
    public void verifyMySelfWithPointcut(){}

    @Before(value = "verifyMySelfWithPointcut()")
    public void verifyMySelf(JoinPoint joinPoint) throws IOException {
        String jws = getHeader("Authorization").substring(7);
        long memberIdFromJws = getMemberIdFromJws(jws);

        long memberIdFromRequest = extractMemberId();

        checkSameMember(memberIdFromJws, memberIdFromRequest);

    }

    private long extractMemberId() throws IOException {
        // (1)
        HttpServletRequest request = 
                  ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
        
        
        String body = IOUtils.toString(request.getReader());  // (2-1)
        or
        String body = 
        	StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); // (2-2)

        ...
        ...
        
    }
}

코드 1-1 MemberVerifyAdvice

먼저 코드 1-1에서 처럼 request body를 파싱하기 위해 AOP Advice에서 (1)과 같이 RequestContextHolder를 이용해 HttpServletRequest를 얻습니다.

그리고 (2-1)과 같이 apache commons io 라이브러리를 이용하거나 (2-2)와 같이 Spring에서 제공하는 StreamUtils를 이용해 request body를 얻기 위한 시도를 할 수 있습니다.

그런데 (2-1) 또는 (2-2)와 같은 코드가 실행되면 request body가 비어있거나 getInputStream() has already been called for this request 같은 에러가 발생해 정상적으로 request body를 얻을 수 없습니다.

⭐ 이유는 Spring Boot에 내장된 Tomcat의 경우 HttpServletRequest의 InputStream을 한번 read 하게되면 다시 읽을 수 없도록 구현되어 있기 때문입니다.

즉, 이 말은 AOP Advice에서 얻은 HttpServletRequest는 Advice에 도달하기 전에 이미 어딘가에서 InputStream을 read 했다는 의미와도 같습니다.

Advice에서 request body를 정상적으로 사용하려면 어떻게 해야할까요?

이런 경우에는 InputStream을 애플리케이션 어딘가에서 read 하기 전에 ContentCachingRequestWrapper를 이용해 원본 HttpServletRequest를 캐싱해 두면 됩니다.

즉, 캐싱해서 저장해 두었다가 나중에 필요할 때 꺼내서 사용할 수 있도록 하는 것입니다.

ContentCachingRequestWrapper로 원본 HttpServletRequest를 캐싱하기 좋은 지점은 바로 필터입니다.

필터를 하나 추가해서 해당 필터에서 ContentCachingRequestWrapper로 원본 HttpServletRequest를 캐싱하면 됩니다.

 

ContentCachingRequestWrapper로 원본 HttpServletRequest를 캐싱하기

Filter 생성

import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class HttpServletWrappingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // ContentCachingRequestWrapper로 HttpServletRequest 캐싱
        ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper wrappingResponse = new ContentCachingResponseWrapper(response);
        filterChain.doFilter(wrappingRequest, wrappingResponse);
        wrappingResponse.copyBodyToResponse();
    }
}

코드 1-2 HttpServletWrappingFilter

코드 1-2와 같이 ContentCachingRequestWrapper를 이용해 HttpServletRequest와 HttpServletResponse를 캐싱하는 필터를 생성합니다.

이렇게하면 나중에 원본 HttpServletRequest를 사용하고자 하는 곳에서 ContentCachingResponseWrapper를 이용해 request body를 사용하면 됩니다.

 

Filter 추가

@Configuration
public class WebConfiguration {
    @Bean
    public FilterRegistrationBean<HttpServletWrappingFilter> firstFilterRegister()  {
    	// HttpServletWrappingFilter 추가
        FilterRegistrationBean<HttpServletWrappingFilter> registrationBean =
                new FilterRegistrationBean<>(new HttpServletWrappingFilter());
        registrationBean.setOrder(Integer.MIN_VALUE);

        return registrationBean;
    }
}

코드 1-3 FilterRegistrationBean

코드 1-3과 같이 FilterRegistrationBean을 이용해 앞에서 생성한 HttpServletWrappingFilterFilterChain에 추가합니다.

 

ContentCachingRequestWrapper로 원본 HttpServletRequest 사용

@Slf4j
@Component
@Aspect
public class MemberVerifyAdvice extends VerifyAdvice {
    private final ObjectMapper objectMapper;
    public MemberVerifyAdvice(JwtTokenizer jwtTokenizer, ObjectMapper objectMapper) {
        super(jwtTokenizer);
        this.objectMapper = objectMapper;
    }

    @Pointcut(
            "execution(* com.itvillage.member.controller.MemberController.postQnaQuestionOfMember(..)) || " +
                    "execution(* com.itvillage.qna.controller.QnaQuestionController.postAnswerOfQuestion(..))"
    )
    public void verifyMySelfWithPointcut(){}

    @Before(value = "verifyMySelfWithPointcut()")
    public void verifyMySelf(JoinPoint joinPoint) throws IOException {
        String jws = getHeader("Authorization").substring(7);
        long memberIdFromJws = getMemberIdFromJws(jws);

        long memberIdFromRequest = extractMemberId();

        checkSameMember(memberIdFromJws, memberIdFromRequest);

    }

    private long extractMemberId() throws IOException {
        HttpServletRequest request = 
                  ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
        
		// (1)
		ContentCachingRequestWrapper requestWrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
        byte[] body = requestWrapper.getContentAsByteArray();  // (2)
        
        ...
        ...
        
    }
}

코드  1-3 개선된 MemberVerifyAdvice

코드 1-3과 같이 (1)에서 WebUtils를 이용해 ContentCachingRequestWrapper 얻은 후에  (2)에서 ContentCachingRequestWrapper의 getContentAsByteArray()로 request body를 얻고 있습니다.

 

이 글에서 처럼 request body를 read하려는데 정상적으로 read하지 못하는 경우가 있다면 이 글이 도움이 되길 바래봅니다.

우리가 흔히 알고 있는 Spring MVC의 요청 처리 흐름 한 가운데에는 DispatcherServlet이 자리잡고 있습니다.

클라이언트로부터 들어오는 HTTP request는 Servlet Filter를 거쳐 DispatcherServlet에 전달되고, DispatcherServlet은 해당 request를 처리할 Controller를 검색하고 대상 Controller에게 request를 전달하기 위해 HandlerMapping과 HandlerAdapter 등의 컴포넌트를 이용합니다.

대부분 다 알고 있는 내용이기 때문에 자세한 설명은 필요없을 것 같습니다.

다만, 구글링을 통해 그림으로 표현한 Spring MVC의 요청 처리 흐름에 대한 자료는 많이 볼 수 있지만 코드 베이스의 자료는 생각보다 찾기 힘든 것 같아서 이 참에 코드 베이스의 흐름을 정리해 볼까 합니다.

 

SSR(Server Side Rendering) 방식의 코드 흐름

SSR(Server Side Rendering) 방식은 CSR(Client Side Rendering) 방식의 애플리케이션이 본격적으로 사용되기 이 전부터 사용되던 전통적인 방식으로 아파치 톰캣(Apache Tomcat) 같은 서블릿 컨테이너가 Thymeleaf 등의 템플릿 기술을 이용해 HTML 템플릿을 렌더링 한 후에 클라이언트 쪽으로 내려주는 방식입니다.

[그림 1-1] SSR 방식의 Spring MVC 요청 처리 흐름

[그림 1-1]은 우리가 흔히들 알고 있는 Spring MVC의 요청 처리 흐름 중에서 SSR(Server Side Rendering) 방식의 요청 처리 흐름입니다.

SSR 방식의 요청 처리 흐름의 특징은 ViewResolver가 적절한 뷰 정보를 해석한 후에 대상 View 객체에게 응답 데이터를 전달하고 View를 통해 이 응답 데이터를 HTML 같은 템플릿에 채워 넣어서 최종적으로 클라이언트에게 전송한다는 것입니다.

따라서 [그림 1-1]에 있는 Spring MVC 컴포넌트들을 모두 사용합니다.

SSR 방식의 요청 처리 흐름을 코드로 표현하면 다음과 같은 순서로 호출이 됩니다.

  1. DispatcherServlet
    1. doDispatch(HttpServletRequest request, HttpServletResponse response)
    2. getHandler(HttpServletRequest request)  // HandlerMapping을 통해 HandlerChain을 얻는다.
    3. HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
    4. mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
    5. processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
    6. render(mv, request, response);
    7. View view = resolveViewName(viewName, mv.getModelInternal(), locale, request);  // ViewResolver가 View를 해석해 적절한 View 객체를 얻는다.
    8. view.render(mv.getModelInternal(), request, response);  // View를 렌더링한다.
  2. ThymeleafView (Thymeleaf 템플릿 기술을 사용할 경우)
    1. render(final Map<String, ?> model, final HttpServletRequest request, final HttpServletResponse response)
    2. renderFragment(this.markupSelectors, model, request, response);
    3. String viewTemplateName = getTemplateName();
    4. ISpringTemplateEngine viewTemplateEngine = getTemplateEngine();
    5. viewTemplateEngine.process(templateName, processMarkupSelectors, context, templateWriter);
  3. TemplateEngine
    1. process(new TemplateSpec(template, templateSelectors, null,  null,null), context, writer);
    2. TemplateManager templateManager = this.configuration.getTemplateManager();
    3. templateManager.parseAndProcess(templateSpec, context, writer);
  4. TemplateManager
    1. TemplateModel cached =  this.templateCache.get(cacheKey);
    2. cached.process(processingHandlerChain);
  5. TemplateEngine
    1. writer.flush();

 

코드의 흐름을 보면 꽤 복잡한 흐름을 거치고 실제 내부 코드들은 더 복잡하지만 어쨌든 각각의 컴포넌트들이 각자 맡은바 역할에만 집중하고 있는 것을 볼 수 있습니다.

 

View를 렌더링하는 과정은 일반적으로 Thymeleaf 기술을 사용하고 있다고 가정했을 때, View 인터페이스의 구현 클래스인 ThymeleafViewTemplateEngineTemplateManager를 이용해 클라이언트에게 전송할 HTML에 응답 데이터를 채워 넣은 후, 최종 템플릿을 만듭니다.

그리고 마지막은 Writer가 write한 템플릿 데이터를 flush 해서 클라이언트에게 전달합니다.

템플릿 데이터는 TemplateEngineTemplateManager 어딘가에서 write 된다라고 생각하면 될 것 같습니다. ^^;

 

CSR(Client Side Rendering) 방식의 코드 흐름

CSR(Client Side Rendering) 방식은 SSR 방식처럼 HTML 템플릿을 렌더링 해서 클라이언트 쪽으로 내려주는것이 아니라 Controller가 리턴한 데이터를 주로 JSON 형태로 HTTP response body에 실어서 응답 데이터만 클라이언트 쪽에 전달해주는 방식입니다.

여기서 말하는 클라이언트는 Apache나 Nginx 같은 웹서버에서 실행되는 Frontend 측 웹 앱이 될 수도 있고, 스마트 폰에서 실행되는 네이티브 앱이 될 수도 있으며, 데스크탑에서 실행되는 데스크탑 애플리케이션이 될 수도 있습니다.

[그림 1-2] CSR 방식의 Spring MVC 요청 처리 흐름

[그림 1-2]은 Spring MVC의 요청 처리 흐름 중에서 CSR(Client Side Rendering) 방식의 요청 처리 흐름입니다.

그림에서 보다시피 View 자체가 필요 없기 때문에 ViewResolver와 View의 로직을 타지 않습니다.

private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
			@Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
			@Nullable Exception exception) throws Exception {

		...
		...

		// HandlerAdapter가 리턴한 ModelAndView가 null이면 뷰를 렌더링하지 않는다.
		if (mv != null && !mv.wasCleared()) {
			render(mv, request, response); 
			if (errorView) {
				WebUtils.clearErrorRequestAttributes(request);
			}
		}
		else {
			if (logger.isTraceEnabled()) {
				logger.trace("No view rendering, null ModelAndView returned.");
			}
		}
		...
		...
}

위 코드는 DispatcherServlet의 processDispatchResult() 코드의 일부입니다.
코드를 보면 HandlerAdapter가 리턴 받은 ModelAndView 객체가 null이면 뷰를 렌더링하지 않는 것을 볼 수 있습니다.

즉, 이 말은 Controller에서 리턴한 객체가 ResponseEntity 같은 타입의 객체인 경우 내부적으로 ModelAndView 객체를 생성하지 않는다는 의미와도 같습니다.

CSR 방식의 요청 처리 흐름의 코드는 앞에서 살펴보았던 SSR 방식의 코드 흐름 중, DispatcherServlet의 processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException) 내부에서 render(mv, request, response)의 호출부터 이 후 나머지 과정의 처리를 생략하면 됩니다.

 

이 글을 통해 Spring MVC의 요청 처리 흐름을 조금 더 깊게 이해하는데 조금이라도 도움이 될 수 있길 바랍니다.

Mac OS의 기본 셸 확인하기

Mac OS Catalina 이 전 버전까지는 기본 셸이 Bash shell(bash)이었으나, Catalina부터 Z shell(zsh)이 기본 셸로 변경되었습니다. 
따라서 Mac OS에서 시스템 환경 변수를 등록하기 위해서는 현재 사용하고 있는 Mac OS의 기본 셸이 무엇인지부터 확인을 해야 합니다.

Mac OS에서 기본 셸은 아래의 터미널 명령으로 손쉽게 확인할 수 있습니다.

kevin@hwang ~ % echo $SHELL
/bin/zsh
or
/bin/bash

 

Z shell에서 시스템 환경 변수 등록 방법

Z shell의 경우 다음과 같은 절차를 따릅니다.

  • 아래와 같이 vi 편집기로 .zshrc 파일을 생성 또는 오픈합니다.
kevin@hwang ~ % vi ~/.zshrc

 

  • .zshrc 파일에 아래와 같이 등록하고자 하는 환경 변수명과 값을 입력합니다.
export JWT_SECRET_KEY=kevin111111111111111111111111111111111111111111

 

  • 아래와 같이 source 명령어를 이용해 환경 변수를 등록합니다.
kevin@hwang ~ % source ~/.zshrc

 

Bash shell에서 시스템 환경 변수 등록 방법 

Bash shell의 경우 다음과 같은 절차를 따릅니다.

  • 아래와 같이 vi 편집기로 .bashrc 파일을 생성 또는 오픈합니다.
kevin@hwang ~ % vi ~/.bashrc

 

  • .bashrc 파일에 아래와 같이 등록하고자 하는 환경 변수명과 값을 입력합니다.
export JWT_SECRET_KEY=kevin111111111111111111111111111111111111111111

 

  • 아래와 같이 source 명령어를 이용해 환경 변수를 등록합니다.
kevin@hwang ~ % source ~/.bashrc

 

터미널 사용이 익숙하지 않을 경우 방법

만약 터미널을 사용해서 Shell을 편집하는 것이 익숙하지 않다면 아래와 같은 방법으로 시스템 환경 변수를 등록할 수 있습니다.

  • ⌘⇧. (Command + Shfit + . )을 눌러 숨김 파일을 표시합니다.
  • 아래와 같이 사용자 계정의 디렉토리에서 .zprofile을 찾아 더블 클릭한 후 파일을 엽니다.

 

  • 아래와 같이 등록하고자 하는 환경 변수명과 값을 입력한 후, 창을 닫습니다.

 

  • 열려진 터미널과 IntelliJ IDE가 있다면 창을 닫고 다시 시작합니다.

 

  • 아래와 같이 터미널과 IntelliJ IDE를 다시 오픈해서 환경 변수가 잘 등록 되었는지 확인합니다.

 

★ 시스템 환경 변수를 Spring Boot 애플리케이션에서 정상적으로 로드하기 위해서는 IntelliJ 같은 IDE를 반드시 Restart 하도록합니다.

HttpMessageConverter란?

웹 브라우저 같은 클라이언트에서 보여지는 HTML 컨텐츠가 렌더링(Rendering)되는 방식은 크게 두 가지입니다.

하나는 웹 애플리케이션 서버에서 동적으로 변하는 데이터를 포함하는 HTML을 만들어서 HTML 자체를 한번에 클라이언트 쪽으로 내려주는 방식입니다.
이 방식은 JSP나 타임리프(Thymeleaf) 같은 기술을 사용해서 HTML을 템플릿화 한 다음에 Controller의 핸들러 메서드에서 리턴하는 모델 데이터를 템플릿에 동적으로 채워 넣은 후, 최종적으로 완성된 HTML을 클라이언트 쪽으로 내려주는 방식입니다. 이 방식을 바로 서버 사이드 렌더링(Server Side Rendering)이라고 합니다.

또 하나는 클라이언트 쪽을 담당하는 Apache나 NginX 같은 웹 서버에 HTML을 올려 놓은 후, 자바스크립트의 Ajax 기술을 이용해서 웹 애플리케이션 서버에 데이터를 요청 합니다.
그리고 응답으로 전달받은 데이터를 웹 서버에 올려둔 HTML에 동적으로 채워 넣은 후, 최종적으로 완성된 HTML을 클라이언트 쪽으로 내려주는 방식입니다. 이 방식을 바로 클라이언트 사이드 렌더링(Client Side Rendering)이라고 합니다.

위 두가지 방식 중에서 HttpMessageConverter는 두 번째 방식인 클라이언트 사이드 렌더링과 관련이 있습니다.
즉, 클라이언트 사이드 렌더링을 위해 필요한 데이터를 특정 형식으로 변환해 주는 것이 바로 HttpMessageConverter입니다.

HttpMessageConverter의 사용 목적

HttpMessageConverter는 크게 두 가지 목적으로 사용됩니다.

하나는 웹 서버에서 전송된 Reqeust Body를 DTO 같은 클래스의 객체로 변환해서 웹 애플리케이션 서버 쪽에서 사용할 수 있도록 해주는 것이고, 

다른 하나는 웹 서버 쪽으로 전달할 응답 데이터를 Response Body로 변환해주는 것입니다.

 

HttpMessageConverter의 동작 과정

[그림 1-1] Spring MVC 요청/응답 동작 과정

 

[그림 1-1]은 클라이언트의 요청을 처리하는 Spring MVC의 기본 동작 과정입니다.
이 Spring MVC의 동작 과정에서 HandlerAdapter의 동작 과정을 조금 더 구체적으로 설명하겠습니다.

요청 처리 시 HandlerAdapter의 동작 과정

  • HandlerMapping을 통해 적절한 HandlerAdapter를 찾으면 HandlerAdapterController로 넘겨줄 파라미터를 결정하기 위해 이 작업을 HandlerMethodArgumentResolver에게 위임합니다.
  • HandlerMethodArgumentResolverHttpMessageConverter에게 HTTP Request Body를 특정 타입의 객체로 변환해주기를 요청합니다.
  • HttpMessageConverter는 HTTP Request Body를 특정 타입의 객체로 변환합니다.
  • HandlerMethodArgumentResolver는 변환된 데이터를 전달 받아서 이 데이터를 다시 HandlerAdapter에게 전달합니다.
  • HandlerAdapterHandlerMethodArgumentResolver로부터 전달 받은 데이터를 핸들러 메서드의 파라미터로 포함 시킨 후, 핸들러 메서드를 호출합니다.

 

응답 처리 시 HandlerAdapter의 동작 과정

  • 핸들러 메서드가 응답으로 전달할 데이터를 리턴합니다.
  • HandlerMethodReturnValueHandler는 핸들러 메서드로부터 전달 받은 응답 데이터를 HttpMessageConverter에게 전달합니다.
  • HttpMessageConverterHandlerMethodReturnValueHandler로부터 전달 받은 데이터를 HTTP Response Body에 포함되는 형식의 데이터로 변환합니다.
  • HandlerMethodReturnValueHandlerHttpMessageConverter로부터 전달 받은 데이터를 HandlerAdapter에게 전달합니다.

 

REST API에서의 HttpMessageConverter

Spring MVC에서는 기본적으로 아래의 HttpMessageConverter 구현체가 활성화 되어 있습니다.

  • ByteArrayHttpMessageConverter
  • StringHttpMessageConverter
  • ResourceHttpMessageConverter
  • SourceHttpMessageConverter
  • FormHttpMessageConverter
  • Jaxb2RootElementHttpMessageConverter
  • MappingJackson2HttpMessageConverter
  • MappingJacksonHttpMessageConverter
  • AtomFeedHttpMessageConverter
  • RssChannelHttpMessageConverter

 

만약 우리가 HTTP Request 또는 Response를 위한 통신 프로토콜로 JSON을 사용하는 REST API Controller를 구현했다고 가정해 봅시다.

특정 Controller의 핸들러 메서드 파라미터에 @RequestBody 애너테이션이 추가되거나 파라미터의 타입이 HttpEntity라면 위에서 나열한 HttpMessageConverter가 순서대로 HTTP Request Body를 체크해서 핸들러 파라미터에 지정한 클래스로 변환이 되는지, HTTP Message의 Content Type을 지원하는지 여부를 확인하게 됩니다.

이 두가지 조건을 모두 만족한다면 아마도 MappingJackson2HttpMessageConverter가 Request Body를 해당 클래스의 객체로 변환할 것이고, 조건에 부합하는 HttpMessageConverter가 존재하지 않는다면 예외가 발생할 것입니다.

이제 응답 데이터를 JSON 형식으로 전달하는 상황을 생각해 봅시다.
핸들러 메서드에 @ResponseBody 애너테이션을 추가하거나 핸들러 메서드의 리턴 타입이 HttpEntity라면 역시 위에서 나열한 HttpMessageConverter가 순서대로 핸들러 메서드의 리턴 값을 체크해서 해당 클래스 타입을 지원하는지, Accept Header에 명시된 MediaType으로 변환이 되는지 여부를 확인하게 됩니다.

 

HttpMessageConverter가 동작하는 시점

HttpMessageConverter가 동작하는 시점은 JSON을 DTO로 deserialization 할 때와 DTO를 JSON으로 serialization 할 때가 다릅니다. 아래는 HttpMessageConverter의 대략적인 동작 흐름입니다. 참고하세요.

JSON -> DTO로 deserialization

HandlerMethodArgumentResolverComposite.resolveArgument() 

RequestResponseBodyMethodProcessor.resolveArgument()

RequestResponseBodyMethodProcessor.readWithMessageConverters() > converter.read()

objectReader.readValue()

 

DTO -> JSON serialization

HandlerMethodReturnValueHandlerComposite.handleReturnValue() 
→ HttpEntityMethodProcessor.handleReturnValue() 
→ AbstractMessageConverterMethodProcessor.writeWithMessageConverters() > genericConverter.write() 
→ AbstractGenericHttpMessageConverter.write() 
→ AbstractJackson2HttpMessageConverter.writeInternal() > objectWriter.writeValue(generator, value)
→ write() and flush() 과정을 거친다.
→ ...
→ ...
CoyoteAdapter.service() > response.finishResponse() // response가 close되면서 클라이언트 쪽에 출력

HttpMessageConverter 참고 자료

 

Spring Framework 5.3.19 API

 

docs.spring.io

 

ResponseEntity란?

ResponseEntityHttpEntity의 확장 클래스로써 HttpStatus 상태 코드를 추가한 전체 HTTP 응답(상태 코드, 헤더 및 본문)을 표현하는 클래스입니다. 

ResponseEntity를 어디에 사용할 수 있나요?

  • ResponseEntity 클래스는 주로 @Controller 또는 @RestController 애너테이션이 붙은 Controller 클래스의 핸들러 메서드(Handler Method)에서 요청 처리에 대한 응답을 구성하는데 사용됩니다. Used in RestTemplate as well as in @Controller methods.
  • 그리고 RestTemplate으로 외부의 API 통신에 대한 응답을 전달 받아서 처리할 경우, 역시 ResponseEntity를 사용합니다.

ResponseEntity를 어떻게 사용해야 할까요?

가장 일반적인 방식

가장 일반적인 방식은 [코드 1-1]과 같이 new로 ResponseEntity 객체를 생성하는 방식입니다.

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/v1/coffees")
public class ResponseEntityExample01 {
    @PostMapping
    public ResponseEntity postCoffee(Coffee coffee) {
 
        // coffee 정보 저장
        
        return new ResponseEntity<>(coffee, HttpStatus.CREATED);
    }
}
[코드 1-1]

 

[코드 1-1]은 ResponseEntity 객체를 생성하면서 응답의 body 데이터와 HttpStatus의 상태를 전달하고 있습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/v1/coffees")
public class ResponseEntityExample01 {
    @GetMapping("/{coffee-id}")
    public ResponseEntity getCoffee(@PathVariable("coffee-id"long coffeeId) {
 
        if (coffeeId < 0) {
            return new ResponseEntity<>(
                    "coffeeId should be greater than 0",
                    HttpStatus.BAD_REQUEST);
        }
 
        return new ResponseEntity<>(new Coffee(), HttpStatus.OK);
    }
}
[코드 1-2]

 

코드 1-2는 coffeeId에 따라서 HttpStatus 상태를 동적으로 지정하는 예입니다.

 

코드 1-2는 요청 데이터에 대한 유효성 검사가 API 계층에 포함에 직접 포함이 되어 있는데, 이는 좋은 개발 방식은 아닙니다.

Handler Method는 단순히 클라이언트의 요청을 전달 받고, 처리된 응답을 다시 클라이언트에게 전달하는 역할만 하도록 로직을 단순화 하는 것이 좋으며, 유효성 검사나 비즈니스 처리 등의 로직은 별도의 메서드 또는 클래스로 분리를 하는 것이 좋습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
@RequestMapping("/v1/coffees")
public class ResponseEntityExample01 {
    @GetMapping("/{coffee-id}")
    public ResponseEntity getCoffee(@PathVariable("coffee-id"long coffeeId) {
 
        if (coffeeId < 0) {
            return new ResponseEntity<>(
                    "coffeeId should be greater than 0",
                    HttpStatus.BAD_REQUEST);
        }
 
        HttpHeaders headers = new HttpHeaders();
        headers.add("Custom-Header""bar");
        return new ResponseEntity<>(new Coffee(), headers, HttpStatus.OK);
    }
}
[코드 1-3]

만일 ResponseEntity에 Custom header를 포함하고 싶다면 코드 1-3과 같이 HttpHeaders에 원하는 header를 추가하고 ResponseEntity의 생성자로 headers 객체를 전달하면 됩니다.

 

BodyBuilder를 이용한 메서드 체인 방식

ResponseEntity 클래스의 생성자로 body, header, HttpStatus 등을 추가하는 방식과 달리 BodyBuilder 클래스를 이용하면 각각의 항목들을  메서드 체인 방식으로 전달할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/v1/coffees")
public class ResponseEntityExample02 {
    @PostMapping
    public ResponseEntity<Coffee> postCoffee(Coffee coffee) {
 
        // coffee 정보 저장
        long savedCoffeeId = 1L;
        return ResponseEntity.created(URI.create("/members/" + savedCoffeeId)).body(coffee);
    }
}
[코드 1-4]

코드 1-4는 코드 1-1을 ResponseEntity.BodyBuilder를 이용해서 메서드 체인 방식으로 ResponseEntity 객체를 리턴해주고 있습니다.

created() 메서드의 경우 URI를 지정할 수 있는데, 이는 새롭게 생성된 리소스에 대한 접근 URI를 Location 헤더 값으로 포함시킴으로써 클라이언트 쪽에서 이 정보를 이용해 해당 리소스에 접근할 수 있도록 해줍니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/v1/coffees")
public class ResponseEntityExample02 {
    @GetMapping("/{coffee-id}")
    public ResponseEntity getCoffee(@PathVariable("coffee-id"long coffeeId) {
 
        if (coffeeId < 0) {
            return ResponseEntity.badRequest().body("coffeeId should be greater than 0");
        }
 
        HttpHeaders headers = new HttpHeaders();
        headers.add("Custom-Header""bar");
        return ResponseEntity.ok().headers(headers).body(new Coffee());
    }
}
[코드 1-5]

코드 1-5는 코드 1-3을 BodyBuilder를 이용해서 메서드 체인 방식으로 변경한 코드입니다.

 

ResponseEntity 관련 자료

Controller의 핸들러 메서드는 다양한 유형의 Argument(인수)를 지원합니다.

그 중에서 REST API 애플리케이션에서 자주 사용되는 유형의 Argument를 간단히 살펴보도록 하겠습니다.

Method Argument 설명
@RequestParam 쿼리 파라미터, form-data 등의 Servlet request Parameter를 바인딩 해야 할 경우 사용합니다.
@RequestHeader request header를 바인딩해서 header의 key/value에 접근할 수 있습니다.
@RequestBody request body를 읽어서 지정한 Java 객체로 deserialization 해줍니다.
@RequestPart 'multipart/form-data' 형식의 request 데이터를 part 별로 바인딩할 수 있도록 해줍니다.
@PathVariable @RequestMapping 에 패턴 형식으로 정의된 URL의 변수에 바인딩할 수 있도록 해줍니다.
@MatrixVariable URL 경로 세그먼트 부분에 key/value 쌍으로 된 데이터에 바인딩할 수 있도록 해줍니다.
HttpEntity request header와 body에 접근할 수 있는 컨테이너 객체를 사용할 수 있습니다.
javax.servlet.ServletRequest, 
javax.servlet.ServletResponse
로우 레벨의 ServeletRequest와 ServletResponse의 정보가 필요할 때 사용할 수 있습니다.

 

@RequestParam

@RequestParam은 클라이언트 쪽에서 쿼리 파라미터, form data, x-www-form-urlencoded 등의 형식으로 전달되는 요청 데이터를 바인딩해서 사용할 수 있도록 해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Controller
@RequestMapping("/coffees")
public class CoffeeController {
 
    // ...
 
    @GetMapping
    public Coffee getCoffee(@RequestParam("coffeeId"int coffeeId) {
       Coffee coffee = coffeeService.getCoffee(coffeeId);
        return coffee;
    }
 
    // ...
 
}
[코드 1-1]
cs

 

@RequestHeader

@RequestHeader는 HTTP request header의 key/value 쌍의 데이터에 접근할 수 있도록 해줍니다.

1
2
3
4
5
6
@GetMapping("/coffee")
public void getCoffee(
        @RequestHeader("Content-Type"String contentType, 
        @RequestHeader("Content-Length"long contentLength) { 
    //...
}
[코드 1-2]
cs

 

@RequestBody

@RequestBody는 HTTP request body를 읽어서 지정한 Java 객체로 변환(deserialization) 해줍니다.

1
2
3
4
5
@PostMapping("/coffees")
public void handle(@RequestBody Coffee coffee) {
    // ...
    coffeeService.save(coffee)
}
[코드 1-3]
cs

@RequestBody는 특히 리소스를 등록하는 @PostMapping에서 주로 사용됩니다.

 

@RequestPart

'multipart/form-data' 형식의 request 데이터를 part 별로 바인딩할 수 있도록 해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Controller
public class CoffeeController {
    @PostMapping("/coffees")
    public String postCoffee(@RequestPart("coffee"Coffee coffee,
           @RequestPart("file") MultipartFile photoFile) {
 
        if (!photoFile.isEmpty()) {
            byte[] bytes = file.getBytes();
            // TODO 파일 저장
            return "success";
        }
        return "failed";
    }
}
[코드 1-4]
cs

예를 들어, 클라이언트 쪽에서 커피 사진이 포함된 multpart/form-data 타입의 커피 정보를 전송한다면,  [코드 1-4]와 같이  form data를 part별로 나누어서 전달 받을 수 있습니다.

 

@PathVariable

@PathVariable@RequestMapping 에 패턴 형식으로 정의된 URL의 변수에 바인딩할 수 있도록 해줍니다.

1
2
3
4
@GetMapping("/members/{member-id}/coffees/{coffee-id}")
public Pet getCoffee(@PathVariable("member-id") Long memberId,
@PathVariable("coffee-id") Long coffeeId) {
    // ...
}
[코드 1-5]
cs

[코드 1-5]와 같이 '{variable name}'과 같은 형태의 URL 변수가 여러개 있을 경우, @PathVariable을 핸들러 메서드에 순차적으로 추가해서 URL 변수의 값을 받을 수 있습니다.

 

@MatrixVariable

@MatrixVariable은 URL 경로 세그먼트 부분에 key/value 쌍으로 된 데이터에 바인딩할 수 있도록 해줍니다.

@MatrixVariable의 사용은 예제 코드를 보는게 가장 이해가 빠릅니다.

1
2
3
4
5
6
7
8
9
10
11
12
// 클라이언트 요청 URL
// GET /coffees/42;q=11;r=22
 
@GetMapping("/coffees/{coffeeId}")
public void getCoffee(@PathVariable("coffee-id") coffeeId, 
                      @MatrixVariable int q,
                      @MatrixVariable int r) {
 
    // coffeeId == 42
    // q == 11
    // r == 22
}
[코드 1-6]
cs

만약에 클라이언트 쪽 요청 URL이 '/coffees/42;q=11;r=22'와 같다면 세미콜론(;) 뒤에 오는 q, r을 변수로 보고 각 변수의 값을 [코드 1-6]과 같이 핸들러 메서드에서 전달 받을 수 있습니다.

 

HttpEntity

클라이언트 요청 body와 header 정보를 HttpEntity 객체를 통해 한번에 전달 받을 수 있습니다. 물론 HttpServletRequest 객체를 통해서도 요청 body와 header에 접근할 수 있지만 HttpEntity 객체를 통해 조금 더 간단하게 두 정보에 접근할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequestMapping("/v1/coffees")
public class HttpEntityExample {
    @PostMapping
    public Coffee postCoffee(HttpEntity<Coffee> entity) {
        Coffee coffee = entity.getBody();
        HttpHeaders headers = entity.getHeaders();
        
        // coffee 정보 저장
        return coffee;
    }
}
[코드 1-7]
cs

 

​@RequestMapping 애너테이션은 어디에 사용할 수 있나요?

@RequestMapping 애너테이션은 클라이언트의 요청과 Controller의 핸들러 메서드를 매핑하기 위해서 사용하는 애너테이션입니다. 즉, @RequestMapping 애너테이션에 정의된 URI에 매치되는 요청을 처리할 수 있도록 해줍니다.

@RequestMapping 애너테이션은 클래스 레벨에 사용할 수 있고, 메서드 레벨에 사용할 수 있습니다.
일반적으로 공통 URI는 클래스 레벨에 정의하고, 핸들러 메서드별로 달라지는 URI는 각각의 핸들러 메서드에 정의합니다.

 

@RequestMapping 애너테이션 단축 표현

@RequestMapping 애너테이션은 HTTP Method 에 해당하는 단축 표현들을 주로 사용합니다.

  • @GetMapping: HTTP Get Method에 해당하는 단축 표현으로 서버의 리소스를 조회할 때 사용
  • @PostMapping: HTTP Post Method에 해당하는 단축 표현으로 서버에 리소스를 등록(저장)할 때 사용
  • @PutMapping: HTTP Put Method에 해당하는 단축 표현으로 서버의 리소스를 수정할 때 사용. 리소스의 모든 정보를 수정할 때 사용한다.
  • @PatchMapping: HTTP Put Method에 해당하는 단축 표현으로 서버의 리소스를 수정할 때 사용. 리소스의 일부 정보만 수정할 때 사용한다.
  • @DeleteMapping: HTTP Delete Method에 해당하는 단축 표현으로 서버의 리소스를 삭제할 때 사용.

일반적으로 클래스 레벨에는 @RequestMapping 애너테이션을 사용하고, 메서드 레벨에서는 단축 표현을 사용하는 것을 권장하고 있습니다.

 

@RequestMapping 애너테이션에 사용되는 Attribute

@RequestMapping 애너테이션에 사용되는 Attribute는 생각보다 많이 사용되지 않습니다. 하지만 필요할 경우 그때 그때 설정해서 사용할 수 있으므로 아래 링크를 확인하시고 어떤 Attribute가 있는 살펴보기 바랍니다.

 

Handler 용어의 의미

핸들(Handle) 이라고 하면 일반적으로 자동차의 핸들을 제일 먼저 떠올릴 수 있는데, 자동차의 핸들은 운전자가 직접 핸들을 움직이면서 직접적으로 자동차의 주행을 처리하는 역할을 합니다.

Spring MVC에서는 자동차의 핸들과 마찬가지로 클라이언트의 요청을 처리하는 처리자Handler라고 합니다.

그렇다면 Spring MVC에서 Handler는 누구일까요?
Spring MVC에서의 요청 Handler는 바로 여러분들이 작성하는 Controller 클래스를 의미합니다. 그리고 Controller 클래스에 있는 @GetMapping, @PostMapping 같은 애너테이션이 붙어 있는 메서드들을 핸들러 메서드라고 합니다.

HandlerMapping이란 의미는 결국 사용자의 요청과 이 요청을 처리하는 Handler를 매핑해주는 역할을 하는 것입니다.

그렇다면, 사용자의 요청과 Handler 메서드의 매핑은 어떤 기준으로 이루어질까요?
@GetMapping(”/coffee”) 처럼 HTTP Request Method(GET, POST 등)와 Mapping URL을 기준으로 해당 Handler와 매핑이 되는데 Spring 에서는 여러가지 유형의 HandlerMapping 클래스를 제공하고 있습니다.

실무에서는 Spring에서 디폴트로 지정한 ‘RequestMappingHandlerMapping’을 대부분 사용하는데 원한다면 얼마든지 HandlerMapping 전략을 바꿀 수 있습니다. 

 

Adapter의 의미

Java에서 사용하는 클래스나 인터페이스의 이름을 살펴보다 보면 흥미로운 용어들이 많이 나옵니다.
Adapter라는 용어도 그 중에 하나라고 볼 수 있습니다.

우리말로 아답터, 어댑터(Adapter, Adaptor)하면 220V 전압을 110V 전압으로 또는 그 반대로 바꿔주는 어댑터(일명, 돼지코)나 USB 충전기를 떠올릴 수 있습니다.

220V를 5V 전압으로 바꿔주는 USB 충전기 예

 

이처럼 Adapter는 무언가를 다른 형식이나 형태로 바꿔주는 역할을 합니다.

그렇다면 HandlerAdater는 무엇을 바꿔줄까요?

Spring은 객체지향의 설계 원칙을 잘 따르는 아주 유연한 구조를 가지는 프레임워크입니다.
따라서 Spring이 제공하는 Spring MVC에서 지원하는 Handler를 사용해도 되지만 다른 프레임워크의 Handler를 Spring MVC에 통합할 수 있습니다.

이처럼 다른 프레임워크의 핸들러를 Spring MVC에 통합하기 위해서 HandlerAdapter를 사용할 수 있습니다.

 

ViewResolver의 의미

’Resolve’는 무언가를 해석하고, 해결해주다라는 뜻이 있습니다.

ViewResolver는 DispatcherServlet에서 ‘이런 이름을 가진 View를 줘’ 라고 요청하면 DispatcherServlet에서 전달한 View 이름을 해석한 뒤 적절한 View 객체를 리턴해주는 역할을 합니다.

+ Recent posts

출처: http://large.tistory.com/23 [Large]