Skip to content

백엔드 폴더 / 패키지 구조

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:

  1. config.Load().env.<APP_ENV> 로드 (prod에선 Railway 환경변수 직접 주입)
  2. config.NewDBPool() — pgxpool 생성 + Goose 마이그레이션 자동 적용
  3. 도메인별 repository / service / handler 인스턴스 생성 (수동 DI, 프레임워크 없음)
  4. scheduler.AnalysisScheduler / DisclosureMonitor 백그라운드 goroutine 기동 (조건부 auto-start)
  5. route.NewRegistry(mux, cfg) 로 Registry 생성 — 각 handler 의 RegisterRoutes(reg) 가 카테고리 helper (Authenticated / Anonymous / AuthIssue / Admin / AdMobSSV / Actuator) 호출로 ACL 명시. mux.Handle 직접 호출 X — Registry 가 helper 우회 차단.
  6. 외곽 미들웨어 체인 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 표시 → Anonymous helper 가 보고 AppCheck 면제 + Worker bucket 사용. (HTTPCache 가 chain 외곽에 있으면 모바일 403 응답이 캐시돼 Worker 요청에 누출 — fix 2026-05-13)
  7. http.ListenAndServe(":" + cfg.Port, chain)PORT env (기본 8000)

Route ACL 매트릭스

pkg/route 의 6 helper 가 카테고리별 ACL 묶음:

HelperAppCheckJWTRateLimit기타
Authenticated필요필수General (IP, 2/10)
Anonymous필요¹선택General¹¹ ctx.IsWorker 면 AppCheck 면제 + Worker bucket (단일, 50/200)
AuthIssue필요스킵AuthBucket (IP, 0.5/5)토큰 발급 브루트포스 방어
Admin스킵스킵GeneralX-Admin-Key
AdMobSSV스킵스킵bypassECDSA (핸들러 안)
Actuator스킵스킵General의도된 무 인증

전체 endpoint 분류는 domain-auth.md "공개 경로" 섹션 + 각 domain-*.md "관련 API" 표 참조.

internal/config/

파일역할
config.goConfig 구조체 + Load() (godotenv + 환경변수 파싱). APP_ENV 기본값 local.
database.goNewDBPool() — pgxpool + 마이그레이션 적용 (go:embed FS)

env 변수 정의는 docs/dev/environment.md.

internal/auth/

카카오 OAuth → JWT 발급. JWT 미들웨어는 pkg/middleware/auth.go에 있고 이 패키지는 JWT provider만 제공.

파일역할
kakao.goKakao /v2/user/me 호출 → 카카오 user ID 추출
jwt.goHS256 JWT 발급 + 검증 (JWTProvider)
repository.gousers 테이블 CRUD (FindByOAuth, Create, 탈퇴 트랜잭션)
handler.goPOST /auth/kakao/token, GET /auth/me, DELETE /auth/withdraw, POST /auth/logout

도메인 동작은 domain-auth.md.

internal/disclosure/

DART OpenAPI 래퍼 + 공시 저장.

파일역할
dart_client.goDART API 호출 (FetchDisclosures, FetchDocumentText). TLS 1.2 강제(DART legacy SSL 호환) + ZIP/XML 파싱
repository.godisclosures 테이블 CRUD + SaveBatch (ON CONFLICT DO NOTHING)
handler.goGET /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.goVLLMClient — vLLM /v1/responses 비동기 어댑터 (background:true). Submit (즉시 id 반환) / Poll (GET 결과) / Cancel 3 메서드. Cloudflare Access 헤더 첨부. 동기 LLM 대기가 없어 CF 100s timeout 영향 없음 — 폴링은 AnalysisScheduler.pollLoop 가 담당
repository.goanalysis_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). ClassifyRevealPolicy 메서드. admob 도 QuotaPolicy.CurrentWeekStart() 공유
reveal.golazy-reveal 도메인 모듈Outcome sum-type (Pending/Unavailable/FreeReveal/PaidReveal/QuotaExhausted) + RevealPolicy.Classify (pure) + Inspect (analyze 진입점) + Reveal (트랜잭션 + race-safe 재검증)
quota_repository.gouser_analysis_quota 테이블 raw DB 접근 (GetOrInit, DecrementUsed) + Quota 순수 데이터 구조체. 한도/Remaining 계산은 주입된 QuotaPolicy 가 담당
feedback_repository.goanalysis_feedback 테이블
handler.goGET /ai/analyze/{rceptNo}Inspect(), POST /ai/analyze/{rceptNo}/revealReveal(), GET /quota
feedback_handler.goPOST /ai/analyze/{rceptNo}/feedback

도메인 동작: domain-quota.md, 정책: lazy-reveal.

핵심 패턴 — 정책 결정 한 곳, 두 컨텍스트에서 호출:

  • Inspect (GET 진입점) 안에서 Classify 한 번 호출 (read-only 응답)
  • Reveal (POST 진입점) 의 트랜잭션 안에서 Classify 한 번 더 호출 (잠금 시점 race-safe 재검증)
  • analyzereveal 의 분기가 같은 Outcome 에서 파생 — 정책 변경 시 한 함수만 손대면 됨

