개요
며칠전에 회사업무 도중에 다음과 같은 의존성 설정을 리뷰했었고, 별다른 댓글 없이 approve 처리하였다.
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml'
그 이후에 실 배포 과정에서 RestTemplate 을 사용하는 request 중 일부가Content-type:application/xml;charset=UTF-8 이 사용되었고, RestTemplate 이 이 실패하는 상황이 발행하였다.
롤백한 이후에 찾았던 문제의 원인과 방지하기 위한 방법을 정리하고자한다.
들여다 보기
동작하던 코드
배포가 있기전 이전 버전에서 정상 수행되는 코드를 테스트로 표현하였다.
@Test
void post_content_type() {
// given
mockRestServiceServer.expect(requestTo("/json"))
.andExpect(method(HttpMethod.POST))
.andExpect(header(CONTENT_TYPE, APPLICATION_JSON_VALUE))
.andExpect(content().string("{\"value\":\"doItForMe\"}"))
.andRespond(withSuccess());
// when
restTemplate.postForObject("/json", new Name("doItForMe"), Object.class);
// then
}
관련 설정은 제외
mockRestServiceServer 을 사용하여 실제 요청이 수행되는 환경을 구성하였다.applicatoin/json 이며 {"value":"doItForMe"} 를 body 로 받으면 200으 반환한다.
이 코드는 xml 의존성을 선언하기 이전에 잘 동작했던 코드이다.
다음 단계로 xml 을 추가하였으며 이제부터는 테스트가 실패하게 된다.
implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml'
java.lang.AssertionError: Request header [Content-Type] expected:<application/json> but was:<application/xml;charset=UTF-8>
Expected :application/json
Actual :application/xml;charset=UTF-8Expected 는 mockserver 의 andExpect 로 선언한 application/json 이고, Actual 은 RestTemplate 이 생성한 헤더값이다.
왜 이런 것일까?
RestTemplate HttpConverter 살펴보기
RestTemplate 는 파라매터로 전달받은 body 의 타입에 따라서 request 를 생성하는 HttpConverter 라는 인터페이스가 있다.
RestTemplate 내에서 이 설정을 찾아보면 되지 않을까?
public class RestTemplate extends InterceptingHttpAccessor implements RestOperations {
// ...
private final List<HttpMessageConverter<?>> messageConverters = new ArrayList<>();
public RestTemplate() {
this.messageConverters.add(new ByteArrayHttpMessageConverter());
this.messageConverters.add(new StringHttpMessageConverter());
this.messageConverters.add(new ResourceHttpMessageConverter(false));
if (!shouldIgnoreXml) {
try {
this.messageConverters.add(new SourceHttpMessageConverter<>());
} catch (Error err) {
// Ignore when no TransformerFactory implementation is available
}
}
this.messageConverters.add(new AllEncompassingFormHttpMessageConverter());
if (romePresent) {
this.messageConverters.add(new AtomFeedHttpMessageConverter());
this.messageConverters.add(new RssChannelHttpMessageConverter());
}
if (!shouldIgnoreXml) {
if (jackson2XmlPresent) {
this.messageConverters.add(new MappingJackson2XmlHttpMessageConverter());
} else if (jaxb2Present) {
this.messageConverters.add(new Jaxb2RootElementHttpMessageConverter());
}
}
if (jackson2Present) {
this.messageConverters.add(new MappingJackson2HttpMessageConverter());
} else if (gsonPresent) {
this.messageConverters.add(new GsonHttpMessageConverter());
} else if (jsonbPresent) {
this.messageConverters.add(new JsonbHttpMessageConverter());
} else if (kotlinSerializationJsonPresent) {
this.messageConverters.add(new KotlinSerializationJsonHttpMessageConverter());
}
if (jackson2SmilePresent) {
this.messageConverters.add(new MappingJackson2SmileHttpMessageConverter());
}
if (jackson2CborPresent) {
this.messageConverters.add(new MappingJackson2CborHttpMessageConverter());
}
this.uriTemplateHandler = initUriTemplateHandler();
}
}
중간에 익숙한 단어가 보인다.jackson2XmlPresent 이번에 추가하려고 했던 jackson 의 xml 의존성 이름이다.messageConverters 필드는 ArrayList 로 선언되어서 add 한 순서에 따라서 우선 순위가 결정된다.MappingJackson2HttpMessageConverter 보다 MappingJackson2XmlHttpMessageConverter 을 add 하는 순서가 빠른상태이고,
이 때문에 생성되었던 http 요청의 Content-type 이 xml 로 선언된 것을 알 수 있었다.
해결
해결 방법은 간단하다.
xml 의존성도 사용하면서 MappingJackson2HttpMessageConverter 가 우선적으로 사용되도록 list 의 순서를 정렬해주면 된다.
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
List<HttpMessageConverter<?>> messageConverters = restTemplate.getMessageConverters();
for (int i = 0; i < messageConverters.size() - 1; i++) {
if (messageConverters.get(i) instanceof MappingJackson2XmlHttpMessageConverter) {
Collections.swap(messageConverters, i, messageConverters.size() - 1);
}
}
restTemplate.setMessageConverters(messageConverters);
return restTemplate;
}
RestTemplate 의 Bean 을 등록할 때 messageConverter 에서 MappingJackson2XmlHttpMessageConverter 인 경우 List 의 맨 뒤로 보내주었다.
다시 테스트를 돌려보면 잘 도는 것을 확인할 수 있다.
방지
잘못 짜여진 테스트 코드가 가장 큰 문제였다.
if (!shouldIgnoreXml) {
if (jackson2XmlPresent) {
this.messageConverters.add(new MappingJackson2XmlHttpMessageConverter());
} else if (jaxb2Present) {
this.messageConverters.add(new Jaxb2RootElementHttpMessageConverter());
}
}
프로젝트에 사용되는 의존성에 따라서 RestTemplate 의 converter 가 달라지는 것은 이해할 수 있는 영역이었다.
이번 검증과정에서 사용한 mockRestServiceServer 으로 테스트 코드가 짜여있었다면, CI 단계에서 문제를 쉽게 찾을 수 있었을 것이다.
하지만 아쉽게도 기존의 많은 코드들이 RestTemplate을 모킹하여 파라매터가 전달되는지만 확인하고 있었고,
이 때문에 이런 문제점이 tc 에서 걸러지지 않고 실 운영 환경에서 발현되었다.
( 구조상 QA 가 불가능한 영역이었던 것도 한 몫 하였다.)
Controller 단에서도 mockMvc를 사용하여 실제 요청이 전달되는 일련의 과정을 테스트하곤 하는데,
외부 컴포넌트를 호출하는 과정에서도 mockServer 를 띄워서 테스트 하는 것을 지향하고, 기존 코드들을 리팩토링 하는데 힘써야겠다.