Blogcurl에서 특수문자 때문에 삽질한 적 있다면 — !, @, $ 완벽 정복 가이드

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.com

CI/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.com

JSON 안의 더블 쿼트는 셸에게 보이지 않으므로 안전하다. 대부분의 경우 이걸로 충분하다.

문제: 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/register

jq--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-urlencodename@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.com

CMD: 싱글 쿼트를 지원하지 않는다. 더블 쿼트만 쓸 수 있고, 내부 더블 쿼트는 \"로 이스케이프한다.

curl -d "{\"name\":\"test\"}" https://api.example.com

크로스 플랫폼 스크립트를 짜야 한다면, 데이터를 파일에 넣고 -d @file.json을 쓰는 것이 가장 안전하다. 파일 참조 방식은 플랫폼에 관계없이 동일하게 동작한다.

실전 트러블슈팅 치트시트

에러 메시지원인해결
bash: !word: event not found더블 쿼트 안의 !가 히스토리 확장싱글 쿼트로 변경
curl: (3) URL using bad/illegal formatURL에 인코딩 안 된 특수문자--data-urlencode -G 사용
JSON 값이 비어있음더블 쿼트 안의 $변수명이 치환됨싱글 쿼트 또는 \$ 이스케이프
curl: (26) couldn't open file-d에서 @로 시작하는 데이터가 파일 참조로 해석--data-raw 사용

최종 정리: 상황별 베스트 프랙티스

  1. JSON 바디 → 싱글 쿼트로 감싸기: curl -d '{"key":"value"}'
  2. 비밀번호 인증 → 싱글 쿼트 또는 Base64 헤더: curl -u 'user:P@ss!'
  3. URL 파라미터--data-urlencode -G 조합
  4. 변수를 JSON에 삽입jq 활용
  5. 복잡한 바디 → heredoc(<< 'EOF') 또는 파일 참조(-d @file.json)
  6. @가 파일로 오인될 때--data-raw (curl 7.43.0+)
  7. 스크립트에서 ! 이슈set +H로 히스토리 확장 비활성화

이 7가지 규칙만 기억하면, curl과 특수문자의 전쟁에서 더 이상 패배하지 않을 것이다.