어제까지 잘 돌던 스크립트가 오늘 아침에 갑자기 멈춰 있습니다. 로그를 열어보면 401 Unauthorized 한 줄이 전부입니다. 토큰을 새로 발급받아 넣어봐도 그대로고, 검색해보면 “인증 정보를 확인하세요”라는 하나 마나 한 답변만 나옵니다. API 오류코드 정리가 필요한 이유가 여기 있습니다. 세 자리 숫자는 생각보다 많은 정보를 담고 있고, 그 의미를 정확히 알면 확인해야 할 범위가 절반 이하로 줄어듭니다. 이 글에서는 실무에서 가장 자주 만나는 401·403·429를 중심으로, 각각 무엇을 확인해야 하는지와 재시도 로직을 어떻게 짜야 하는지를 정리했습니다.
먼저 갈림길: 4xx인가 5xx인가
숫자를 외우기 전에 앞자리 하나만 봐도 조사 범위가 갈립니다.
| 범위 | 누구 잘못인가 | 재시도하면 되는가 |
|---|---|---|
| 4xx | 요청을 보낸 쪽(내 코드) | 대부분 소용없음. 429만 예외 |
| 5xx | 요청을 받은 쪽(서버) | 대부분 재시도 가치 있음 |
이 구분이 중요한 이유는, 401이 났을 때 재시도 루프를 돌리면 아무 소용도 없이 같은 요청만 다섯 번 더 나가기 때문입니다. 심한 경우 인증 실패가 누적되어 계정이 일시 잠기기도 합니다. 재시도할 코드와 그러면 안 되는 코드를 코드 안에서 명확히 구분해두는 것이 첫 단추입니다.
그리고 원인을 좁히기 전에, 응답을 제대로 들여다보는 것부터 해야 합니다. 상태 코드만 찍고 헤더와 본문을 버리는 코드가 의외로 많습니다.
# 응답 본문만 보면 원인을 알 수 없다. 상태 코드와 헤더를 같이 봐야 한다.
# -i 는 응답 헤더까지, -s 는 진행률 표시 끄기.
curl -i -s https://api.example.com/v1/users \
-H "Authorization: Bearer $API_TOKEN"
# 상태 코드만 빠르게 확인하고 싶을 때
curl -o /dev/null -s -w "%{http_code}\n" https://api.example.com/v1/users \
-H "Authorization: Bearer $API_TOKEN"
# 요청이 실제로 어떻게 나가는지 전부 보고 싶을 때 (-v)
# 토큰이 잘려 나가거나 헤더 이름이 틀린 걸 여기서 발견하는 경우가 많다.
curl -v https://api.example.com/v1/users -H "Authorization: Bearer $API_TOKEN"
401 Unauthorized — 나를 못 알아본 것
이름은 “권한 없음”처럼 읽히지만 실제 의미는 “당신이 누군지 확인이 안 됩니다”입니다. 즉 인증 단계에서 막힌 겁니다. 확인할 순서는 이렇습니다.
- 토큰이 코드까지 도달했는가.
.env파일을 안 읽었거나, 크론에서 실행할 때 환경변수가 비어 있는 경우가 가장 흔합니다. 요청 직전에 값을 한 번 출력해보면(앞 4자리만) 바로 드러납니다. - 헤더 형식이 맞는가.
Authorization: Bearer <토큰>에서Bearer와 토큰 사이 공백은 정확히 하나여야 합니다. 어떤 API는Token이나Basic을 쓰고, 아예X-API-Key라는 별도 헤더를 요구하기도 합니다. - 토큰에 공백이나 줄바꿈이 붙지 않았는가. 웹 콘솔에서 복사할 때 끝에 개행이 딸려오는 일이 잦습니다.
- 만료되지 않았는가. OAuth 액세스 토큰은 보통 1시간 안팎입니다. “어제까지 됐는데”의 정체가 대개 이것입니다.
- 환경을 헷갈리지 않았는가. 테스트 키를 운영 엔드포인트에 보내면 정확히 401이 납니다.
import os
import requests
# 나쁜 예 — 토큰이 없거나 빈 문자열이어도 그대로 요청이 나간다.
# 서버는 401을 돌려주고, 우리는 "토큰이 틀렸나?" 하며 시간을 버린다.
token = os.getenv("API_TOKEN")
r = requests.get(url, headers={"Authorization": "Bearer " + str(token)})
# 좋은 예 — 보내기 전에 토큰 자체를 검증한다.
token = os.getenv("API_TOKEN")
if not token:
raise RuntimeError("API_TOKEN 환경변수가 비어 있습니다. .env 로딩을 확인하세요.")
# 복사·붙여넣기 과정에서 끼어든 공백과 줄바꿈은 401의 단골 원인이다.
token = token.strip()
headers = {"Authorization": "Bearer " + token}
r = requests.get("https://api.example.com/v1/users", headers=headers, timeout=10)
print(r.status_code)
print(r.headers.get("WWW-Authenticate")) # 401일 때 원인이 여기 담겨 온다
print(r.text[:500])
401 응답의
WWW-Authenticate헤더를 꼭 확인하세요.error="invalid_token",error_description="The access token expired"처럼 원인이 그대로 적혀 오는 경우가 많습니다. 본문만 읽고 헤더를 버리면 이 정보를 놓칩니다.
403 Forbidden — 알아는 봤는데 안 된다는 것
401과 403의 차이는 한 문장으로 정리됩니다. 401은 인증(누구인가)이 실패한 것이고, 403은 인증은 됐는데 인가(무엇을 할 수 있는가)가 거부된 것입니다. 그래서 403이 났다면 토큰을 재발급받아도 대부분 그대로입니다. 봐야 할 곳이 다릅니다.
- 스코프(scope)·권한 범위. 읽기 전용 토큰으로
POST를 시도했거나, 앱에 해당 권한을 체크하지 않고 발급받은 경우입니다. 토큰을 다시 발급하되 권한을 추가해서 받아야 합니다. - 리소스 소유권. 내 계정이 접근할 수 없는 남의 워크스페이스·저장소·문서를 가리키고 있는 경우입니다. 노션·슬랙처럼 “앱을 해당 페이지에 초대”해야 하는 API에서 특히 자주 걸립니다.
- IP 허용 목록·지역 차단. 로컬에서는 되는데 서버에 올리면 403이라면 이쪽을 의심합니다.
- 봇 차단(WAF·Cloudflare). API가 아니라 앞단 방화벽이 막은 경우입니다. 응답 본문이 JSON이 아니라 HTML이면 거의 확실합니다.
- User-Agent 누락. 일부 서비스는 UA가 없는 요청을 자동으로 거부합니다. 라이브러리 기본 UA를 실제 브라우저 형식으로 바꾸면 통과하는 경우가 있습니다.
403인데 응답 본문에 <!DOCTYPE html>로 시작하는 페이지가 들어 있다면, 그건 API가 준 답이 아니라 방화벽이 준 답입니다. 이때는 코드를 아무리 고쳐도 소용없고 요청의 모양새(헤더 구성, 호출 빈도, 출발 IP)를 바꿔야 합니다.
429 Too Many Requests — 너무 빨랐던 것
세 코드 중 유일하게 기다렸다가 다시 보내면 해결되는 오류입니다. 그래서 처리 방식도 완전히 다릅니다. 핵심은 서버가 알려주는 헤더를 읽는 것입니다.
| 헤더 | 의미 |
|---|---|
Retry-After | 몇 초 뒤에 다시 오라는 지시. 있으면 무조건 이 값을 따릅니다 |
X-RateLimit-Limit | 해당 기간에 허용된 총 요청 수 |
X-RateLimit-Remaining | 남은 요청 수. 0에 가까워지면 미리 속도를 늦춥니다 |
X-RateLimit-Reset | 한도가 초기화되는 시각(대개 유닉스 타임스탬프) |
헤더 이름은 서비스마다 조금씩 다릅니다(ratelimit-remaining처럼 소문자만 쓰는 곳도 있습니다). 정확한 이름은 해당 API 문서에서 확인하되, Retry-After는 표준이라 지원하는 곳이 많습니다.
import time
import requests
def get_with_rate_limit(url, headers, max_retry=5):
"""429를 만나면 서버가 알려준 시간만큼 기다렸다가 다시 시도한다."""
for attempt in range(max_retry):
r = requests.get(url, headers=headers, timeout=10)
if r.status_code != 429:
return r
# 서버가 Retry-After를 주면 그 값을 그대로 따르는 것이 정답이다.
# 임의로 정한 대기 시간보다 항상 정확하다.
wait = r.headers.get("Retry-After")
if wait is not None:
try:
sleep_sec = float(wait) # 보통 초 단위 숫자
except ValueError:
sleep_sec = 60 # 드물게 HTTP-date 형식이면 넉넉히
else:
sleep_sec = 2 ** attempt # 헤더가 없으면 지수 백오프
print("429 발생. " + str(sleep_sec) + "초 대기 후 재시도 (" + str(attempt + 1) + "회차)")
time.sleep(sleep_sec)
raise RuntimeError("재시도 " + str(max_retry) + "회를 모두 소진했습니다.")
Retry-After가 없는 API라면 지수 백오프에 지터를 섞어 기다립니다. 1초, 2초, 4초, 8초로 늘리되 매번 0~1초의 무작위 값을 더하는 방식입니다. 지터가 필요한 이유는, 지터 없이 백오프만 쓰면 동시에 막힌 프로세스들이 정확히 같은 시각에 다시 몰려들기 때문입니다.
import random
import time
import requests
# 지터(jitter)가 없으면, 동시에 막힌 여러 프로세스가 같은 시각에 한꺼번에
# 재시도하면서 서버를 다시 밀어버린다. 무작위 값을 섞어야 한다.
RETRYABLE = {429, 500, 502, 503, 504}
def request_with_backoff(method, url, max_retry=5, **kwargs):
kwargs.setdefault("timeout", 10)
for attempt in range(max_retry):
try:
r = requests.request(method, url, **kwargs)
except requests.exceptions.RequestException as e:
# 연결 실패·타임아웃도 재시도 대상이다. 상태 코드가 아예 없는 경우.
if attempt == max_retry - 1:
raise
print("네트워크 오류: " + str(e))
else:
if r.status_code not in RETRYABLE:
return r # 성공했거나, 재시도해도 소용없는 오류
if attempt == max_retry - 1:
return r
print("HTTP " + str(r.status_code) + " 재시도 대상")
sleep_sec = (2 ** attempt) + random.uniform(0, 1)
time.sleep(sleep_sec)
# 사용 예
res = request_with_backoff("GET", "https://api.example.com/v1/items",
headers={"Authorization": "Bearer " + token})
하지만 재시도는 어디까지나 사후 대응입니다. 반복 작업을 돌린다면 애초에 한도를 넘지 않도록 속도를 제한하는 쪽이 훨씬 안정적입니다.
# 애초에 429를 안 만나는 것이 최선이다.
# 문서에 "초당 5회"라고 적혀 있으면 0.2초 간격을 강제한다.
import time
class RateLimiter:
def __init__(self, calls_per_second):
self.interval = 1.0 / calls_per_second
self.last = 0.0
def wait(self):
elapsed = time.monotonic() - self.last
if elapsed < self.interval:
time.sleep(self.interval - elapsed)
self.last = time.monotonic()
limiter = RateLimiter(calls_per_second=5)
for user_id in user_ids:
limiter.wait()
r = requests.get(base_url + "/users/" + str(user_id), headers=headers, timeout=10)
나머지 자주 만나는 코드
401·403·429 다음으로 자주 마주치는 코드들입니다. 원인이 겹치지 않으므로 표로 정리해둘 만합니다.
| 코드 | 의미 | 실제로 봐야 할 곳 |
|---|---|---|
| 400 | Bad Request | JSON 문법 오류, 필수 파라미터 누락, 타입 불일치. 본문에 어느 필드가 문제인지 적혀 옵니다 |
| 404 | Not Found | 경로 오타, API 버전 누락(/v1/), 그리고 권한이 없어 존재를 숨긴 리소스. 일부 API는 403 대신 404를 줍니다 |
| 405 | Method Not Allowed | 경로는 맞는데 GET/POST를 바꿔 보낸 경우 |
| 413 | Payload Too Large | 업로드 파일이나 요청 본문이 한도를 넘음. 분할 업로드로 전환 |
| 415 | Unsupported Media Type | Content-Type: application/json을 빠뜨림. json= 대신 data=로 보낼 때 잘 납니다 |
| 422 | Unprocessable Entity | 형식은 맞지만 값이 규칙에 어긋남(존재하지 않는 ID, 잘못된 날짜 범위 등) |
| 500 | Internal Server Error | 서버 버그. 재시도해보고 계속되면 요청 ID와 함께 문의 |
| 502 / 504 | Bad Gateway / Gateway Timeout | 중간 프록시 문제이거나 처리 시간 초과. 재시도 대상 |
| 503 | Service Unavailable | 점검 중이거나 과부하. Retry-After가 함께 오는 경우가 많습니다 |
특히 404를 만났을 때 권한 문제를 의심하는 습관은 시간을 많이 아껴줍니다. 보안상의 이유로 “권한이 없다”는 사실조차 알려주지 않고 “그런 건 없다”고 답하는 API가 적지 않습니다. 경로가 확실히 맞는데 404라면 토큰의 접근 범위를 확인해보세요.
원인을 좁히는 순서
어떤 코드가 나오든 조사 순서는 비슷합니다. 위에서부터 차례로 하면 대부분 몇 분 안에 갈립니다.
- curl로 똑같이 보내본다. curl은 되는데 코드는 안 된다면 원인은 API가 아니라 내 코드(헤더 구성, 인코딩, 라이브러리 설정)입니다.
- 응답 헤더와 본문 전체를 찍어본다. 대부분의 API는 사람이 읽을 수 있는 원인 설명을 본문에 담아 보냅니다.
- 문서의 인증 예시와 한 글자씩 대조한다. 헤더 이름, 접두사, 엔드포인트 버전을 순서대로 확인합니다.
- 최소 요청으로 줄여본다. 파라미터를 다 빼고
GET /me같은 가장 단순한 엔드포인트만 호출해봅니다. 이게 되면 인증은 정상이고 문제는 요청 내용에 있습니다. - 다른 환경에서 시도한다. 로컬은 되고 서버는 안 된다면 IP 차단이나 환경변수 누락입니다.
그리고 조사에 필요한 정보를 애초에 남겨두면 이 과정 자체가 필요 없어지는 경우가 많습니다.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
filename="api.log",
encoding="utf-8",
)
def log_failure(r):
"""실패했을 때 다시 조사할 수 있을 만큼만 남긴다."""
logging.error(
"%s %s -> %s | x-request-id=%s | body=%s",
r.request.method,
r.url,
r.status_code,
r.headers.get("x-request-id", "-"), # 문의할 때 이 값이 있으면 조사가 빨라진다
r.text[:300],
)
r = requests.get(url, headers=headers, timeout=10)
if not r.ok:
log_failure(r)
로그에 x-request-id(또는 x-correlation-id)를 남겨두면, 서비스 측에 문의할 때 “언제쯤 안 됐어요” 대신 요청 하나를 특정해서 물어볼 수 있습니다. 답변 속도가 눈에 띄게 달라집니다.
주의할 점
- 401에 재시도 루프를 걸지 마세요. 인증 실패가 누적되면 일정 횟수 이후 계정이나 IP가 일시 차단되는 서비스가 있습니다.
- 토큰을 로그에 찍지 마세요. 디버깅용으로 출력하더라도 앞 4자리와 길이 정도만 남깁니다. 로그 파일이 그대로 저장소에 올라가는 사고가 흔합니다.
timeout을 반드시 지정하세요.requests는 기본 타임아웃이 없어서, 서버가 응답하지 않으면 스크립트가 무한정 멈춰 있습니다. 자동화 작업에서는 이게 429보다 더 골치 아픕니다.- 재시도 횟수에 상한을 두세요. 무한 재시도는 상대 서버에 대한 부하이자, 유료 API라면 그대로 비용입니다.
- POST·PUT 재시도는 신중하게. 응답을 못 받았을 뿐 서버에서는 이미 처리됐을 수 있습니다. 결제나 생성 요청이라면 멱등성 키(
Idempotency-Key)를 지원하는지 먼저 확인하세요.
마무리
오늘 당장 할 수 있는 가장 작은 조치 하나만 고르라면, 지금 쓰고 있는 API 호출 코드에 timeout=10과 실패 시 r.status_code·응답 본문을 남기는 로그 한 줄을 추가하는 것입니다. 다음번에 스크립트가 멈췄을 때, 원인을 찾는 데 걸리는 시간이 몇십 분에서 몇 분으로 줄어듭니다.
다음 글에서는 노션(Notion) API로 흩어진 할 일을 자동으로 정리하는 스크립트를 만들어 보겠습니다. 오늘 정리한 401·403 구분이 실제로 어떻게 쓰이는지 확인할 수 있을 겁니다.