[Spring] RestTemplate + UriComponentBuilder 사용시 double url encoding 을 조심하자

식빵·2022년 7월 8일
1

Spring Lab

목록 보기
13/33
post-thumbnail

가끔 RestTemplate 에서 사용할 복잡한
URL 작성을 위해서 UriComponentBuilder 를 사용하는 경우가 종종 있다.

그런데 주의할 게 있다!

UriComponentBuilder 에서 url encoding 이 1번,
RestTemplate 메소드 호출에서 url encoding 이 또 1번 일어나서,
결과적으로 query parameter 로 전송하는 값들이 우리가 기대했던 대로 가지 않을 수 있다.

코드를 짜고 눈으로 확인해보자.

코드 작성 환경은 Spring Boot (v.2.7.1) + gradle 이며,
gradle의 dependencies 설정은 아래와 같다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    implementation 'org.apache.httpcomponents:httpclient'
}



🥝 간단 테스트 코드

🥥 테스트 서버 코드

@SpringBootApplication
@RestController
public class SpringRestTemplateApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringRestTemplateApplication.class, args);
    }


    @GetMapping(value = "/good/morning/{requestId}")
    public String getMethod(@RequestParam MultiValueMap<String, ?> good,
    						@PathVariable String requestId) {

        System.out.println("================" + requestId + "==================");
        good.forEach((s, objects) -> {
            System.out.println(s + ": " + objects);
        });
        System.out.println();

        return "good";
    }
}



🥥 클라이언트 코드 (feat.Junit5)

new RestTemplate.getForEntity(~) 메소드를 통해서 테스트를 할 예정이다.
여기서 집중해서 봐야할 것은

  1. UriComponentBuildersencode 메소드 사용여부
  2. new RestTemplate.getForEntity(~) 메소드의 첫번째 인자값 형태
    • UriComponents.toUri 사용
    • UriComponents.toUriString 사용

모든 경우의 수를 생각하면 4가지 조합이 나온다.

UriComponents.toUri / toUriStringUriComponentsBuilder.encode() 사용 여부
toUriO
toUriX
toUriStringO
toUriStringX

위 4가지 조합을 아래와 같이 Junit5 테스트 코드로 작성해봤다.

package hello.dailycode.rest;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.Map;

public class DoubleUrlEncodingTest {

    private static final RestTemplate restTemplate
		= new RestTemplate(new HttpComponentsClientHttpRequestFactory());

@Test
@DisplayName("방식1. UriComponentsBuilder.encode() 사용 + UriComponents.toUri() 사용")
    void requestV1() {
        UriComponents complexUrl = UriComponentsBuilder
                .fromUriString("http://localhost:8080/good/morning/{requestId}")
                .uriVariables(Map.of("requestId", "requestV1"))
                .queryParam("userName", "한글이름")
                .queryParam("userId", "dailyCode")
                .encode().build();

        restTemplate.getForEntity(complexUrl.toUri(), String.class);
    }


@Test
@DisplayName("방식2. UriComponentsBuilder.encode() 미사용 + UriComponents.toUri() 사용")
    void requestV2() {
        UriComponents complexUrl = UriComponentsBuilder
                .fromUriString("http://localhost:8080/good/morning/{requestId}")
                .uriVariables(Map.of("requestId", "requestV2"))
                .queryParam("userName", "한글이름")
                .queryParam("userId", "dailyCode")
                /*.encode()*/.build();

        restTemplate.getForEntity(complexUrl.toUri(), String.class);
    }


@Test
@DisplayName("방식3. UriComponentsBuilder.encode() 사용 + UriComponents.toUriString() 사용")
    void requestV3() {
        UriComponents complexUrl = UriComponentsBuilder
                .fromUriString("http://localhost:8080/good/morning/{requestId}")
                .uriVariables(Map.of("requestId", "requestV3"))
                .queryParam("userName", "한글이름")
                .queryParam("userId", "dailyCode")
                .encode().build();

        restTemplate.getForEntity(complexUrl.toUriString(), String.class);
    }


@Test
@DisplayName("방식4. UriComponentsBuilder.encode() 미사용 + UriComponents.toUriString() 사용")
    void requestV4() {
        UriComponents complexUrl = UriComponentsBuilder
                .fromUriString("http://localhost:8080/good/morning/{requestId}")
                .uriVariables(Map.of("requestId", "requestV4"))
                .queryParam("userName", "한글이름")
                .queryParam("userId", "dailyCode")
                /*.encode()*/.build();

        restTemplate.getForEntity(complexUrl.toUriString(), String.class);
    }

}



🥝 출력 관찰

