Spring/Spring

[Spring] RestClient URI Encoding 문제 (feat. 퍼센트 인코딩)

gakko 2024. 5. 21. 00:01

 

RestClient의 URI 인코딩

DefaultUriBuilderFactory

  • RestClient를 생성할 때 보통 Builder를 사용해 만들게 됩니다.
public interface RestClient {

    ...

    static RestClient.Builder builder() {
        return new DefaultRestClientBuilder();
    }
    
    ....
    
}
  • RestClient.Bulider가 build() 하는 시점에 아래와 같이 DefaultUriBuilderFactory를 기본으로 생성해 가지고 있습니다.
public class DefaultRestClientBuilder {

    ...

    @Override
    public RestClient build() {
        ClientHttpRequestFactory requestFactory = initRequestFactory();
        UriBuilderFactory uriBuilderFactory = initUriBuilderFactory();
        HttpHeaders defaultHeaders = copyDefaultHeaders();
        List<HttpMessageConverter<?>> messageConverters = (this.messageConverters != null ?
                this.messageConverters : initMessageConverters());
        return new DefaultRestClient(requestFactory,
                this.interceptors, this.initializers, uriBuilderFactory,
                defaultHeaders,
                this.statusHandlers,
                messageConverters,
                this.observationRegistry,
                this.observationConvention,
                new DefaultRestClientBuilder(this)
                );
    }
    
    private UriBuilderFactory initUriBuilderFactory() {
	    if (this.uriBuilderFactory != null) {
		    return this.uriBuilderFactory;
	    }
	    DefaultUriBuilderFactory factory = (this.baseUrl != null ?
			    new DefaultUriBuilderFactory(this.baseUrl) : new DefaultUriBuilderFactory());
	    factory.setDefaultUriVariables(this.defaultUriVariables);
	    return factory;
    }
    
    ....
}
  • DefaultUriBuilderFactory의 EncodingMode 기본값을 확인해보면 EncodingMode.TEMPLATE_AND_VALUES입니다.
public class DefaultUriBuilderFactory implements UriBuilderFactory {

    ...
    
    private EncodingMode encodingMode = EncodingMode.TEMPLATE_AND_VALUES;
    
    ...
}

 

DefaultUriBuilderFactory EncodingMode

EncodingMode를 이해하기 전에 먼저 URI 변수URI 템플릿에 대해 알고 있어야 합니다. 아래와 같은 형식을 URI 템플릿이라고 부릅니다. {} 중괄호를 사용해 변수를 넣을 수 있는 부분(URI 변수)이며 나머지는 고정값입니다.

https://api.example.com/{version}/users/{userId}/details
  • URI 변수에 해당하는 중괄호 값인 {version}과 {userId}는 어떤 값이든 들어갈 수 있습니다.
https://api.example.com/v1/users/1/details
https://api.example.com/v2/users/33/details

이와 같이 URI를 특정 형식에 맞춰 동적으로 구성 가능한 기술을 URI 템플릿이라고 이해하시면 됩니다.

 

이제 다시 EncodingMode에 대해 알아봅시다. 종류는 아래와 같습니다.

  • TEMPLATE_AND_VALUES: URI 템플릿과 URI 변수 모두 인코딩 합니다.
  • VALUES_ONLY: URI 템플릿을 인코딩하지 않고 URI 변수를 인코딩합니다.
  • URI_COMPONENT: URI 변수를 적용한 후 URI 컴포넌트를 인코딩합니다.
  • NONE: 인코딩을 적용하지 않습니다.

RestClient는 기본적 설정이  TEMPLATE_AND_VALUES 였으므로, URI 템플릿과 URI 변수 모두 인코딩한다고 보시면 될 것 같습니다.

 

 

인코딩 문제?

콜론을 PathVariable로 전달

  • 종종 외부 API의 Path를 보면 중간에 비밀키를 넣어야 하는 경우가 있습니다. 영문과 숫자만 들어있다면 다행이지만, 특수문자가 들어가는 때도 있습니다.
  • 메시지 키 은닉을 위해 PathVariable로 messageKey를 전달해봅시다.
public interface MessageClient {
  @PostExchange("/v1/services/{messageKey}/messages")
  MessageResponse sendMessage(
      @PathVariable String messageKey,
      @RequestBody MessageRequest request);
}
  • messageKey에 할당된 값을 example:sms:XXXXXXXXXX:myApp 라고 해봅시다.
@Service
@Transactional
public class MessageService {

    private final String MESSAGE_KEY;
    private final MessageClient messageClient;
    
    public MessageService(
        @Value("${external-api.message.key}") final String MESSAGE_KEY,
        final MessageClient messageClient) {
        this.MESSAGE_KEY = MESSAGE_KEY;
        this.messageClient = messageClient;
    }
    
    public MessageResponse sendMessage(final Message Request) {
        return messageClient(MESSAGE_KEY, request);
    }
}
  • 하지만 막상 실행해보면 제대로 동작하지 않습니다. 문제가 뭘까요?

 

% 인코딩

  • 문제는 인코딩에 있습니다. 위의 경로로 실제 URI가 인코딩된 모습을 보면 아래와 같습니다.
  • 콜론( : )이 %3A로 인코딩되면서 SecretKey 값이 변형되어 UNAUTHORIZED 에러가 발생했기 때문에 제대로 동작하지 않았던 것입니다.
https://api.example.com/v1/services/example%3Asms%3AXXXXXXXXXX%3AmyApp/messages

 

 

인코딩 문제를 해결하려면?

DefaultUriBuilderFactory의 EncodingMode를 변경

  • 아래가 기존의 RestClient를 생성하는 로직이었습니다.
private RestClient createRestClient(String baseUrl) {
    return RestClient.builder()
        .baseUrl(baseUrl)
        .build();
}
  • DefaultUriBuilderFactory를 선언하고 setEncodingMode 메소드를 사용해 EncodingMode.NONE을 설정한 뒤, uriBuilderFactory 메소드를 통해 RsetClient에 추가해줬습니다. 이제 URI가 인코딩되지 않기 때문에 SecretKey가 변형되지 않고 잘 보내질 겁니다!
private RestClient createRestClient(String baseUrl) {
    DefaultUriBuilderFactory uriBuilderFactory = new DefaultUriBuilderFactory(baseUrl);
    uriBuilderFactory.setEncodingMode(EncodingMode.NONE);
    
    return RestClient.builder()
        .uriBuilderFactory(uriBuilderFactory)
        .build();
}

 

EncodingMode를 None으로 두면 문제가 없을까요?

  • QueryParam으로 한글이나 특수문자가 넘어오면 문제가 생길 수 있습니다. (하지만 제가 경험한 바로는 외부 API에서 params 값으로 한글이나 특수문자를 넘겨받기를 원하는 스펙이 보편적인 경우는 아닌 것 같습니다.)
  • 이 때는 RestClient로 전달하기 전에 미리 Encoding을 해주고 변수로 넘겨주면 됩니다!
public interface PaymentClient {

    @PostExchange("/payment")
    ExampleResponse confirmPayment(@RequestParam String param);
}
  • UriEncoder를 통해 한글을 인코딩해줘서 RestClient에게 넘기면 됩니다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ExternalPaymentService {

    private final PaymentClient paymentClient;

    public PaymentExample example(final ExampleRequest request) {
      String encodedKorean = UriEncoder.encode(request.koreanStr()); // 한글 및 특수문자 인코딩
      return paymentClient.confirmPayment(encodedKorean);
    }
}

 

 

 

관련 글