LLM 엔지니어링13장 · REST API 와 FastAPI18 / 22← 이전목차다음 →MetaContents

13장. REST API 와 FastAPI

실습 5 — API 키로 접근 막기

예측 API 에 API 키 인증을 붙입니다. 키는 코드에 적지 않고 환경 변수 DELAY_API_KEYS 에서 읽고, 서버에는 키의 해시만 둡니다. 실습에서는 시험용 키를 그 자리에서 만들어 환경 변수에 넣습니다. 실습 1을 먼저 실행해야 합니다.

1단계. 시험용 키를 만들어 환경 변수에 넣기

Python
import hashlib, hmac, json, os, secrets
import joblib
import pandas as pd

# 실제로는 관리자가 키를 만들어 사용자에게 한 번만 알려 주고, 서버의 환경 변수(또는 비밀 저장소)에 넣는다
issued = {"물류팀": secrets.token_urlsafe(24), "고객센터": secrets.token_urlsafe(24)}
os.environ["DELAY_API_KEYS"] = json.dumps({k: v for k, v in issued.items()})
print({team: key[:4] + "…" for team, key in issued.items()})    # 키는 앞 네 글자만 보인다
출력
{'물류팀': 'mOtS…', '고객센터': 'JeiA…'}

2단계. 키를 확인하는 의존성 만들기

Python
from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.security import APIKeyHeader
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

# 서버가 뜰 때 환경 변수의 키를 읽어 해시만 남긴다(키 원문은 들고 있지 않는다)
digest = lambda k: hashlib.sha256(k.encode()).hexdigest()
KEY_HASHES = {digest(k): team for team, k in json.loads(os.environ["DELAY_API_KEYS"]).items()}
header = APIKeyHeader(name="X-API-Key", auto_error=False)      # X-API-Key 머리글을 읽는다. 없으면 None


def require_key(key: str | None = Security(header)):
    """키가 없거나 맞지 않으면 401. 맞으면 키 주인(팀 이름)을 돌려준다"""
    if not key:
        raise HTTPException(status_code=401, detail="API 키가 없습니다")
    for h, team in KEY_HASHES.items():
        if hmac.compare_digest(h, digest(key)):
            return team
    raise HTTPException(status_code=401, detail="API 키가 맞지 않습니다")


print("등록된 팀:", sorted(KEY_HASHES.values()))
출력
등록된 팀: ['고객센터', '물류팀']

3단계. 키가 필요한 예측 API

Python
BUNDLE = joblib.load("out_delay_model.joblib")
app = FastAPI(title="배송 지연 예측 API(키 필요)")


class Order(BaseModel):
    주문시각: int = Field(ge=0, le=23)
    거리km: float = Field(gt=0, le=1000)
    무게kg: float = Field(ge=0, le=100)
    지역: str
    상품분류: str
    날씨: str = "모름"
    택배사: str


@app.get("/health")                                 # 감시용 주소는 키 없이 열어 둔다
def health():
    return {"상태": "ok"}


@app.post("/predict")
def predict(order: Order, team: str = Depends(require_key)):     # Depends: 함수 전에 require_key 를 먼저 부른다
    p = BUNDLE["파이프라인"].predict_proba(pd.DataFrame([order.model_dump()]))[0, 1]
    return {"지연확률": round(float(p), 4), "부른 팀": team}


client = TestClient(app)
order = {"주문시각": 22, "거리km": 197.6, "무게kg": 4.6, "지역": "지방", "상품분류": "도서", "날씨": "눈", "택배사": "빠른별택배"}
print(client.get("/health").status_code)
출력
200

4단계. 키 없이, 틀린 키로, 맞는 키로 부르기

Python
cases = {"키 없음": {}, "틀린 키": {"X-API-Key": "wrong-key-0000"}, "물류팀 키": {"X-API-Key": issued["물류팀"]},
         "고객센터 키": {"X-API-Key": issued["고객센터"]}}
for name, headers in cases.items():
    r = client.post("/predict", json=order, headers=headers)
    print(f"{name}: {r.status_code} {r.json()}")
# 문서(OpenAPI)에도 키가 필요하다는 것이 적힌다
print(client.get("/openapi.json").json()["components"]["securitySchemes"])
출력
키 없음: 401 {'detail': 'API 키가 없습니다'}
틀린 키: 401 {'detail': 'API 키가 맞지 않습니다'}
물류팀 키: 200 {'지연확률': 0.6887, '부른 팀': '물류팀'}
고객센터 키: 200 {'지연확률': 0.6887, '부른 팀': '고객센터'}
{'APIKeyHeader': {'type': 'apiKey', 'in': 'header', 'name': 'X-API-Key'}}

