PathVariable에 Slash(/)가 값으로 처리하기

RESTful 방식에서 URI는 Resource에 대한 접근을 어떤 방식으로 허용할지를 결정하는 중요한 요소이다.  당연히 특정 리소스의 구성 요소를 지정하는 방식으로 PathVariable을 사용해야한다. 대부분의 경우에는 별 문제없이 사용할 수 있지만 PathVariable에 특수문자가 들어오는 경우에 예상외의 오류가 발생하는 경우가 있다.  이런 대표적인 특수 문자가 Slash(/)이다.  Slash가 문제가 되는 이유는 짐작하겠지만, 이걸로 인해서 URI의 Path Separation이 발생하기 때문이다.

API Server Application

일반 설정으로는 Springboot 기반의 API 서버에서 Slash가 포함된 값을 PathVariable로 받을 수 없다.  Springboot에서는 기본적으로 /가 포함된 경우, 기본적으로 다음과 같은 정책을 적용한다.

  • Encoding된 Slash가 포함되었다 하더라도, 이를 Decode해서 변수 자체를 세부 Path로 구분해버린다.
  • 중복된 Slash가 존재하면 이를 합쳐서 하나의 Slash로 만들어버린다.

이걸 제대로 원래의 Original Value로 획득하기 위해서는 다음과 같은 옵션이 추가되어야 한다.

@Configuration
@EnableAutoConfiguration
@ComponentScan
@SpringBootApplication
public class ServiceApplication extends WebMvcConfigurerAdapter {
    ...
    public static void main(String[] args) throws Exception {
        System.setProperty("org.apache.tomcat.util.buf.UDecoder.ALLOW_ENCODED_SLASH", "true");
        SpringApplication.run(ServiceApplication.class, args);
    }

    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        UrlPathHelper urlPathHelper = new UrlPathHelper();
        urlPathHelper.setUrlDecode(false);
        urlPathHelper.setAlwaysUseFullPath(true);
        configurer.setUrlPathHelper(urlPathHelper);
    }

코드에서 주의깊게 봐야 할 포인트는 두 가지다.

  • main() 메소드에서 SpringApplication 실행 전 단계에 ALLOW_ENCODE_SLASH 속성 값을 false로 만들어야 한다. Springboot의 Embeded Tomcat이 URI값을 임의로 decode하는 것을 차단하고, 이를 그 값 그대로 Spring 영역으로 보내도록 설정을 잡는다.  이걸 왜 굳이 System property에서 잡아야 하는지 애매하지만 톰캣과 스프링이 그리 친하지 않는걸로 일단 생각한다.
  • WebMvcConfigurerAdapter를 상속받아서 configurePathMatch() 메소드를 Overriding한다.  Overriding 메소드에서 UrlPathHelper 객체를 생성하여 다음의 두가지 속성을 추가로 false로 반영한다.
    • urlDecode – 스프링의 기본 URI filter에서 추가적인 decode를 수행하지 않는다. (그런데 이 옵션이 맞는지는 좀 까리하다. 앞단의 ALLOW_ENCODE_SLASH 만으로 충분한 것 같기도 하고…)
    • alwasyUseFullPath – // 경우에 자동으로 이걸 / 로 치환하도록 하는 규칙을 적용하지 않도록 한다.

이 두가지 설정을 반영하면, 일단 PathVariable을 입력으로 받는데 문제는 없다.

RESTful Endpoint Request

받는걸 살펴봤으니, 이제 보내는 걸 살펴보도록 하자.  PathVariable로 값을 전달하는 경우, 마찬가지로 / 가 들어가면 여러 가지가지 문제를 일으킨다.  가장 간단한 방법은 /가 포함된 값을 URLEncode로 encoding해버리면 될거다… 라고 생각할 수 있다.  하지만 Spring에서 우리가 흔히 사용하는 RestTemplate을 끼고 생각해보면 예상외의 문제점에 봉착한다.

간단히 고생한 걸 정리해보자면…

