""" 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}")