DEV BOX

'26/11/09 UPDATE

WEB / URL / FIELD NOTES

검색어 C++를 보냈는데, 서버에는 왜 'C '로 도착할까

검색창에 C++를 넣었는데 서버에서 꺼낸 검색어는 C 뒤에 공백 두 칸이었습니다. 쿼리 문자열의 +는 폼 형식으로 읽는 함수에게만 공백이고, encodeURIComponent·decodeURIComponent 같은 퍼센트 인코딩 함수에게는 그냥 +이기 때문입니다. Node.js v24.21.0과 Python 3.9.6에서 실제로 실행한 결과로, 쓰는 함수와 읽는 함수를 어떻게 짝지어야 +와 공백이 그대로 돌아오는지 확인합니다.

검색창에 C++를 넣으면 프런트엔드가 '/search?q=' + keyword로 주소를 만들어 요청을 보냅니다. 그런데 서버에서 검색어를 꺼내 보면 C 뒤에 공백 두 칸만 남아 있습니다. 이 글을 읽고 나면 쿼리 문자열을 만드는 함수와 읽는 함수 가운데 서로 맞는 짝을 고를 수 있고, +와 공백이 오가는 동안 바뀌지 않게 할 수 있습니다.

예제는 2026-10-25에 macOS 27.0.1에서 실행했습니다. JavaScript는 Node.js v24.21.0의 내장 fetch와, node:http로 만든 로컬 서버(http://127.0.0.1:4392)를 썼습니다. Python은 macOS의 /usr/bin/python3인 Python 3.9.6의 urllib.parse를 썼습니다. 검색 서비스와 검색어는 설명을 위한 가상의 예입니다. 브라우저의 폼 제출은 실행하지 않았습니다.

그대로 재현해 보기

로컬 서버는 요청이 들어오면 받은 주소를 로그에 남기고, q 값을 두 가지 방법으로 읽어 돌려줍니다. 하나는 new URL(req.url, base).searchParams.get('q')이고, 다른 하나는 q= 뒤의 원문을 그대로 decodeURIComponent에 넣는 방법입니다. 클라이언트는 같은 검색어를 세 가지 방법으로 쿼리에 넣어 보냈습니다.

'?q=' + 'C++'                            // 손으로 이어 붙이기
'?q=' + encodeURIComponent('C++')
'?' + new URLSearchParams({ q: 'C++' })

손으로 붙인 주소는 q=C++ 그대로 나갔고, 서버 로그에도 GET /search?q=C++로 찍혔습니다. 오는 도중에 바뀐 글자는 없습니다. 그런데 searchParams.get('q')는 이 값을 C와 공백 두 칸으로 읽었습니다. 같은 원문을 decodeURIComponent로 읽으면 "C++"였습니다. 반대로 encodeURIComponent와 URLSearchParams로 만든 주소는 둘 다 q=C%2B%2B를 보냈고, 두 방법 모두 "C++"로 읽었습니다. 글자가 사라진 곳은 네트워크가 아니라 서버가 값을 읽는 단계였습니다.

Node.js v24.21.0 실행 결과. '?q=' + 'C++'로 붙이면 q=C++가 전송되고 get()은 "C  ", decodeURIComponent는 "C++". encodeURIComponent('C++')와 new URLSearchParams({q:'C++'})는 q=C%2B%2B를 보내고 둘 다 "C++". new URLSearchParams({q:'a b'})는 q=a+b를 보내고 get()은 "a b", decodeURIComponent는 "a+b". encodeURIComponent('a b')는 q=a%20b를 보내고 둘 다 "a b". 서버 로그에는 GET /search?q=C++, q=C%2B%2B 두 번, q=a+b, q=a%20b가 찍혔다.
같은 검색어를 세 가지 방법으로 쿼리에 넣어 로컬 서버에 보낸 실제 출력입니다. 서버는 받은 값을 searchParams.get과 decodeURIComponent 두 방법으로 읽었습니다.

+를 공백으로 읽는 형식이 따로 있다

원인은 쿼리 문자열을 읽는 규칙이 하나가 아니라는 데 있습니다. URI의 일반 문법을 정한 RFC 3986에서 +는 !, $, &, = 등과 함께 sub-delims라는 예약 문자 묶음에 들어 있을 뿐, 공백이라는 뜻은 없습니다. 이 문서에서 공백 문자에 해당하는 퍼센트 인코딩은 %20입니다.

공백을 +로 쓰는 것은 application/x-www-form-urlencoded라는 다른 형식입니다. WHATWG URL Standard는 이 형식의 파서에 name과 value 안의 0x2B(+)를 0x20(공백)으로 바꾸는 단계를 두고 있습니다. 같은 문서는 이 형식이 여러 해 동안 쌓인 구현상의 사고와 타협의 결과라고 평하면서도, HTML 폼이 널리 쓰인 탓에 지금도 많이 쓰인다고 적었습니다.

URLSearchParams는 읽을 때도 쓸 때도 이 형식을 따릅니다. 명세에서 생성자는 문자열을 이 형식의 파서로 읽고, 문자열로 바꿀 때는 이 형식의 직렬화 규칙을 씁니다. MDN도 URLSearchParams 생성자가 더하기 기호(+)를 공백으로 해석한다고 경고하고, 직렬화할 때는 공백을 +로 쓴다고 설명합니다. URL 객체의 searchParams 속성도 URLSearchParams 객체를 돌려주므로, 서버의 searchParams.get이 +를 공백으로 바꾼 것입니다.

encodeURIComponent와 decodeURIComponent는 이 형식을 모릅니다. MDN에 따르면 encodeURIComponent는 영문자, 숫자와 - _ . ! ~ * ' ( )를 뺀 모든 문자를 이스케이프하므로 +는 %2B, 공백은 %20이 됩니다. decodeURIComponent는 %로 시작하는 이스케이프만 풀고 +는 그대로 둡니다. MDN이 쿼리 매개변수를 읽는 예제에서 decodeURIComponent를 부르기 전에 +를 공백으로 먼저 바꾸는 것도 이 때문입니다.

어긋남은 두 방향으로 일어난다

첫 번째 방향은 앞에서 본 경우입니다. 클라이언트가 값을 인코딩하지 않고 붙이면 +가 원문 그대로 나가고, 폼 형식으로 읽는 서버는 그것을 공백으로 바꿉니다. C++뿐 아니라 +가 의미를 갖는 값이면 모두 같은 일을 겪습니다. MDN의 URLSearchParams 문서는 Base64 문자열에 든 +가 공백으로 바뀌어 원래 데이터로 되돌릴 수 없게 되는 예를 보여 줍니다.

두 번째 방향은 반대입니다. 클라이언트가 new URLSearchParams({ q: 'a b' })로 주소를 만들면 공백이 +가 되어 q=a+b로 나갑니다. 서버가 이것을 decodeURIComponent로 읽으면 공백이 아니라 "a+b"가 남습니다. 실행 결과에서도 searchParams.get은 "a b", decodeURIComponent는 "a+b"였습니다. 쓰는 쪽은 규칙대로 인코딩했지만, 읽는 쪽이 다른 형식을 기대한 것입니다.

눈여겨볼 줄이 하나 더 있습니다. encodeURIComponent('a b')가 만든 q=a%20b는 두 방법 모두 "a b"로 읽었습니다. %20과 %2B는 폼 형식 파서로 읽어도, decodeURIComponent로 읽어도 같은 글자가 됩니다. 원문에 +를 남기지 않으면 이 어긋남은 생기지 않습니다.

Python에서도 똑같이 갈린다

서버가 Python이어도 사정은 같습니다. urllib.parse에는 두 계열의 함수가 나란히 있습니다. 공식 문서는 quote_plus를 quote()와 같되 공백을 더하기 기호로 바꾸는 함수로, unquote_plus를 unquote()와 같되 더하기 기호를 공백으로 바꾸는 함수로 설명하고, 둘 다 HTML 폼 값에 필요한 동작이라고 적었습니다. urlencode는 기본으로 quote_plus를 쓰며, parse_qs와 parse_qsl은 application/x-www-form-urlencoded 데이터를 읽는 함수입니다.

Python 3.9.6에서 실행해 보니 parse_qs('q=C++')는 C 뒤에 공백 두 칸이 붙은 값을 돌려주었습니다. Node.js의 searchParams.get과 같은 결과입니다. %2B로 보낸 parse_qs('q=C%2B%2B')만 'C++'였습니다. unquote('C++')는 'C++'를 그대로 두었고, unquote_plus('C++')는 다시 공백 두 칸을 만들었습니다. 쓰는 쪽에서는 quote('a b')가 'a%20b', quote_plus('a b')가 'a+b'를 만들었고, + 자체는 두 함수 모두 %2B로 바꿨습니다. urlencode({'q': 'a b'})는 'q=a+b'였고, quote_via=quote를 넘기면 'q=a%20b'가 되었습니다.

Python 3.9.6 실행 결과. parse_qs('q=C++')는 {'q': ['C  ']}, parse_qs('q=C%2B%2B')는 {'q': ['C++']}. unquote('C++')는 'C++', unquote_plus('C++')는 'C  '. quote('C++')는 'C%2B%2B', quote('a b')는 'a%20b', quote_plus('a b')는 'a+b', quote_plus('C++')는 'C%2B%2B'. urlencode({'q': 'a b'})는 'q=a+b', quote_via=quote를 주면 'q=a%20b'. unquote('a+b')는 'a+b'.
Python urllib.parse로 같은 값을 쓰고 읽은 실제 출력입니다. 폼 형식 함수만 +를 공백으로 다뤘습니다.

쓰는 함수와 읽는 함수를 짝으로 고른다

고치는 방법은 단순합니다. 쿼리 문자열에 값을 손으로 이어 붙이지 말고, 값마다 인코딩하는 함수를 거치게 하십시오. JavaScript에서는 다음 두 가지 가운데 하나를 쓰면 됩니다. 앞의 실행에서 클라이언트가 보낸 방식과 같습니다.

const BASE = 'http://127.0.0.1:4392/search';
await fetch(BASE + '?' + new URLSearchParams({ q: keyword }));
await fetch(BASE + '?q=' + encodeURIComponent(keyword));

읽는 쪽은 쓰는 쪽에 맞춥니다. URLSearchParams나 Python urlencode처럼 공백을 +로 쓰는 쪽에서 온 값은 searchParams.get, parse_qs, unquote_plus처럼 +를 공백으로 읽는 함수로 읽어야 합니다. decodeURIComponent나 unquote로 읽으면 공백 자리에 +가 남습니다. 반대로 encodeURIComponent나 quote가 만든 값은 %20과 %2B만 쓰므로, 실행해 본 두 가지 읽기 방법 어느 쪽으로도 같은 값이 나왔습니다.

두 가지를 더 조심하십시오. MDN은 URLSearchParams를 동적으로 이어 붙인 문자열로 만들지 말고 append()를 쓰라고 권합니다. new URLSearchParams('q=' + keyword)는 손으로 붙인 주소와 마찬가지로 +를 공백으로 읽기 때문입니다. 또 MDN에 따르면 URL 객체의 search는 공백을 %20으로, searchParams는 +로 인코딩하므로, searchParams를 한 번 고치면 값이 같아도 주소의 모양이 달라질 수 있습니다.

개발 노트

공백은 무엇으로 쓰고, +는 무엇으로 읽나

폼 형식 함수는 +를 공백으로 읽고, 퍼센트 인코딩 함수는 +를 그대로 둔다

  • URLSearchParams (JavaScript)

    q=a+b → get('q') = 'a b'

    • 쓰기: 공백 → +, + → %2B
    • 읽기: + → 공백
  • Python 폼 함수

    urlencode({'q': 'a b'}) → 'q=a+b'

    • quote_plus, urlencode
    • → 공백을 +로 쓴다
    • parse_qs, unquote_plus
    • → +를 공백으로 읽는다
  • encodeURIComponent · decodeURIComponent

    decodeURIComponent('a+b') → 'a+b'

    • 쓰기: 공백 → %20, + → %2B
    • 읽기: +는 그대로 +
  • Python quote · unquote

    unquote('C++') → 'C++'

    • 쓰기: 공백 → %20, + → %2B
    • 읽기: +는 그대로 +

Node.js v24.21.0과 Python 3.9.6에서 2026-10-25에 실행해 확인했다. 둘을 가르는 것은 읽는 쪽이 +를 공백으로 바꾸는지다.

쓰는 함수와 읽는 함수를 같은 칸에서 고르면 +와 공백이 그대로 돌아옵니다. 칸을 섞으면 한쪽이 바뀝니다.

검색어에서 +가 사라졌다면 서버 로그에 찍힌 원문부터 보십시오. q=C++처럼 +가 그대로 있으면 보낸 쪽이 인코딩하지 않은 것이고, q=a+b를 decodeURIComponent로 읽어 +가 남았다면 읽는 쪽이 폼 형식을 몰랐던 것입니다. 기억할 것은 한 줄입니다. +는 폼 형식 파서에게만 공백입니다.

끝 · END OF NOTE
RSS 구독목록으로
COMMENTS BOX

이 기록에 대화를 더해 주세요.

궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…