백엔드 폴더 / 패키지 구조
Go 백엔드(backend/)의 폴더 구조와 각 패키지 책임. 시스템 전체 흐름은 architecture.md, 도메인별 동작은 domain-*.md 참조.
디렉토리 트리
backend/
├── cmd/
│ └── server/
│ ├── main.go # 진입점 + 의존성 wiring + 미들웨어 체인
│ └── migrations/ # SQL 마이그레이션 (go:embed)
│ ├── 00001_baseline.sql
│ ├── 00002_quota_ad_reward.sql
│ └── ... # Goose가 부팅 시 자동 적용
├── internal/ # 도메인 패키지 (외부 import 차단)
│ ├── config/ # 환경변수 로딩 + DB pool 생성
│ ├── auth/ # 카카오 OAuth + JWT
│ ├── disclosure/ # DART API + disclosures 테이블
│ ├── ai/ # AI 분석 + quota + reveal + feedback
│ ├── stock/ # KOSPI/KOSDAQ 마스터
│ ├── watchlist/ # 관심종목
│ ├── admob/ # AdMob SSV
│ ├── appcheck/ # Firebase App Check 검증
│ ├── notification/ # 푸시 (VAPID 키 전달)
│ ├── dashboard/ # 대시보드 집계
│ ├── admin/ # 백필 + 스케줄러 컨트롤
│ └── scheduler/ # DisclosureMonitor + AnalysisScheduler
├── pkg/ # 외부 사용 가능한 유틸
│ ├── middleware/ # CORS / RateLimit / JWTAuth / HTTPCache
│ └── httputil/ # 응답 헬퍼
└── docs/ # 백엔드 전용 노트 (있으면)패키지 책임
cmd/server/main.go
진입점. 다음 순서로 wiring:
config.Load()—.env.<APP_ENV>로드 (prod에선 Railway 환경변수 직접 주입)config.NewDBPool()— pgxpool 생성 + Goose 마이그레이션 자동 적용- 도메인별 repository / service / handler 인스턴스 생성 (수동 DI, 프레임워크 없음)
scheduler.AnalysisScheduler/DisclosureMonitor백그라운드 goroutine 기동 (조건부 auto-start)route.NewRegistry(mux, cfg)로 Registry 생성 — 각 handler 의RegisterRoutes(reg)가 카테고리 helper (Authenticated/Anonymous/AuthIssue/Admin/AdMobSSV/Actuator) 호출로 ACL 명시.mux.Handle직접 호출 X — Registry 가 helper 우회 차단.- 외곽 미들웨어 체인 wrap (바깥→안쪽):
WorkerContext → CORS → JWTAuth (파싱만) → mux. ACL (AppCheck / RateLimit / RequireAuth / AdminKey) + Anonymous endpoint 의 HTTPCache 는 Registry 가 per-route 적용 — chain 에 없음.WorkerContext가 최외곽 —X-Worker-Secret헤더 일치 GET 요청에ctx.IsWorker=true표시 →Anonymoushelper 가 보고 AppCheck 면제 + Worker bucket 사용. (HTTPCache 가 chain 외곽에 있으면 모바일 403 응답이 캐시돼 Worker 요청에 누출 — fix 2026-05-13) http.ListenAndServe(":" + cfg.Port, chain)—PORTenv (기본8000)
Route ACL 매트릭스
pkg/route 의 6 helper 가 카테고리별 ACL 묶음:
| Helper | AppCheck | JWT | RateLimit | 기타 |
|---|---|---|---|---|
Authenticated | 필요 | 필수 | General (IP, 2/10) | — |
Anonymous | 필요¹ | 선택 | General¹ | ¹ ctx.IsWorker 면 AppCheck 면제 + Worker bucket (단일, 50/200) |
AuthIssue | 필요 | 스킵 | AuthBucket (IP, 0.5/5) | 토큰 발급 브루트포스 방어 |
Admin | 스킵 | 스킵 | General | X-Admin-Key |
AdMobSSV | 스킵 | 스킵 | bypass | ECDSA (핸들러 안) |
Actuator | 스킵 | 스킵 | General | 의도된 무 인증 |
전체 endpoint 분류는 domain-auth.md "공개 경로" 섹션 + 각 domain-*.md "관련 API" 표 참조.
internal/config/
| 파일 | 역할 |
|---|---|
config.go | Config 구조체 + Load() (godotenv + 환경변수 파싱). APP_ENV 기본값 local. |
database.go | NewDBPool() — pgxpool + 마이그레이션 적용 (go:embed FS) |
env 변수 정의는 docs/dev/environment.md.
internal/auth/
카카오 OAuth → JWT 발급. JWT 미들웨어는 pkg/middleware/auth.go에 있고 이 패키지는 JWT provider만 제공.
| 파일 | 역할 |
|---|---|
kakao.go | Kakao /v2/user/me 호출 → 카카오 user ID 추출 |
jwt.go | HS256 JWT 발급 + 검증 (JWTProvider) |
repository.go | users 테이블 CRUD (FindByOAuth, Create, 탈퇴 트랜잭션) |
handler.go | POST /auth/kakao/token, GET /auth/me, DELETE /auth/withdraw, POST /auth/logout |
도메인 동작은 domain-auth.md.
internal/disclosure/
DART OpenAPI 래퍼 + 공시 저장.
| 파일 | 역할 |
|---|---|
dart_client.go | DART API 호출 (FetchDisclosures, FetchDocumentText). TLS 1.2 강제(DART legacy SSL 호환) + ZIP/XML 파싱 |
repository.go | disclosures 테이블 CRUD + SaveBatch (ON CONFLICT DO NOTHING) |
handler.go | GET /disclosures/recent, /disclosures?corpCode=X, GET /disclosures/{rceptNo}, GET /companies/{corpCode} 등 (Anonymous). 수동 fetch 는 admin 으로 이동 — internal/admin/ 참조 |
도메인 동작은 domain-disclosure.md.
internal/ai/
가장 큰 도메인. 분석 / quota / reveal / feedback이 한 패키지에 모여 있음.
| 파일 | 역할 |
|---|---|
analyzer.go | VLLMClient — vLLM /v1/responses 비동기 어댑터 (background:true). Submit (즉시 id 반환) / Poll (GET 결과) / Cancel 3 메서드. Cloudflare Access 헤더 첨부. 동기 LLM 대기가 없어 CF 100s timeout 영향 없음 — 폴링은 AnalysisScheduler.pollLoop 가 담당 |
repository.go | analysis_cache 테이블 (ClaimPending — stale PROCESSING 흡수 회수 포함 (in-flight 는 제외), ClaimRetryable, ClaimBodyPending, SetLLMResponseID, ListInflight, TouchInflight, MarkCompletedForResponse, MarkFailedForResponse — 모두 llm_response_id 가드로 race 차단) |
policy.go | 정책 도메인 모듈 — RevealPolicy = QuotaPolicy + AnalysisPolicy 값 타입. DefaultRevealPolicy() 가 prod 디폴트 (limit 10 / shortDoc 1000 / unavailableRetry 90). Classify 는 RevealPolicy 메서드. admob 도 QuotaPolicy.CurrentWeekStart() 공유 |
reveal.go | lazy-reveal 도메인 모듈 — Outcome sum-type (Pending/Unavailable/FreeReveal/PaidReveal/QuotaExhausted) + RevealPolicy.Classify (pure) + Inspect (analyze 진입점) + Reveal (트랜잭션 + race-safe 재검증) |
quota_repository.go | user_analysis_quota 테이블 raw DB 접근 (GetOrInit, DecrementUsed) + Quota 순수 데이터 구조체. 한도/Remaining 계산은 주입된 QuotaPolicy 가 담당 |
feedback_repository.go | analysis_feedback 테이블 |
handler.go | GET /ai/analyze/{rceptNo} → Inspect(), POST /ai/analyze/{rceptNo}/reveal → Reveal(), GET /quota |
feedback_handler.go | POST /ai/analyze/{rceptNo}/feedback |
도메인 동작: domain-quota.md, 정책: lazy-reveal.
핵심 패턴 — 정책 결정 한 곳, 두 컨텍스트에서 호출:
Inspect(GET 진입점) 안에서Classify한 번 호출 (read-only 응답)Reveal(POST 진입점) 의 트랜잭션 안에서Classify한 번 더 호출 (잠금 시점 race-safe 재검증)analyze와reveal의 분기가 같은Outcome에서 파생 — 정책 변경 시 한 함수만 손대면 됨
internal/scheduler/
백그라운드 잡. main.go에서 go (...).Start(ctx)로 spawn.
| 파일 | 역할 |
|---|---|
monitor.go | DisclosureMonitor — 5분 주기, 장중(KST 8~20시 평일)에만 DART 폴링 → 신규 공시 INSERT + analysis_cache PENDING 생성 |
scheduler.go | AnalysisScheduler — AI_CONCURRENCY 개 영구 submit 워커 (PENDING/BODY_PENDING claim → DART fetch → vLLM Submit → SetLLMResponseID) + 별도 pollLoop 1개 (LLM_POLL_INTERVAL_MS 마다 ListInflight → fan-out 16 goroutine 으로 Poll → completed 시 MarkCompletedForResponse). 모든 마킹은 llm_response_id 가드로 stale 회수 재submit race 차단. SIGTERM 시 graceful shutdown (워커 + pollLoop 모두), stale PROCESSING (llm_response_id IS NULL) 은 ClaimPending 이 흡수 회수, in-flight 는 부팅 시 ListInflight 가 재포착 |
stock_sync.go | StockSyncScheduler — 매일 KST 5시 KOSPI/KOSDAQ 마스터 동기화 (STOCK_SYNC_SCHEDULER_ENABLED로 토글) |
internal/stock/
| 파일 | 역할 |
|---|---|
master_sync.go | DART 회사 마스터 XML fetch → stocks 테이블 upsert |
handler.go | GET /stocks/search, GET /companies/{corpCode} |
internal/watchlist/
| 파일 | 역할 |
|---|---|
handler.go | GET/POST/DELETE /watchlist, GET /watchlist/capacity. (repository 분리 안 됨 — 단순 CRUD라 내부에서 SQL 직접 실행) |
도메인 동작: domain-watchlist.md.
internal/admob/
| 파일 | 역할 |
|---|---|
verifier.go | ECDSA-SHA256 서명 검증 + Google 공개키 캐시 (KeyCache) |
handler.go | GET /admob/ssv (Google 콜백) — tx 안에서 ad_reward_transactions 멱등 INSERT + (신규 시) ai.QuotaRepository.DecrementUsedTx 호출. V4 음수 허용. 이전 단일 CTE → 도메인별 분리 (admob 은 멱등 키만, quota SQL 은 ai 패키지) + 동일 tx 로 atomicity 유지 |
도메인 동작: domain-admob.md.
internal/appcheck/
| 파일 | 역할 |
|---|---|
verifier.go | Firebase App Check JWT 검증. JWK 캐시 6h TTL |
middleware.go | Middleware(v, mode, next) — strict/soft 분기. Registry helper 가 카테고리별로 wrap (면제 매트릭스는 helper 선택으로 표현) |
도메인 동작: domain-app-check.md.
internal/notification/
| 파일 | 역할 |
|---|---|
handler.go | POST /push/fcm-token (FCM 토큰 등록 — fcm_token 단일 소유권을 unique 제약으로 보장), DELETE /push/fcm-token (해지) |
internal/admin/
| 파일 | 역할 |
|---|---|
backfill.go | DART 과거 공시 배치 수집 (관리자용) |
handler.go | POST /admin/disclosures/backfill, POST /admin/scheduler/{action}, POST /admin/stocks/sync (Admin 키 보호) |
internal/dashboard/
| 파일 | 역할 |
|---|---|
handler.go | GET /dashboard — 사용자별 대시보드 집계 (관심종목 + 최신 분석) |
pkg/middleware/
각 미들웨어는 Registry (pkg/route) 의 helper 가 카테고리별로 조립해 적용한다. 외곽 chain 은 WorkerContext / CORS / JWTAuth 만. HTTPCache 는 Anonymous helper 의 chain 안 (인증 후) 에 적용.
| 파일 | 역할 |
|---|---|
auth.go | JWTAuth — 토큰 있으면 userID ctx 주입, 없거나 invalid 면 pass-through. RequireAuth — userID 없으면 401 (Registry Authenticated helper 가 사용) |
cors.go | Access-Control-Allow-Origin = FRONTEND_URL |
ratelimit.go | RateLimit (IP 토큰 버킷, LRU 10k 상한) — Registry 가 General / AuthBucket 두 store 로 인스턴스화. SharedBucketRateLimit — 단일 공유 버킷 (Worker 트래픽 단일 IP 수렴 회피용, RPS 50 / Burst 200) |
admin_key.go | RequireAdminKey — X-Admin-Key 헤더 검증. Registry Admin helper 가 사용 |
worker_context.go | WorkerContext — X-Worker-Secret GET 요청에 ctx.IsWorker=true 부착. Anonymous helper 가 보고 AppCheck 면제 + Worker bucket 분기 |
httpcache.go | GET 응답 in-memory 캐싱. CacheRule로 path별 TTL (5m/30s/1m). Anonymous helper 안 (인증 후) 에서 적용 — 인증 실패 응답이 다른 청중에 누출 X |
각 파일에 동급의 *_test.go가 있음.
pkg/route/
| 파일 | 역할 |
|---|---|
registry.go | Registry — http.ServeMux 를 감싸 6 카테고리 helper (Authenticated / Anonymous / AuthIssue / Admin / AdMobSSV / Actuator) 만 노출. mux.Handle 직접 호출 차단으로 ACL 누락 회귀 방지 |
pkg/httputil/
| 파일 | 역할 |
|---|---|
response.go | JSON 응답 / 에러 응답 헬퍼 |
컨벤션
- 레이아웃: 도메인 = 패키지. 한 패키지에
handler.go+repository.go+ 도메인 로직 파일 (*_client.go,*_service.go). Java식service.go단독 파일은 만들지 않음 — 도메인 로직이 단순하면 handler에 직접, 복잡하면 별도 파일. - DI:
main.go에서 수동 wiring. wire / fx 등 프레임워크 없음. - 에러 핸들링:
pkg/httputil/response.go의WriteError(w, status, code, message)사용. 도메인별 에러 코드는docs/api/참조. - 테스트: 같은 패키지에
*_test.go. 통합 테스트(DB 필요)는_repository_test.go처럼 명시 — 로컬 DB 필요. - 마이그레이션: 새 테이블/컬럼은
cmd/server/migrations/에<NN>_<slug>.sql추가. Goose는 부팅 시 자동 적용. 룰은docs/dev/migration-rules.md.
새 도메인 추가 체크리스트
internal/<domain>/폴더 생성repository.go(DB 접근),handler.go(HTTP), 필요 시<domain>_service.gocmd/server/main.go에서 wiring (repo → service → handler 인스턴스 생성,mux.Handle등록)- 마이그레이션 추가 (
docs/dev/migration-rules.md) tbls doc --rm-dist재생성 (db-schema.md)docs/api/<endpoint>.md작성,docs/spec/domain-<X>.md작성- PR 전 docs 업데이트 모두 같은 브랜치에 커밋