Spring Boot 를 실행시키고 나서 작성한 테스트를 한번에
실행시키면 아래와 같은 로그가 나온다.

================================requestV1================================
userName: [한글이름]
userId: [dailyCode]

================================requestV2================================
userName: [한글이름]
userId: [dailyCode]

================================requestV3================================
userName: [%ED%95%9C%EA%B8%80%EC%9D%B4%EB%A6%84]
userId: [dailyCode]

================================requestV4================================
userName: [한글이름]
userId: [dailyCode]

딱보면 알겠지만, requestV3 요청에 사용된 query parameter 에서 "한글이름" 이라는
한글 문자열이 깨졌다.

이게 바로 2번 url encoding에 의한 현상이다.
실제로 저게 2번 url encoding 이 되었다는 것은 어떻게 알 수 있을까?


이를 확인하기 전에 저 깨진 문자열이 나오게 된 경위를 파악하겠다.

  1. [Client] : query parameter 가 2번 encode 되서 요청 전송 요청 전송
  2. [Server] : 프레임워크의 도움으로 query parameter의 decode 가 1번 일어남

즉, Server 에 도착한 깨진 문자열을 한번만 더 decode 를 하면 정상적인 값이 나온다는 뜻이다.


그렇다면 저 깨진 문자열을 한번 더 decode 를 해서 정상적인 "한글이름"이 출력되면
2번 url encoding 이 일어났다는 것이 증명되는 것이다.


확인을 위해서 이 웹사이트 접속해서 아래와 같이 간단한 테스트를 해봤다.
깨진 문자열을 복사, 붙여넣기 하고 decode를 클릭하면...

정상적으로 "한글이름"이라고 나온다!


결론
이런 url encoding 중복 현상이 일어나지 않도록 될 수 있으면
UriComponentsBuilder.encode() 사용 + UriComponents.toUriString()
방식을 자제하자.




🥝 그런데 말입니다.

사실 위에서 아예 요청 자체가 안될 가능성이 있는 것도 하나 있다.
그건 바로 requestV2 이다. 만약 내가 아래처럼 하지 않고,

private static final RestTemplate restTemplate
    = new RestTemplate(new HttpComponentsClientHttpRequestFactory());

요렇게 하고 요청을 보내면 어떨끼?

private static final RestTemplate restTemplate
    = new RestTemplate();
    //= new RestTemplate(new SimpleClientHttpRequestFactory()); 도 마찬가지

아래와 같은 에러 문구를 보인다.

23:40:22.805 [Test worker] DEBUG org.springframework.web.client.RestTemplate 
	- Response 400 BAD_REQUEST

나는 그냥 "아, 에러가 나는구나"하고 넘어가겠다.
이것에 대한 상세한 이유가 알고 싶다면... 개인적으로 알아내보시길 바랍니다!
아무튼 requestV2 방식도 Factory 를 뭘 쓰냐에 따라 될 수도 안될 수도 있으니 조심하자.




🥝 그래서 뭘 써야될까?

한번도 에러를 내지 않고, 한글 문자열 파라미터도 제대로 보냈던
requestV1, requestV4 를 쓰면 될 거 같다.

@Test
@DisplayName("방식1. UriComponentsBuilder.encode() 사용 + UriComponents.toUri() 사용")
void requestV1() {
    UriComponents complexUrl = UriComponentsBuilder
            .fromUriString("http://localhost:8080/good/morning/{requestId}")
            .uriVariables(Map.of("requestId", "requestV1"))
            .queryParam("userName", "한글이름")
            .queryParam("userId", "dailyCode")
            .encode().build();

    restTemplate.getForEntity(complexUrl.toUri(), String.class);
}

@Test
@DisplayName("방식4. UriComponentsBuilder.encode() 미사용 + UriComponents.toUriString() 사용")
void requestV4() {
    UriComponents complexUrl = UriComponentsBuilder
            .fromUriString("http://localhost:8080/good/morning/{requestId}")
            .uriVariables(Map.of("requestId", "requestV4"))
            .queryParam("userName", "한글이름")
            .queryParam("userId", "dailyCode")
            /*.encode()*/.build();

    restTemplate.getForEntity(complexUrl.toUriString(), String.class);
}

사실 위에 저렇게 결론을 냈지만, 결국은 자기가 어떤 Factory 를 RestTemplate 에 제공하는지, 어떤 Interceptor 를 제공하는지에 따라서 또 바뀔 수 있다.
그러니 자기가 일하는 곳에서 내가 작성한 테스트 코드를 가져다가 한번 테스트를 실행해보고 결론을 내리자.

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

1개의 댓글

comment-user-thumbnail
2023년 8월 28일

도움이 됐습니다

답글 달기