API 요청 시 자주 만나는 오류코드(401/403/429) 정리

어제까지 잘 돌던 스크립트가 오늘 아침에 갑자기 멈춰 있습니다. 로그를 열어보면 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 — 나를 못 알아본 것

이름은 “권한 없음”처럼 읽히지만 실제 의미는 “당신이 누군지 확인이 안 됩니다”입니다. 즉 인증 단계에서 막힌 겁니다. 확인할 순서는 이렇습니다.

  1. 토큰이 코드까지 도달했는가. .env 파일을 안 읽었거나, 크론에서 실행할 때 환경변수가 비어 있는 경우가 가장 흔합니다. 요청 직전에 값을 한 번 출력해보면(앞 4자리만) 바로 드러납니다.
  2. 헤더 형식이 맞는가. Authorization: Bearer <토큰>에서 Bearer와 토큰 사이 공백은 정확히 하나여야 합니다. 어떤 API는 Token이나 Basic을 쓰고, 아예 X-API-Key라는 별도 헤더를 요구하기도 합니다.
  3. 토큰에 공백이나 줄바꿈이 붙지 않았는가. 웹 콘솔에서 복사할 때 끝에 개행이 딸려오는 일이 잦습니다.
  4. 만료되지 않았는가. OAuth 액세스 토큰은 보통 1시간 안팎입니다. “어제까지 됐는데”의 정체가 대개 이것입니다.
  5. 환경을 헷갈리지 않았는가. 테스트 키를 운영 엔드포인트에 보내면 정확히 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&#91;:500&#93;)

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 다음으로 자주 마주치는 코드들입니다. 원인이 겹치지 않으므로 표로 정리해둘 만합니다.

코드의미실제로 봐야 할 곳
400Bad RequestJSON 문법 오류, 필수 파라미터 누락, 타입 불일치. 본문에 어느 필드가 문제인지 적혀 옵니다
404Not Found경로 오타, API 버전 누락(/v1/), 그리고 권한이 없어 존재를 숨긴 리소스. 일부 API는 403 대신 404를 줍니다
405Method Not Allowed경로는 맞는데 GET/POST를 바꿔 보낸 경우
413Payload Too Large업로드 파일이나 요청 본문이 한도를 넘음. 분할 업로드로 전환
415Unsupported Media TypeContent-Type: application/json을 빠뜨림. json= 대신 data=로 보낼 때 잘 납니다
422Unprocessable Entity형식은 맞지만 값이 규칙에 어긋남(존재하지 않는 ID, 잘못된 날짜 범위 등)
500Internal Server Error서버 버그. 재시도해보고 계속되면 요청 ID와 함께 문의
502 / 504Bad Gateway / Gateway Timeout중간 프록시 문제이거나 처리 시간 초과. 재시도 대상
503Service Unavailable점검 중이거나 과부하. Retry-After가 함께 오는 경우가 많습니다

특히 404를 만났을 때 권한 문제를 의심하는 습관은 시간을 많이 아껴줍니다. 보안상의 이유로 “권한이 없다”는 사실조차 알려주지 않고 “그런 건 없다”고 답하는 API가 적지 않습니다. 경로가 확실히 맞는데 404라면 토큰의 접근 범위를 확인해보세요.

원인을 좁히는 순서

어떤 코드가 나오든 조사 순서는 비슷합니다. 위에서부터 차례로 하면 대부분 몇 분 안에 갈립니다.

  1. curl로 똑같이 보내본다. curl은 되는데 코드는 안 된다면 원인은 API가 아니라 내 코드(헤더 구성, 인코딩, 라이브러리 설정)입니다.
  2. 응답 헤더와 본문 전체를 찍어본다. 대부분의 API는 사람이 읽을 수 있는 원인 설명을 본문에 담아 보냅니다.
  3. 문서의 인증 예시와 한 글자씩 대조한다. 헤더 이름, 접두사, 엔드포인트 버전을 순서대로 확인합니다.
  4. 최소 요청으로 줄여본다. 파라미터를 다 빼고 GET /me 같은 가장 단순한 엔드포인트만 호출해봅니다. 이게 되면 인증은 정상이고 문제는 요청 내용에 있습니다.
  5. 다른 환경에서 시도한다. 로컬은 되고 서버는 안 된다면 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&#91;:300&#93;,
    )

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 구분이 실제로 어떻게 쓰이는지 확인할 수 있을 겁니다.

댓글 남기기