서비스 운영 중 가끔 사용자가 검색한 쿼리값을 통해 API 요청을 보냈을 때, 에러가 발생하는 경우가 종종 있었습니다.
이를 해결하기 위해 진행했던 버그 분석 및 해결 과정을 기록하고자 합니다.
비슷한 에러를 겪는 분께 도움이 되었으면 하네요 😮

 

1. 에러 분석

사용자가 어떠한 값을 검색하고자 할 때, Axios 를 활용하여 검색 쿼리 정보와 함께 API 요청을 보내는 방식으로 코드를 구현하여 서비스를 잘 운영하고 있었습니다.

그러던 중 가끔씩 검색 API 요청에 대한 서버 응답이 500이 떨어지는 현상을 발견하게 되었습니다. 

어떤 경우에 에러가 발생하는 건지 확인해본 결과. 사용자가 입력한 검색 쿼리와 함께 API 를 요청할 때 url query string 에 대괄호가 인코딩 되지 않은 상태로 요청이 보내지게 되어 서버에서 net::ERR_ABORTED 500 (Internal Server Error) 에러를 내고 있었습니다.

발생 에러: 대괄호를 그대로 url 에 포함시켜 서버에 요청하는 경우 500 에러 발생

 

분명히 Axios 라이브러리는 내부적으로 url, query 등 API 요청에 필요한 정보들을 인코딩 한 상태로 서버로 전달해주는데,
왜 이런 에러가 발생하는 건지 수상쩍은 마음에 자료를 조사하기 시작했습니다.

