13장. REST API 와 FastAPI
프로젝트 1 — 사내 규정 질의 API
7장의 가상 회사 "솔내기술" 사내 규정집(company_rules.txt)을 근거로 직원의 질문에 답하는 사내 질의 API 를 만들어 보세요. 직원마다 API 키를 주고, 관리자만 사용 통계를 보게 하고, 너무 자주 부르면 막습니다. 답은 이 PC 의 Ollama(qwen2.5:3b)가 찾은 조항으로 씁니다. 회사, 규정, 직원 이름은 모두 지어낸 것입니다.
| 파일 | 생김새 |
|---|---|
company_rules.txt | 솔내기술 사내 규정집. "제N조(제목) 내용" 꼴의 조항 30개 |
company_rules_qa.jsonl | 질문, 정답열쇠(답이 여럿이면 세로줄 | 로 나눔), 근거조항. 25줄 |
요구사항은 다섯 가지입니다.
- 조항을 7장의 e5 임베딩으로 색인하고,
POST /ask가 질문을 받아 조항 3개를 찾아 LLM 으로 답하고 근거 조항의 제목을 함께 돌려줍니다. - 질문은 2~200글자로 검사합니다(어긋나면 422).
- 직원마다 API 키를 주고 키의 해시만 보관합니다. 키가 없거나 틀리면 401 입니다.
GET /me는 키 주인을 알려 줍니다. - 한 사람이 1분에 10번보다 많이 부르면 429 를 돌려줍니다.
- 요청마다 사용자, 주소, 상태, 걸린 시간을 기록하고,
GET /stats는 관리자만 볼 수 있게 합니다(직원은 403).
단계별 힌트입니다.
- 조항은
re.match(r"^제\d+조\(", 줄)로 고르고, 제목은 괄호 안의 글입니다(10장 실습 5). - 키 확인, 횟수 제한, 기록을 한 의존성 함수에 넣으면 모든 엔드포인트에 같은 규칙이 걸립니다.
- 횟수 제한은 사용자마다 최근 요청 시각을 목록으로 두고, 60초가 지난 것은 빼고 센 뒤 10개 이상이면 429 를 냅니다.
- 실제 서비스라면 키는 환경 변수나 비밀 저장소에서 읽습니다. 예시 풀이는 시험용 키를 그 자리에서 만들어 환경 변수
RULES_API_KEYS에 넣고 읽습니다. - Ollama 가 가끔 오류를 내니 LLM 호출에는 모델을 내렸다 다시 묻는 재시도를 붙입니다(10장).
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
실제 데이터로 해 보려면
사내 규정은 공개된 공공기관 규정으로 대신해 연습할 수 있습니다. 내려받기 전에 이용 약관을 확인합니다.
| 자료 | 구하는 곳 | 내용 |
|---|---|---|
| 공공기관의 내부 규정 | 국가법령정보센터(law.go.kr)의 행정규칙, 공공기관 경영정보 공개시스템(alio.go.kr) | 복무, 여비, 보안 규정. 조항 단위로 나누기 좋다 |
| 공개 API 설계 예 | 공공데이터포털(data.go.kr)의 오픈 API 명세 | 인증키를 머리글이나 쿼리로 받는 실제 API 의 꼴. 인증키는 발급받은_인증키 자리표시와 환경 변수로 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 조항을 색인하고 답을 쓰는 answer() 를 만들며, 2단계(마젠타)에서 키 검사와 횟수 제한, 기록을 의존성 who() 하나로 묶습니다. 3단계(다홍)는 /ask, /me, /stats 엔드포인트를 만들고, 4단계(빨강)는 TestClient 로 질문 8개를 물어 채점하며, 5단계(짙은 청록)는 401, 403, 422, 429 로 막아야 할 요청이 막히는지 보고 통계와 기록을 남깁니다.
결과 보기 (예시 풀이)
1단계. 조항 색인과 답하는 함수
import hashlib, hmac, json, os, re, secrets, time, urllib.request
from collections import defaultdict
from datetime import datetime
import numpy as np
from openai import OpenAI
from sentence_transformers import SentenceTransformer
# Ollama 모델을 내려 깨끗하게 다시 올린다(10차시)
def unload():
urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", headers={"Content-Type": "application/json"},
data=json.dumps({"model": "qwen2.5:3b", "keep_alive": 0}).encode())).read()
unload()
articles = [l.strip() for l in open("company_rules.txt", encoding="utf-8") if re.match(r"^제\d+조\(", l)]
titles = [re.match(r"^제\d+조\((.+?)\)", a).group(1) for a in articles]
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
A = emb.encode(["passage: " + a for a in articles], normalize_embeddings=True)
llm = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
SYSTEM = "당신은 솔내기술(가상 회사)의 규정 도우미입니다. [조항]에 있는 내용만으로 한두 문장으로 답하세요. 없으면 '규정에서 찾을 수 없습니다'라고 답하세요."
def answer(question):
"""조항 3개를 찾아 LLM 으로 답한다. 오류나 깨진 글자면 모델을 내렸다 두 번까지 다시 묻는다"""
q = emb.encode("query: " + question, normalize_embeddings=True)
top = np.argsort(-(A @ q))[:3]
msgs = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": "[조항]\n" + "\n".join(articles[i] for i in top) + f"\n\n[질문]\n{question}"}]
for _ in range(3):
try:
text = llm.chat.completions.create(model="qwen2.5:3b", messages=msgs, temperature=0, seed=42).choices[0].message.content.strip()
if "@" not in text:
return text, [titles[i] for i in top]
except Exception as e:
text = f"(오류: {e})"
unload()
return text, [titles[i] for i in top]
print(len(articles), "조항 ·", titles[:5])
print(answer("집에서 일하는 건 일주일에 며칠까지 돼요?"))30 조항 · ['근무 시간', '유연 근무', '재택 근무', '초과 근무', '연차']
('제3조에 따르면 일주일에 이틀까지 재택 근무를 할 수 있습니다.', ['재택 근무', '연차', '유연 근무'])2단계. 키, 횟수 제한, 기록을 한 의존성으로
from fastapi import Depends, FastAPI, HTTPException, Request, Security
from fastapi.security import APIKeyHeader
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
# 시험용 키를 만들어 환경 변수에 넣는다. 실제로는 관리자가 발급해 비밀 저장소에 둔다
staff = {"김솔내": "직원", "이영업": "직원", "박관리": "관리자"}
issued = {name: secrets.token_urlsafe(24) for name in staff}
os.environ["RULES_API_KEYS"] = json.dumps({name: {"키": issued[name], "역할": role} for name, role in staff.items()}, ensure_ascii=False)
digest = lambda k: hashlib.sha256(k.encode()).hexdigest()
KEYS = {digest(v["키"]): {"이름": n, "역할": v["역할"]} for n, v in json.loads(os.environ["RULES_API_KEYS"]).items()}
LIMIT, WINDOW = 10, 60 # 1분에 10번까지
recent = defaultdict(list) # 사용자 → 최근 요청 시각들
LOG = []
header = APIKeyHeader(name="X-API-Key", auto_error=False)
def who(request: Request, key: str | None = Security(header)):
"""키 확인(401) → 횟수 제한(429) → 기록. 통과하면 사용자 정보를 돌려준다"""
user = next((u for h, u in KEYS.items() if key and hmac.compare_digest(h, digest(key))), None)
if user is None:
LOG.append({"시각": datetime.now().isoformat(timespec="seconds"), "사용자": None, "주소": request.url.path, "상태": 401})
raise HTTPException(status_code=401, detail="API 키가 없거나 맞지 않습니다")
now = time.time()
recent[user["이름"]] = [t for t in recent[user["이름"]] if now - t < WINDOW] # 1분이 지난 기록은 버린다
if len(recent[user["이름"]]) >= LIMIT:
LOG.append({"시각": datetime.now().isoformat(timespec="seconds"), "사용자": user["이름"], "주소": request.url.path, "상태": 429})
raise HTTPException(status_code=429, detail=f"1분에 {LIMIT}번까지 부를 수 있습니다")
recent[user["이름"]].append(now)
return user
print({n: v["역할"] for n, v in json.loads(os.environ["RULES_API_KEYS"]).items()}, "· 키 해시", len(KEYS), "개"){'김솔내': '직원', '이영업': '직원', '박관리': '관리자'} · 키 해시 3 개3단계. 엔드포인트 만들기
app = FastAPI(title="솔내기술 사내 규정 질의 API")
class Ask(BaseModel):
질문: str = Field(min_length=2, max_length=200)
@app.post("/ask")
def ask(body: Ask, user=Depends(who)):
start = time.perf_counter()
text, refs = answer(body.질문)
ms = round((time.perf_counter() - start) * 1000)
LOG.append({"시각": datetime.now().isoformat(timespec="seconds"), "사용자": user["이름"], "주소": "/ask", "상태": 200, "걸린ms": ms})
return {"답": text, "근거조항": refs, "걸린ms": ms}
@app.get("/me")
def me(user=Depends(who)):
return user
@app.get("/stats")
def stats(user=Depends(who)):
if user["역할"] != "관리자":
raise HTTPException(status_code=403, detail="관리자만 볼 수 있습니다")
done = [r for r in LOG if r["상태"] == 200]
return {"요청": len(LOG), "성공": len(done), "평균ms": round(np.mean([r["걸린ms"] for r in done])) if done else None,
"사용자별": {u: sum(r["사용자"] == u for r in LOG) for u in staff},
"상태별": {s: sum(r["상태"] == s for r in LOG) for s in sorted({r["상태"] for r in LOG})}}
client = TestClient(app)
print(client.get("/me", headers={"X-API-Key": issued["김솔내"]}).json()){'이름': '김솔내', '역할': '직원'}4단계. 질문 묶음으로 채점하기
qa = [json.loads(line) for line in open("company_rules_qa.jsonl", encoding="utf-8")][:8]
right = 0
for i, item in enumerate(qa):
name = ["김솔내", "이영업"][i % 2] # 두 직원이 번갈아 묻는다
r = client.post("/ask", json={"질문": item["질문"]}, headers={"X-API-Key": issued[name]})
body = r.json()
ok = any(k in body["답"].replace(" ", "") for k in item["정답열쇠"].replace(" ", "").split("|"))
right += ok
print("O" if ok else "X", r.status_code, item["질문"], "→", body["답"][:50], "|", body["근거조항"][0], f"{body['걸린ms']}ms")
print(f"맞힌 수: {right} / {len(qa)}")X 200 출근 시간을 늦출 수 있나요? → 규정에서 찾을 수 없습니다 | 유연 근무 1086ms
O 200 집에서 일하는 건 일주일에 며칠까지 돼요? → 제3조에 따르면 일주일에 이틀까지 재택 근무를 할 수 있습니다. | 재택 근무 570ms
O 200 한 주에 야근은 최대 몇 시간까지 할 수 있어요? → 한 주에 초과 근무는 12시간을 넘길 수 없습니다. | 초과 근무 1195ms
O 200 입사 첫해에는 휴가가 어떻게 생기나요? → 입사 첫해에는 한 달 개근할 때마다 연차 하루가 생깁니다. 따라서 입사 첫해에는 한 달에 | 연차 1892ms
O 200 연차는 최대 며칠까지 늘어나요? → 연차는 최대 25일까지 늘어나요. | 연차 1042ms
O 200 아내가 아기를 낳으면 며칠 쉴 수 있나요? → 아내가 아기를 낳으면 10일의 경조 휴가를 이용할 수 있습니다. | 경조 휴가 1453ms
O 200 아파서 나흘 쉬면 무엇을 내야 하나요? → 아파서 3일 넘게 쉬면 진단서를 냅니다. | 병가 1381ms
O 200 휴가는 며칠 전에 올려야 하나요? → 제9조에 따르면 휴가는 그룹웨어에서 쓰기 3일 전까지 신청해야 합니다. | 휴가 신청 1582ms
맞힌 수: 7 / 85단계. 막아야 할 요청 막기와 통계
checks = {
"키 없음": client.post("/ask", json={"질문": "연차는 며칠이에요?"}).status_code,
"틀린 키": client.post("/ask", json={"질문": "연차는 며칠이에요?"}, headers={"X-API-Key": "wrong-key-0000"}).status_code,
"너무 짧은 질문": client.post("/ask", json={"질문": "?"}, headers={"X-API-Key": issued["이영업"]}).status_code,
"직원이 통계": client.get("/stats", headers={"X-API-Key": issued["김솔내"]}).status_code,
}
# 김솔내가 /me 를 빠르게 여러 번 부른다. 앞서 /me 1번, /ask 4번, /stats 1번(모두 6번)을 불렀으니 곧 1분에 10번을 넘는다
codes = [client.get("/me", headers={"X-API-Key": issued["김솔내"]}).status_code for _ in range(8)]
checks["빠르게 8번"] = codes
print(checks)
r = client.get("/stats", headers={"X-API-Key": issued["박관리"]})
print("관리자 통계:", r.status_code, json.dumps(r.json(), ensure_ascii=False))
with open("out_rules_api_log.jsonl", "w", encoding="utf-8") as fp:
fp.writelines(json.dumps(x, ensure_ascii=False) + "\n" for x in LOG){'키 없음': 401, '틀린 키': 401, '너무 짧은 질문': 422, '직원이 통계': 403, '빠르게 8번': [200, 200, 200, 200, 429, 429, 429, 429]}
관리자 통계: 200 {"요청": 14, "성공": 8, "평균ms": 1275, "사용자별": {"김솔내": 8, "이영업": 4, "박관리": 0}, "상태별": {"200": 8, "401": 2, "429": 4}}조항 30개를 색인하고 직원 둘이 번갈아 8개를 물어 7개를 맞혔습니다. 한 번 답하는 데 0.6~1.9초, 평균 1.3초쯤 걸렸고, 근거 조항도 모두 맞는 조항이 1위로 왔습니다. 틀린 하나("출근 시간을 늦출 수 있나요?")는 "유연 근무" 조항을 1위로 찾고도 모델이 "규정에서 찾을 수 없습니다"라고 답한 것으로, 검색이 아니라 답 쓰기의 문제입니다. 막아야 할 요청은 모두 막혔습니다. 키 없음과 틀린 키는 401, 한 글자 질문은 422, 직원이 통계를 보려 하면 403 이었습니다. 김솔내는 앞서 6번을 부른 뒤 /me 를 빠르게 8번 부르자 4번까지 200 이고 다섯 번째부터 429 가 되어, 1분에 10번의 제한이 걸렸습니다. 관리자의 통계에는 요청 14번(성공 8, 401 둘, 429 넷)이 사용자별로 모였고, 기록은 out_rules_api_log.jsonl 에 키 없이 저장했습니다. 이 API 는 아직 내 PC 안에서만 돕니다. 14장에서 웹 화면을 붙이고 컨테이너로 묶어 다른 곳에서도 띄울 수 있게 합니다.
이 프로젝트가 뜻하는 것
이 프로젝트는 사내 규정집을 갖춘 안내 데스크를 차리는 일과 같습니다. 규정집을 조항마다 카드로 만들어 찾기 쉽게 꽂고, 카드를 보고 답하는 직원을 둡니다(1단계). 데스크 앞에는 사원증 확인, "1분에 10번까지"라는 줄 서기 규칙, 방문 장부를 한꺼번에 맡는 경비를 세우고(2단계), 질문 창구, "나는 누구인가" 창구, 관리자용 장부 창구를 엽니다(3단계). 직원 둘이 질문 8개를 번갈아 물어 채점하고(4단계), 막아야 할 방문이 막히는지 보고 장부를 정리합니다(5단계). 여기서 카드 꽂이가 e5 임베딩 색인, 답하는 직원이 LLM(Large Language Model, 거대 언어 모델) qwen2.5:3b, 경비가 의존성 who(), 방문 장부가 LOG 와 out_rules_api_log.jsonl 입니다. 비유와 다른 점은, 진짜 경비는 모든 방문을 적지만 이 장부에는 /ask 성공과 401, 429 만 적힌다는 것입니다(코드가 그때만 LOG.append 를 부릅니다).
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 색인과 답 | 30 조항, ('제3조에 따르면 일주일에 이틀까지 재택 근무를 할 수 있습니다.', ['재택 근무', '연차', '유연 근무']) | 조항 30개를 색인했고, 재택 근무 질문에 맞는 조항을 1위로 찾아 답했습니다 |
| 2. 키 | {'김솔내': '직원', '이영업': '직원', '박관리': '관리자'} · 키 해시 3 개 | 직원 둘과 관리자 하나에게 키를 주고, 서버는 해시만 보관합니다 |
| 3. /me | {'이름': '김솔내', '역할': '직원'} | 키만으로 누구인지, 어떤 역할인지 알아봅니다 |
| 4. 채점 | 맞힌 수: 7 / 8, 한 번에 570ms~1892ms | 8개 가운데 7개를 맞혔습니다. 틀린 하나는 "유연 근무" 조항을 찾고도 "규정에서 찾을 수 없습니다"라고 답한 것으로, 검색이 아니라 답 쓰기의 문제입니다 |
| 5. 막기 | '키 없음': 401, '틀린 키': 401, '너무 짧은 질문': 422, '직원이 통계': 403 | 막아야 할 요청이 모두 알맞은 상태 코드로 막혔습니다 |
| 5. 횟수 제한 | '빠르게 8번': [200, 200, 200, 200, 429, 429, 429, 429] | 앞서 6번 부른 김솔내가 4번을 더 불러 1분에 10번을 채우자, 다섯 번째부터 429(요청이 너무 많음)가 나왔습니다 |
| 5. 통계 | "요청": 14, "성공": 8, "평균ms": 1275 | 장부에 적힌 14건(성공 8, 401 둘, 429 넷)이 사람별로 모였습니다. 답 하나에 평균 1.3초쯤 걸렸습니다 |
한 줄로 줄이면, 조항을 찾아 LLM 으로 답하는 기능에 키 인증, 관리자 권한, 횟수 제한, 기록을 붙여 여러 사람이 함께 쓸 수 있는 API 의 꼴을 갖췄다는 프로젝트입니다. 정답 수는 질문 8개로, 시간은 이 PC 에서 한 번 잰 값이라 다른 질문과 다른 PC 에서는 달라질 수 있습니다. 통계의 "요청 14"는 장부에 적힌 것만 센 수라, 실제로 받은 요청(성공한 /me, 403, 422 포함)은 더 많습니다. 14장에서 이 API 에 웹 화면을 붙이고 컨테이너로 묶습니다.