Get 요청을 하면 되는 것이기 때문에 RestTemplate에서 제공하는 endpoint.get(…)을 사용해 처음 작성을 했었다. 간단한 테스트 케이스에 대해서는 잘 작동을 했지만, 같이 개발하는 친구의 playerId값에는 / 가 포함되어 500 오류를 발생시켰다.

    @Autowire
    RestTemplate endpoint;
    ....

    public SomeResponse queryList(String playerId) {
        ResponseEntity<Log[]> response;
        response = endpoint.getForEntity("http://localhost:8080/api/v1/log/" + playerId, Log[].class);

        Log[] logs = response.getBody();
        ....
    }

위의 코드가 문제 코드인데 보면 아무 생각없이 playerId라는 값을 GET Operation의 path variable의 값으로 넣었다. / 가 없는 경우에는 별 문제가 없지만, 이게 있는 경우에는 “http://localhost:8080/api/v1/log/뭐시기뭐시기/지랄맞을”와 같은 형식이 되버린다. 받는 쪽에서 이걸 제대로 인식할리가 없다.
앞에서 이야기한 것처럼 URLEncoder.encode()를 걸어봤지만, %2F 값을 RestTemplate내에서 %252F로 encoding을 한번 더 해버리는 경우가 발생한다. 뭐 받는쪽에서 한번 더 decoding을 하면 되는거 아냐?? 라고 이야기할 수도 있겠지만, 그건 제대로 된 방법이 아니다.

이래 저래 해결 방법을 찾아봤는데 단순 getForObject, getForEntity의 소스 코드 내용을 확인해봤을 때는 제대로 사용이 어렵고, URITemplate을 사용하는 메소드를 가지고 처리를 해주는게 정답이었다. 다행히 exchange 계열 메소드에서 이걸 지원한다.

public <T> ResponseEntity<T> exchange(String url, HttpMethod method, HttpEntity<?> requestEntity, ParameterizedTypeReference<T> responseType, Object... uriVariables) throws RestClientException {
        Type type = responseType.getType();
        RequestCallback requestCallback = this.httpEntityCallback(requestEntity, type);
        ResponseExtractor<ResponseEntity<T>> responseExtractor = this.responseEntityExtractor(type);
        return (ResponseEntity)this.execute(url, method, requestCallback, responseExtractor, uriVariables);
    }
...
    public <T> T execute(String url, HttpMethod method, RequestCallback requestCallback, ResponseExtractor<T> responseExtractor, Object... urlVariables) throws RestClientException {
        URI expanded = this.getUriTemplateHandler().expand(url, urlVariables);
        return this.doExecute(expanded, method, requestCallback, responseExtractor);
    }
...

RestTemplate의 execute 메소드의 소스 코드에서 포인트는 UriTemplateHandler를 통해 변수를 바인딩한다는 것이다.

        response = endpoint.exchange("http://localhost:8080/api/v1/log/{playerId}", HttpMethod.GET, HttpEntity.EMPTY, Log[].class, playerId);

exchange 메소드는 전달된 parameter를 내부적으로 variable mapping을 한다는 점이다. 그리고 / 를 encoding하게 만들려면 추가적으로 RestTemplate 객체를 생성할 때 강제로 / 를 처리하라고 지정을 해줘야한다. RestTemplate 객체를 생성하는 @Bean 메소드쪽에서 아래와 같이 defaultUrlTemplateHandler 객체를 생성한다.

        RestTemplate template = new RestTemplate(factory);

        DefaultUriTemplateHandler defaultUriTemplateHandler = new DefaultUriTemplateHandler();
        defaultUriTemplateHandler.setParsePath(true);
        template.setUriTemplateHandler(defaultUriTemplateHandler);

setParsePath() 메소드의 값을 true로 설정한 default handler를 RestTemplate의 기본 핸들러로 설정해준다. 기본 설정이 false이기 때문에 / 가 Path variable에 들어가 있다고 하더라도 따로 encoding처리가 되지 않아 문제가 발생했다.

이렇게 설정 및 실행 방법 등등을 변경하고 실행하면… 문제 해결.

ㅇㅋ

이렇게 마무리하면 된다.