Post

Content Negotiation in Spring

스프링에서 콘텐츠 협상(Content Negotiation)을 처리하는 방법과 관련된 메커니즘을 설명합니다.

Content Negotiation in Spring

Content Negotiation

동일한 URI 에서 리소스의 서로 다른 버전을 제공하기 위해 사용하는 메커니즘

  • 클라이언트가 보내는 특정 HTTP 헤더를 이용해, 서버 주도 협상으로 이루어짐
  • 서버에 의해 전달되는 상태코드 300(다중 선택) 406 등을 사용해 폴백 형식으로 이루어짐

서버 주도 협상

브라우저는 URL 과 몇 개의 HTTP 헤더를 전송한다. 서버는 그 헤더를 기반으로 클라이언트한테 서빙할 최선의 컨텐츠를 선택한다.

500

서버는 어떤 헤더가 사용되느지 가르키기 위해 Vary 를 사용한다. - 캐시를 최적으로 동작하게 해줌 ( 특정 헤더가 달라졌거나, 없어졌으면 캐시 적용하지 않고 다른 요소로 처리 )

가장 일반적인 방식이나, 몇 가지 결점 역시 존재한다.

  • 서버가 브라우저에 대한 전체적인 지식을 가지고 있지 않다. - 수용 능력을 모름
  • 주어진 리소스에 몇몇의 프레젠테이션이 전송되므로, 캐시가 덜 효율적이고 서버 구현도 복잡해질 수 있다.

Accept

에이전트가 처리하고자 하는 미디어 리소스의 MIME 타입 컨텍스트에 따라 다양할 수 있다. ( 주소창에 입력된 문서를 받는다거나, 이미지 & 비디오 등을 다운 받는다거나 )

일반적인, 웹 브라우저는 text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7 이런식으로 보낸다. html -> xml -> webp,avif -> *.*

User-Agent

요청을 전송하는 브라우저를 식별하게 해준다.

  • PostmanRuntime/7.45.0 : 포스트맨임을 명시 + 버전
  • Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/140.0.0.0 Safari/537.36 : 어떤 브라우저 + 버전 ( 추가로, 기기 OS 정보도 전달 )

이 User-Agent 를 담지 않으면, 요청을 거부하는 서비스들도 있다. EX) GITHUB API, pexels


그러면, 스프링에선? 매우 간편하게 협상을 처리할 수 있다.

개발자가 직접 코드를 작성하지 않아도 ContentNegotiationManager 가 여러 HttpMessageConverter 들과 동작해서 처리해준다.

Strategy 들을 의존성 주입 받아서 추가하고, resolver 라면 추가해준다.

  • PathExtensionContentNegotiationStrategy : 경로 끝에 확장자를 확인하는 전략 - 비활성화, REST API 위반, 보안 문제 야기
  • ParameterContentNegotiationStrategy : URL 의 쿼리 파라미터 확인 ( ?format=xml ) - 비활성화

HeaderContentNegotiationStrategy 가 우리가 위에서 본 Accept 기반 전략이다.

1
2
3
4
5
@Override  
public List<MediaType> resolveMediaTypes(NativeWebRequest request)  
       throws HttpMediaTypeNotAcceptableException {
       ...
}

ContentNegotiationStrategy 가 요청을 기반으로 받을 수 있는 미디어 타입을 반환하면

RequestResponseBodyMethodProcessor 가 미디어 타입을 기반으로 변환을 처리해서 반환해준다.

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
/**  
 * Writes the given return type to the given output message. * @param value the value to write to the output message  
 * @param returnType the type of the value  
 * @param inputMessage the input messages. Used to inspect the {@code Accept} header.  
 * @param outputMessage the output message to write to  
 * @throws IOException thrown in case of I/O errors  
 * @throws HttpMediaTypeNotAcceptableException thrown when the conditions indicated  
 * by the {@code Accept} header on the request cannot be met by the message converters  
 * @throws HttpMessageNotWritableException thrown if a given message cannot  
 * be written by a converter, or if the content-type chosen by the server * has no compatible converter. */@SuppressWarnings({"rawtypes", "unchecked", "NullAway"})  
protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType,  
       ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage)  
       throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
       
       
		...
		if (selectedMediaType != null) {  
		    selectedMediaType = selectedMediaType.removeQualityValue();  
		  
		    ResolvableType targetResolvableType = null;  
		    for (HttpMessageConverter converter : this.messageConverters) {
			    ...
			    
		switch (converterTypeToUse) {  
		    case BASE -> converter.write(body, selectedMediaType, outputMessage);  
		    case GENERIC -> ((GenericHttpMessageConverter) converter).write(body, targetType, selectedMediaType, outputMessage);  
		    case SMART -> ((SmartHttpMessageConverter) converter).write(body, targetResolvableType, selectedMediaType, outputMessage, null);  
		}

MediaType 을 기반으로, converter 가 선택되어서 본문을 작성함으로써 똑똑하게 Content Negotiation 이 처리된다.

근데, 이러면 너무 자동으로만 이루어지는거 같지 않은가?  스프링이 제공해주는 WebMvcConfigurer 에서 협상을 설정할 수 있다.

1
2
3
4
5
6
@Override  
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {  
    // Accept Header 전략 사용 ( 기본값 true )
    configurer.ignoreAcceptHeader(false)  
        .defaultContentType(MediaType.APPLICATION_JSON);
}

default 를 통해, 협상을 못 찾으면 기본 MediaType 도 지정 가능하다.


XmlMapper 를 설치했더니, 기본이 XML 로 반환

사실, 이 내용 때문에 학습을 했다 ㅋ.ㅋ

1
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml'

이번 이슈를 진행하며, xml 을 파싱해야 하는 요구사항이 있어 의존성을 추가했다.

기존과 같이 JSON 으로 나와야 하는 API 가

XML 로 나오는걸 볼 수 있다.

1
2
3
4
5
6
7
@Autowired  
List<HttpMessageConverter> messageConverters;  
  
@PostConstruct  
public void init() {  
    log.info("Application started : {}", messageConverters);  
}

아무곳에서나 이렇게 HttpMessageConverter 를 찍어보면

1
[org.springframework.http.converter.StringHttpMessageConverter@13d26ed3, org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter@39f1bf06, org.springframework.http.converter.json.MappingJackson2HttpMessageConverter@2cea567b]

와 같이 XmlHttpMessageConverter 가 추가된 걸 볼 수 있다.

1
2
3
4
@Override  
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {  
    converters.removeIf(converter -> converter instanceof MappingJackson2XmlHttpMessageConverter);  
}

instanceof 로 XmlConverter 를 찾아서 제거하자.

그러면, 기존처럼 JSON 을 반환하는걸 볼 수 있다!