API Routing Conventions
새 endpoint / secret 추가 시 따를 컨벤션. PR #79 (Registry 도입, 2026-05-12) + 봇 리뷰 라운드에서 합의된 룰을 영구화.
Why
ACL Registry 가 helper 자동 선택을 강제하지만 path 선택 / secret 비교 방식 까지는 자동화 X. 명시 안 하면 새 endpoint 추가하는 사람이 옛 패턴 (path-prefix exempt 매트릭스 / 일반 == 비교) 으로 회귀할 위험.
룰
R1 — Audience-based path
endpoint 가 호출되는 청중에 따라 path prefix 결정:
| 청중 | path | 등록 helper |
|---|---|---|
| admin (X-Admin-Key) 전용 | /api/v1/admin/* | Admin |
| 사용자 (모바일/웹) 전용 | /api/v1/* (admin 제외) | Authenticated / Anonymous / AuthIssue |
| 시스템 (Google SSV, health probe) | /api/v1/admob/ssv, /actuator/* | AdMobSSV / Actuator |
한 동작을 두 청중이 모두 호출 하면 endpoint 2 개 (각 청중의 path 아래). 같은 핸들러를 두 helper 로 wrap 하지 말고, 핸들러 로직을 서비스 함수로 빼고 각 endpoint 핸들러가 서비스 호출.
예: PR #79 에서 POST /api/v1/disclosures/fetch (JWT 보호 — admin-only 디버깅 endpoint 임) → POST /api/v1/admin/disclosures/fetch 로 이동. 사용자가 호출할 일 없는 endpoint 가 admin path 밖에 있으면 audience contract 가 깨짐.
R2 — Registry helper 강제
mux.Handle / mux.HandleFunc 직접 호출 금지. 모든 라우트는 pkg/route.Registry 의 6 helper 중 하나로 등록:
Authenticated— AppCheck + JWT 필수Anonymous— AppCheck (or Worker secret) + JWT 선택. 비로그인 read.AuthIssue— AppCheck + JWT 면제. 토큰 발급 자체.Admin— X-Admin-KeyAdMobSSV— ECDSA (핸들러 안)Actuator— 의도된 무 ACL
Registry 우회 (mux 직접 등록) 시 ACL 누락 가능. 카테고리 매트릭스는 docs/spec/backend-structure.md "Route ACL 매트릭스" 참조.
R3 — Secret 비교는 ConstantTimeCompare
X-Admin-Key, X-Worker-Secret 등 secret 비교는 crypto/subtle.ConstantTimeCompare 사용. 일반 == / != 는 첫 바이트 불일치에서 short-circuit → 타이밍 oracle 로 prefix 노출 가능.
import "crypto/subtle"
if subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 {
w.WriteHeader(http.StatusForbidden)
return
}HTTPS + IP rate limit 으로 실제 exploit 난이도는 높지만, secret 비교는 cost-free 한 줄 교체이므로 일관 적용.
R4 — RateLimit 우선순위
Authenticated / Admin chain 에서 rate limit 미들웨어가 인증 검증 (RequireAuth / RequireAdminKey) 보다 바깥:
// 올바름 (Authenticated 는 writeRL — 비-GET 만 RL)
appCheckMW(writeRL(requireAuthMW(next)))
generalRLMW(requireAdminKeyMW(next))
// 잘못됨 — 인증 실패 요청이 RL 토큰 소비 없이 차단
appCheckMW(requireAuthMW(writeRL(next)))
requireAdminKeyMW(generalRLMW(next))인증 검증이 바깥이면 무인증/잘못된 키 요청은 RL 소비 없이 즉시 401/403 → 방어 심도 손실 (브루트포스 시도가 RL 카운터에 안 잡힘, soft AppCheck 모드에선 무인증 플러딩 가능).
일반 RL 은 mutating(비-GET) 요청에만 적용 (2026-05-22,
writeRL), 키는 userId(비인증 시 IP fallback, CGNAT 회피). GET read 는 무제한 — 정상 사용(목록 새로고침) 과방어 제거. write 만 2 RPS 유지 (스팸/어뷰즈 차단).Anonymous는 전부 GET read 라 RL 없음. 개인화 응답(GET /ai/analyze)은 캐시 불가(사용자별)라 캐시로도 안 막음 — read 무제한 허용이 정책. store 는 프로세스 인메모리(단일 replica 가정). 상세backend-structure.mdHelper 표.
R5 — 같은 청중 내에서는 클라이언트(web/mobile)로 분리 금지, 리소스 + 필터로
R1 의 audience 는 admin / 사용자 / 시스템 계층이다. 웹과 모바일은 둘 다 "사용자" — 같은 청중이다. 따라서 웹/모바일을 path 나 파라미터로 가르지 않는다.
- ❌
/api/v1/web/disclosures— 호출자(웹)를 path 에 인코딩 (R1 의 audience 가 아님) - ❌
?platform=WEB— 호출자를 파라미터에 인코딩. 응답을 통째로 분기 → 필터 아님 - ✅
/api/v1/disclosures?hasSummary=true— 원하는 데이터(요약 있는 것)를 필터로 표현 - ✅
/api/v1/stocks/active?days=7— 종목 컬렉션의 활성 부분집합
원칙: "내가 누구냐(WHO)" 가 아니라 "뭘 원하냐(WHAT)" 를 표현한다. 클라이언트 차이는 쿼리 필터 / 필드 투영으로 흡수.
따름:
- 필터 파라미터는 응답 스키마를 바꾸지 않는다 — 어떤 row/field 가 포함되냐만 좁힌다. 파라미터 하나로 응답 구조가 통째로 바뀌면 그건 필터가 아니라 다른 리소스 (= 별도 path 신호)
- 인증 tier 로 path 를 가르지 않는다 —
Anonymous= 로그인 불필요(로그인해도 호출 가능)지 "비로그인 전용" 이 아니다. 공개 데이터는 로그인 유저가 봐도 무방. "비로그인 전용 강제"(로그인 거부)는 거부 분기를 추가해 오히려 복잡 - 인증 여부로 데이터가 달라져야 하면 ACL 이 아니라 핸들러 안 필드 투영으로 (예:
GET /ai/analyze/{rceptNo}가 비로그인엔result생략). endpoint 는Anonymous하나 - BFF(클라이언트별 응답 조립)는 응답이 리소스로 안 떨어질 때만 — 그것도 path prefix 가 아니라 별도 게이트웨이. 단일 백엔드 규모엔 오버킬
PR #112 (2026-05-21): 초안의 /api/v1/web/* 2개를 이 원칙으로 /disclosures?hasSummary=true + /stocks/active 로 재편.
적용 시점
- 새 endpoint 핸들러 작성 시 → R1, R2, R5 검토
- 새 secret 환경변수 추가 + 검증 미들웨어 작성 시 → R3 적용
- Registry helper 추가 (7번째 카테고리 신설) 시 → R4 패턴 따라 chain 합성
참조
- 구현:
backend/pkg/route/registry.go,backend/pkg/middleware/admin_key.go,backend/pkg/middleware/worker_context.go - 매트릭스:
docs/spec/backend-structure.md"Route ACL 매트릭스" - PR #79 (Registry 도입), 봇 리뷰 P1/P2 코멘트 6건