빌드노트
URL 쿼리로 보낸 토큰의 +가 공백으로 바뀌었다
쿼리에 직접 붙인 a+b는 form-urlencoded 파싱 뒤 a b로 읽힌다. 로컬 HTTP 요청에서 직접 보간·원문 append·미리 인코딩한 값 append를 비교하고, URLSearchParams에 원문을 전달해 +를 보존하는 방법과 이중 인코딩을 피할 위치를 설명한다.
쿼리로 보낸 토큰은 a+b인데 서버가 읽은 값은 a b라면, URL을 만드는 쪽에서 값을 문자열로 바로 붙였는지 확인한다. 쿼리를 form-urlencoded 규칙으로 읽는 파서는 리터럴 +를 공백으로 해석한다. url.searchParams.append('token', token)에 인코딩 전 원문을 전달하면 더하기 기호를 보존할 수 있다.
서버에 도착한 쿼리와 읽어 낸 값은 달랐다
실제 서비스 대신 127.0.0.1의 임의 포트에 작은 echo 서버를 열었다. Windows·Node.js 22.23.2에서 가짜 토큰 a+b를 세 방식으로 보냈다. 서버는 수신한 요청 주소에서 쿼리 원문을 먼저 저장한 뒤, new URL(req.url, 'http://127.0.0.1').searchParams.get('token')으로 값을 읽었다. 여기서 원문은 파싱 전 요청 주소의 문자열이며, 패킷을 캡처한 자료는 아니다.
세 요청의 수신 쿼리와 파싱 결과는 다음과 같았다. ? 뒤가 쿼리이며, 오른쪽 값은 공백을 구별하도록 JSON 문자열로 표시했다.
직접 보간: ?token=a+b -> "a b"
원문 append: ?token=a%2Bb -> "a+b"
인코딩 후 append: ?token=a%252Bb -> "a%2Bb"
첫 요청에서는 더하기 기호가 서버에 도착할 때까지 남아 있었다. 값이 달라진 지점은 그다음 파싱이었다. WHATWG의 form-urlencoded 파싱 절차 (새 탭에서 열림)는 먼저 리터럴 +를 공백으로 바꾸고, 이어서 %xx를 해당 바이트로 복원하도록 정한다. %2B로 보낸 더하기 기호는 첫 단계에서 공백으로 바뀌지 않는다. 그 뒤 %2B가 +로 복원되어 값에 남는다.
두 번째 요청은 append에 원문 a+b를 전달했다. 직렬화할 때 +가 %2B로 표현되어 한 번의 파싱으로 원래 값을 얻었다. new URL(...)로 전체 주소를 감싸는 것만으로는 이런 값별 처리가 되지 않았다. 첫 요청도 new URL로 만든 주소였다.
세 번째 요청은 encodeURIComponent(token)의 결과인 a%2Bb를 append에 넣었다. 그러면 %도 값의 일부로 취급되어 %25로 인코딩된다. 서버가 한 번 복원한 결과는 토큰 원문이 아니라 a%2Bb였다. MDN의 퍼센트 인코딩 설명 (새 탭에서 열림)처럼 개별 키와 값에는 인코딩 전 문자열을 사용한다. 이 경우에는 서버에 추가 디코딩을 붙이기보다, 보내는 쪽에서 append 앞의 encodeURIComponent를 제거한다.
URL을 만드는 코드에서 바로 비교하기
Node.js 22가 있는 환경이라면 다음 내용을 새 파일 query-check.mjs에 저장하고, 그 파일이 있는 폴더의 터미널에서 node query-check.mjs를 실행할 수 있다. 네트워크 연결 없이 URL 생성과 같은 API의 쿼리 파싱만 비교한다. 위 서버 실험과 별도로 이 코드도 실행했으며, 위에 적은 세 줄을 그대로 출력했다.
const token = 'a+b';
const direct = new URL(`http://127.0.0.1/echo?token=${token}`);
const original = new URL('http://127.0.0.1/echo');
original.searchParams.append('token', token);
const preencoded = new URL('http://127.0.0.1/echo');
preencoded.searchParams.append('token', encodeURIComponent(token));
for (const [label, url] of [
['직접 보간', direct],
['원문 append', original],
['인코딩 후 append', preencoded],
]) {
console.log(`${label}: ${url.search} -> ${JSON.stringify(url.searchParams.get('token'))}`);
}
자기 코드에서는 original처럼 빈 쿼리에서 시작해 원문 값을 추가한 URL을 요청에 사용한다. 토큰이 한 개여야 하는 기존 URL의 값을 바꾸는 경우라면 append가 같은 이름의 항목을 추가한다는 점도 확인한다. token 항목을 하나의 값으로 교체하려면 searchParams.set('token', token)을 사용한다. MDN의 set 설명 (새 탭에서 열림)
문자열을 먼저 조합한 뒤 new URLSearchParams(...)에 넣는 방식도 주의한다. 실험에서는 token=a+b라는 문자열로 생성한 객체가 이미 값을 a b로 읽었고, 이를 직렬화해 보낸 요청도 첫 요청과 같았다. 생성자에 완성된 쿼리를 넣는 일과 append에 원문 값을 넣는 일은 다르다. MDN의 더하기 기호 보존 예제 (새 탭에서 열림)도 이 두 입력 방식을 구분한다.
수정한 뒤에도 값이 다르면, 합성 값으로 송신 직전 URL과 수신 직후의 미파싱 쿼리를 비교한다. 수신 쿼리가 token=a%2Bb인데 최종 값이 a b라면 이 실험의 한 번 파싱 결과와 다르다. 중간 계층의 URL 재구성이나 추가 파싱을 따로 추적해야 한다. 원문 대신 이미 디코딩된 파라미터 목록만 보면 어느 단계에서 바뀌었는지 알기 어렵다.
공백을 다시 +로 바꾸면 원문을 알 수 없다
대조 요청으로 원문 a b를 append에 전달했더니 쿼리는 token=a+b, 파싱 결과는 a b였다. 잘못 보낸 토큰 a+b와 올바르게 보낸 공백 문자열이 같은 쿼리가 된 것이다. 따라서 파싱된 공백을 일괄적으로 +로 바꾸면 실제 공백까지 바뀐다. 받는 쪽에서 추측해 복구하는 대신, 보내는 쪽에서 두 값을 다르게 표현해야 한다.
이 규칙을 URL 전체에 적용해서는 안 된다. 같은 실험에서 경로 /a+b의 pathname은 /a+b로 유지됐다. 여기서 확인한 것은 form-urlencoded 규칙을 쓰는 쿼리 직렬화와 파싱이다. 로컬 HTTP 요청 여섯 개를 마친 뒤 서버를 닫았으며, 브라우저·특정 서버 프레임워크·프록시를 통과하는 경로의 동작은 실행하지 않았다.