diff --git a/docs/EXECUTION_PLAN_PHASE0_CLOSEOUT_AND_PHASE1_KICKOFF.md b/docs/EXECUTION_PLAN_PHASE0_CLOSEOUT_AND_PHASE1_KICKOFF.md new file mode 100644 index 00000000..1dfdd4da --- /dev/null +++ b/docs/EXECUTION_PLAN_PHASE0_CLOSEOUT_AND_PHASE1_KICKOFF.md @@ -0,0 +1,968 @@ +# QuantEngine 현대화 실행 계획 +**Phase 0 마무리 + Phase 1 준비** (2026-07-24 ~ 2026-09-30) + +--- + +## Executive Overview + +**현재 상태**: Phase 0 ✅ 기술적 기초 완료 +- CI/CD 파이프라인 리팩토링 (9-job parallel, ~15-20min) ✅ +- CLAUDE.md 종합 문서화 ✅ +- 현대화 로드맵 수립 ✅ + +**목표**: Phase 0 운영 검증 + Phase 1 (데이터 아키텍처 고도화) 착수 +**기간**: 2026-07-24 ~ 2026-09-30 (9주) +**리소스**: 1 FTE (클로드 코드) + 팀 지원 + +--- + +## Part 1: Phase 0 운영 검증 (Jul 24 - Aug 31) — 4주 + +### 목표 +현대화 로드맵의 기초가 견고한지 검증 + +### 1.1 CI/CD 파이프라인 안정성 검증 + +#### Task 1.1.1: 실제 워크플로우 성능 측정 +**목표**: 예상 15-20분이 실제 달성되는지 확인 + +**구체적 작업**: +```yaml +Week 1 (Jul 24-31): + - Commit 3-5개 추가 (다양한 변경 유형) + * C# 코드 변경 + * Python 스크립트 변경 + * 데이터베이스 마이그레이션 추가 + * YAML 워크플로우 변경 + + - 각 CI 실행 로그 분석: + ├─ core job 시간 (DB 마이그레이션 포함) + ├─ 병렬 job 시간 (wbs-audit, dotnet-contracts, ui-storage, etc.) + ├─ notify-results 시간 + └─ 총 벽시간 (wall clock time) + + - 병목 지점 식별: + * 만약 core > 10분: DB 마이그레이션 최적화 필요 + * 만약 any parallel > 8분: 해당 job 분할 검토 + * 만약 total > 25분: 추가 병렬화 또는 검증 제거 검토 + +Expected output: "CI Performance Baseline 2026-07-31.json" +``` + +**SOLID 원칙 적용**: +- **Single Responsibility**: 각 job은 하나의 검증만 담당 +- **Dependency Inversion**: 모든 job이 동등하게 core에만 의존 (필요시) + +#### Task 1.1.2: 워크플로우 재현성 검증 +**목표**: 같은 커밋에서 CI 실행 결과가 항상 동일한지 확인 + +**구체적 작업**: +```python +# tools/verify_ci_reproducibility_v1.py +class CIReproducibilityValidator: + def test_same_commit_same_result(self, commit_sha): + """ + 같은 커밋을 2번 이상 재실행하여 결과 비교 + - All jobs: PASS or FAIL 결과 동일 + - Test output: 정확히 일치 + - Build artifacts: 바이너리 동일 (deterministic build) + """ + results = [] + for run in range(3): + result = self.trigger_ci(commit_sha) + results.append(result) + + assert all(r == results[0] for r in results), \ + "CI results not reproducible!" + + return True + + def test_no_hidden_state(self): + """ + CI가 외부 상태에 의존하지 않는지 확인 + - 시간에 따른 결과 변화 없음 (timestamp-independent) + - 환경변수 없어도 성공 (except secrets) + - 테스트 데이터 일관성 (seed 고정) + """ + pass + +# CI에 추가할 Step +ci.yml: + - name: "Verify CI Reproducibility" + run: python3 tools/verify_ci_reproducibility_v1.py +``` + +**목표 지표**: +- ✅ 3회 연속 재실행 성공률: 100% +- ✅ 결과 일관성: 100% (no flaky tests) +- ✅ Deterministic build: 바이너리 hash 일치 + +--- + +### 1.2 데이터 일관성 기초 다지기 + +#### Task 1.2.1: PostgreSQL 이력 테이블 설계 및 구현 +**목표**: 모든 데이터 변경의 감시 추적(audit trail) 기초 마련 + +**구체적 작업**: +```sql +-- src/dotnet/QuantEngine.Infrastructure/Migrations/V003_add_audit_trail.sql + +-- 이력 테이블 템플릿 +CREATE TABLE kis_collection_runs_audit ( + id BIGSERIAL PRIMARY KEY, + run_id UUID NOT NULL, -- 원본 테이블의 FK + action VARCHAR(10) NOT NULL, -- INSERT, UPDATE, DELETE + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + changed_by VARCHAR(256), -- 누가? (user ID 또는 "scheduler") + change_reason TEXT, -- 왜? (migration, manual edit, etc.) + + -- 변경 전/후 스냅샷 + old_values JSONB, -- 변경 전 전체 row + new_values JSONB, -- 변경 후 전체 row + + INDEX (run_id, changed_at DESC), + INDEX (changed_by, changed_at DESC) +); + +-- kis_collection_snapshots_audit 유사 구조 +CREATE TABLE kis_collection_snapshots_audit ( + id BIGSERIAL PRIMARY KEY, + snapshot_id UUID NOT NULL, + action VARCHAR(10) NOT NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + changed_by VARCHAR(256), + change_reason TEXT, + old_values JSONB, + new_values JSONB, + INDEX (snapshot_id, changed_at DESC) +); + +-- Trigger: kis_collection_snapshots 변경 시 자동 기록 +CREATE OR REPLACE FUNCTION kis_collection_snapshots_audit_trigger() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + INSERT INTO kis_collection_snapshots_audit (snapshot_id, action, changed_by, new_values) + VALUES (NEW.id, 'INSERT', CURRENT_USER, row_to_json(NEW)); + ELSIF TG_OP = 'UPDATE' THEN + INSERT INTO kis_collection_snapshots_audit (snapshot_id, action, old_values, new_values) + VALUES (NEW.id, 'UPDATE', row_to_json(OLD), row_to_json(NEW)); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER kis_collection_snapshots_after_change +AFTER INSERT OR UPDATE ON kis_collection_snapshots +FOR EACH ROW +EXECUTE FUNCTION kis_collection_snapshots_audit_trigger(); +``` + +**C# Repository 패턴 (Wrapper)**: +```csharp +public class AuditedSnapshotRepository : ISnapshotRepository +{ + private readonly ISnapshotRepository _inner; + private readonly IAuditLogger _audit; + + public async Task SaveSnapshotAsync(SnapshotDto snapshot, string changedBy, string reason) + { + // 변경 전 상태 저장 + var before = await _inner.GetAsync(snapshot.Id); + + // 실제 저장 + await _inner.SaveAsync(snapshot); + + // 감시 추적 기록 + await _audit.LogChangeAsync(new AuditEntry + { + EntityId = snapshot.Id, + EntityType = "Snapshot", + Action = "UPDATE", + ChangedBy = changedBy, + ChangeReason = reason, + OldValues = before, + NewValues = snapshot, + ChangedAt = DateTime.UtcNow + }); + } +} +``` + +**성과지표**: +- ✅ 모든 kis_* 테이블에 이력 추적 활성화 +- ✅ 이력 조회 API 구현 (`/api/audit/logs?entity=snapshot&id=...`) +- ✅ 수동 개입 추적: who, when, why 100% 기록 + +#### Task 1.2.2: 데이터 정합성 검증 자동화 +**목표**: 매일 자동으로 데이터 품질 점검 + +**구체적 작업**: +```python +# tools/validate_data_consistency_daily_v1.py + +class DailyDataConsistencyValidator: + def validate_kis_snapshots(self): + """ + kis_collection_snapshots 데이터 품질 검사 + """ + issues = [] + + # 1. 완전성 (Completeness) + total = self.db.query("SELECT COUNT(*) FROM kis_collection_snapshots") + nulls = self.db.query("SELECT COUNT(*) FROM kis_collection_snapshots WHERE price IS NULL") + completeness = (total - nulls) / total * 100 + if completeness < 95: + issues.append(f"Completeness low: {completeness:.1f}%") + + # 2. 신선도 (Freshness) + latest = self.db.query("SELECT MAX(created_at) FROM kis_collection_snapshots") + age_hours = (now() - latest).total_seconds() / 3600 + if age_hours > 25: + issues.append(f"Data stale: {age_hours:.1f} hours old") + + # 3. 정합성 (Consistency) — bid <= mid <= ask + invalid = self.db.query(""" + SELECT COUNT(*) FROM kis_collection_snapshots + WHERE NOT (bid <= price AND price <= ask) + """) + if invalid > 0: + issues.append(f"Bid-mid-ask consistency violated: {invalid} rows") + + # 4. 이상값 (Outliers) — 3-sigma rule + stats = self.db.query(""" + SELECT + AVG(price) as mean, + STDDEV(price) as std + FROM kis_collection_snapshots + WHERE created_at > NOW() - INTERVAL 30 DAY + """) + outliers = self.db.query(""" + SELECT COUNT(*) FROM kis_collection_snapshots + WHERE ABS(price - %s) > 3 * %s + """, stats.mean, stats.std) + outlier_pct = outliers / total * 100 + if outlier_pct > 5: + issues.append(f"Outliers detected: {outlier_pct:.1f}%") + + # 5. 중복 검사 (Duplicates) + duplicates = self.db.query(""" + SELECT COUNT(*) - COUNT(DISTINCT ticker, created_at) + FROM kis_collection_snapshots + WHERE created_at > NOW() - INTERVAL 1 DAY + """) + if duplicates > 0: + issues.append(f"Duplicates found: {duplicates} rows") + + return { + "timestamp": now(), + "completeness_pct": completeness, + "freshness_hours": age_hours, + "consistency_violations": invalid, + "outliers_pct": outlier_pct, + "duplicates": duplicates, + "status": "PASS" if not issues else "FAIL", + "issues": issues + } + +# 매일 cron으로 실행 (kis_data_collection.yml 확장) +# Slack 알림: completeness < 95% 또는 freshness > 25h +``` + +**CI 게이트로 추가**: +```yaml +# .gitea/workflows/kis_data_collection.yml (기존) → 확장 +- name: "Validate Daily Data Consistency" + run: python3 tools/validate_data_consistency_daily_v1.py --mode strict + # strict mode: 모든 게이트 PASS 필요 +``` + +**성과지표**: +- ✅ 자동 데이터 품질 점검 일일 1회 +- ✅ 신선도, 완전성, 정합성, 이상값 추적 +- ✅ 수동 개입 필요 시 → Slack 알림 자동화 + +--- + +### 1.3 운영 안정성 검증 + +#### Task 1.3.1: 배포 프로세스 엔드-투-엔드 테스트 +**목표**: 실제 배포까지 자동화 검증 + +**구체적 작업**: +```bash +# 시나리오 1: 정상 배포 +1. Local build (Release) → 성공 +2. E2E 테스트 → 성공 +3. Admin 페이지 모두 200 응답 +4. git push main +5. CI 모든 job 통과 +6. prepare-release.yml 수동 실행 + → Gitea Release 생성 (v0.1.20260731.0.abc1234) +7. deploy-prod.yml 수동 실행 + → SSH 배포 + 6점 health check +8. 검증: + - Login 페이지 로드 ✓ + - CSS/JS 로드 ✓ + - Service active ✓ + - DB 연결 ✓ + - Release tag 일치 ✓ + +# 시나리오 2: 배포 실패 및 롤백 +1. Deploy 중단 (health check 실패) +2. 이전 버전 확인: ln -sfn quantengine_20260718_abc1234 +3. systemctl restart quantengine +4. Health check 재실행 → 통과 + +# 시나리오 3: 데이터베이스 마이그레이션 +1. V003_add_audit_trail.sql 배포 +2. 기존 데이터 호환성 확인 + - SELECT COUNT(*) FROM kis_collection_runs (레코드 동일) + - Audit 트리거 작동 확인 +3. Rollback 계획 검증 + - DROP TRIGGER / DROP TABLE 스크립트 준비 + - 테스트 환경에서 실행 +``` + +**체크리스트 작성**: +```markdown +# docs/DEPLOYMENT_VERIFICATION_CHECKLIST.md + +## Pre-Deployment +- [ ] Local build: 0 errors, 0 warnings +- [ ] E2E tests: all pass +- [ ] Admin pages: /Dashboard, /Users, /Collection → 200 +- [ ] git status: clean (no uncommitted changes) +- [ ] git log: all commits pushed to origin + +## Release Creation (prepare-release.yml) +- [ ] Workflow status: SUCCESS +- [ ] Gitea Release created (v0.1.YYYYMMDD.N.hash) +- [ ] Artifact downloaded locally (for manual verification) +- [ ] Checksum validated: `sha256sum -c artifact.sha256` + +## Production Deployment (deploy-prod.yml) +- [ ] SSH connection: successful +- [ ] Artifact uploaded: confirmed on server +- [ ] Extract & symlink: verified +- [ ] Service restart: active + +## Health Checks (6-point) +- [ ] HTTP 200: GET /Account/Login +- [ ] Login page content: contains "login" or "로그인" +- [ ] CSS: GET /css/admin.css → 200 +- [ ] Service: systemctl is-active quantengine → active +- [ ] Release tag: matches deployed version +- [ ] DB auth: journalctl -u quantengine (no 28P01 errors) + +## Post-Deployment Verification +- [ ] Live app accessible: https://quant.taxbaik.com/ +- [ ] Admin pages load: /Admin/Dashboard → 200 +- [ ] API responds: /api/collection/state → 200 +- [ ] Monitoring active: Prometheus/Grafana (if enabled) +``` + +**성과지표**: +- ✅ 3회 연속 배포 성공 (prepare-release + deploy-prod) +- ✅ 배포 실패 시 자동 롤백 검증 +- ✅ 배포 시간 추적: <30분 total + +--- + +## Part 2: Phase 1 준비 (Sep 1-30) — 5주 + +### 목표 +데이터 정규화 설계 완료 및 첫 마이그레이션 준비 + +### 2.1 데이터 정규화 설계 + +#### Task 2.1.1: 3NF 스키마 설계 및 검증 +**목표**: 현재 비정규 kis_collection_snapshots → 3NF로 재설계 + +**구체적 작업**: +```sql +-- Current (비정규화) — kis_collection_snapshots +-- 100+ columns: ticker, price, volume, bid1-5, ask1-5, pe_ratio, eps, ... + +-- Target (3NF) — 테이블 분리 +CREATE TABLE stocks ( + id UUID PRIMARY KEY, + ticker VARCHAR(10) NOT NULL UNIQUE, + name VARCHAR(256), + market VARCHAR(20), -- KOSPI, KOSDAQ, KONEX + created_at TIMESTAMPTZ, + INDEX (ticker) +); + +CREATE TABLE quotes ( + id UUID PRIMARY KEY, + stock_id UUID NOT NULL REFERENCES stocks(id), + timestamp TIMESTAMPTZ NOT NULL, + price DECIMAL(15,2) NOT NULL, + volume BIGINT, + source VARCHAR(50), -- KIS, Naver, Yahoo + created_at TIMESTAMPTZ, + FOREIGN KEY (stock_id) REFERENCES stocks(id), + INDEX (stock_id, timestamp DESC), + INDEX (timestamp) +); + +CREATE TABLE order_book ( + id UUID PRIMARY KEY, + quote_id UUID NOT NULL REFERENCES quotes(id), + bid_prices DECIMAL(15,2)[] NOT NULL, -- [bid1, bid2, ..., bid5] + bid_sizes BIGINT[] NOT NULL, + ask_prices DECIMAL(15,2)[] NOT NULL, + ask_sizes BIGINT[] NOT NULL, + FOREIGN KEY (quote_id) REFERENCES quotes(id), + INDEX (quote_id) +); + +CREATE TABLE fundamentals ( + id UUID PRIMARY KEY, + stock_id UUID NOT NULL REFERENCES stocks(id), + as_of_date DATE NOT NULL, + eps DECIMAL(15,4), + pe_ratio DECIMAL(15,2), + dividend DECIMAL(15,2), + book_value DECIMAL(15,2), + FOREIGN KEY (stock_id) REFERENCES stocks(id), + UNIQUE (stock_id, as_of_date), + INDEX (stock_id) +); +``` + +**정규화 검증**: +```python +# tools/validate_schema_normalization_v1.py + +class NormalizationValidator: + def validate_3nf(self): + """ + 3NF 검증: + 1. 1NF: 모든 테이블이 atomic values만 포함 + 2. 2NF: 비키 속성이 전체 키에 의존 (partial dependency 없음) + 3. 3NF: 비키 속성이 다른 비키 속성에 의존하지 않음 (transitive dependency 없음) + """ + issues = [] + + # 1NF: 배열/객체 타입 확인 (JSON 제외 대부분) + for table in self.db.tables: + for col in table.columns: + if col.type in ['array', 'object']: + if col.name not in ['bid_prices', 'ask_prices', 'bid_sizes', 'ask_sizes']: + issues.append(f"1NF violation: {table}.{col} is {col.type}") + + # 2NF: Foreign Key 의존성 확인 + for table in self.db.tables: + for col in table.columns: + if col.is_foreign_key: + # 비키 속성이 전체 키에만 의존하는지 확인 + if not self._depends_on_full_key(table, col): + issues.append(f"2NF violation: {table}.{col} partial dependency") + + # 3NF: 비키 속성 간 의존성 확인 + for table in self.db.tables: + for col in table.columns: + if not col.is_key and not col.is_foreign_key: + for other_col in table.columns: + if not other_col.is_key and col != other_col: + if self._functionally_dependent(col, other_col): + issues.append(f"3NF violation: {table}.{col} depends on {other_col}") + + return { + "status": "PASS" if not issues else "FAIL", + "issues": issues, + "tables_checked": len(self.db.tables) + } +``` + +**과유불급(YAGNI) 원칙 적용**: +- ✅ 필요한 분리만: 100+ columns → 5개 주요 테이블 +- ✅ 과도한 정규화 금지: 과도한 조인 피함 +- ❌ 조회 성능 향상 위해 의도적 역정규화는 나중 (벤치마크 후) + +**성과지표**: +- ✅ 3NF 검증 통과 (1NF, 2NF, 3NF 모두) +- ✅ 데이터 무결성 제약 정의 (FK, CHECK, UNIQUE) +- ✅ 스토리지 절감 예상: 40% (column 중복 제거) + +#### Task 2.1.2: 마이그레이션 전략 수립 (Blue-Green Deployment) +**목표**: 무중단 데이터 마이그레이션 계획 + +**구체적 작업**: +```markdown +# 마이그레이션 전략: Blue-Green (Parallel Run) + +## Phase 1: Prepare (1주) +1. 새 테이블 생성 (stocks, quotes, order_book, fundamentals) +2. 데이터 변환 로직 구현 + - kis_snapshots → stocks/quotes/order_book 변환 + - 데이터 검증 (row count, aggregates) +3. 테스트 환경에서 전체 마이그레이션 실행 및 검증 + +## Phase 2: Dual Write (1주) +1. 애플리케이션 수정: 새 테이블에도 INSERT/UPDATE + ```csharp + await _legacyRepository.SaveAsync(snapshot); // 기존 + await _normalizedRepository.SaveAsync(snapshot); // 신규 + ``` +2. 두 테이블 데이터 정합성 비교 + - SELECT COUNT(*) 일치 확인 + - Aggregates (SUM, AVG) 일치 확인 +3. 한 주일 운영: 모든 쿼리가 일관된 결과 반환하는지 확인 + +## Phase 3: Read Cutover (1주) +1. 읽기(SELECT) 쿼리를 새 테이블에서 수행 시작 + ```csharp + // Before + var snapshot = await _legacyRepository.GetAsync(id); + + // After + var snapshot = await _normalizedRepository.GetAsync(id); + ``` +2. API 응답이 동일한지 검증 +3. 성능 비교: 새 테이블 쿼리가 더 빠른지 확인 + +## Phase 4: Write Cutover (1주) +1. 쓰기(INSERT/UPDATE) 쿼리도 새 테이블만 사용 +2. 기존 테이블은 읽기 전용으로 전환 +3. Dual write 제거 + +## Phase 5: Cleanup (1주) +1. 기존 테이블 백업: kis_snapshots_archived_20260930 +2. 모니터링: 일주일 후에도 안정적인지 확인 +3. 필요시 기존 테이블 제거 +``` + +**Adapter Pattern으로 호환성 유지**: +```csharp +public class LegacySnapshotAdapter : ISnapshotRepository +{ + private readonly IQuoteRepository _newQuotes; + + public async Task GetAsync(string ticker) + { + // 새 테이블에서 읽음 + var quote = await _newQuotes.GetLatestAsync(ticker); + + // 기존 SnapshotDto 형식으로 변환 + return new SnapshotDto + { + Ticker = quote.Stock.Ticker, + Price = quote.Price, + Volume = quote.Volume, + Bid = quote.OrderBook.BidPrices[0], + Ask = quote.OrderBook.AskPrices[0], + // ... 나머지 100+ 필드들도 매핑 + }; + } +} + +// 사용처: API, Controller는 변경 없음 +public class CollectionApiEndpoints +{ + public async Task GetSnapshot(string ticker) + { + var snapshot = await _repository.GetAsync(ticker); // 자동으로 새 테이블 사용 + return Ok(snapshot); + } +} +``` + +**성과지표**: +- ✅ 마이그레이션 계획 상세 정의 +- ✅ Rollback 프로세스 테스트 +- ✅ 예상 다운타임: 0분 (무중단) + +--- + +### 2.2 SOLID 원칙 적용 설계 + +#### Task 2.2.1: Repository 인터페이스 분리 (Interface Segregation) +**목표**: 비대한 ICollectionRepository → 작은 책임의 인터페이스로 분리 + +**구체적 작업**: +```csharp +// BEFORE (ISP 위반) +public interface ICollectionRepository +{ + Task GetSnapshotAsync(string ticker); + Task GetRunAsync(Guid runId); + Task GetErrorAsync(Guid errorId); + Task SaveSnapshotAsync(SnapshotDto snapshot); + Task SaveRunAsync(RunDto run); + Task DeleteErrorAsync(Guid errorId); +} + +// AFTER (ISP 준수) +public interface IQuoteRepository +{ + Task GetLatestAsync(string ticker); + Task> GetHistoryAsync(string ticker, DateRange range); + Task SaveAsync(QuoteDto quote); +} + +public interface ICollectionRunRepository +{ + Task GetAsync(Guid runId); + Task> GetRecentAsync(int limit); + Task SaveAsync(RunDto run); +} + +public interface ICollectionErrorRepository +{ + Task GetAsync(Guid errorId); + Task> GetByRunAsync(Guid runId); + Task SaveAsync(ErrorDto error); +} + +public interface IStockRepository +{ + Task GetByTickerAsync(string ticker); + Task> GetAllAsync(); +} + +// 사용처 +public class CollectionService +{ + private readonly IQuoteRepository _quotes; + private readonly ICollectionRunRepository _runs; + private readonly ICollectionErrorRepository _errors; + + public CollectionService( + IQuoteRepository quotes, + ICollectionRunRepository runs, + ICollectionErrorRepository errors) + { + _quotes = quotes; + _runs = runs; + _errors = errors; + } + + // 각 메서드는 필요한 인터페이스만 사용 +} +``` + +**성과지표**: +- ✅ 불필요한 메서드 의존성 제거 +- ✅ 테스트 편의성: Mock 주입 간단 +- ✅ 변경 영향도 최소화 + +#### Task 2.2.2: Dependency Inversion 구현 (DI Container) +**목표**: 고수준 모듈이 저수준 모듈에 의존하지 않기 + +**구체적 작업**: +```csharp +// Program.cs (DI 설정) +services + // Repository abstraction + .AddScoped(sp => + new AuditedQuoteRepository( + new QuoteRepository(sp.GetRequiredService()), + sp.GetRequiredService())) + + // Data source abstraction (Strategy pattern) + .AddScoped(sp => + new DataSourceFactory( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())) + + // Fallback chain + .AddScoped(sp => + new FallbackQuotationService( + new KisQuotationService(sp.GetRequiredService()), + new NaverQuotationService(sp.GetRequiredService()), + new YahooQuotationService(sp.GetRequiredService()))) + + // Validation + .AddScoped(sp => + new DataQualityValidator(sp.GetRequiredService())) + + .AddScoped(); + +// CollectionService (고수준)는 세부 구현을 모름 +public class CollectionService +{ + private readonly IQuotationService _quotation; // 추상화만 의존 + private readonly IQuoteRepository _repository; // 추상화만 의존 + + public async Task RunAsync() + { + // 구체적 구현은 DI container가 주입 + var quote = await _quotation.GetAsync("005930"); + await _repository.SaveAsync(quote); + } +} +``` + +**성과지표**: +- ✅ 느슨한 결합 (Loose coupling) +- ✅ 런타임 구성 가능 (Strategy switching) +- ✅ 테스트 용이 (Mock 쉽게 주입) + +--- + +### 2.3 패턴 및 표준 정립 + +#### Task 2.3.1: Architecture Decision Records (ADR) 작성 +**목표**: 왜 이런 선택을 했는가? 의사결정 기록 + +**구체적 작업**: +```markdown +# docs/adr/0003-3nf-normalization.md + +## Status +ACCEPTED + +## Context +현재 kis_collection_snapshots 테이블이 비정규화되어 있음: +- 100+ columns (price, bid1-5, ask1-5, eps, pe_ratio, ...) +- 데이터 중복 (ticker는 매번 저장) +- 업데이트 이상 (fundamentals 변경 시 모든 행 수정) +- 스토리지 비효율 (같은 데이터 반복) + +## Decision +PostgreSQL 스키마를 3NF로 정규화: +- stocks: 종목 마스터 (ticker, name, market) +- quotes: 시세 (stock_id, timestamp, price, volume) +- order_book: 호가 (quote_id, bid/ask arrays) +- fundamentals: 재무 (stock_id, eps, pe_ratio, ...) + +## Consequences +**Positive**: +- 스토리지 40% 감소 +- 데이터 무결성 자동 보장 (FK 제약) +- 업데이트 이상 제거 +- 명확한 데이터 의미 (각 테이블이 하나의 개념 표현) + +**Negative**: +- JOIN 증가 (성능 영향, 인덱싱으로 완화) +- 마이그레이션 복잡도 증가 (blue-green 필요) + +## Alternatives Considered +1. 비정규화 유지 + 인덱싱만 개선 (rejected: 장기 유지 어려움) +2. 부분 정규화 (1NF만) (rejected: 불완전) + +## Implementation +- Phase 1a (Sep): 새 테이블 생성 + 검증 +- Phase 1b (Oct): Blue-green 마이그레이션 +- Phase 1c (Nov): 기존 테이블 아카이빙 +``` + +**추가 ADR들**: +``` +docs/adr/ +├── 0001-razor-pages-over-wasm.md +├── 0002-dapper-orm-not-ef.md +├── 0003-3nf-normalization.md +├── 0004-game-theoretic-portfolio.md +├── 0005-audit-trail-every-change.md +└── 0006-fallback-data-sources.md +``` + +**성과지표**: +- ✅ 5개 이상의 ADR 작성 +- ✅ 팀 검토 및 승인 +- ✅ CLAUDE.md에 ADR 참조 추가 + +#### Task 2.3.2: Code Style Guide 작성 +**목표**: "이 프로젝트에서는 이렇게 코딩한다" + +**구체적 작업**: +```markdown +# CODING_STANDARDS.md + +## C# Guidelines + +### Repository Pattern +```csharp +// DO +public interface IQuoteRepository +{ + Task GetByTickerAsync(string ticker); + Task SaveAsync(QuoteDto quote); +} + +// DON'T +public interface IRepository +{ + T Get(object id); + void Save(T entity); +} +``` + +### Error Handling +```csharp +// DO: Validate at boundary (API input) +[HttpPost] +public async Task CreateSnapshot(SaveSnapshotRequest request) +{ + var validation = new SaveSnapshotValidator().Validate(request); + if (!validation.IsValid) return BadRequest(validation.Errors); + // ... +} + +// DO: Trust internal guarantees +public class QuoteRepository +{ + public async Task SaveAsync(QuoteDto quote) + { + // quote가 null이 아님을 가정 (caller가 검증함) + await _db.SaveAsync(quote); + } +} + +// DON'T: Unnecessary defensive checks +if (quote != null && !quote.IsEmpty()) // 불필요 +{ + // ... +} +``` + +### Comments +```csharp +// DON'T: 무엇을 하는지 설명 (코드가 이미 말함) +// 가격을 저장한다 +await _repository.SaveAsync(quote); + +// DO: 왜 이렇게 하는지 설명 +// KIS API는 대체로 가격을 30분 지연해서 보고하므로, +// 최신 3시간 데이터만 보관하여 조회 성능 향상 +const int RETENTION_HOURS = 3; +``` + +## Python Guidelines + +### Data Validation +```python +# DO: 파이프라인 입구에서만 검증 +def collect_quotes(raw_data: List[Dict]): + """raw_data는 이미 스키마 검증됨""" + quotes = [Quote(**item) for item in raw_data] + return quotes + +# DON'T: 모든 곳에서 검증 +def process_quote(q: Quote): + if q is None: # 불필요 + return + if q.price < 0: # 불필요 (Quote 생성 시 이미 검증) + return +``` + +### Test Data +```python +# DO: seed 고정 (재현성) +np.random.seed(42) +test_data = np.random.normal(100, 15, 1000) + +# DON'T: 시간에 따른 변화 +test_timestamp = datetime.now() # ❌ 매번 다름 +``` + +## SQL Guidelines + +```sql +-- DO: 매개변수화된 쿼리 +SELECT * FROM quotes WHERE ticker = @ticker AND date > @startDate + +-- DON'T: 문자열 연결 (SQL injection 위험) +SELECT * FROM quotes WHERE ticker = '" + ticker + "'" + +-- DO: 명확한 의도 +CREATE INDEX idx_quotes_lookup ON quotes(stock_id, timestamp DESC); +-- 인덱스 이름이 쿼리 의도를 반영 (stock_id로 최신부터) + +-- DO: 트랜잭션 명시 +BEGIN TRANSACTION; +INSERT INTO quotes (...) VALUES (...); +INSERT INTO quotes_audit (...) VALUES (...); +COMMIT; +``` + +## Naming Conventions + +| 대상 | 규칙 | 예 | +|------|------|-----| +| 클래스 | PascalCase | `QuoteRepository`, `DailyDataValidator` | +| 메서드 | PascalCase (verb-noun) | `GetQuoteAsync`, `ValidateDataAsync` | +| 속성 | PascalCase | `StockId`, `CollectedAt` | +| 지역변수 | camelCase | `quoteList`, `isValid` | +| 상수 | UPPER_SNAKE_CASE | `MAX_RETRIES`, `DEFAULT_TIMEOUT` | +| 인터페이스 | I + PascalCase | `IQuoteRepository`, `IDataValidator` | +| DB 테이블 | snake_case (단수) | `kis_quote`, `collection_run` | +| DB 컬럼 | snake_case | `created_at`, `stock_id` | + +``` + +**성과지표**: +- ✅ Code style guide 작성 및 승인 +- ✅ Pre-commit hook 추가 (자동 스타일 체크) +- ✅ 팀 리뷰 시간 30% 단축 (기준 명확) + +--- + +## Part 3: 통합 성과 추적 + +### 주간 진행도 추적표 (2026-07-24 ~ 2026-09-30) + +``` +Week Phase Task Status Owner Target Date +───────────────────────────────────────────────────────────────────────────── +1 P0.V CI performance measurement 🔄 Team 2026-07-31 +2 P0.V Reproducibility validation 🔄 Team 2026-08-07 +3 P0.V Data consistency audit table ▶ Team 2026-08-14 +4 P0.V Deployment e2e test ▶ Team 2026-08-21 + +5 P0.V Daily data quality check ▶ Team 2026-08-28 +6 P0.V Phase 0 validation complete 🔲 Team 2026-08-31 + +7 P1.D Schema normalization design 🔲 Claude 2026-09-07 +8 P1.D 3NF validation tool 🔲 Claude 2026-09-14 +9 P1.D Blue-green migration plan 🔲 Claude 2026-09-21 + +10 P1.P Repository interface design 🔲 Claude 2026-09-28 +11 P1.P ADR & style guide 🔲 Team 2026-09-30 +``` + +### 리스크 추적 + +| 리스크 | 영향 | 확률 | 완화 계획 | 담당 | +|-------|------|------|---------|------| +| CI 성능 개선 못 함 | 높음 | 낮음 | 병렬화 추가 검토 | Team | +| 데이터 마이그레이션 실패 | 매우높음 | 중간 | Blue-green test 철저 | Claude | +| 팀 역량 부족 | 중간 | 중간 | Phase 우선순위 조정 | Owner | +| KIS API 변경 | 중간 | 낮음 | Adapter + fallback 활성 | Team | + +--- + +## 최종 성공 기준 (2026-09-30) + +``` +✅ Phase 0 운영 검증 완료 + - CI: 실제 15-20분 달성 확인 + - 배포: 3회 연속 성공 + 롤백 검증 + - 재현성: 3회 연속 CI 같은 결과 + +✅ Phase 1 설계 및 준비 완료 + - 3NF 스키마: 설계 + 검증 완료 + - 마이그레이션 계획: 상세 blue-green 전략 수립 + - SOLID 설계: Repository 분리 + DI 설계 완료 + - 표준화: ADR 5개 + Style guide 승인 + +✅ 팀 준비 완료 + - Phase 1 리소스 할당 확정 + - 마이그레이션 리스크 공유 및 대응 계획 수립 + - CLAUDE.md Phase 1 업데이트 + +🚀 Phase 1 시작 준비: 2026-10-01 +``` + +--- + +**Document Version**: 1.0 +**Status**: Ready for Execution +**Next Review**: Weekly (every Monday) +**Emergency Contact**: Claude Code (@claude)