서버 아키텍쳐 docs 커밋 .
parent
f1dd675ecb
commit
157d1b1ad9
|
|
@ -0,0 +1,788 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>O2O CastAD Backend - 인프라 아키텍처</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--surface2: #232733;
|
||||
--border: #2e3345;
|
||||
--text: #e1e4ed;
|
||||
--text-dim: #8b90a0;
|
||||
--accent: #6c8cff;
|
||||
--accent2: #a78bfa;
|
||||
--green: #34d399;
|
||||
--orange: #fb923c;
|
||||
--red: #f87171;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* 네비게이션 */
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 40px;
|
||||
height: 60px;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
nav .logo {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
nav a:hover { color: var(--accent); }
|
||||
|
||||
/* 메인 */
|
||||
main {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 100px 32px 80px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.2rem;
|
||||
margin-bottom: 8px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
color: var(--text-dim);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* 섹션 */
|
||||
section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 36px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 4px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.section-header h2 .num {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.section-header .desc {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* 좌우 2컬럼 (테이블/텍스트용) */
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.col { min-width: 0; }
|
||||
|
||||
/* 다이어그램 - 항상 풀 와이드, 아래 배치 */
|
||||
.diagram-box {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 32px;
|
||||
margin-top: 28px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.diagram-box .mermaid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.diagram-box .mermaid svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.diagram-label {
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* 서브 타이틀 */
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
|
||||
h3:first-child { margin-top: 0; }
|
||||
|
||||
/* 테이블 */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 12px 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--surface2);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td { color: var(--text-dim); }
|
||||
|
||||
/* 태그 */
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-green { background: rgba(52,211,153,0.15); color: var(--green); }
|
||||
.tag-orange { background: rgba(251,146,60,0.15); color: var(--orange); }
|
||||
.tag-red { background: rgba(248,113,113,0.15); color: var(--red); }
|
||||
|
||||
/* 비용 카드 */
|
||||
.cost-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin: 16px 0 24px;
|
||||
}
|
||||
|
||||
.cost-card {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cost-card .stage {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cost-card .amount {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cost-card .krw {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.cost-card.s1 { border-top: 3px solid var(--green); }
|
||||
.cost-card.s1 .amount { color: var(--green); }
|
||||
.cost-card.s2 { border-top: 3px solid var(--orange); }
|
||||
.cost-card.s2 .amount { color: var(--orange); }
|
||||
.cost-card.s3 { border-top: 3px solid var(--red); }
|
||||
.cost-card.s3 .amount { color: var(--red); }
|
||||
|
||||
/* 리스트 */
|
||||
ul {
|
||||
padding-left: 18px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
ul li {
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 5px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
ul li strong { color: var(--text); }
|
||||
|
||||
/* 노트 */
|
||||
.note {
|
||||
background: rgba(108,140,255,0.08);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 12px 16px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-dim);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* PDF 다운로드 버튼 */
|
||||
.btn-pdf {
|
||||
margin-left: auto;
|
||||
padding: 6px 16px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-pdf:hover { background: #5a7ae6; }
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 1024px) {
|
||||
main { padding: 80px 20px 60px; }
|
||||
.cols { grid-template-columns: 1fr; }
|
||||
.cost-cards { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
nav { padding: 0 16px; gap: 16px; }
|
||||
nav a { font-size: 0.8rem; }
|
||||
section { padding: 20px; }
|
||||
.hero h1 { font-size: 1.6rem; }
|
||||
table { font-size: 0.8rem; }
|
||||
th, td { padding: 6px 8px; }
|
||||
.diagram-box { padding: 16px; }
|
||||
}
|
||||
|
||||
/* 인쇄 / PDF 저장 — 화면과 동일한 다크 테마 유지 */
|
||||
@media print {
|
||||
@page {
|
||||
size: A3 landscape;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
nav { display: none !important; }
|
||||
.btn-pdf { display: none !important; }
|
||||
|
||||
main {
|
||||
max-width: 100% !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
section {
|
||||
break-inside: auto;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
|
||||
.diagram-box {
|
||||
break-inside: auto;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cols {
|
||||
grid-template-columns: 1fr 1fr !important;
|
||||
}
|
||||
|
||||
.cost-cards {
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="logo">O2O CastAD</div>
|
||||
<a href="#load-balancing">부하 분산</a>
|
||||
<a href="#architecture">아키텍처</a>
|
||||
<a href="#cost">비용 산출</a>
|
||||
<button class="btn-pdf" onclick="downloadPDF()">PDF 다운로드</button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<div class="hero">
|
||||
<h1>O2O CastAD Backend</h1>
|
||||
<p>인프라 아키텍처 및 비용 산출 문서</p>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 1. 부하 분산 ==================== -->
|
||||
<section id="load-balancing">
|
||||
<div class="section-header">
|
||||
<h2><span class="num">1</span>DB 및 서버 부하 분산 방법</h2>
|
||||
<p class="desc">Nginx 로드밸런싱, 커넥션 풀 관리, 단계별 수평 확장 전략</p>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<h3>현재 구현 현황 (단일 인스턴스)</h3>
|
||||
<ul>
|
||||
<li><strong>API 커넥션 풀</strong>: pool_size=20, max_overflow=20 → 최대 <code>40</code></li>
|
||||
<li><strong>백그라운드 풀</strong>: pool_size=10, max_overflow=10 → 최대 <code>20</code></li>
|
||||
<li><strong>인스턴스당 총 DB 연결</strong>: <code>40 + 20 = 60</code></li>
|
||||
<li><strong>풀 리사이클</strong>: 280초 (MySQL wait_timeout 300초 이전), pre-ping 활성화</li>
|
||||
</ul>
|
||||
|
||||
<h3>단계별 확장 전략</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>단계</th>
|
||||
<th>동시접속</th>
|
||||
<th>App Server</th>
|
||||
<th>LB</th>
|
||||
<th>DB ( MySQL Flexible)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="tag tag-green">S1</span></td>
|
||||
<td>~50명</td>
|
||||
<td>x1</td>
|
||||
<td>Nginx x1</td>
|
||||
<td>Burstable B1ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag tag-orange">S2</span></td>
|
||||
<td>50~200명</td>
|
||||
<td>x2~4</td>
|
||||
<td>Nginx</td>
|
||||
<td>GP D2ds_v4 + Replica x1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="tag tag-red">S3</span></td>
|
||||
<td>200~1,000명</td>
|
||||
<td><span style="font-size:0.75rem; line-height:1.4;">API ServerxN<br/>+ Scheduler</span></td>
|
||||
<td>Nginx</td>
|
||||
<td>BC D4ds_v4 + Replica x2 + Redis P1</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h3>커넥션 풀 수치 계산</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>항목</th>
|
||||
<th>Stage 1 (1대)</th>
|
||||
<th>Stage 2 (4대)</th>
|
||||
<th>Stage 3 (8대)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Main Pool / 인스턴스</strong></td>
|
||||
<td>20+20 = 40</td>
|
||||
<td>10+10 = 20</td>
|
||||
<td>5+5 = 10</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>BG Pool / 인스턴스</strong></td>
|
||||
<td>10+10 = 20</td>
|
||||
<td>5+5 = 10</td>
|
||||
<td>3+3 = 6</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>인스턴스당 소계</strong></td>
|
||||
<td><code>60</code></td>
|
||||
<td><code>30</code></td>
|
||||
<td><code>16</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Primary 총 연결</strong></td>
|
||||
<td>60</td>
|
||||
<td>4 x 30 = <code>120</code></td>
|
||||
<td>8 x 16 = <code>128</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>max_connections 권장</strong></td>
|
||||
<td>100</td>
|
||||
<td>200</td>
|
||||
<td>300</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="note" style="margin-top: 16px;">
|
||||
<strong>핵심:</strong>
|
||||
JWT Stateless 설계로 Nginx 세션 어피니티 불필요 (round-robin / least_conn).
|
||||
Stage 2부터 Read Replica로 읽기 분산, Redis는 Stage 3에서 캐싱/Rate Limiting 도입.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 다이어그램: 내용 아래 풀 와이드 -->
|
||||
<div class="diagram-box">
|
||||
<pre class="mermaid">
|
||||
graph TB
|
||||
subgraph S1["Stage 1: ~50명"]
|
||||
direction LR
|
||||
S1N["Nginx<br/>(Reverse Proxy)"] --> S1A["App Server x1"]
|
||||
S1A --> S1D[" MySQL<br/>Burstable B1ms"]
|
||||
end
|
||||
|
||||
subgraph S2["Stage 2: 50~200명"]
|
||||
direction LR
|
||||
S2N["Nginx<br/>(Reverse Proxy)"] --> S2API["APP Server<br/>x 1 ~ 2"]
|
||||
S2N --> S2WK["Scheduler<br/>Server"]
|
||||
S2API --> S2P["MySQL BC<br/>Primary<br/>(D4ds_v4)"]
|
||||
S2API --> S2R1["Read Replica<br/>x1"]
|
||||
S2WK --> S2P
|
||||
S2WK --> S2R1
|
||||
end
|
||||
|
||||
subgraph S3["Stage 3: 200~1,000명"]
|
||||
direction LR
|
||||
S3N["Nginx<br/>(Reverse Proxy)"] --> S3API["APP Server<br/>x N"]
|
||||
S3N --> S3WK["Scheduler<br/>Server"]
|
||||
S3API --> S3P["MySQL BC<br/>Primary<br/>(D4ds_v4)"]
|
||||
S3API --> S3R1["Read Replica<br/>xN"]
|
||||
S3API --> S3RD["Redis<br/>Premium P1"]
|
||||
S3WK --> S3P
|
||||
S3WK --> S3R1
|
||||
end
|
||||
|
||||
S1 ~~~ S2 ~~~ S3
|
||||
|
||||
style S1 fill:#0d3320,stroke:#34d399,stroke-width:2px,color:#e1e4ed
|
||||
style S2 fill:#3b2506,stroke:#fb923c,stroke-width:2px,color:#e1e4ed
|
||||
style S3 fill:#3b1010,stroke:#f87171,stroke-width:2px,color:#e1e4ed
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================== 2. 아키텍처 ==================== -->
|
||||
<section id="architecture">
|
||||
<div class="section-header">
|
||||
<h2><span class="num">2</span>전체 아키텍처 다이어그램</h2>
|
||||
<p class="desc">Nginx + FastAPI App Server 구성과 외부 서비스 연동 구조</p>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<ul>
|
||||
<li><strong>로드밸런서</strong>: Nginx (Reverse Proxy, L7 LB, SSL 종단)</li>
|
||||
<li><strong>App Server</strong>: FastAPI (Python 3.13) — Auth, Home, Lyric, Song, Video, Social, SNS, Archive, Admin, Background Worker</li>
|
||||
<li><strong>DB</strong>: Database for MySQL Flexible Server — Stage 2+ Read Replica</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col">
|
||||
<ul>
|
||||
<li><strong>캐시</strong>: Cache for Redis (Stage 3 도입)</li>
|
||||
<li><strong>콘텐츠 생성</strong>: 가사(ChatGPT) → 음악(Suno AI) → 영상(Creatomate) → SNS 업로드</li>
|
||||
<li><strong>외부 연동</strong>: Kakao OAuth, Naver Map/Search API, Blob Storage</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 다이어그램: 내용 아래 풀 와이드 -->
|
||||
<div class="diagram-box">
|
||||
<pre class="mermaid">
|
||||
graph TB
|
||||
Client["클라이언트<br/>(Web / App)"]
|
||||
LB["Nginx<br/>(Reverse Proxy + SSL 종단)"]
|
||||
|
||||
subgraph APP["App Server (FastAPI)"]
|
||||
direction LR
|
||||
Auth["Auth"] --- Home["Home"] --- Lyric["Lyric"] --- Song["Song"] --- Video["Video"]
|
||||
Social["Social"] --- SNS["SNS"] --- Archive["Archive"] --- Admin["Admin"] --- BG["BG Worker"]
|
||||
end
|
||||
|
||||
subgraph DB[" MySQL Flexible Server"]
|
||||
direction LR
|
||||
Primary["Primary (R/W)"]
|
||||
Replica["Read Replica"]
|
||||
end
|
||||
|
||||
subgraph AI["AI 콘텐츠 생성 파이프라인"]
|
||||
direction LR
|
||||
ChatGPT["ChatGPT<br/>(가사 생성)"]
|
||||
Suno["Suno AI<br/>(음악 생성)"]
|
||||
Creatomate["Creatomate<br/>(영상 생성)"]
|
||||
ChatGPT --> Suno --> Creatomate
|
||||
end
|
||||
|
||||
subgraph EXT["외부 서비스"]
|
||||
direction LR
|
||||
Blob[" Blob<br/>Storage"]
|
||||
Kakao["Kakao<br/>OAuth"]
|
||||
YT["YouTube /<br/>Instagram"]
|
||||
Naver["Naver Map /<br/>Search API"]
|
||||
end
|
||||
|
||||
Redis[" Cache for Redis<br/>(Stage 3 도입)"]
|
||||
|
||||
Client -->|HTTPS| LB
|
||||
LB --> APP
|
||||
APP --> Primary
|
||||
APP -->|"읽기 전용"| Replica
|
||||
APP -.->|"Stage 3"| Redis
|
||||
APP --> AI
|
||||
APP --> Blob
|
||||
APP --> Kakao
|
||||
APP --> YT
|
||||
APP --> Naver
|
||||
|
||||
style Client fill:#1a3a1a,stroke:#34d399,stroke-width:2px,color:#e1e4ed
|
||||
style LB fill:#1a3a1a,stroke:#34d399,stroke-width:2px,color:#e1e4ed
|
||||
style APP fill:#1a2744,stroke:#6c8cff,stroke-width:2px,color:#e1e4ed
|
||||
style DB fill:#2a1f00,stroke:#fb923c,stroke-width:2px,color:#e1e4ed
|
||||
style AI fill:#2a0f2a,stroke:#a78bfa,stroke-width:2px,color:#e1e4ed
|
||||
style EXT fill:#0d2a2a,stroke:#34d399,stroke-width:2px,color:#e1e4ed
|
||||
style Redis fill:#3b1010,stroke:#f87171,stroke-width:1px,color:#e1e4ed
|
||||
</pre>
|
||||
</div>
|
||||
<p class="diagram-label">전체 시스템 아키텍처 구성도</p>
|
||||
|
||||
<div class="note">
|
||||
<strong>콘텐츠 생성 흐름:</strong> 사용자 요청 → Naver 크롤링 → ChatGPT 가사 생성 → Suno AI 음악 생성 → Creatomate 영상 생성 → Blob 저장 → YouTube/Instagram 업로드
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================== 3. 비용 산출 ==================== -->
|
||||
<section id="cost">
|
||||
<div class="section-header">
|
||||
<h2><span class="num">3</span>예상 리소스 및 비용</h2>
|
||||
<p class="desc"> 기반 단계별 월 예상 비용 (인프라 + 외부 API)</p>
|
||||
</div>
|
||||
|
||||
<div class="cost-cards">
|
||||
<div class="cost-card s1">
|
||||
<div class="stage">Stage 1 · 동시 ~50명</div>
|
||||
<div class="amount">$170~390</div>
|
||||
<div class="krw">약 22~51만원/월</div>
|
||||
</div>
|
||||
<div class="cost-card s2">
|
||||
<div class="stage">Stage 2 · 동시 50~200명</div>
|
||||
<div class="amount">$960~2,160</div>
|
||||
<div class="krw">약 125~280만원/월</div>
|
||||
</div>
|
||||
<div class="cost-card s3">
|
||||
<div class="stage">Stage 3 · 동시 200~1,000명</div>
|
||||
<div class="amount">$3,850~8,500</div>
|
||||
<div class="krw">약 500~1,100만원/월</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<h3>항목별 비용 상세</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>항목</th>
|
||||
<th>Stage 1</th>
|
||||
<th>Stage 2</th>
|
||||
<th>Stage 3</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>App Server</strong></td>
|
||||
<td>$50~70</td>
|
||||
<td>$200~400</td>
|
||||
<td>$600~1,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Nginx</strong></td>
|
||||
<td>-</td>
|
||||
<td>포함 / VM $15~30</td>
|
||||
<td>VM $30~60</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>MySQL Primary</strong></td>
|
||||
<td>B1ms $15~25</td>
|
||||
<td>GP $130~160</td>
|
||||
<td>BC $350~450</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>MySQL Replica</strong></td>
|
||||
<td>-</td>
|
||||
<td>GP x1 $130~160</td>
|
||||
<td>BC x2 $260~360</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Redis</strong></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>P1 $225</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>스토리지/네트워크</strong></td>
|
||||
<td>$10~20</td>
|
||||
<td>$55~100</td>
|
||||
<td>$160~270</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>AI API (합계)</strong></td>
|
||||
<td>$90~280</td>
|
||||
<td>$400~1,250</td>
|
||||
<td>$2,100~5,800</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="col">
|
||||
<h3>DB 용량 예측 (1년 후)</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Stage 1 (500명)</th>
|
||||
<th>Stage 2 (5,000명)</th>
|
||||
<th>Stage 3 (50,000명)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>DB 용량</strong></td>
|
||||
<td>~1.2GB</td>
|
||||
<td>~12GB</td>
|
||||
<td>~120GB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Blob 스토리지</strong></td>
|
||||
<td>~1.1TB</td>
|
||||
<td>~11TB</td>
|
||||
<td>~110TB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>MySQL 추천</strong></td>
|
||||
<td>32GB SSD</td>
|
||||
<td>128GB SSD</td>
|
||||
<td>512GB SSD</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="note" style="margin-top: 16px;">
|
||||
<strong>비용 최적화 팁:</strong>
|
||||
3rd party 의존도 낮춰야함
|
||||
<br>
|
||||
Blob Lifecycle Policy (30일 미접근 → Cool 티어),
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 다이어그램: 내용 아래 풀 와이드 -->
|
||||
<div class="diagram-box">
|
||||
<pre class="mermaid">
|
||||
pie title Stage 3 월 비용 구성 비중
|
||||
"App Server (APP+Scheduler)" : 800
|
||||
"Nginx" : 45
|
||||
"MySQL Primary" : 400
|
||||
"MySQL Replica x2" : 310
|
||||
"Redis Premium" : 225
|
||||
"스토리지/네트워크" : 215
|
||||
"OpenAI API" : 550
|
||||
"Suno AI" : 1400
|
||||
"Creatomate" : 2000
|
||||
</pre>
|
||||
</div>
|
||||
<p class="diagram-label">Stage 3 월간 비용 구성 비율 — AI API 비중이 전체의 약 66%</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function downloadPDF() {
|
||||
// Mermaid SVG가 렌더링된 후 인쇄
|
||||
window.print();
|
||||
}
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'dark',
|
||||
themeVariables: {
|
||||
primaryColor: '#1a2744',
|
||||
primaryTextColor: '#e1e4ed',
|
||||
primaryBorderColor: '#6c8cff',
|
||||
lineColor: '#6c8cff',
|
||||
secondaryColor: '#232733',
|
||||
tertiaryColor: '#2e3345',
|
||||
pieSectionTextColor: '#e1e4ed',
|
||||
pieLegendTextColor: '#e1e4ed',
|
||||
pieTitleTextColor: '#e1e4ed',
|
||||
pieStrokeColor: '#2e3345'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
|
@ -0,0 +1,551 @@
|
|||
"""
|
||||
O2O CastAD Backend - 인프라 아키텍처 PPT 생성 스크립트
|
||||
실행: python3 docs/generate_ppt.py
|
||||
출력: docs/architecture.pptx
|
||||
"""
|
||||
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt, Emu
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
|
||||
# ── 색상 팔레트 (HTML 다크 테마 매칭) ──
|
||||
BG = RGBColor(0x0F, 0x11, 0x17)
|
||||
SURFACE = RGBColor(0x1A, 0x1D, 0x27)
|
||||
SURFACE2 = RGBColor(0x23, 0x27, 0x33)
|
||||
BORDER = RGBColor(0x2E, 0x33, 0x45)
|
||||
TEXT = RGBColor(0xE1, 0xE4, 0xED)
|
||||
TEXT_DIM = RGBColor(0x8B, 0x90, 0xA0)
|
||||
ACCENT = RGBColor(0x6C, 0x8C, 0xFF)
|
||||
ACCENT2 = RGBColor(0xA7, 0x8B, 0xFA)
|
||||
GREEN = RGBColor(0x34, 0xD3, 0x99)
|
||||
ORANGE = RGBColor(0xFB, 0x92, 0x3C)
|
||||
RED = RGBColor(0xF8, 0x71, 0x71)
|
||||
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
||||
|
||||
SLIDE_W = Inches(13.333)
|
||||
SLIDE_H = Inches(7.5)
|
||||
|
||||
|
||||
def set_slide_bg(slide, color):
|
||||
bg = slide.background
|
||||
fill = bg.fill
|
||||
fill.solid()
|
||||
fill.fore_color.rgb = color
|
||||
|
||||
|
||||
def add_textbox(slide, left, top, width, height, text, font_size=14,
|
||||
color=TEXT, bold=False, alignment=PP_ALIGN.LEFT, font_name="맑은 고딕"):
|
||||
txBox = slide.shapes.add_textbox(left, top, width, height)
|
||||
tf = txBox.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.text = text
|
||||
p.font.size = Pt(font_size)
|
||||
p.font.color.rgb = color
|
||||
p.font.bold = bold
|
||||
p.font.name = font_name
|
||||
p.alignment = alignment
|
||||
return txBox
|
||||
|
||||
|
||||
def add_bullet_list(slide, left, top, width, height, items, font_size=13):
|
||||
txBox = slide.shapes.add_textbox(left, top, width, height)
|
||||
tf = txBox.text_frame
|
||||
tf.word_wrap = True
|
||||
for i, item in enumerate(items):
|
||||
if i == 0:
|
||||
p = tf.paragraphs[0]
|
||||
else:
|
||||
p = tf.add_paragraph()
|
||||
p.space_after = Pt(4)
|
||||
p.font.size = Pt(font_size)
|
||||
p.font.color.rgb = TEXT_DIM
|
||||
p.font.name = "맑은 고딕"
|
||||
# bold 부분 처리
|
||||
if isinstance(item, tuple):
|
||||
run_bold = p.add_run()
|
||||
run_bold.text = item[0]
|
||||
run_bold.font.bold = True
|
||||
run_bold.font.color.rgb = TEXT
|
||||
run_bold.font.size = Pt(font_size)
|
||||
run_bold.font.name = "맑은 고딕"
|
||||
run_normal = p.add_run()
|
||||
run_normal.text = item[1]
|
||||
run_normal.font.color.rgb = TEXT_DIM
|
||||
run_normal.font.size = Pt(font_size)
|
||||
run_normal.font.name = "맑은 고딕"
|
||||
else:
|
||||
p.text = f"• {item}"
|
||||
return txBox
|
||||
|
||||
|
||||
def add_table(slide, left, top, width, height, headers, rows, col_widths=None):
|
||||
n_rows = len(rows) + 1
|
||||
n_cols = len(headers)
|
||||
table_shape = slide.shapes.add_table(n_rows, n_cols, left, top, width, height)
|
||||
table = table_shape.table
|
||||
|
||||
# 컬럼 폭 설정
|
||||
if col_widths:
|
||||
for i, w in enumerate(col_widths):
|
||||
table.columns[i].width = w
|
||||
|
||||
# 헤더 행
|
||||
for j, h in enumerate(headers):
|
||||
cell = table.cell(0, j)
|
||||
cell.text = h
|
||||
for paragraph in cell.text_frame.paragraphs:
|
||||
paragraph.font.size = Pt(11)
|
||||
paragraph.font.bold = True
|
||||
paragraph.font.color.rgb = ACCENT
|
||||
paragraph.font.name = "맑은 고딕"
|
||||
paragraph.alignment = PP_ALIGN.CENTER
|
||||
cell.fill.solid()
|
||||
cell.fill.fore_color.rgb = SURFACE2
|
||||
|
||||
# 데이터 행
|
||||
for i, row in enumerate(rows):
|
||||
for j, val in enumerate(row):
|
||||
cell = table.cell(i + 1, j)
|
||||
cell.text = str(val)
|
||||
for paragraph in cell.text_frame.paragraphs:
|
||||
paragraph.font.size = Pt(10)
|
||||
paragraph.font.color.rgb = TEXT_DIM
|
||||
paragraph.font.name = "맑은 고딕"
|
||||
paragraph.alignment = PP_ALIGN.CENTER
|
||||
cell.fill.solid()
|
||||
cell.fill.fore_color.rgb = SURFACE if i % 2 == 0 else BG
|
||||
|
||||
# 테이블 테두리 제거 (깔끔하게)
|
||||
for i in range(n_rows):
|
||||
for j in range(n_cols):
|
||||
cell = table.cell(i, j)
|
||||
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
|
||||
for border_name in ['top', 'bottom', 'left', 'right']:
|
||||
border = getattr(cell, f'border_{border_name}' if hasattr(cell, f'border_{border_name}') else border_name, None)
|
||||
|
||||
return table_shape
|
||||
|
||||
|
||||
def add_rounded_rect(slide, left, top, width, height, fill_color, border_color=None, text="",
|
||||
font_size=12, text_color=TEXT, bold=False):
|
||||
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = fill_color
|
||||
if border_color:
|
||||
shape.line.color.rgb = border_color
|
||||
shape.line.width = Pt(1.5)
|
||||
else:
|
||||
shape.line.fill.background()
|
||||
if text:
|
||||
tf = shape.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.text = text
|
||||
p.font.size = Pt(font_size)
|
||||
p.font.color.rgb = text_color
|
||||
p.font.bold = bold
|
||||
p.font.name = "맑은 고딕"
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
tf.paragraphs[0].space_before = Pt(0)
|
||||
tf.paragraphs[0].space_after = Pt(0)
|
||||
return shape
|
||||
|
||||
|
||||
def add_section_number(slide, left, top, number, color=ACCENT):
|
||||
shape = slide.shapes.add_shape(MSO_SHAPE.OVAL, left, top, Inches(0.4), Inches(0.4))
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = color
|
||||
shape.line.fill.background()
|
||||
tf = shape.text_frame
|
||||
p = tf.paragraphs[0]
|
||||
p.text = str(number)
|
||||
p.font.size = Pt(14)
|
||||
p.font.color.rgb = WHITE
|
||||
p.font.bold = True
|
||||
p.font.name = "맑은 고딕"
|
||||
p.alignment = PP_ALIGN.CENTER
|
||||
return shape
|
||||
|
||||
|
||||
def add_arrow(slide, x1, y1, x2, y2, color=ACCENT):
|
||||
connector = slide.shapes.add_connector(1, x1, y1, x2, y2) # 1 = straight
|
||||
connector.line.color.rgb = color
|
||||
connector.line.width = Pt(1.5)
|
||||
connector.end_x = x2
|
||||
connector.end_y = y2
|
||||
return connector
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
# PPT 생성 시작
|
||||
# ══════════════════════════════════════════════════════════════════
|
||||
prs = Presentation()
|
||||
prs.slide_width = SLIDE_W
|
||||
prs.slide_height = SLIDE_H
|
||||
|
||||
blank_layout = prs.slide_layouts[6] # Blank
|
||||
|
||||
# ── Slide 1: 타이틀 ──
|
||||
slide = prs.slides.add_slide(blank_layout)
|
||||
set_slide_bg(slide, BG)
|
||||
|
||||
add_textbox(slide, Inches(0), Inches(2.2), SLIDE_W, Inches(1),
|
||||
"O2O CastAD Backend", font_size=44, color=ACCENT, bold=True,
|
||||
alignment=PP_ALIGN.CENTER)
|
||||
add_textbox(slide, Inches(0), Inches(3.3), SLIDE_W, Inches(0.6),
|
||||
"인프라 아키텍처 및 비용 산출 문서", font_size=20, color=TEXT_DIM,
|
||||
alignment=PP_ALIGN.CENTER)
|
||||
|
||||
# 하단 구분선
|
||||
line = slide.shapes.add_connector(1, Inches(4.5), Inches(4.2), Inches(8.8), Inches(4.2))
|
||||
line.line.color.rgb = ACCENT
|
||||
line.line.width = Pt(2)
|
||||
|
||||
add_textbox(slide, Inches(0), Inches(4.5), SLIDE_W, Inches(0.5),
|
||||
"Nginx + FastAPI + MySQL + AI Pipeline", font_size=14, color=TEXT_DIM,
|
||||
alignment=PP_ALIGN.CENTER)
|
||||
|
||||
|
||||
# ── Slide 2: 부하 분산 - 현재 구현 & 확장 전략 ──
|
||||
slide = prs.slides.add_slide(blank_layout)
|
||||
set_slide_bg(slide, BG)
|
||||
|
||||
add_section_number(slide, Inches(0.5), Inches(0.4), "1")
|
||||
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
|
||||
"DB 및 서버 부하 분산 방법", font_size=26, color=ACCENT, bold=True)
|
||||
add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3),
|
||||
"Nginx 로드밸런싱, 커넥션 풀 관리, 단계별 수평 확장 전략", font_size=12, color=TEXT_DIM)
|
||||
|
||||
# 좌측: 현재 구현 현황
|
||||
add_textbox(slide, Inches(0.5), Inches(1.5), Inches(5), Inches(0.4),
|
||||
"현재 구현 현황 (단일 인스턴스)", font_size=16, color=TEXT, bold=True)
|
||||
|
||||
items = [
|
||||
("API 커넥션 풀: ", "pool_size=20, max_overflow=20 → 최대 40"),
|
||||
("백그라운드 풀: ", "pool_size=10, max_overflow=10 → 최대 20"),
|
||||
("인스턴스당 총 DB 연결: ", "40 + 20 = 60"),
|
||||
("풀 리사이클: ", "280초 (MySQL wait_timeout 300초 이전)"),
|
||||
("Pre-ping: ", "활성화 (죽은 커넥션 자동 복구)"),
|
||||
]
|
||||
add_bullet_list(slide, Inches(0.5), Inches(2.0), Inches(5.5), Inches(2.5), items, font_size=12)
|
||||
|
||||
# 우측: 확장 전략 테이블
|
||||
add_textbox(slide, Inches(6.8), Inches(1.5), Inches(6), Inches(0.4),
|
||||
"단계별 확장 전략", font_size=16, color=TEXT, bold=True)
|
||||
|
||||
headers = ["단계", "동시접속", "App Server", "LB", "DB (MySQL Flexible)"]
|
||||
rows = [
|
||||
["S1", "~50명", "x1", "Nginx x1", "Burstable B1ms"],
|
||||
["S2", "50~200명", "x2~4", "Nginx", "GP D2ds + Replica x1"],
|
||||
["S3", "200~1,000명", "API xN\n+ Scheduler", "Nginx", "BC D4ds + Replica x2\n+ Redis P1"],
|
||||
]
|
||||
add_table(slide, Inches(6.8), Inches(2.0), Inches(6), Inches(2.0), headers, rows)
|
||||
|
||||
# 하단: 핵심 노트
|
||||
note_shape = add_rounded_rect(slide, Inches(0.5), Inches(4.6), Inches(12.3), Inches(0.7),
|
||||
SURFACE2, ACCENT)
|
||||
tf = note_shape.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.text = ""
|
||||
run = p.add_run()
|
||||
run.text = "핵심: "
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = ACCENT
|
||||
run.font.size = Pt(11)
|
||||
run.font.name = "맑은 고딕"
|
||||
run = p.add_run()
|
||||
run.text = "JWT Stateless 설계로 Nginx 세션 어피니티 불필요 (round-robin / least_conn). Stage 2부터 Read Replica로 읽기 분산, Redis는 Stage 3에서 캐싱/Rate Limiting 도입."
|
||||
run.font.color.rgb = TEXT_DIM
|
||||
run.font.size = Pt(11)
|
||||
run.font.name = "맑은 고딕"
|
||||
|
||||
|
||||
# ── Slide 3: 커넥션 풀 수치 계산 ──
|
||||
slide = prs.slides.add_slide(blank_layout)
|
||||
set_slide_bg(slide, BG)
|
||||
|
||||
add_section_number(slide, Inches(0.5), Inches(0.4), "1")
|
||||
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
|
||||
"커넥션 풀 수치 계산", font_size=26, color=ACCENT, bold=True)
|
||||
add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3),
|
||||
"인스턴스 수 증가에 따른 인스턴스당 풀 사이즈 축소 및 총 DB 커넥션 관리", font_size=12, color=TEXT_DIM)
|
||||
|
||||
headers = ["항목", "Stage 1 (1대)", "Stage 2 (4대)", "Stage 3 (8대)"]
|
||||
rows = [
|
||||
["Main Pool / 인스턴스", "20+20 = 40", "10+10 = 20", "5+5 = 10"],
|
||||
["BG Pool / 인스턴스", "10+10 = 20", "5+5 = 10", "3+3 = 6"],
|
||||
["인스턴스당 소계", "60", "30", "16"],
|
||||
["Primary 총 연결", "60", "4 x 30 = 120", "8 x 16 = 128"],
|
||||
["max_connections 권장", "100", "200", "300"],
|
||||
]
|
||||
add_table(slide, Inches(1.5), Inches(1.6), Inches(10.3), Inches(2.8), headers, rows)
|
||||
|
||||
# 시각적 요약 박스
|
||||
stages = [
|
||||
("Stage 1", "1대 × 60 = 60", GREEN, Inches(2)),
|
||||
("Stage 2", "4대 × 30 = 120", ORANGE, Inches(5.5)),
|
||||
("Stage 3", "8대 × 16 = 128", RED, Inches(9)),
|
||||
]
|
||||
for label, val, color, left in stages:
|
||||
add_rounded_rect(slide, left, Inches(4.8), Inches(2.3), Inches(0.9),
|
||||
SURFACE2, color, f"{label}\n{val}", font_size=13, text_color=color, bold=True)
|
||||
|
||||
|
||||
# ── Slide 4: 아키텍처 다이어그램 (상세 블록) ──
|
||||
slide = prs.slides.add_slide(blank_layout)
|
||||
set_slide_bg(slide, BG)
|
||||
|
||||
add_section_number(slide, Inches(0.5), Inches(0.4), "2")
|
||||
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
|
||||
"전체 아키텍처 다이어그램", font_size=26, color=ACCENT, bold=True)
|
||||
add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3),
|
||||
"Nginx + FastAPI App Server 구성과 외부 서비스 연동 구조", font_size=12, color=TEXT_DIM)
|
||||
|
||||
# 클라이언트
|
||||
add_rounded_rect(slide, Inches(5.5), Inches(1.4), Inches(2.3), Inches(0.6),
|
||||
RGBColor(0x1A, 0x3A, 0x1A), GREEN, "클라이언트 (Web / App)",
|
||||
font_size=11, text_color=TEXT)
|
||||
|
||||
# Nginx
|
||||
add_rounded_rect(slide, Inches(5.5), Inches(2.3), Inches(2.3), Inches(0.6),
|
||||
RGBColor(0x1A, 0x3A, 0x1A), GREEN, "Nginx\n(Reverse Proxy + SSL)",
|
||||
font_size=10, text_color=TEXT)
|
||||
|
||||
# App Server
|
||||
app_box = add_rounded_rect(slide, Inches(2.5), Inches(3.2), Inches(8.3), Inches(1.2),
|
||||
RGBColor(0x1A, 0x27, 0x44), ACCENT, "", font_size=10)
|
||||
add_textbox(slide, Inches(2.7), Inches(3.15), Inches(3), Inches(0.3),
|
||||
"App Server (FastAPI)", font_size=11, color=ACCENT, bold=True)
|
||||
|
||||
modules = ["Auth", "Home", "Lyric", "Song", "Video", "Social", "SNS", "Archive", "Admin", "BG Worker"]
|
||||
for i, mod in enumerate(modules):
|
||||
col = i % 10
|
||||
x = Inches(2.7 + col * 0.8)
|
||||
y = Inches(3.55)
|
||||
add_rounded_rect(slide, x, y, Inches(0.7), Inches(0.5),
|
||||
SURFACE2, BORDER, mod, font_size=8, text_color=TEXT_DIM)
|
||||
|
||||
# DB
|
||||
add_rounded_rect(slide, Inches(1.0), Inches(4.85), Inches(3.5), Inches(0.7),
|
||||
RGBColor(0x2A, 0x1F, 0x00), ORANGE, "MySQL Flexible Server\nPrimary (R/W) + Read Replica",
|
||||
font_size=10, text_color=TEXT)
|
||||
|
||||
# Redis
|
||||
add_rounded_rect(slide, Inches(5.0), Inches(4.85), Inches(2.3), Inches(0.7),
|
||||
RGBColor(0x3B, 0x10, 0x10), RED, "Cache for Redis\n(Stage 3 도입)",
|
||||
font_size=10, text_color=TEXT)
|
||||
|
||||
# AI Pipeline
|
||||
add_rounded_rect(slide, Inches(7.8), Inches(4.85), Inches(4.5), Inches(0.7),
|
||||
RGBColor(0x2A, 0x0F, 0x2A), ACCENT2, "AI Pipeline: ChatGPT → Suno AI → Creatomate",
|
||||
font_size=10, text_color=TEXT)
|
||||
|
||||
# 외부 서비스
|
||||
ext_items = [("Blob\nStorage", Inches(1.0)), ("Kakao\nOAuth", Inches(3.2)),
|
||||
("YouTube /\nInstagram", Inches(5.4)), ("Naver Map /\nSearch", Inches(7.6))]
|
||||
for label, x in ext_items:
|
||||
add_rounded_rect(slide, x, Inches(6.0), Inches(1.8), Inches(0.7),
|
||||
RGBColor(0x0D, 0x2A, 0x2A), GREEN, label,
|
||||
font_size=9, text_color=TEXT_DIM)
|
||||
|
||||
# 콘텐츠 생성 흐름 노트
|
||||
note_shape = add_rounded_rect(slide, Inches(1.0), Inches(6.85), Inches(11.3), Inches(0.45),
|
||||
SURFACE2, ACCENT)
|
||||
tf = note_shape.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
run = p.add_run()
|
||||
run.text = "콘텐츠 생성 흐름: "
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = ACCENT
|
||||
run.font.size = Pt(10)
|
||||
run.font.name = "맑은 고딕"
|
||||
run = p.add_run()
|
||||
run.text = "사용자 요청 → Naver 크롤링 → ChatGPT 가사 → Suno AI 음악 → Creatomate 영상 → Blob 저장 → YouTube/Instagram 업로드"
|
||||
run.font.color.rgb = TEXT_DIM
|
||||
run.font.size = Pt(10)
|
||||
run.font.name = "맑은 고딕"
|
||||
|
||||
|
||||
# ── Slide 5: Stage별 인프라 스케일링 ──
|
||||
slide = prs.slides.add_slide(blank_layout)
|
||||
set_slide_bg(slide, BG)
|
||||
|
||||
add_section_number(slide, Inches(0.5), Inches(0.4), "2")
|
||||
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
|
||||
"단계별 인프라 스케일링", font_size=26, color=ACCENT, bold=True)
|
||||
|
||||
# Stage 1
|
||||
stage1_box = add_rounded_rect(slide, Inches(0.5), Inches(1.3), Inches(3.8), Inches(5.2),
|
||||
RGBColor(0x0D, 0x33, 0x20), GREEN)
|
||||
add_textbox(slide, Inches(0.7), Inches(1.4), Inches(3.4), Inches(0.3),
|
||||
"Stage 1: ~50명", font_size=16, color=GREEN, bold=True)
|
||||
items = [
|
||||
("Nginx: ", "Reverse Proxy x1"),
|
||||
("App Server: ", "x1"),
|
||||
("MySQL: ", "Burstable B1ms"),
|
||||
("Redis: ", "미사용"),
|
||||
("월 비용: ", "$170~390"),
|
||||
]
|
||||
add_bullet_list(slide, Inches(0.7), Inches(1.9), Inches(3.4), Inches(3), items, font_size=12)
|
||||
|
||||
# Stage 2
|
||||
stage2_box = add_rounded_rect(slide, Inches(4.7), Inches(1.3), Inches(3.8), Inches(5.2),
|
||||
RGBColor(0x3B, 0x25, 0x06), ORANGE)
|
||||
add_textbox(slide, Inches(4.9), Inches(1.4), Inches(3.4), Inches(0.3),
|
||||
"Stage 2: 50~200명", font_size=16, color=ORANGE, bold=True)
|
||||
items = [
|
||||
("Nginx: ", "Reverse Proxy (LB)"),
|
||||
("App Server: ", "x2~4"),
|
||||
("Scheduler: ", "Server x1"),
|
||||
("MySQL: ", "GP D2ds + Replica x1"),
|
||||
("Redis: ", "미사용"),
|
||||
("월 비용: ", "$960~2,160"),
|
||||
]
|
||||
add_bullet_list(slide, Inches(4.9), Inches(1.9), Inches(3.4), Inches(3.5), items, font_size=12)
|
||||
|
||||
# Stage 3
|
||||
stage3_box = add_rounded_rect(slide, Inches(8.9), Inches(1.3), Inches(3.8), Inches(5.2),
|
||||
RGBColor(0x3B, 0x10, 0x10), RED)
|
||||
add_textbox(slide, Inches(9.1), Inches(1.4), Inches(3.4), Inches(0.3),
|
||||
"Stage 3: 200~1,000명", font_size=16, color=RED, bold=True)
|
||||
items = [
|
||||
("Nginx: ", "Reverse Proxy (LB)"),
|
||||
("API Server: ", "x N (Auto Scale)"),
|
||||
("Scheduler: ", "Server x1"),
|
||||
("MySQL: ", "BC D4ds + Replica x2"),
|
||||
("Redis: ", "Premium P1 (캐싱)"),
|
||||
("월 비용: ", "$3,850~8,500"),
|
||||
]
|
||||
add_bullet_list(slide, Inches(9.1), Inches(1.9), Inches(3.4), Inches(3.5), items, font_size=12)
|
||||
|
||||
|
||||
# ── Slide 6: 비용 산출 개요 ──
|
||||
slide = prs.slides.add_slide(blank_layout)
|
||||
set_slide_bg(slide, BG)
|
||||
|
||||
add_section_number(slide, Inches(0.5), Inches(0.4), "3")
|
||||
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
|
||||
"예상 리소스 및 비용", font_size=26, color=ACCENT, bold=True)
|
||||
add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3),
|
||||
"단계별 월 예상 비용 (인프라 + 외부 API)", font_size=12, color=TEXT_DIM)
|
||||
|
||||
# 비용 카드 3개
|
||||
cost_data = [
|
||||
("Stage 1", "동시 ~50명", "$170~390", "약 22~51만원/월", GREEN),
|
||||
("Stage 2", "동시 50~200명", "$960~2,160", "약 125~280만원/월", ORANGE),
|
||||
("Stage 3", "동시 200~1,000명", "$3,850~8,500", "약 500~1,100만원/월", RED),
|
||||
]
|
||||
for i, (stage, users, usd, krw, color) in enumerate(cost_data):
|
||||
x = Inches(1.0 + i * 3.9)
|
||||
card = add_rounded_rect(slide, x, Inches(1.5), Inches(3.5), Inches(1.8), SURFACE2, color)
|
||||
|
||||
add_textbox(slide, x, Inches(1.6), Inches(3.5), Inches(0.3),
|
||||
f"{stage} · {users}", font_size=12, color=TEXT_DIM, alignment=PP_ALIGN.CENTER)
|
||||
add_textbox(slide, x, Inches(2.0), Inches(3.5), Inches(0.5),
|
||||
usd, font_size=28, color=color, bold=True, alignment=PP_ALIGN.CENTER)
|
||||
add_textbox(slide, x, Inches(2.6), Inches(3.5), Inches(0.3),
|
||||
krw, font_size=12, color=TEXT_DIM, alignment=PP_ALIGN.CENTER)
|
||||
|
||||
# 항목별 비용 상세 테이블
|
||||
add_textbox(slide, Inches(0.5), Inches(3.6), Inches(5), Inches(0.4),
|
||||
"항목별 비용 상세", font_size=16, color=TEXT, bold=True)
|
||||
|
||||
headers = ["항목", "Stage 1", "Stage 2", "Stage 3"]
|
||||
rows = [
|
||||
["App Server", "$50~70", "$200~400", "$600~1,000"],
|
||||
["Nginx", "포함", "포함 / VM $15~30", "VM $30~60"],
|
||||
["MySQL Primary", "B1ms $15~25", "GP $130~160", "BC $350~450"],
|
||||
["MySQL Replica", "-", "GP x1 $130~160", "BC x2 $260~360"],
|
||||
["Redis", "-", "-", "P1 $225"],
|
||||
["스토리지/네트워크", "$10~20", "$55~100", "$160~270"],
|
||||
["AI API (합계)", "$90~280", "$400~1,250", "$2,100~5,800"],
|
||||
]
|
||||
add_table(slide, Inches(0.5), Inches(4.1), Inches(12.3), Inches(3.0), headers, rows)
|
||||
|
||||
|
||||
# ── Slide 7: 비용 구성 비중 & DB 용량 ──
|
||||
slide = prs.slides.add_slide(blank_layout)
|
||||
set_slide_bg(slide, BG)
|
||||
|
||||
add_section_number(slide, Inches(0.5), Inches(0.4), "3")
|
||||
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
|
||||
"Stage 3 비용 구성 & DB 용량", font_size=26, color=ACCENT, bold=True)
|
||||
|
||||
# 좌측: Stage 3 비용 구성 (수평 바 차트 시뮬레이션)
|
||||
add_textbox(slide, Inches(0.5), Inches(1.2), Inches(5), Inches(0.4),
|
||||
"Stage 3 월 비용 구성 비중", font_size=16, color=TEXT, bold=True)
|
||||
|
||||
cost_items = [
|
||||
("Creatomate", 2000, RED),
|
||||
("Suno AI", 1400, ORANGE),
|
||||
("App Server", 800, ACCENT),
|
||||
("OpenAI API", 550, ACCENT2),
|
||||
("MySQL Primary", 400, ORANGE),
|
||||
("MySQL Replica x2", 310, ORANGE),
|
||||
("Redis Premium", 225, RED),
|
||||
("스토리지/네트워크", 215, GREEN),
|
||||
("Nginx", 45, GREEN),
|
||||
]
|
||||
total = sum(v for _, v, _ in cost_items)
|
||||
max_bar_width = 5.0 # inches
|
||||
|
||||
y_start = Inches(1.7)
|
||||
for i, (label, value, color) in enumerate(cost_items):
|
||||
y = y_start + Emu(int(i * Inches(0.45)))
|
||||
bar_w = Inches(max_bar_width * value / total)
|
||||
|
||||
# 바
|
||||
bar = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(2.2), y, bar_w, Inches(0.32))
|
||||
bar.fill.solid()
|
||||
bar.fill.fore_color.rgb = color
|
||||
bar.line.fill.background()
|
||||
|
||||
# 레이블
|
||||
add_textbox(slide, Inches(0.5), y, Inches(1.6), Inches(0.32),
|
||||
label, font_size=9, color=TEXT_DIM, alignment=PP_ALIGN.RIGHT)
|
||||
|
||||
# 값
|
||||
pct = value / total * 100
|
||||
val_x = Inches(2.3) + bar_w
|
||||
add_textbox(slide, val_x, y, Inches(1.5), Inches(0.32),
|
||||
f"${value:,} ({pct:.0f}%)", font_size=9, color=TEXT_DIM)
|
||||
|
||||
add_textbox(slide, Inches(0.5), y_start + Emu(int(len(cost_items) * Inches(0.45))), Inches(6), Inches(0.3),
|
||||
f"AI API 비중: 전체의 약 66% (${(2000+1400+550):,} / ${total:,})",
|
||||
font_size=11, color=ACCENT, bold=True)
|
||||
|
||||
# 우측: DB 용량 예측
|
||||
add_textbox(slide, Inches(7.5), Inches(1.2), Inches(5), Inches(0.4),
|
||||
"DB 용량 예측 (1년 후)", font_size=16, color=TEXT, bold=True)
|
||||
|
||||
headers = ["", "Stage 1\n(500명)", "Stage 2\n(5,000명)", "Stage 3\n(50,000명)"]
|
||||
rows = [
|
||||
["DB 용량", "~1.2GB", "~12GB", "~120GB"],
|
||||
["Blob 스토리지", "~1.1TB", "~11TB", "~110TB"],
|
||||
["MySQL 추천", "32GB SSD", "128GB SSD", "512GB SSD"],
|
||||
]
|
||||
add_table(slide, Inches(7.5), Inches(1.7), Inches(5.3), Inches(1.8), headers, rows)
|
||||
|
||||
# 비용 최적화 팁
|
||||
add_textbox(slide, Inches(7.5), Inches(3.8), Inches(5), Inches(0.4),
|
||||
"비용 최적화 팁", font_size=16, color=TEXT, bold=True)
|
||||
|
||||
items = [
|
||||
("3rd party 의존도: ", "낮춰야 함 (AI API가 전체 비용의 66%)"),
|
||||
("Blob Lifecycle: ", "30일 미접근 미디어 → Cool 티어 자동 이전"),
|
||||
("App Server: ", "비활성 시간대(야간) 인스턴스 축소"),
|
||||
("OpenAI Batch API: ", "비실시간 가사생성은 50% 절감 가능"),
|
||||
("Reserved Instances: ", "1년 예약 시 ~30% 할인"),
|
||||
]
|
||||
add_bullet_list(slide, Inches(7.5), Inches(4.3), Inches(5.3), Inches(3), items, font_size=11)
|
||||
|
||||
|
||||
# ── 저장 ──
|
||||
output_path = "/Users/marineyang/Desktop/work/code/o2o-castad-backend/docs/architecture.pptx"
|
||||
prs.save(output_path)
|
||||
print(f"PPT 생성 완료: {output_path}")
|
||||
Loading…
Reference in New Issue