curl에서 특수문자 때문에 삽질한 적 있다면 — !, @, $ 완벽 정복 가이드
curl -u "admin:MyP@ss!word" https://api.example.com
이 명령어를 치고 bash: !word: event not found를 본 적 있는가? 아니면 JSON 바디에 이메일 주소를 넣었는데 curl이 파일을 찾겠다고 난리를 친 경험은? 개발자라면 한 번쯤은 curl과 특수문자의 전쟁을 치러봤을 것이다.
curl은 API 테스트의 국민 도구다. Postman이나 Insomnia 같은 GUI 도구가 있지만, 터미널에서 바로 때려 넣을 수 있는 curl의 즉시성은 여전히 대체 불가다. 문제는 셸(shell)이라는 중간 레이어가 있다는 것이다. 우리가 입력한 curl 명령어를 curl이 직접 받는 게 아니라, Bash(혹은 Zsh)가 먼저 해석한 뒤 그 결과를 curl에 넘긴다. 이 한 문장을 이해하면 특수문자 문제의 90%가 설명된다.
셸이 먼저 해석한다 — 모든 문제의 근원
curl을 탓하기 전에 셸의 인용(quoting) 규칙을 알아야 한다. 터미널에 입력한 문자열은 curl이 보기 전에 셸이 한 번 씹는다. $, !, `, \ 같은 문자는 셸에게 특별한 의미를 가지고 있어서, 우리 의도와 다르게 변환되거나 에러를 일으킨다.
싱글 쿼트: 가장 안전한 방패
curl -d '{"email":"user@test.com","password":"P@ss!word#123"}' \
-H 'Content-Type: application/json' https://api.example.com/login싱글 쿼트('...') 안에서는 모든 문자가 문자 그대로 전달된다. 변수 치환($VAR)도 안 되고, 히스토리 확장(!)도 안 되고, 이스케이프(\)도 안 된다. 그래서 curl에서 JSON 데이터를 보낼 때 가장 먼저 써야 할 방법이다.
단 하나, 싱글 쿼트 안에 싱글 쿼트를 넣을 수 없다는 제약이 있다. 이건 뒤에서 다룬다.
더블 쿼트: 편리하지만 함정 투성이
# 이렇게 쓰면 ! 때문에 터진다
curl -u "admin:P@ss!word" https://api.example.com
# bash: !word: event not found더블 쿼트("...") 안에서는 $, `, \, ", ! 가 여전히 특별하게 해석된다. 특히 !는 interactive 셸에서 히스토리 확장을 트리거하는데, 이게 가장 흔한 함정이다. 스크립트(.sh 파일)에서는 발생하지 않지만, 터미널에서 직접 치면 바로 만난다.
빠른 참조표
| 문자 | 주의할 의미 | 싱글 쿼트 | 더블 쿼트 | 해결 방법 |
|---|---|---|---|---|
! | 히스토리 확장 (셸) | 안전 | 위험 | 싱글 쿼트 또는 set +H |
@ | curl -d: 파일 참조 (@로 시작 시) | 안전 | 안전 | --data-raw |
$ | 변수 치환 (셸) | 안전 | 위험 | 싱글 쿼트 또는 \$ |
# | 주석 시작 (셸, 쿼트 밖) | 안전 | 안전 | 쿼트로 감싸기 |
& | 백그라운드 실행 (셸, 쿼트 밖) | 안전 | 안전 | 쿼트로 감싸기 |
' | 문자열 구분 (셸) | 불가 | 안전 | '"'"' 또는 $'...' |
" | 문자열 구분 (셸) | 안전 | 이스케이프 필요 | \" |
` | 명령 치환 (셸) | 안전 | 위험 | 싱글 쿼트 또는 \` |
결론: 잘 모르겠으면 싱글 쿼트를 써라.
인증(-u)에서의 특수문자 — 5가지 해법
비밀번호에 !@#$가 들어가는 건 보안 정책상 흔한 일이다. 그런데 curl의 -u 옵션에 이런 비밀번호를 넣으면 셸이 먼저 해석해버린다.
방법 1: 싱글 쿼트 감싸기 (가장 간단)
curl -u 'user:abc123!@#' https://api.example.com대부분의 경우 이걸로 끝난다.
방법 2: Base64 + Authorization 헤더 (가장 깔끔)
셸 해석을 완전히 우회하는 방법. 비밀번호에 어떤 문자가 있든 상관없다.
AUTH=$(echo -n 'user:abc123!@#' | base64)
curl -H "Authorization: Basic $AUTH" https://api.example.comCI/CD 파이프라인이나 자동화 스크립트에서 특히 유용하다. 환경 변수로 Base64 값을 주입하면 셸 해석 이슈를 근본적으로 피할 수 있다.
방법 3: 비밀번호 프롬프트 (가장 안전)
curl -u user https://api.example.com
# Enter host password for user 'user':비밀번호를 생략하면 curl이 대화형으로 물어본다. 셸 히스토리에 비밀번호가 남지 않아서 보안적으로 가장 안전하다. 다만 자동화에는 쓸 수 없다.
방법 4: .netrc 파일
# ~/.netrc (chmod 600 필수)
machine api.example.com
login user
password abc123!@#curl --netrc https://api.example.com서버별로 인증 정보를 관리할 때 편하다. 파일 권한만 잘 관리하면 꽤 안전한 방법이다. 참고로 curl 7.84.0 이상에서는 .netrc 안에서 따옴표 문자열도 지원해서 공백이 포함된 비밀번호도 쓸 수 있다.
방법 5: —config 파일
# curl.conf
user = "user:abc123!@#"curl -K curl.conf https://api.example.com프로젝트별로 curl 설정을 관리할 때 유용하다.
URL 파라미터의 특수문자 — --data-urlencode가 답이다
URL에서 특수문자는 퍼센트 인코딩(%XX)으로 변환해야 한다. C++은 C%2B%2B, 공백은 %20, @는 %40이 된다. 이걸 직접 하는 건 고통이다.
—data-urlencode의 마법
# POST 요청: 자동 인코딩
curl --data-urlencode "query=hello world & foo" \
--data-urlencode "email=user@test.com" \
https://api.example.com/search
# GET 요청: -G 플래그를 추가하면 쿼리스트링으로 붙는다
curl -G --data-urlencode "q=C++ programming" \
--data-urlencode "page=1" \
https://api.example.com/search
# 결과 URL: https://api.example.com/search?q=C%2B%2B%20programming&page=1-G 플래그를 붙이면 --data-urlencode로 전달한 데이터가 URL 쿼리스트링으로 변환된다. 이 조합은 GET 요청에서 특수문자를 다룰 때 거의 유일하게 안전한 방법이다.
—data vs —data-urlencode
| 항목 | --data / -d | --data-urlencode |
|---|---|---|
| 인코딩 | 수동 (사용자 책임) | 자동 (curl 처리) |
@ 의미 | 첫 문자일 때 파일 읽기 | 문법에 따라 다름 |
| 공백 처리 | 직접 %20 | 자동 변환 |
| 추천 상황 | 이미 인코딩된 데이터 | 원시 데이터 전송 시 |
JSON 바디에서의 특수문자 — 따옴표 전쟁
JSON은 더블 쿼트를 사용한다. Bash의 더블 쿼트와 충돌한다. 이 전쟁에서 살아남는 법을 알아보자.
기본: 싱글 쿼트로 감싸기
curl -d '{"name":"test","value":"hello!@#$%"}' \
-H 'Content-Type: application/json' \
https://api.example.comJSON 안의 더블 쿼트는 셸에게 보이지 않으므로 안전하다. 대부분의 경우 이걸로 충분하다.
문제: JSON 값에 싱글 쿼트가 있을 때
O'Brien 같은 값이 있으면 싱글 쿼트 안에 싱글 쿼트를 넣을 수 없는 제약에 걸린다.
# 방법 1: 싱글 쿼트 분리 + 연결 (클래식)
curl -d '{"name":"O'"'"'Brien"}' https://api.example.com
# 분해: '{"name":"O' + "'" + 'Brien"}'
# 방법 2: 더블 쿼트 + 이스케이프
curl -d "{\"name\":\"O'Brien\"}" https://api.example.com
# 방법 3: $'...' ANSI-C 인용
curl -d $'{"name":"O\'Brien"}' https://api.example.com방법 1의 '"'"'는 처음 보면 암호 같지만, 원리를 알면 간단하다: 현재 싱글 쿼트를 닫고('), 더블 쿼트로 싱글 쿼트 하나를 넣고("'"), 다시 싱글 쿼트를 여는(') 것이다.
복잡한 JSON: Heredoc이 정답
JSON이 서너 줄을 넘어가면 한 줄에 우겨 넣는 건 고문이다. Heredoc을 쓰자.
curl -X POST -H 'Content-Type: application/json' \
-d @- https://api.example.com/data << 'EOF'
{
"name": "O'Brien",
"email": "user@test.com",
"note": "Price is $100! (50% off)",
"query": "SELECT * FROM users WHERE id = 1"
}
EOF핵심: << 'EOF'처럼 구분자를 싱글 쿼트로 감싸야 한다. 그래야 heredoc 내부에서 $나 ! 같은 문자가 셸에 의해 해석되지 않는다. << EOF (따옴표 없이)를 쓰면 $100이 변수로 치환되어 빈 문자열이 된다.
변수를 JSON에 넣어야 할 때: jq
셸 변수를 JSON에 넣으면서도 안전하게 이스케이프하려면 jq가 최고다.
NAME="O'Brien"
EMAIL="user@test.com"
PASSWORD='P@ss!word$123'
curl -X POST -H 'Content-Type: application/json' \
-d "$(jq -n \
--arg name "$NAME" \
--arg email "$EMAIL" \
--arg pw "$PASSWORD" \
'{name: $name, email: $email, password: $pw}')" \
https://api.example.com/registerjq의 --arg는 값을 자동으로 JSON 이스케이프해준다. 싱글 쿼트, 더블 쿼트, 백슬래시, 개행 등 JSON에서 문제가 될 수 있는 문자를 알아서 처리한다. 수동으로 이스케이프하다 실수하는 것보다 훨씬 안전하다.
curl에서 @의 이중 인격
@는 셸에서는 특별한 의미가 없지만, curl에서는 파일 읽기 지시자로 사용된다.
-d 옵션에서의 @
-d 옵션에서 데이터 전체가 @로 시작하면 나머지를 파일명으로 해석한다:
# @로 시작 → 파일 참조
curl -d @data.json https://api.example.com
# @가 중간에 있으면 문제없음
curl -d "email=user@domain.com" https://api.example.com # 그대로 전송주의할 점은 @가 데이터의 첫 문자일 때만 파일 참조로 해석된다는 것이다. email=user@domain.com처럼 중간에 있으면 문자 그대로 전송된다.
—data-urlencode에서의 @
--data-urlencode는 name@filename 문법을 지원해서 좀 더 주의가 필요하다:
# name@filename 문법 → user라는 필드에 domain.com 파일 내용을 읽으려 함
curl --data-urlencode "user@domain.com" https://api.example.com
# 해결: name=value 형태로 명시하면 안전
curl --data-urlencode "email=user@domain.com" https://api.example.com만능 해결책: —data-raw
# --data-raw: @를 파일 참조로 해석하지 않음 (curl 7.43.0+)
curl --data-raw "@로_시작해도_파일로_안_읽음" https://api.example.com--data-raw는 -d와 동일하되, @를 파일 참조로 해석하지 않는다. curl 7.43.0 이상이면 쓸 수 있다. 모르면 억울하게 삽질하는 옵션 중 하나다.
Windows에서 curl을 쓴다면
PowerShell과 CMD에서는 Bash와 규칙이 다르다. 특히 주의할 점:
PowerShell: curl을 치면 실제로는 Invoke-WebRequest가 실행된다. 진짜 curl을 쓰려면 curl.exe로 호출해야 한다.
# PowerShell에서 진짜 curl 사용
curl.exe -d '{"name":"test"}' https://api.example.comCMD: 싱글 쿼트를 지원하지 않는다. 더블 쿼트만 쓸 수 있고, 내부 더블 쿼트는 \"로 이스케이프한다.
curl -d "{\"name\":\"test\"}" https://api.example.com크로스 플랫폼 스크립트를 짜야 한다면, 데이터를 파일에 넣고 -d @file.json을 쓰는 것이 가장 안전하다. 파일 참조 방식은 플랫폼에 관계없이 동일하게 동작한다.
실전 트러블슈팅 치트시트
| 에러 메시지 | 원인 | 해결 |
|---|---|---|
bash: !word: event not found | 더블 쿼트 안의 !가 히스토리 확장 | 싱글 쿼트로 변경 |
curl: (3) URL using bad/illegal format | URL에 인코딩 안 된 특수문자 | --data-urlencode -G 사용 |
| JSON 값이 비어있음 | 더블 쿼트 안의 $변수명이 치환됨 | 싱글 쿼트 또는 \$ 이스케이프 |
curl: (26) couldn't open file | -d에서 @로 시작하는 데이터가 파일 참조로 해석 | --data-raw 사용 |
최종 정리: 상황별 베스트 프랙티스
- JSON 바디 → 싱글 쿼트로 감싸기:
curl -d '{"key":"value"}' - 비밀번호 인증 → 싱글 쿼트 또는 Base64 헤더:
curl -u 'user:P@ss!' - URL 파라미터 →
--data-urlencode -G조합 - 변수를 JSON에 삽입 →
jq활용 - 복잡한 바디 → heredoc(
<< 'EOF') 또는 파일 참조(-d @file.json) - @가 파일로 오인될 때 →
--data-raw(curl 7.43.0+) - 스크립트에서 ! 이슈 →
set +H로 히스토리 확장 비활성화
이 7가지 규칙만 기억하면, curl과 특수문자의 전쟁에서 더 이상 패배하지 않을 것이다.