빌드노트
curl --data로 보낸 폼 값이 &에서 끊길 때
curl --data에 원문 폼 값을 붙이면 값 속의 &가 필드 구분자로 읽힌다. 같은 A&B + C를 POST로 보내 수신 본문과 필드 수·값을 비교하고, 필드마다 --data-urlencode를 쓰는 방법과 사전 인코딩을 제거할 위치를 설명한다.
curl로 note=A&B + C를 보냈는데 서버가 읽은 note는 A뿐이라면, --data에 필드 값을 그대로 붙였는지 확인한다. application/x-www-form-urlencoded 본문에서 &는 다음 필드를 시작하는 구분자다. 원문 값을 보낼 때는 --data-urlencode 'note=A&B + C'로 값 부분을 인코딩한다. 따옴표로 감싸는 일과 폼 값 인코딩은 서로 다른 단계다.
curl의 --data 설명 (새 탭에서 열림)에 따르면 명령줄로 준 데이터는 자동으로 올바른 폼으로 변환되지 않는다. 기본 Content-Type이 application/x-www-form-urlencoded여도 값 속의 &까지 알아서 보호해 주지는 않는다. 아래 비교는 이 형식의 POST 본문을 대상으로 한다.
본문에는 남았지만 note에서는 잘렸다
Windows 11, PowerShell 7.6.6, curl 8.21.0에서 합성 값 A&B + C를 loopback 에코 서버에 보냈다. 수신기는 Python 3.12.10으로 작성했고, 파싱 전 본문과 parse_qsl(..., keep_blank_values=True)로 읽은 이름·값 쌍을 함께 기록했다. 실제 서비스의 장애 기록이 아니라 별도 재현이다.
자기 요청에서 비교하려면 폼 POST를 받는 로컬 API를 준비하고, 수신 쪽에서 파싱 전 본문과 파싱된 필드를 확인할 수 있어야 한다. 아래 $url은 이번 실험에 배정됐던 주소이며 현재 실행 중인 서비스가 아니다. 자기 로컬 경로로 바꾼 뒤 두 요청을 보낸다. curl.exe를 직접 호출하고, --disable과 --noproxy '*'로 개인 curl 설정과 프록시를 제외했다.
$url = 'http://127.0.0.1:32391/echo'
curl.exe --disable --noproxy '*' --max-time 4 --silent --show-error --data 'note=A&B + C' $url
curl.exe --disable --noproxy '*' --max-time 4 --silent --show-error --data-urlencode 'note=A&B + C' $url
두 요청에서 수신한 본문과 필드 목록을 추리면 다음과 같다. fields의 각 항목은 [이름, 값]이며 빈 값도 보존했다. 자기 API가 아래 형식으로 응답한다는 뜻은 아니다.
--data
raw_body: note=A&B + C
field_count: 2
fields: [["note", "A"], ["B C", ""]]
--data-urlencode
raw_body: note=A%26B+%2B+C
field_count: 1
fields: [["note", "A&B + C"]]
첫 요청에서도 & 뒤의 글자는 본문에 도착했다. 다만 폼 파서가 note=A와 B + C를 별도 항목으로 읽었다. 두 번째 항목에는 =가 없으므로 전체가 이름이 되고 값은 빈 문자열이 된다. 이름 안의 +도 공백으로 바뀌어 B와 C 사이에 공백 세 개가 남았다.
이는 WHATWG의 form-urlencoded 파싱 절차 (새 탭에서 열림)와 맞는다. 먼저 본문을 &로 나누고, 각 조각의 첫 =로 이름과 값을 구분한다. 이어서 +를 공백으로 바꾼 뒤 퍼센트 인코딩을 복원한다. 따라서 값 속의 &를 %26으로 보내야 필드 경계를 나눈 뒤에 문자 &로 돌아온다.
두 번째 요청에서는 &가 %26, 원문 더하기 기호가 %2B로 전송됐다. 이 curl 버전은 공백을 +로 보냈으며 한 번의 파싱 뒤 원문 A&B + C가 복원됐다. 다른 환경에서 공백이 %20으로 보이더라도 그것만으로 실패는 아니다. 파싱한 값에 &, 더하기 기호, 공백이 각각 남았는지 확인한다.
필드 수만 보면 문제를 놓칠 수도 있다. 같은 수신기에서 Python 파서의 기본 설정으로 읽으면 빈 값 항목이 빠져 [["note", "A"]]만 남았다. parse_qsl의 빈 값 처리 (새 탭에서 열림)처럼 파서 설정에 따라 잘못 생긴 필드가 목록에서 사라질 수 있으므로, note의 값과 파싱 전 본문까지 같이 봐야 한다.
필드마다 옵션을 하나씩 준다
note 외에 kind=demo도 보내려면 각 필드를 별도 --data-urlencode 인자로 전달한다. 다음 명령은 같은 로컬 주소로 두 필드를 보내는 수정이다.
curl.exe --disable --noproxy '*' --max-time 4 --silent --show-error --data-urlencode 'note=A&B + C' --data-urlencode 'kind=demo' $url
이 요청의 본문은 note=A%26B+%2B+C&kind=demo였고, 파싱 결과는 [["note", "A&B + C"], ["kind", "demo"]]였다. 값 안의 &는 %26으로 표현되고 필드 사이에는 실제 &가 들어간다. curl의 --data-urlencode 설명 (새 탭에서 열림)에서 name=content 형식은 content 부분을 인코딩한다. 이름 부분은 이미 인코딩되어 있어야 하며, 여기서는 변환할 필요가 없는 단순한 이름 note와 kind를 사용했다.
반대로 --data-urlencode 'note=A&B + C&kind=demo'처럼 전체를 한 인자로 묶어 보낸 대조 요청은 필드가 하나였다. note의 값에 &kind=demo까지 들어갔다. 여러 필드를 먼저 문자열로 조합한 뒤 통째로 값 인코딩을 하면, 의도했던 구분자도 값의 일부가 된다.
이미 인코딩한 값을 다시 넣으면
기존 코드가 A&B + C를 A%26B%20%2B%20C로 바꾼 뒤 curl을 호출하고 있다면, 옵션만 교체해서는 안 된다. 같은 인코딩 문자열을 두 옵션에 넣어 어디서 한 번 더 변환되는지 비교했다.
curl.exe --disable --noproxy '*' --max-time 4 --silent --show-error --data 'note=A%26B%20%2B%20C' $url
curl.exe --disable --noproxy '*' --max-time 4 --silent --show-error --data-urlencode 'note=A%26B%20%2B%20C' $url
--data
raw_body: note=A%26B%20%2B%20C
fields: [["note", "A&B + C"]]
--data-urlencode
raw_body: note=A%2526B%2520%252B%2520C
fields: [["note", "A%26B%20%2B%20C"]]
첫 요청은 이미 올바르게 직렬화한 본문을 그대로 보내 한 번의 파싱으로 원문을 얻었다. 두 번째 요청은 % 자체를 %25로 인코딩했다. 그래서 서버가 한 번 읽은 값은 원문이 아니라 인코딩 문자열이었다. --data-urlencode로 바꿀 때는 앞선 수동 인코딩을 제거하고 원문 값을 넘긴다. 입력 자체가 문자 그대로 A%26B인 경우도 있으므로, 서버에 무조건 한 번 더 디코딩하는 처리를 붙이지 않는다.
수정 뒤에도 note가 잘리면 합성 값으로 수신 직후 본문부터 비교한다. note=A&B + C가 남아 있으면 여전히 원문을 직접 조합하는 전송 경로를 찾는다. note=A%26B+%2B+C가 도착했는데 최종 값이 다르면 이번 실험의 한 번 파싱 결과와 다르므로 중간 계층의 재가공이나 추가 디코딩을 확인한다. Content-Type을 명시적으로 붙인 대조 요청도 원문 --data와 같은 결과였으므로 헤더 추가만으로 해결되지는 않았다.
이번에 확인한 범위는 단순한 ASCII 필드 이름과 합성 값을 사용한 로컬 POST다. 비교가 끝난 뒤 수신기를 닫았다. 특정 웹 프레임워크의 바인딩, 프록시를 거치는 경로, 비ASCII 값, JSON·multipart 요청은 실행하지 않았다. 이 결과는 form-urlencoded 본문에서 각 값을 어디서 인코딩할지 판단하는 근거다.