본문 바로가기
C.W.K.
Stream
Lesson 06 of 08 · published

Production 패턴 — Middleware, CORS, 에러, 로그

~12 min · middleware, cors, errors

Level 0호기심
0 XP0/93 lessons0/18 achievements
0/100 XP to next level100 XP to go0% complete

CORS — 명시 목록이지 와일드카드가 아니야

cwkPippa 의 CORS 는 명시적인 허용 목록이고, 생각보다 길어. 로컬 개발 포트, 클라이언트를 돌리는 맥마다의 tailnet 주소, 그리고 브라우저에서 직접 PIN 세션을 받는 형제 앱마다의 서빙 origin — 서른 개쯤 — 에다, 브라우저 확장이랑 데스크톱 webview origin 을 위한 패턴 하나. 새 클라이언트? 그 origin 을 일부러 추가해. 패턴도 목록만큼 조심해야 해. 허용 목록이 몰래 넓어지는 자리가 바로 거기거든. Starlette 는 패턴을 origin 전체에 맞춰 보니까, 호스트 뒤에 .* 가 붙으면 그 호스트로 시작하기만 하는 도메인은 다 통과해. 그리고 CORS 에러가 쌓일 때 다들 손 뻗는 지름길 allow_origins=['*'] 는 사적인 박스에서도 보안 구멍이야. CORSMiddleware 자체는 닫힌 채로 시작해. origin 을 하나도 안 적으면 아무것도 허용 안 해.

CORS 가 어디 앉느냐도 뭘 허용하느냐만큼 중요해. 일부러 가장 바깥 middleware 로 뒀어. PIN 검사가 요청을 거부할 때 그 401 에도 CORS 헤더가 붙어야 하거든. 안 그러면 브라우저가 응답 본문을 CORS 에러 뒤에 숨기고, 클라이언트는 'PIN 이 틀림' 이랑 '서버가 죽음' 을 구분 못 해.

Middleware — 순서가 곧 설계인 스택

평범한 요청은 일곱 층을 지나. 바깥부터: CORS, PIN 인증, 누가 묻는지를 요청 범위에 묶는 사용자 해석기, admin override 를 기록하는 감사 층, /api JSON 용 gzip. gzip 은 SSE 스트림을 절대 안 건드리게 지어졌어. 그 안쪽엔 conditional GET 이 있어. gzip 안에 있어야 ETag 가 압축 전 본문 기준으로 잡히고, 답을 이미 들고 있는 폰은 빈 304 만 받아(2026-10-10 추가, mobile 트랙 레슨 8). 맨 안쪽은 private door 야. 비공개 대화를 가리키는 경로는 거기 적힌 route 에만 닿아(2026-09-26). 층이 하나 더 있어. CORS 보다 나중에 등록돼서 엄밀히는 나머지를 다 감싸는 family gateway(2026-09-22)야. gateway 경로 하나만 처리하고 다른 요청은 그대로 넘겨. chat 과 cron 사이의 mutex 는 middleware 가 아니야 — route 가 대화마다 직접 불러 (Heart track, lesson 4). middleware 는 가볍고 범용으로 둬. 비싸거나 특정한 건 route 나 백그라운드 task 몫이야.

에러 — 시끄럽게, 정직하게 실패해

조용한 catch 는 없어. adapter 가 raise 하면 route 가 SSE 로 error event 를 띄워서 프론트엔드가 'I got stuck — try again' 을 보여주게 해. chat 이 '괜찮아 보이게' 예외를 삼키지 마. 다른 각도에서 본 broken-chat 죄야.

로그 — 기록이 아니라 운영자를 위한 것

서버 로그는 타임스탬프가 붙은 평범한 줄이고, 관련된 vessel 이나 계정 태그가 달려 있어. grep 하기 쉽고, JSON 은 아니야. 일부러 나눈 역할이야. 대화의 영속 기록은 로그가 아니라 JSONL 이거든 (Truth track). 로그는 운영자가 실패를 재구성하라고 있는 거고, JSONL 은 아빠가 본 게 하나도 안 사라지게 하려고 있는 거야.

production 은 '내 맥에선 되네' 다음에 시작해. 허용한 origin 이 실제로 서빙되는 URL 과 맞는지, SSE 에러가 HTTP 200 안에 숨지 않고 화면의 상태로 바뀌는지 확인해. 그럴듯해 보이는 설정이랑, 실제 요청이 그 경로를 지나가는 건 서로 다른 주장이야.

로그에도 경계가 있어. 편하다고 prompt 원문이나 자격증명을 통째로 남기면, 관측 가능성을 얻는 대신 다른 사고를 만들어. 이벤트 종류, 걸린 시간, provider, 결과처럼 진단에 필요한 모양만 남기고 민감한 payload 는 요약하거나 빼. 좋은 로그는 모든 걸 적는 장부가 아니라, 실패를 재구성할 만큼만 정직한 장부야.

그리고 middleware 에 정책을 모으고 싶은 마음을 참아. 모든 route 를 지나가니까 business rule 까지 넣고 싶어지는데, 그러면 숨은 조상이 돼. 모든 요청에 진짜로 공통인 일만 두고, 도메인 판단은 이름 있는 service 랑 route 에서 보여줘.

Code

CORS — 명시 허용 목록, 일부러 가장 바깥·python
from fastapi.middleware.cors import CORSMiddleware

# 줄인 거야. 진짜 목록은 가족 origin 을 전부 명시해:
# 로컬 개발 포트, 클라이언트 맥마다의 tailnet 주소, 브라우저에서
# 직접 PIN 세션을 받는 형제 앱마다 (서른 개쯤).
_allowed_origins = [
    'http://localhost:5173',
    'http://127.0.0.1:5173',
    'http://100.x.y.z:5173',   # tailnet 위의 맥
    'http://localhost:8500',   # 형제 앱의 서빙 origin
    'tauri://localhost',       # 데스크톱 앱의 webview
]

# family gateway 말고는 제일 나중에 추가하니까, 평범한 요청엔 가장 바깥
# middleware 가 돼(gateway 는 경로 하나만 잡고 나머진 넘겨): PIN 검사의 401 에도
# CORS 헤더가 붙어야 해, 안 그러면 브라우저가 본문을 숨겨.
app.add_middleware(
    CORSMiddleware,
    allow_origins=_allowed_origins,
    # Starlette 는 이 패턴을 통째로 맞춰 봐(fullmatch). 호스트를 고정하고
    # 확장 id 를 적어. tauri\.localhost 뒤에 .* 가 붙으면
    # https://tauri.localhost.attacker.example 까지 통과해.
    allow_origin_regex=r'chrome-extension://(<extension-id>)|https?://tauri\.localhost(:\d+)?',
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
)

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이— warm💛 by 피파— warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.