그에 비슷한 에러를 겪어 그에 대한 논의를 진행한 몇몇 글을 찾게 되었고,
(참고: https://github.com/axios/axios/issues/3316 , https://github.com/AtlasOfLivingAustralia/biocache-hubs/issues/369)
그 내용들을 토대로 "Tomcat 8 버전 이상에서부터는 대괄호가 url 에 포함될시 아래의 캡쳐과 같은 에러를 내뱉기 때문에 500 에러가 내려오고 있다" 라고 추측할 수 있었습니다.

[캡쳐1] Tomcat 에러 로그

 

2. RFC 3986 문서?

[캡쳐1] Tomcat 에러 로그 를 살펴 보면 RFC 7230과 RFC 3986 에서 정의된 유효한 문자를 사용하라고 이야기하고 있습니다.
어떤 RFC 문서인지 살펴보았는데요. RFC 3986 규격 문서에 다음과 같은 내용이 있었습니다.
(참고: https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2)

RFC3986

영어로 뭐라뭐라 적혀있는데요.
요약하면 "Pv6 주소를 식별하는 호스트는 대괄호 "["와 "]"로 둘러싸인 IP 리터럴 형식을 사용한다." 라는 내용이었습니다. 🤔

실제로 URL 객체와 URLSearchParams 클래스는 위와 같은 RFC3986 기반의 최신 URI 사양을 기반으로 처리되고 있습니다. 반면에 encodeURI 와 같이 오래된 함수들은 RFC2396 기반으로 처리된다고 합니다. 이에 아래 코드와 같이 다른 결과를 볼 수 있습니다.

/**
 * @see https://ko.javascript.info/url
 * 유효한 IPv6 주소를 가진 URL
**/
let url = 'http://[2607:f8b0:4005:802::1007]/';

alert(encodeURI(url)); // http://%5B2607:f8b0:4005:802::1007%5D/
alert(new URL(url)); // http://[2607:f8b0:4005:802::1007]/

encodeURI는 대괄호를 치환한 것을 볼 수 있다. Ipv6 규격을 모르기 때문에 그냥 변환해주는거라고 볼 수 있습니다.
여튼 비교적 최근에 나온 URL 객체의 경우 3986 규격에 따라 Ipv6 에 사용되는 대괄호는 인코딩하지 않는 걸 확인할 수 있습니다.

 

3. 에러 정리

위 규격문서 및 톰켓 에러로그를 분석해본 결과,

" 8버전 이상의 톰켓의 경우 위의 규격을 따르고 있고
그로 인해 대괄호가 url 에 인코딩되지 않은 채로 포함된 상태로 넘어오게 되면
IPv6 를 구분하는데에 사용되는 대괄호를 본 톰켓이 유효하지 않은 문자라는 에러를 내뱉어 결과적으로 서버 500 에러가 내려오고 있구나 "

라고 에러를 분석할 수 있었습니다.

정리: 대괄호를 인코딩하지 않고 쿼리 스트링에 포함시켜 보내게 되면
8버전 이상의 톰켓은 RFC3869 규격에 따라 유효하지 않은 문자라고 판단하여 에러를 내뿜는다.

 

 

4. 해결 과정

위 에러 해결을 하기 위해 아래 2가지 분석을 순서대로 진행했습니다.

  1. 대괄호가 왜 인코딩되지 않고 있을까
  2. 왜 인코딩되지 않고 있는지 알았다면, 어떻게 인코딩한 결과값을 만들어낼 수 있을까

2가지 각각에 대한 분석 과정 및 결과는 다음과 같습니다.

 

4.1. 대괄호가 왜 인코딩되지 않고 있을까

에러가 발생한 서비스는 Axios 를 활용하여 query, params 을 포함한 API 요청을 하고 있습니다.
아래의 코드는 API 요청 로직을 최대로 간소화한 수도코드입니다.

const axiosClient: AxiosInstance = axios.create({baseURL: '/'});
 
const api = async <T>(config: AxiosRequestConfig) => {
    const requestApi = axiosClient(config); // config (query, params, url, method ...)
 
    try {
        const res = await requestApi;
        return successHandler<T>(res);
    } catch (err) {
        return errorHandler();
    }
}

위의 수도코드를 보면 알 수 있듯이

  1. AxiosInstance 를 생성한뒤,
  2. 해당 query, parmas, url, method 등의 정보가 들어있는 config 를 인자로 전달하여 AxiosPromise 객체를 생성
  3. 최종 url 을 빌드시 AxiosPromise 객체가 query string, parmas, url 를 합성 (직렬화)


이 때 인스턴스에 전달한 config는 Axios 내부 로직을 타게 되어 인코딩 되고 있습니다.
Axios 내부 로직을 통해 인코딩 되고 있는데, 대괄호만 인코딩 되지 않는다?
그렇다면 여기서 하나 의심할 수 있는 점은 "Axiosinstance가 전달받은 url 및 config 를 빌드하면서 대괄호 인코딩을 안해주고 있다"라는 점이었습니다.

의구심 해결을 위해 코드를 뜯어보았습니다.

Axios를 활용해 API 요청은 대부분 아래와 같은 형태로 구현되어있을거라 생각합니다.

// 1. 인스턴스 사용
const response = await axiosClient(instanceConfig);
 
// 2. axios method 직접 사용
const response = await axios.get('/api', commonConfig);
 

위 2가지 방식으로 요청을 하게 되면 Axios 라이브러리는 내부적으로 다음 4개 로직을 순서대로 수행하게됩니다.
(참고: https://github.com/axios/axios)

  1. 라이브러리 내부 Axios 객체는 request 함수를 호출합니다. (request)
  2. request 함수는 XMLRequest 객체를 생성해서 요청을 하기위한 준비를 합니다. (request > dispatchRequest > xhrAdapter > XMLRequest)
  3. 이 때 buildURL 이라는 메소드를 통해 주입된 config, url 들을 합쳐 url 을 생성합니다. (buildURL:L22)
  4. url 생성시 
    1. params 가 없는 경우 url 그대로를 반환합니다. (buildURL:L25)
    2. params 가 있는 경우 params 의 key value를 내부 encode 함수로 인코딩하여 직렬화해줍니다. (buildURL:L53)

4-2 에서 문제가 발생하고 있었습니다.

내부 encode 함수에서 문자열을 encodeURIComponent 함수를 통해 인코딩 할때 아래와 같은 로직이 수행되는데요. (buildURL:L5)

function encode(val) {
  return encodeURIComponent(val).
    replace(/%3A/gi, ':').
    replace(/%24/g, '$').
    replace(/%2C/gi, ',').
    replace(/%20/g, '+').
    replace(/%5B/gi, '[').
    replace(/%5D/gi, ']');
}

 

encodeURIComponent 를 통해 인코딩을 해준뒤 다시 몇몇 특수문자를 다시 replace 해주는 것을 볼 수 있습니다. (인코딩 했는데 굳이 다시 디코딩..? )

굳이 인코딩처리해준 값에서 몇몇 문자 외에 대괄호를 디코딩해준 이유가 위에서 언급한 RFC 3986 문서의 내용 때문이 아닌가 싶지만 여러 사람들이  https://github.com/axios/axios/issues/3316 이슈에서 버그다, 고쳐달라 라는 논의가 진행중인걸로 보아 버그성 기능인가? 라는 의구심이 생기는 코드라 생각합니다. (정확히 어떤 이유에 저 코드를 넣어둔건지는.. 모르겠네요. 알고계시다면 공유부탁드립니다 😶)

추가로 관련해서 수정하기 위한 Axios github 내 PR 시도까지 있었으나 최근까지 고쳐지지 않았습니다.. (0버전, 1버전대 모두 동일)

여튼 위와 같은 이유로 대괄호 인코딩 되지 않는 현상은 "Axios 라이브러리 자체의 버그" 다 라는 결론을 지었습니다.

 

4.2. 왜 인코딩되지 않고 있는지 알았다면, 어떻게 인코딩한 결과값을 만들어낼 수 있을까

대괄호를 인코딩한 결과값을 어떻게 만들어낼 수 있을지, 떠올린 방법으로는 2가지가 있었습니다.

  1. url을 직접 빌드
  2. Axios 커스텀 직렬화 로직 추가

 

4.2.1. url을 직접 빌드

첫번째로 떠올린 방법은 AxiosInstance 를 만들어 query, params 등의 옵션값을 넘긴뒤 url 빌드를 요청하는 형태가 아닌,
아래와 같이 직접 query, params 등의 옵션들로 url 을 빌드하여 AxiosInstance 객체 또는 axios.get(post...) 메소드에 직접 전달하는 방법 입니다.

// 1. 인스턴스 사용
const response = await axiosClient({
    method: 'get',
    url: '/api?query=encodeURIComponent(value)',
});
 
// 2. axios method 직접 사용
const response = await axios.get('/api?query=encodeURIComponent(value)', {});

 

4.2.2. Axios 커스텀 직렬화 로직 추가

두번째로 떠올린 방법은 AxiosInstance 에 전달하는 config 에 paramsSerializer 을 전달하는 방법이었습니다.

Axios 문서에 소개되고 있는 request config 중 하나인 paramsSerializer를 활용하여 AxiosInstance 내부에 전달되는 params(query string)가 url 에 합쳐지는 직렬화 과정을 아래와 같이 커스텀해줄 수 있습니다.
(참고: https://github.com/axios/axios/tree/v0.27.2?tab=readme-ov-file#request-config)

const response = await axiosClient({
    method: 'get',
    url: '/api',
    params: {
        query: 'test]'
    },
    paramsSerializer: params => Object.keys(params)
          .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
          .join('&'),
});

위와 같이 paramsSerializer 를 전달해주면 axios 내부의 buildURL 동작시 내부 encode 로직을 타지 않고 전달해준 직렬화 함수를 실행하게 됩니다. (buildURL#L30)

헌데 위 두번째 방법의 문제가 있다면 0점대 버전과 1점대 버전의 paramsSerializer 활용 방식이 다르다는 점입니다.
0점대의 경우 위의 예시처럼 바로 함수객체를 전달해주면 되는데에 비해
1점대의 경우 다음과 같은 명세를 나타내고 있어 정해진 속성의 객체형태로 전달을 해주어야 합니다.

// `paramsSerializer` is an optional config that allows you to customize serializing `params`.
paramsSerializer: {
 
  //Custom encoder function which sends key/value pairs in an iterative fashion.
  encode?: (param: string): string => { /* Do custom operations here and return transformed string */ },
   
  // Custom serializer function for the entire parameter. Allows user to mimic pre 1.x behaviour.
  serialize?: (params: Record<string, any>, options?: ParamsSerializerOptions ),
   
  //Configuration for formatting array indexes in the params.
  indexes: false // Three available options: (1) indexes: null (leads to no brackets), (2) (default) indexes: false (leads to empty brackets), (3) indexes: true (leads to brackets with indexes).   
},

즉, 만일 0버전대의 Axios를 사용하다가 1버전대의 Axios로 변경할 때 신경써야할 점이 생긴다는 문제가 있습니다.

 

5. 최종 해결

저는 서비스 내에 구축해둔 API 모듈이 있었고, 이에 첫번째 방법을 사용하게 되면 검색외의 API 요청들 모두 영향이 생기게 되기 때문에 두번째 방법인 paramsSerializer 를 활용해 대괄호를 포함한 모든 문자열을 인코딩하도록 처리하여 버그이슈를 해결할 수 있었습니다.

해당 방법들이 정답은 아니기 때문에 각자의 서비스 성격에 맞춰 방법을 선택하거나,
새로운 방법을 찾아봐도 좋을 것 같습니다. 👍👍
혹시나 본글의 에러에 대한 새로운 해결 방법이 떠오르신 분들은 댓글로 공유 부탁드립니다. 🙏

 

6. 참조

 

'책장 > Javascript' 카테고리의 다른 글

ArrayBuffer, binary arrays  (0) 2024.01.05
윈도우간 통신 (cross-window-communication)  (0) 2024.01.04
BOM / DOM ???  (0) 2021.04.28
[JavaScript] JavaScript 소개  (0) 2020.09.23