거래소 API 인증 방식 3가지 비교 — API Key vs JWT vs HMAC 서명
거래소 API 연동에서 처음 며칠을 잡아먹는 건 대개 전략이 아니라 401 Unauthorized입니다. 문서대로 했는데 안 되고, 어디가 틀렸는지 서버는 알려주지 않습니다. 원인은 대부분 인증 방식의 구조를 이해하지 못한 채 예제만 복붙해서 생깁니다. 이 글은 실제로 쓰이는 세 가지 방식을 원리부터 갈라서 정리합니다.
⚠️ 이 글은 교육·정보 제공 목적입니다. 투자 권유가 아니며, 투자 손실 책임은 본인에게 있습니다. 아래 코드의 키 값은 전부 예시 자리표시자입니다.
왜 방식이 나뉘는가
인증이 해결하려는 문제는 두 개입니다.
- 신원 확인 — 이 요청이 누구 계정인가
- 변조 방지 — 중간에서 수량이나 가격을 바꿔치기하지 않았는가
시세 조회는 첫 번째만 있으면 됩니다. 그래서 헤더에 키 하나 넣는 것으로 끝납니다. 반면 주문은 두 번째가 필수라, 요청 내용 자체를 재료로 서명(signature)을 만들어 함께 보냅니다. 서명은 비밀키를 아는 사람만 만들 수 있고, 요청이 한 글자만 바뀌어도 완전히 달라집니다.
방식 1 — API Key 헤더 (가장 단순)
키를 헤더에 그대로 실어 보냅니다. 서명이 없으니 공개 시세·잔고 조회처럼 읽기 전용에 쓰거나, 주문 API에서도 "신원 확인용" 파트로만 씁니다.
headers = {"X-MBX-APIKEY": API_KEY} # 예시 자리표시자
r = requests.get(url, headers=headers, timeout=(3, 10))
장점은 명확합니다 — 구현이 5분이면 끝납니다. 단점도 명확합니다. 키가 그대로 전달되므로 유출되면 끝이고, 요청 내용 변조를 막지 못합니다. HTTPS가 아니면 절대 쓰면 안 되고, 거래소에서 출금 권한은 반드시 꺼두고 IP 허용목록을 거는 것이 기본입니다.
방식 2 — HMAC 서명 (바이낸스 계열)
HMAC(Hash-based Message Authentication Code, 비밀키를 섞은 해시)은 "보낼 내용 + 비밀키"로 지문을 만들어 붙이는 방식입니다. 비밀키 자체는 네트워크에 나가지 않습니다.
바이낸스의 절차는 이렇습니다.
- 파라미터를 쿼리스트링으로 조립
- 맨 뒤에
timestamp(밀리초) 추가 - 그 완성된 문자열 전체를 HMAC-SHA256으로 서명 → 16진수 문자열
signature=로 덧붙이고, API Key는 헤더에
import hmac, hashlib, time
from urllib.parse import urlencode
def signed_query(params: dict, secret: str) -> str:
params["timestamp"] = int(time.time() * 1000)
query = urlencode(params) # 이 문자열 그대로 서명
sig = hmac.new(secret.encode(), query.encode(),
hashlib.sha256).hexdigest()
return f"{query}&signature={sig}"
Go도 동일합니다.
func sign(query, apiSecret string) string {
mac := hmac.New(sha256.New, []byte(apiSecret))
mac.Write([]byte(query))
return hex.EncodeToString(mac.Sum(nil))
}
// req.Header.Set("X-MBX-APIKEY", apiKey)
여기서 가장 많이 틀리는 지점은 "서명한 문자열"과 "실제로 보낸 문자열"이 달라지는 것입니다. 서명 후에 파라미터 순서를 바꾸거나, 인코딩을 다시 하거나, 딕셔너리를 재정렬하면 서명이 즉시 무효가 됩니다. 서명한 그 문자열을 그대로 전송하세요.
timestamp가 서명 재료에 들어가는 이유는 재전송 공격 방지입니다. 서버는 보통 recvWindow(기본 5초 안팎) 밖의 요청을 거부합니다. 그래서 서버 시계와 5초 이상 차이나면 계속 실패합니다. VM에서 시계가 밀리는 건 흔한 일이라, NTP 동기화는 선택이 아니라 필수입니다.
참고로 같은 HMAC이라도 "무엇을 서명 재료로 삼는가"는 거래소마다 다릅니다. 예를 들어 빗썸은 엔드포인트 + \x00 + 파라미터 + \x00 + nonce를 SHA512로 서명한 뒤 16진수로 바꾸고 그 문자열을 다시 base64합니다. 이중 인코딩을 놓쳐서 401을 며칠 헤매는 경우가 실제로 흔합니다. 그리고 헤더에 넣는 nonce는 서명에 쓴 것과 반드시 같은 값이어야 합니다 — 헤더용으로 시각을 한 번 더 구하면 그 순간 깨집니다.
방식 3 — JWT (업비트 계열)
JWT(JSON Web Token)는 "인증 정보를 담은 JSON을 서명해서 토큰 하나로 만든 것"입니다. HMAC과 암호 원리는 같지만(HS256 = HMAC-SHA256), 서명 대상이 쿼리스트링이 아니라 JSON payload라는 점이 다릅니다.
업비트의 payload 구성은 이렇습니다.
import jwt, uuid, hashlib
from urllib.parse import urlencode
def upbit_token(access_key, secret_key, params: dict | None = None):
payload = {
"access_key": access_key,
"nonce": str(uuid.uuid4()), # 매 요청 고유값
}
if params:
query = urlencode(params)
payload["query_hash"] = hashlib.sha512(query.encode()).hexdigest()
payload["query_hash_alg"] = "SHA512"
return "Bearer " + jwt.encode(payload, secret_key, algorithm="HS256")
# headers = {"Authorization": upbit_token(ACCESS_KEY, SECRET_KEY, params)}
구조를 보면 설계 의도가 읽힙니다. 파라미터를 통째로 서명하는 대신, 파라미터의 해시(query_hash)만 payload에 넣습니다. 덕분에 인증 정보와 요청 본문이 깔끔하게 분리되고, 파라미터가 없는 조회 요청에서는 query_hash를 아예 빼도 됩니다.
여기서의 함정은 두 가지입니다.
query_hash는 실제 전송할 쿼리와 100% 동일한 문자열로 만들어야 합니다. 배열 파라미터를 인코딩하는 방식(uuids[]=a&uuids[]=b등)이 라이브러리마다 달라 여기서 자주 틀립니다.nonce는 매 요청 새로 생성해야 합니다. 재사용하면 거부됩니다.
보너스 — OAuth 토큰 방식 (국내 증권사 API)
세 방식과 결이 다른 네 번째가 있습니다. 앱키·앱시크릿으로 먼저 토큰을 발급받고, 이후 요청은 그 토큰으로 인증하는 방식입니다.
payload = {
"grant_type": "client_credentials",
"appkey": APP_KEY, # 예시 자리표시자
"appsecret": APP_SECRET,
}
# POST /oauth2/tokenP → access_token 수령 (수명 약 24시간)
headers = {
"authorization": f"Bearer {access_token}",
"appkey": APP_KEY,
"appsecret": APP_SECRET,
"tr_id": tr_id, # 엔드포인트 선택자
"custtype": "P",
}
요청마다 서명을 만들지 않아도 되니 코드는 단순합니다. 대신 토큰 수명 관리라는 새 숙제가 생깁니다. 발급 날짜를 기록해두고 매 요청 전에 신선도를 확인하는 패턴이 안전합니다. 실시간 시세용 키(approval key)를 REST 토큰과 별개로 발급받는 곳도 있으니, "REST는 되는데 실시간이 안 붙는다"면 이 지점을 먼저 의심하세요.
한눈에 비교
- API Key 헤더 — 구현 5분 / 변조 방지 없음 / 조회 전용에 적합
- HMAC 서명 — 비밀키 미전송 / 요청 전체 변조 방지 / 문자열 일치가 생명 / 시계 동기화 필수
- JWT — 인증과 본문 분리 /
query_hash만 정확하면 안정적 / nonce 매번 새로 - OAuth 토큰 — 요청 코드는 가장 단순 / 토큰 만료·재발급 로직 필요
401이 뜰 때 확인 순서
- 시계 — 서버 시각과 5초 이상 차이? (HMAC 계열 1순위 원인)
- 서명 대상 문자열 — 서명한 문자열과 실제 전송 문자열을 각각 로그로 찍어 눈으로 비교
- 인코딩 — hex인지 base64인지, 그리고 이중 인코딩이 필요한지
- 키 공백 —
.env나 설정 파일에서 읽을 때 끝의 개행·공백..strip()한 줄로 해결되는 경우가 놀랍도록 많습니다 - 권한·IP — 거래소 콘솔에서 해당 키에 주문 권한이 있는지, IP 허용목록에 서버 IP가 들어 있는지
- 테스트넷/실서버 혼동 — 엔드포인트와 키의 짝이 맞는지
키 보관 — 타협 불가
# ❌ 절대 금지
API_KEY = "abcd1234..." # 소스에 하드코딩 → git에 영구 박제
# ✅ 환경변수 우선, 설정 파일 폴백
import os
API_KEY = os.getenv("EXCHANGE_API_KEY") or cfg.get("api", "key", fallback="")
API_KEY = API_KEY.strip()
- 설정 파일은 반드시
.gitignore에. 커밋 전git diff로 한 번 더 확인 - 실수로 커밋했다면 이력을 지우는 것보다 거래소에서 키를 폐기·재발급하는 게 먼저입니다
- 출금 권한은 기본적으로 끄고, 필요한 최소 권한만 부여
- 로그에 키·서명·토큰을 찍지 마세요. 디버깅 중이라면 앞 4자리만 남기고 마스킹
인증은 전략의 성능과 아무 상관이 없지만, 여기서 막히면 전략을 시험해볼 기회조차 오지 않습니다. 방식의 구조를 한 번 이해해두면 새 거래소를 붙일 때 걸리는 시간이 며칠에서 한 시간으로 줄어듭니다.
댓글
댓글 쓰기