도메인: Disclosure (공시 수집/저장/조회 + AI 분석 파이프라인)
개요
DART OpenAPI에서 공시를 수집·저장하고, AI 분석 결과를 analysis_cache 테이블에 캐싱한다. DisclosureMonitor가 주기적으로 신규 공시를 감지하면 AnalysisScheduler가 vLLM으로 분석 후 결과를 저장한다.
데이터 모델 (핵심 테이블)
dartbrief.disclosures
| 컬럼 | 타입 | 설명 |
|---|---|---|
rcept_no | VARCHAR(255) PK | DART 접수번호 (14자리 숫자) |
corp_code | VARCHAR(8) | DART 고유 회사 코드 |
corp_name | VARCHAR(255) | 회사명 |
stock_code | VARCHAR(6) | 종목코드 (nullable) |
corp_type | VARCHAR(10) | 기업 구분 (Y=KOSPI, K=KOSDAQ 등) |
report_name | VARCHAR(255) | 공시 제목 |
submitter | VARCHAR(255) | 제출인 (nullable) |
rcept_dt | VARCHAR(8) | 접수일자 (YYYYMMDD) |
remark | VARCHAR(255) | 비고 (nullable) |
created_at | TIMESTAMP(6) | DB 저장 시각 |
인덱스: corp_code, rcept_dt, (rcept_dt DESC, created_at DESC) (피드 정렬용)
dartbrief.analysis_cache
| 컬럼 | 타입 | 설명 |
|---|---|---|
rcept_no | VARCHAR(255) PK | 공시 접수번호 (FK 없음 — 독립 캐시) |
one_liner | VARCHAR(100) | deprecated — 더 이상 쓰지 않음 (컬럼만 잔존, drop 마이그레이션 별건). summary 로 통합 |
summary | TEXT | AI 한줄요약 — 목록(비게이트) + reveal 본문 상단에 노출 |
key_points_json | TEXT | 핵심 포인트 (||| 구분자로 join된 문자열) |
sentiment | VARCHAR(10) | 감성 분석 (positive/negative/neutral) |
model | VARCHAR(255) | 사용된 LLM 모델명 |
status | VARCHAR(20) | PENDING / PROCESSING / COMPLETED / FAILED |
retry_count | INTEGER | 실패 후 재시도 횟수 |
doc_length | INT | 원문 글자 수 (짧은 공시 면제 판단에 사용) |
created_at | TIMESTAMP(6) | — |
인덱스: status (스케줄러 PENDING 조회용)
동작 플로우
신규 공시 수집 파이프라인
mermaid
sequenceDiagram
participant Monitor as DisclosureMonitor
participant DART as DART OpenAPI
participant DB as PostgreSQL
participant Scheduler as AnalysisScheduler
participant vLLM as vLLM
Note over Monitor: 설정된 interval마다 실행<br/>(장중 시간대 KST 8~20시, 평일만)
Monitor->>DART: FetchDisclosures(today, today, "Y", page=1, count=100)
Monitor->>DART: FetchDisclosures(today, today, "K", page=1, count=100)
DART-->>Monitor: 공시 목록
Note over Monitor: SaveBatch 는 모든 공시 INSERT (카테고리 필터 X)<br/>본문 없는 공시도 피드 노출, 분석 단계에서 UNAVAILABLE 처리
Monitor->>DB: SaveBatch — INSERT ON CONFLICT DO NOTHING
DB-->>Monitor: newOnes (신규 공시 목록)
loop 신규 공시마다
Monitor->>DB: analysis_cache INSERT (status=PENDING)
end
Note over Scheduler: concurrency 개 영구 워커가 끊김 없이 실행<br/>(죽은 워커가 남긴 stale PROCESSING row 는<br/>ClaimPending 이 staleThreshold 기준으로 흡수 회수)
par concurrency 개 워커가 각자 claim → 처리 → 반복
Note over Scheduler: 워커 1개당 일감 1건 claim<br/>(우선순위: PENDING > 재시도 FAILED > BODY_PENDING)
Scheduler->>DB: ClaimPending / ClaimRetryable / ClaimBodyPending (LIMIT 1)
Scheduler->>DB: FindByRceptNo (공시 메타 조회)
Scheduler->>DART: FetchDocumentText (OpenAPI document.xml)
alt OpenAPI 성공 + 본문 있음
Note over Scheduler: 정상 경로
else OpenAPI 실패 (status=014 / zip 에러 / 빈 본문)
Scheduler->>DART: fetchViaViewer (dsaf001/main.do + report/viewer.do)
Note over Scheduler: 웹 viewer 는 API 보다 일찍 인덱싱되는 경우가 많아<br/>인덱싱 지연 케이스 회수율 ↑.
end
alt 둘 다 빈 본문 + 1차가 status=014
Scheduler->>DB: MarkBodyPending (status=BODY_PENDING, retry_count++)
Note over Scheduler: 영구 부재 결론 보류 — DART 정기보고서 인덱싱이<br/>수 시간 지연되는 케이스 다수 (2026-05-13 발견).<br/>워커 풀이 BODY_PENDING 을 회수 (행당 5분 cooldown — ClaimBodyPending).<br/>created_at + 24h 경과 시 MarkFailedPermanent → UNAVAILABLE.
else 둘 다 빈 본문 + status=014 아님 (인덱싱 지연)
Scheduler->>DB: RequeuePending — status=PENDING 유지, retry_count++
Note over Scheduler: LLM 호출 X. 다음 워커가 재시도.<br/>retry_count >= 60 (≈1시간) 도달 시 FAILED.
else 본문 확보 (API or viewer)
Scheduler->>vLLM: Analyze(text)
vLLM-->>Scheduler: Summary, KeyPoints, Sentiment, Model
Scheduler->>DB: MarkCompleted (status=COMPLETED, doc_length 저장)
end
end공시 조회 (사용자 요청)
mermaid
flowchart TD
A[GET /api/v1/disclosures/recent] --> B[FindRecent limit=10~50]
C[GET /api/v1/disclosures?corpCode=X] --> D[FindByCorpCode]
E[GET /api/v1/disclosures?beginDate=X&endDate=Y] --> F[FindByDateRange]
G[GET /api/v1/disclosures/rceptNo] --> H[FindByRceptNo]
I[POST /api/v1/admin/disclosures/fetch] --> J[DART API 직접 호출 후 SaveBatch]
B & D & F & H & J --> K[(disclosures 테이블)]수동 fetch (POST /api/v1/admin/disclosures/fetch)
DART API를 즉시 호출해 DB에 저장한다 (디버깅/응답 확인용 동기 호출). 대량 백필은 /admin/backfill (비동기 + 진행률 추적) 사용. 파라미터:
beginDate,endDate(필수, YYYYMMDD)corpCode(선택 — 있으면corp_cls미설정(전체), 없으면corp_cls=YKOSPI. 특정 기업 필터링 미지원 — DART corpCode를 그대로 전달하지 않음)pageNo(기본 1),pageCount(기본 20)- 인증:
X-Admin-Key필수 (Admin helper).
룰 / 정책
| 항목 | 값 | 환경변수/설정 |
|---|---|---|
| 모니터링 간격 | 설정값 | MONITOR_INTERVAL |
| 모니터링 활성화 | 기본 활성 | DISCLOSURE_MONITOR_AUTO_START=false 로 비활성화 |
| 모니터링 시간대 | KST 8~20시, 평일 | 코드 하드코딩 |
| 분석 스케줄러 활성화 | 기본 비활성 | ANALYSIS_SCHEDULER_AUTO_START=true |
| submit 워커 수 | 설정값 | AI_CONCURRENCY — PENDING → DART fetch → vLLM Submit 까지만 처리하는 영구 워커 수. 동기 LLM 대기 없음 (background:true) |
| 워커 빈 폴링 간격 | 2초 | SCHEDULER_IDLE_POLL_MS — claim 할 일감 없을 때 |
| 폴링 루프 사이클 간격 | 3초 | LLM_POLL_INTERVAL_MS — pollLoop 가 in-flight (PROCESSING + llm_response_id NOT NULL) row 들을 GET 으로 회수하는 주기. fan-out 16 goroutine 으로 한 사이클 최대 inflightPollLimit=200 처리 |
| 분석 최대 재시도 (LLM/일반 에러) | 3회 | 코드: retry_count >= 3 → UNAVAILABLE |
| 본문 미도착 최대 재대기 | 60회 (1시간) | 코드: maxBodyRetries — 초과 시 FAILED |
| BODY_PENDING 최대 보류 시간 | 24h | policy.BodyPendingMaxAge — 초과 시 MarkFailedPermanent (UNAVAILABLE) |
| BODY_PENDING 회수 cooldown | 5분 (행당) | ClaimBodyPending — updated_at < now-5min 인 row 만 워커 풀이 재시도 |
| stale PROCESSING 회수 임계 | 5분 (고정) | staleThreshold — PROCESSING + llm_response_id IS NULL 이면서 이 시간 이상 묶인 row 를 ClaimPending 이 회수. in-flight (id NOT NULL) 는 폴링 루프 책임이라 stale 회수 제외 — 폴링 루프가 죽더라도 부팅 시 ListInflight 가 재포착 |
| job 최대 소요 (jobTimeout) | 90초 (고정) | DART fetch + vLLM Submit 까지의 워커 점유 시간 cap. 초과 시 재시도 가능 FAILED |
| vLLM in-flight 상한 | AI_CONCURRENCY 와 동일 | submit 워커 수 = in-flight cap (단일 knob). cap predicate 는 active inflight 만 집계 — status IN ('PROCESSING','BODY_PROCESSING') AND (llm_response_id IS NOT NULL OR updated_at >= now-staleThreshold). BODY_PROCESSING fresh 도 카운트 — DART 본문 fetch 후 곧바로 vLLM submit 으로 전이하므로 cap 슬롯을 미리 차지해야 PROCESSING 누적이 cap 초과 안 함. stale-NULL row 는 cap 슬롯 점유 X (회수 deadlock 방지). 각 Claim tx 가 pg_advisory_xact_lock(claimLockKey) 로 직렬화 → 다음 tx 는 이전 commit 의 active inflight 를 보고 cap 계산 → 동시 워커 race 가 cap 못 깸 (TestClaimPending_StrictCapUnderConcurrentWorkers, TestClaimPending_StaleProcessingDoesNotConsumeCapSlot, TestClaim_BodyProcessingFreshConsumesCapSlot). cap 도달 시 워커 claim 0건 → idlePoll 후 재시도. <=0 입력 시 defaultInflightCap=60 fallback. Claim* 3종 (Pending/Retryable/BodyPending) 은 private claimWithCap helper 공유 — 골격 1군데, selector/order 만 다름 |
| BODY_PENDING UX (Flutter) | 분석 카드 분기 | 헤더 phosphor Clock + "본문 대기" pill, 본문에 "DART 본문 등록을 기다리는 중이에요" 메시지. 일반 PENDING 의 shimmer skeleton 과 시각적 분기 (PR feat/fe-0513-body-pending-ux, 2026-05-13) |
| analysis_cache in-memory LRU | 5000 entry | AnalysisRepository.cache — FindByRceptNo read-through. COMPLETED row 만 캐시 (immutable — mutation 경로 없음). Inspect+Reveal 의 DB read 2회를 핫 공시 기준 0회로 감소 |
| HTTP 캐시 TTL (공시 원문) | 5분 | main.go CacheRule |
| HTTP 캐시 TTL (최근 피드) | 30초 | main.go CacheRule |
| HTTP 캐시 TTL (기업별 목록) | 1분 | main.go CacheRule |
| LLM 엔드포인트 | 설정값 | LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT_MS (개별 HTTP 호출 timeout — submit/poll 짧음), LLM_POLL_INTERVAL_MS |
| Cloudflare Access | 설정값 | CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET |
KOSPI/KOSDAQ만 모니터링
DisclosureMonitor는 corp_cls=Y(KOSPI), corp_cls=K(KOSDAQ) 두 가지만 조회한다. 비상장사/코넥스 등 기타는 수동 fetch로만 수집 가능.
Edge case
| 케이스 | 동작 |
|---|---|
| 중복 공시 SaveBatch | INSERT ON CONFLICT DO NOTHING — 기존 row 유지 |
본문 없는 카테고리 ([첨부정정], 효력발생안내 등) | disclosures INSERT 그대로 진행 → 피드 노출. 분석 단계에서 OpenAPI status=014 → viewer fallback 시도 → viewer 도 빈 응답이면 MarkBodyPending (BODY_PENDING). 24h 이내 워커 풀이 회수 시도, 미회수 시 MarkFailedPermanent → UI UNAVAILABLE. viewer 에 메타 본문이 있으면 ([첨부정정] 정정 사유 등) 분석 진행. |
| DART OpenAPI status=014 | viewer fallback 시도. viewer 도 본문 없으면 ErrBodyUnavailable → 즉시 영구 부재 마킹 X, BODY_PENDING 으로 보류 (PR #82, 2026-05-13). 워커 풀이 행당 5분 cooldown 으로 재시도 — 정기보고서/정정/대량보유 등은 OpenAPI 인덱싱이 1~6시간 지연되는 케이스 다수. 24h 경과 시 영구 부재 확정. GET /ai/analyze 응답 status=BODY_PENDING (대기) 또는 UNAVAILABLE (확정). |
| OpenAPI 인덱싱 지연 (zip 파싱 실패 / 빈 본문) | viewer fallback 시도 (DART 웹 viewer 가 더 일찍 인덱싱). viewer 도 빈 응답이면 RequeuePending → status=PENDING 유지 + retry_count++ → 다음 워커가 재시도. 60회 누적 시 FAILED. |
| PROCESSING 상태 멈춤 (stuck) | 죽은 워커가 남긴 row. ClaimPending 이 staleThreshold 초과 PROCESSING row 를 claim 대상에 포함해 흡수 회수 (별도 sweep 루프 없음). 정상 배포는 graceful shutdown 으로 in-flight job 마무리 후 종료 → stuck 자체가 거의 안 생김 |
| retry_count >= 3 | GET analyze 응답에서 status=UNAVAILABLE 반환 |
| 장외 시간 (주말/야간) | DisclosureMonitor가 isMarketHours() false → fetch skip |
| 분석 없는 rcept_no | FindByRceptNo → pgx.ErrNoRows → UNAVAILABLE 응답 |
| vLLM 장애 | processOne 에러 → MarkFailed → retry_count + 1 |
관련 API
| 엔드포인트 | 설명 | 인증 |
|---|---|---|
GET /api/v1/disclosures/recent?limit=N | 최근 공시 N개 (최대 50) | 불필요 |
GET /api/v1/disclosures?corpCode=X | 기업별 공시 목록 | 불필요 |
GET /api/v1/disclosures?beginDate=X&endDate=Y | 날짜 범위 공시 | 불필요 |
GET /api/v1/disclosures/{rceptNo} | 공시 단건 조회 | 불필요 |
POST /api/v1/admin/disclosures/fetch | DART API 즉시 수집 (디버깅용) | X-Admin-Key |
GET /api/v1/companies/{corpCode} | 기업 정보 조회 | 불필요 |
GET /api/v1/ai/analyze/{rceptNo} | 분석 메타 + (조건부) 결과 | 필요 |
POST /api/v1/ai/analyze/{rceptNo}/reveal | 분석 결과 reveal + quota 차감 | 필요 |