이 실습이 뜻하는 것

이 실습은 사원증을 대야 열리는 사무실 문을 다는 일과 같습니다. 팀마다 사원증을 새로 찍어 내고(1단계), 경비실에는 사원증 원본이 아니라 본뜬 요약값만 두고 견주는 규칙을 만듭니다(2단계). 사원증이 있어야 들어가는 방(예측)과 누구나 들어가는 로비(/health)를 나누고(3단계), 사원증 없이, 가짜로, 진짜로 들어가 봅니다(4단계). 여기서 사원증이 API 키, 본뜬 요약값이 SHA-256 해시, 경비실 규칙이 의존성 require_key() 입니다. 비유와 다른 점은, 사원증은 사진으로 주인을 알아볼 수 있지만 API 키는 글자만 맞으면 누가 내밀어도 통과한다는 것입니다. 그래서 키는 남에게 보이지 않게 다룹니다.

단계출력뜻
1. 키 만들기{'물류팀': 'mOtS…', '고객센터': 'JeiA…'}팀마다 시험용 키를 만들었고, 앞 네 글자만 보여 줍니다. 실행할 때마다 새 키라 이 글자도 달라집니다
2. 등록등록된 팀: ['고객센터', '물류팀']서버에는 두 팀 키의 해시만 남았습니다
3. /health200감시용 주소는 키 없이도 열립니다
4. 키 없음, 틀린 키401 {'detail': 'API 키가 없습니다'}, 401 {'detail': 'API 키가 맞지 않습니다'}401 은 "누구인지 확인하지 못함"이라는 상태 코드입니다. 키가 없을 때와 틀릴 때 안내가 다릅니다
4. 두 팀의 키200 {'지연확률': 0.6887, '부른 팀': '물류팀'} 등맞는 키면 예측을 주고, 어느 팀이 불렀는지도 알 수 있습니다
4. 명세{'APIKeyHeader': {'type': 'apiKey', 'in': 'header', 'name': 'X-API-Key'}}API 명세에 "X-API-Key 머리글에 키를 넣는다"가 적혀, /docs 화면에 키 칸이 생깁니다

한 줄로 줄이면, 키가 맞는 팀만 예측을 부를 수 있게 되었고, 서버는 키 원문 없이 해시만으로 누구인지 가린다는 실습입니다. 키는 "누구인가"만 가립니다. 누가 무엇을 해도 되는지 나누는 것은 실습 6의 토큰과 역할입니다. 이 실습의 키는 그 자리에서 만든 시험용이고, 내 PC 안에서 HTTP 로 부르므로 실제 서비스처럼 HTTPS 로 감싸지는 않았습니다.

코드 설명

  • 1단계는 secrets.token_urlsafe(24) 로 추측하기 어려운 시험용 키를 만들어 환경 변수 DELAY_API_KEYS 에 넣습니다. 키는 실행할 때마다 새로 만들어지므로 출력의 앞 네 글자도 실행마다 다릅니다.
  • APIKeyHeader(name="X-API-Key", auto_error=False) 는 머리글에서 키를 꺼내 주고, 없으면 None 을 줍니다. require_key() 는 받은 키의 해시를 보관한 해시들과 hmac.compare_digest 로 견줘 키 주인을 돌려주거나 401 을 냅니다. 경로 함수의 Depends(require_key) 가 이 검사를 함수보다 먼저 부릅니다.
  • 4단계에서 키 없음과 틀린 키는 401, 두 팀의 키는 200 이고 응답에 부른 팀이 적혔습니다. OpenAPI 명세에는 X-API-Key 머리글이 필요하다는 보안 방식이 함께 적혀, /docs 화면에 키를 넣는 칸이 생깁니다. HTTP 머리글에는 영문, 숫자 같은 ASCII 글자만 넣을 수 있어서, 처음에 틀린 키를 발급받은_인증키 처럼 한글로 적었더니 요청을 보내기도 전에 UnicodeEncodeError 가 났습니다. 그래서 틀린 키의 예를 wrong-key-0000 으로 바꿨습니다.
  • /health 는 키 없이 열어 두었습니다. 서버가 살아 있는지 감시하는 프로그램은 키 없이 부르는 경우가 많기 때문입니다. 무엇을 열어 둘지는 하나씩 정합니다.
← 이전 절13장 · 실습 4 — 서버로 띄워 HTTP 로 부르기다음 절 →13장 · 실습 6 — 토큰과 역할로 권한 나누기