# v4: 재무 데이터 구조 설계 ## 이번 단계에서 할 일 1. 시트 구조 정의 (원본 데이터 / 분석 / 대시보드) 2. 카테고리 매핑 테이블 구현 3. 데이터 검증 로직 추가 4. 시트 템플릿 자동 생성 구현 ## 1. 전체 시트 구조 설계 ### 스프레드시트 구성 | 시트명 | 역할 | 내용 | |--------|------|------| | **RawData** | 원본 데이터 | CSV import된 거래 내역 | | **MonthlySummary** | 월별 분석 | 월별 수입/지출/저축 요약 | | **CategoryAnalysis** | 카테고리별 분석 | 카테고리별 지출 분석 | | **Dashboard** | 대시보드 | KPI + 차트 (v8~v10에서 완성) | | **AIInsights** | AI 분석 | Vertex AI 인사이트 (v11) | | **Config** | 설정 | 카테고리 매핑, 예산 설정 | ### 시트 간 관계도 ``` ┌─────────────────┐ │ RawData │ ─────────────────────────────────┐ │ (원본 거래) │ │ └────────┬────────┘ │ │ │ ▼ │ ┌─────────────────┐ ┌─────────────────┐ │ │ MonthlySummary │ │ CategoryAnalysis │ │ │ (월별 집계) │ │ (카테고리 집계) │ │ └────────┬────────┘ └────────┬────────┘ │ │ │ │ └──────────┬───────────┘ │ │ │ ▼ ▼ ┌─────────────────┐ ┌─────────────────┐ │ Dashboard │ │ AIInsights │ │ (시각화+KPI) │ │ (AI 분석 결과) │ └─────────────────┘ └─────────────────┘ ``` ## 2. 각 시트 상세 설계 ### 2.1 RawData (원본 데이터) ``` | A | B | C | D | E | F | G | |----------|--------|----------|-------------|--------|-------------|----------------| | date | type | category | subcategory | amount | description | payment_method | | 2024-01-02 | expense | 교통비 | 전철/버스 | 1074 | JR | 계좌이체 | | 2024-01-02 | expense | 엔터테인먼트 | 영화 | 4565 | イオンシネマ | PayPay | | ... | | | | | | | ``` ### 2.2 MonthlySummary (월별 요약) ``` | A | B | C | D | E | F | G | |---------|------------|------------|------------|---------|------------|------------| | 월 | 총 수입 | 총 지출 | 순 저축 | 저축률 | 거래 건수 | 전월 대비 | | 2024-01 | 358,432 | 352,456 | 5,976 | 1.7% | 45 | - | | 2024-02 | 345,678 | 298,432 | 47,246 | 13.7% | 42 | +690.7% | | ... | | | | | | | | 연간합계 | 5,343,495 | 4,224,666 | 1,118,829 | 20.9% | 544 | | ``` ### 2.3 CategoryAnalysis (카테고리별 분석) ``` | A | B | C | D | E | F | |-------------|-------------|--------|------------|---------|--------------| | 카테고리 | 연간 지출 | 비율 | 월평균 | 거래수 | 전년 대비 | | 주거비 | 1,116,000 | 26.4% | 93,000 | 24 | - | | 식비 | 924,000 | 21.9% | 77,000 | 180 | - | | 유틸리티 | 456,000 | 10.8% | 38,000 | 60 | - | | ... | | | | | | ``` ### 2.4 Config (설정) ``` | A | B | C | D | |-------------|--------------|-------------|----------------| | 카테고리 | 영문명 | 월간 예산 | 색상코드 | | 식비 | Food | 80,000 | #FF6B6B | | 주거비 | Housing | 95,000 | #4ECDC4 | | 유틸리티 | Utilities | 40,000 | #45B7D1 | | 교통비 | Transportation | 20,000 | #96CEB4 | | 쇼핑 | Shopping | 30,000 | #FFEAA7 | | 의료비 | Healthcare | 15,000 | #DDA0DD | | 교육비 | Education | 10,000 | #98D8C8 | | 엔터테인먼트 | Entertainment | 15,000 | #F7DC6F | | 보험료 | Insurance | 15,000 | #BB8FCE | ``` ## 3. 카테고리 매핑 구현 ### src/analysis/categories.ts ```typescript /** * 카테고리 매핑 및 설정 */ import { ExpenseCategory, IncomeCategory } from '../types'; // 카테고리 색상 매핑 export const CATEGORY_COLORS: Record = { // 수입 '급여': '#2ECC71', '보너스': '#27AE60', '부수입': '#1ABC9C', // 지출 '식비': '#FF6B6B', '주거비': '#4ECDC4', '유틸리티': '#45B7D1', '교통비': '#96CEB4', '쇼핑': '#FFEAA7', '의료비': '#DDA0DD', '교육비': '#98D8C8', '엔터테인먼트': '#F7DC6F', '보험료': '#BB8FCE', }; // 카테고리 영문명 매핑 export const CATEGORY_EN: Record = { '급여': 'Salary', '보너스': 'Bonus', '부수입': 'Side Income', '식비': 'Food', '주거비': 'Housing', '유틸리티': 'Utilities', '교통비': 'Transportation', '쇼핑': 'Shopping', '의료비': 'Healthcare', '교육비': 'Education', '엔터테인먼트': 'Entertainment', '보험료': 'Insurance', }; // 월간 예산 기본값 export const DEFAULT_BUDGETS: Record = { '식비': 80000, '주거비': 95000, '유틸리티': 40000, '교통비': 20000, '쇼핑': 30000, '의료비': 15000, '교육비': 10000, '엔터테인먼트': 15000, '보험료': 15000, }; // 지출 카테고리 목록 export const EXPENSE_CATEGORIES: ExpenseCategory[] = [ '식비', '주거비', '유틸리티', '교통비', '쇼핑', '의료비', '교육비', '엔터테인먼트', '보험료', ]; // 수입 카테고리 목록 export const INCOME_CATEGORIES: IncomeCategory[] = [ '급여', '보너스', '부수입', ]; /** * 색상 코드를 RGB 객체로 변환 */ export function hexToRgb(hex: string): { red: number; green: number; blue: number } { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); if (!result) { return { red: 0, green: 0, blue: 0 }; } return { red: parseInt(result[1], 16) / 255, green: parseInt(result[2], 16) / 255, blue: parseInt(result[3], 16) / 255, }; } /** * 카테고리 유효성 검사 */ export function isValidCategory(category: string): boolean { return [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES].includes(category as any); } ``` ## 4. 데이터 검증 로직 ### src/analysis/validator.ts ```typescript /** * 데이터 검증 모듈 */ import { Transaction } from '../types'; import { isValidCategory } from './categories'; export interface ValidationResult { isValid: boolean; errors: ValidationError[]; warnings: ValidationWarning[]; } export interface ValidationError { row: number; field: string; message: string; value: any; } export interface ValidationWarning { row: number; field: string; message: string; } /** * 거래 데이터 검증 */ export function validateTransactions(transactions: Transaction[]): ValidationResult { const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; transactions.forEach((t, index) => { const row = index + 2; // 헤더 제외, 1-indexed // 1. 필수 필드 검사 if (!t.date) { errors.push({ row, field: 'date', message: '날짜가 비어있습니다', value: t.date }); } if (!t.type || !['income', 'expense'].includes(t.type)) { errors.push({ row, field: 'type', message: '유효하지 않은 거래 타입', value: t.type }); } if (!t.category) { errors.push({ row, field: 'category', message: '카테고리가 비어있습니다', value: t.category }); } else if (!isValidCategory(t.category)) { warnings.push({ row, field: 'category', message: `알 수 없는 카테고리: ${t.category}` }); } // 2. 날짜 형식 검사 if (t.date && !/^\d{4}-\d{2}-\d{2}$/.test(t.date)) { errors.push({ row, field: 'date', message: '날짜 형식이 올바르지 않습니다 (YYYY-MM-DD)', value: t.date }); } // 3. 금액 검사 if (isNaN(t.amount) || t.amount < 0) { errors.push({ row, field: 'amount', message: '금액이 유효하지 않습니다', value: t.amount }); } // 4. 비정상 금액 경고 if (t.amount > 1000000) { warnings.push({ row, field: 'amount', message: `비정상적으로 큰 금액: ¥${t.amount.toLocaleString()}` }); } // 5. 미래 날짜 경고 if (t.date && new Date(t.date) > new Date()) { warnings.push({ row, field: 'date', message: '미래 날짜입니다' }); } }); return { isValid: errors.length === 0, errors, warnings, }; } /** * 검증 결과 출력 */ export function printValidationResult(result: ValidationResult): void { if (result.isValid && result.warnings.length === 0) { console.log('✅ 모든 데이터가 유효합니다.'); return; } if (result.errors.length > 0) { console.log(`\n❌ 오류 ${result.errors.length}건:`); result.errors.forEach((e) => { console.log(` 행 ${e.row}: [${e.field}] ${e.message} (값: ${e.value})`); }); } if (result.warnings.length > 0) { console.log(`\n⚠️ 경고 ${result.warnings.length}건:`); result.warnings.forEach((w) => { console.log(` 행 ${w.row}: [${w.field}] ${w.message}`); }); } } ``` ## 5. 시트 템플릿 생성기 ### src/sheets/template.ts ```typescript /** * 시트 템플릿 생성기 */ import { SheetsClient } from './client'; import { CATEGORY_COLORS, EXPENSE_CATEGORIES, DEFAULT_BUDGETS, hexToRgb } from '../analysis/categories'; export class SheetTemplateGenerator { private client: SheetsClient; constructor(client: SheetsClient) { this.client = client; } /** * 모든 필요한 시트 생성 */ async createAllSheets(spreadsheetId: string): Promise { const sheets = [ 'RawData', 'MonthlySummary', 'CategoryAnalysis', 'Dashboard', 'AIInsights', 'Config', ]; for (const sheetName of sheets) { await this.client.createOrClearSheet(spreadsheetId, sheetName); console.log(`✅ '${sheetName}' 시트 생성 완료`); } } /** * Config 시트 초기화 */ async initConfigSheet(spreadsheetId: string): Promise { const sheetName = 'Config'; // 헤더 const header = ['카테고리', '영문명', '월간 예산', '색상코드']; // 카테고리별 설정 데이터 const rows = EXPENSE_CATEGORIES.map((cat) => [ cat, cat, // 영문명은 별도 매핑 필요시 수정 DEFAULT_BUDGETS[cat], CATEGORY_COLORS[cat], ]); await this.client.writeSheet(spreadsheetId, `${sheetName}!A1`, [header, ...rows]); // 헤더 서식 적용 const sheetId = await this.client.getSheetId(spreadsheetId, sheetName); if (sheetId !== null) { await this.applyConfigFormat(spreadsheetId, sheetId); } console.log('✅ Config 시트 초기화 완료'); } /** * MonthlySummary 시트 헤더 생성 */ async initMonthlySummarySheet(spreadsheetId: string): Promise { const sheetName = 'MonthlySummary'; const header = ['월', '총 수입', '총 지출', '순 저축', '저축률', '거래 건수', '전월 대비']; await this.client.writeSheet(spreadsheetId, `${sheetName}!A1`, [header]); const sheetId = await this.client.getSheetId(spreadsheetId, sheetName); if (sheetId !== null) { await this.applyHeaderFormat(spreadsheetId, sheetId, header.length); } console.log('✅ MonthlySummary 시트 헤더 생성 완료'); } /** * CategoryAnalysis 시트 헤더 생성 */ async initCategoryAnalysisSheet(spreadsheetId: string): Promise { const sheetName = 'CategoryAnalysis'; const header = ['카테고리', '연간 지출', '비율', '월평균', '거래수', '예산 대비']; await this.client.writeSheet(spreadsheetId, `${sheetName}!A1`, [header]); const sheetId = await this.client.getSheetId(spreadsheetId, sheetName); if (sheetId !== null) { await this.applyHeaderFormat(spreadsheetId, sheetId, header.length); } console.log('✅ CategoryAnalysis 시트 헤더 생성 완료'); } /** * 헤더 서식 적용 */ private async applyHeaderFormat( spreadsheetId: string, sheetId: number, columnCount: number ): Promise { await this.client.batchUpdate(spreadsheetId, [ { repeatCell: { range: { sheetId, startRowIndex: 0, endRowIndex: 1, startColumnIndex: 0, endColumnIndex: columnCount, }, cell: { userEnteredFormat: { backgroundColor: { red: 0.2, green: 0.4, blue: 0.6 }, textFormat: { bold: true, foregroundColor: { red: 1, green: 1, blue: 1 }, }, horizontalAlignment: 'CENTER', }, }, fields: 'userEnteredFormat(backgroundColor,textFormat,horizontalAlignment)', }, }, { updateSheetProperties: { properties: { sheetId, gridProperties: { frozenRowCount: 1 }, }, fields: 'gridProperties.frozenRowCount', }, }, ]); } /** * Config 시트 서식 적용 */ private async applyConfigFormat(spreadsheetId: string, sheetId: number): Promise { await this.applyHeaderFormat(spreadsheetId, sheetId, 4); // 색상 컬럼에 조건부 서식 적용 (선택사항) // 각 행의 배경색을 색상코드에 맞게 설정 } /** * 전체 템플릿 초기화 */ async initializeAllTemplates(spreadsheetId: string): Promise { console.log('\n📋 시트 템플릿 초기화 시작...\n'); await this.createAllSheets(spreadsheetId); await this.initConfigSheet(spreadsheetId); await this.initMonthlySummarySheet(spreadsheetId); await this.initCategoryAnalysisSheet(spreadsheetId); console.log('\n✅ 모든 시트 템플릿 초기화 완료!'); } } ``` ## 6. 테스트 실행 ### src/test-structure.ts ```typescript /** * 시트 구조 테스트 */ import * as dotenv from 'dotenv'; dotenv.config(); import { SheetsClient } from './sheets/client'; import { SheetTemplateGenerator } from './sheets/template'; async function main() { const spreadsheetId = process.env.TEST_SPREADSHEET_ID; if (!spreadsheetId) { console.error('❌ TEST_SPREADSHEET_ID가 설정되지 않았습니다.'); process.exit(1); } const client = new SheetsClient(); const templateGen = new SheetTemplateGenerator(client); await templateGen.initializeAllTemplates(spreadsheetId); } main().catch(console.error); ``` ## 현재 프로젝트 구조 ``` project/src/ ├── sheets/ │ ├── client.ts ✅ API 클라이언트 │ ├── reader.ts ✅ 데이터 읽기 │ ├── writer.ts ✅ 데이터 쓰기 │ └── template.ts ✅ 시트 템플릿 생성 ├── analysis/ │ ├── categories.ts ✅ 카테고리 매핑 │ └── validator.ts ✅ 데이터 검증 ├── types/ │ └── index.ts ✅ 타입 정의 ├── test-sheets.ts ✅ └── test-structure.ts ✅ ``` ## 다음 단계 v5에서는: - 월별/카테고리별 집계 로직 구현 - 전월 대비 증감률 계산 - 분석 결과를 시트에 자동 기록 --- **작성일**: 2025-12-01 **상태**: ✅ 완료 **다음**: v5 - 기본 재무 분석 로직 구현