internal/scheduler/

백그라운드 잡. main.go에서 go (...).Start(ctx)로 spawn.

파일역할
monitor.goDisclosureMonitor — 5분 주기, 장중(KST 8~20시 평일)에만 DART 폴링 → 신규 공시 INSERT + analysis_cache PENDING 생성
scheduler.goAnalysisSchedulerAI_CONCURRENCY 개 영구 submit 워커 (PENDING/BODY_PENDING claim → DART fetch → vLLM SubmitSetLLMResponseID) + 별도 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.goStockSyncScheduler — 매일 KST 5시 KOSPI/KOSDAQ 마스터 동기화 (STOCK_SYNC_SCHEDULER_ENABLED로 토글)

internal/stock/

파일역할
master_sync.goDART 회사 마스터 XML fetch → stocks 테이블 upsert
handler.goGET /stocks/search, GET /companies/{corpCode}

internal/watchlist/

파일역할
handler.goGET/POST/DELETE /watchlist, GET /watchlist/capacity. (repository 분리 안 됨 — 단순 CRUD라 내부에서 SQL 직접 실행)

도메인 동작: domain-watchlist.md.

internal/admob/

파일역할
verifier.goECDSA-SHA256 서명 검증 + Google 공개키 캐시 (KeyCache)
handler.goGET /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.goFirebase App Check JWT 검증. JWK 캐시 6h TTL
middleware.goMiddleware(v, mode, next) — strict/soft 분기. Registry helper 가 카테고리별로 wrap (면제 매트릭스는 helper 선택으로 표현)

도메인 동작: domain-app-check.md.

internal/notification/

파일역할
handler.goPOST /push/fcm-token (FCM 토큰 등록 — fcm_token 단일 소유권을 unique 제약으로 보장), DELETE /push/fcm-token (해지)

internal/admin/

파일역할
backfill.goDART 과거 공시 배치 수집 (관리자용)
handler.goPOST /admin/disclosures/backfill, POST /admin/scheduler/{action}, POST /admin/stocks/sync (Admin 키 보호)

internal/dashboard/

파일역할
handler.goGET /dashboard — 사용자별 대시보드 집계 (관심종목 + 최신 분석)

pkg/middleware/

각 미들웨어는 Registry (pkg/route) 의 helper 가 카테고리별로 조립해 적용한다. 외곽 chain 은 WorkerContext / CORS / JWTAuth 만. HTTPCache 는 Anonymous helper 의 chain 안 (인증 후) 에 적용.

파일역할
auth.goJWTAuth — 토큰 있으면 userID ctx 주입, 없거나 invalid 면 pass-through. RequireAuth — userID 없으면 401 (Registry Authenticated helper 가 사용)
cors.goAccess-Control-Allow-Origin = FRONTEND_URL
ratelimit.goRateLimit (IP 토큰 버킷, LRU 10k 상한) — Registry 가 General / AuthBucket 두 store 로 인스턴스화. SharedBucketRateLimit — 단일 공유 버킷 (Worker 트래픽 단일 IP 수렴 회피용, RPS 50 / Burst 200)
admin_key.goRequireAdminKeyX-Admin-Key 헤더 검증. Registry Admin helper 가 사용
worker_context.goWorkerContextX-Worker-Secret GET 요청에 ctx.IsWorker=true 부착. Anonymous helper 가 보고 AppCheck 면제 + Worker bucket 분기
httpcache.goGET 응답 in-memory 캐싱. CacheRule로 path별 TTL (5m/30s/1m). Anonymous helper 안 (인증 후) 에서 적용 — 인증 실패 응답이 다른 청중에 누출 X

각 파일에 동급의 *_test.go가 있음.

pkg/route/

파일역할
registry.goRegistryhttp.ServeMux 를 감싸 6 카테고리 helper (Authenticated / Anonymous / AuthIssue / Admin / AdMobSSV / Actuator) 만 노출. mux.Handle 직접 호출 차단으로 ACL 누락 회귀 방지

pkg/httputil/

파일역할
response.goJSON 응답 / 에러 응답 헬퍼

컨벤션

  • 레이아웃: 도메인 = 패키지. 한 패키지에 handler.go + repository.go + 도메인 로직 파일 (*_client.go, *_service.go). Java식 service.go 단독 파일은 만들지 않음 — 도메인 로직이 단순하면 handler에 직접, 복잡하면 별도 파일.
  • DI: main.go에서 수동 wiring. wire / fx 등 프레임워크 없음.
  • 에러 핸들링: pkg/httputil/response.goWriteError(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.

새 도메인 추가 체크리스트

  1. internal/<domain>/ 폴더 생성
  2. repository.go (DB 접근), handler.go (HTTP), 필요 시 <domain>_service.go
  3. cmd/server/main.go에서 wiring (repo → service → handler 인스턴스 생성, mux.Handle 등록)
  4. 마이그레이션 추가 (docs/dev/migration-rules.md)
  5. tbls doc --rm-dist 재생성 (db-schema.md)
  6. docs/api/<endpoint>.md 작성, docs/spec/domain-<X>.md 작성
  7. PR 전 docs 업데이트 모두 같은 브랜치에 커밋