""" Build the 16-slide deck for the Project-Infra / Project-Auth-Server presentation. Usage: /home/donghyeon/dev/Project-Infra/presentation/.venv/bin/python \ /home/donghyeon/dev/Project-Infra/presentation/scripts/build_pptx.py """ from __future__ import annotations from pathlib import Path from pptx import Presentation from pptx.dml.color import RGBColor from pptx.enum.dml import MSO_LINE_DASH_STYLE as MSO_LINE from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE from pptx.enum.text import PP_ALIGN, MSO_ANCHOR from pptx.util import Cm, Emu, Pt ROOT = Path("/home/donghyeon/dev/Project-Infra/presentation") EXPORTS = ROOT / "exports" OUT = ROOT / "build" / "deck.pptx" FONT = "Pretendard" FONT_FALLBACK = "Helvetica" C_BG = RGBColor(0xFF, 0xFF, 0xFF) C_TEXT = RGBColor(0x24, 0x29, 0x2F) C_MUTED = RGBColor(0x57, 0x60, 0x6A) C_BORDER = RGBColor(0xD0, 0xD7, 0xDE) C_EDGE = RGBColor(0xC2, 0x41, 0x0C) C_APP = RGBColor(0x1E, 0x40, 0xAF) C_DATA = RGBColor(0x04, 0x78, 0x57) C_SECRET = RGBColor(0x5B, 0x21, 0xB6) C_DANGER = RGBColor(0xB9, 0x1C, 0x1C) C_GOOD = RGBColor(0x15, 0x80, 0x3D) C_PANEL = RGBColor(0xF6, 0xF8, 0xFA) C_GOOD_FILL = RGBColor(0xEC, 0xFD, 0xF5) C_DANGER_FILL = RGBColor(0xFE, 0xF2, 0xF2) C_APP_FILL = RGBColor(0xF0, 0xF9, 0xFF) C_WARN_FILL = RGBColor(0xFE, 0xF3, 0xC7) C_WARN_LINE = RGBColor(0xCA, 0x8A, 0x04) C_WARN_TEXT = RGBColor(0x92, 0x40, 0x0E) # ─────────────── layout grid (master) ─────────────── MARGIN_X = Cm(1.2) TITLE_TOP = Cm(0.8) CONTENT_TOP = Cm(3.0) CONTENT_TOP_WIDE = Cm(3.4) CAPTION_BAND_H = Cm(1.1) def content_w(): return SLIDE_W - MARGIN_X * 2 def emu(cm: float) -> int: return Cm(cm) SLIDE_W = Cm(33.867) SLIDE_H = Cm(19.05) def set_run(run, text: str, size: int, bold: bool = False, color=C_TEXT, mono: bool = False): run.text = text run.font.size = Pt(size) run.font.bold = bold run.font.name = "Consolas" if mono else FONT run.font.color.rgb = color def add_text_box( slide, left, top, width, height, text, *, size=18, bold=False, color=C_TEXT, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, mono=False, ): box = slide.shapes.add_textbox(left, top, width, height) tf = box.text_frame tf.word_wrap = True tf.margin_left = Cm(0.1) tf.margin_right = Cm(0.1) tf.margin_top = Cm(0.05) tf.margin_bottom = Cm(0.05) tf.vertical_anchor = anchor p = tf.paragraphs[0] p.alignment = align set_run(p.add_run(), text, size, bold, color, mono) return box def add_panel(slide, left, top, width, height, fill=C_PANEL, line=C_BORDER, line_w=0.75): shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height) shape.fill.solid() shape.fill.fore_color.rgb = fill shape.line.color.rgb = line shape.line.width = Pt(line_w) shape.shadow.inherit = False shape.text_frame.text = "" return shape def add_slide_chrome(slide, page_no, total, domain_label=""): """Domain label (top-left) + page number (bottom-right).""" if domain_label: add_text_box( slide, Cm(0.6), Cm(0.3), Cm(15), Cm(0.7), domain_label, size=12, color=C_MUTED, ) add_text_box( slide, SLIDE_W - Cm(2.4), SLIDE_H - Cm(0.7), Cm(2), Cm(0.5), f"{page_no} / {total}", size=10, color=C_MUTED, align=PP_ALIGN.RIGHT, ) def slide_diagram_only( prs, png_name, page_no, total, domain_label, *, title=None, subtitle=None, caption=None, ): """다이어그램 슬라이드. drawio 는 순수 그림만 들어있고, 제목/부제/캡션은 PPT 단에서 일관된 chrome 으로 그린다. title 은 한 줄 또는 두 줄로 wrap 될 수 있으므로 자체적으로 큰 박스 + 28pt 로 그림 (add_title 의 기본 layout 과 분리 — slide_diagram_only 외 함수에 영향 없음). """ s = blank_slide(prs) if title: # add_title 과 동일한 box (1.4cm × 32pt BOTTOM) — 모든 슬라이드 title baseline 통일 add_title(s, title, subtitle) add_diagram_image( s, EXPORTS / png_name, # title(0.8~2.2) + subtitle(2.3~3.2) + 0.3cm 여백 top_cm=3.5 if title else 1.0, bottom_cm=2.4 if caption else 0.8, ) if caption: add_caption_band(s, caption) add_slide_chrome(s, page_no, total, domain_label) return s def add_notes(slide, notes: str): """Attach speaker notes to a slide.""" tf = slide.notes_slide.notes_text_frame tf.clear() p = tf.paragraphs[0] set_run(p.add_run(), notes, 11, False, C_TEXT) def add_title(slide, title, subtitle=None, top=Cm(0.8)): add_text_box( slide, Cm(1.2), top, SLIDE_W - Cm(2.4), Cm(1.4), title, size=32, bold=True, color=C_TEXT, anchor=MSO_ANCHOR.BOTTOM, ) if subtitle: add_text_box( slide, Cm(1.2), top + Cm(1.5), SLIDE_W - Cm(2.4), Cm(0.9), subtitle, size=14, color=C_MUTED, ) def add_diagram_image(slide, png_path: Path, top_cm=3.0, bottom_cm=2.4): """Insert PNG centered, scaled to fit between top_cm and (slide_h - bottom_cm).""" from PIL import Image img = Image.open(png_path) w_px, h_px = img.size aspect = w_px / h_px avail_w = SLIDE_W - Cm(1.2 * 2) avail_h = SLIDE_H - Cm(top_cm + bottom_cm) if avail_w / aspect <= avail_h: target_w = avail_w target_h = int(avail_w / aspect) else: target_h = avail_h target_w = int(avail_h * aspect) left = (SLIDE_W - target_w) // 2 top = Cm(top_cm) + (avail_h - target_h) // 2 slide.shapes.add_picture(str(png_path), left, top, width=target_w, height=target_h) def add_caption_band(slide, text, *, color=C_TEXT, fill=C_PANEL): band_top = SLIDE_H - Cm(2.0) band = add_panel(slide, Cm(1.2), band_top, SLIDE_W - Cm(2.4), Cm(1.1), fill=fill) tf = band.text_frame tf.margin_left = Cm(0.4) tf.margin_right = Cm(0.4) tf.vertical_anchor = MSO_ANCHOR.MIDDLE p = tf.paragraphs[0] p.alignment = PP_ALIGN.CENTER set_run(p.add_run(), text, 14, True, color) def add_diagram_placeholder(slide, left, top, width, height, *, label="다이어그램 자리", note=""): """발표 후 draw.io 로 채울 자리. 점선 테두리 + 라벨 + 짧은 안내.""" from pptx.dml.color import RGBColor as _RGB PLACEHOLDER_FILL = _RGB(0xFA, 0xFA, 0xFA) PLACEHOLDER_BORDER = _RGB(0xC9, 0xCD, 0xD3) panel = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, int(left), int(top), int(width), int(height) ) panel.fill.solid() panel.fill.fore_color.rgb = PLACEHOLDER_FILL panel.line.color.rgb = PLACEHOLDER_BORDER panel.line.width = Pt(1.25) panel.line.dash_style = MSO_LINE.DASH panel.shadow.inherit = False add_text_box( slide, left, top + (height - Cm(2.0)) // 2, width, Cm(1.0), f"[ {label} ]", size=20, bold=True, color=PLACEHOLDER_BORDER, align=PP_ALIGN.CENTER, ) if note: add_text_box( slide, left, top + (height - Cm(2.0)) // 2 + Cm(1.1), width, Cm(0.8), note, size=12, color=C_MUTED, align=PP_ALIGN.CENTER, ) def add_table( slide, left, top, headers, rows, *, col_widths_cm=None, header_fill=None, row_h_cm=0.95, body_size=12, header_size=13, highlight_rows=None, ): cols = len(headers) if col_widths_cm is None: col_widths_cm = [(SLIDE_W.cm - 2.4) / cols] * cols rows_n = len(rows) + 1 total_w = sum(Cm(w) for w in col_widths_cm) row_h = Cm(row_h_cm) table_shape = slide.shapes.add_table(rows_n, cols, left, top, total_w, row_h * rows_n) table = table_shape.table table.first_row = False table.first_col = False table.last_row = False table.last_col = False table.horz_banding = False table.vert_banding = False for i, w in enumerate(col_widths_cm): table.columns[i].width = Cm(w) for r in range(rows_n): table.rows[r].height = row_h for ci, h in enumerate(headers): cell = table.cell(0, ci) cell.fill.solid() cell.fill.fore_color.rgb = header_fill or C_PANEL tf = cell.text_frame tf.margin_left = Cm(0.2) tf.margin_right = Cm(0.2) tf.vertical_anchor = MSO_ANCHOR.MIDDLE p = tf.paragraphs[0] p.alignment = PP_ALIGN.CENTER set_run(p.add_run(), h, header_size, True, C_TEXT) HIGHLIGHT_FILL = RGBColor(0xFE, 0xF9, 0xC3) HIGHLIGHT_BORDER = RGBColor(0xCA, 0x8A, 0x04) for ri, row in enumerate(rows, start=1): is_highlight = bool(highlight_rows and ri in highlight_rows) for ci, text in enumerate(row): cell = table.cell(ri, ci) cell.fill.solid() if is_highlight: cell.fill.fore_color.rgb = HIGHLIGHT_FILL else: cell.fill.fore_color.rgb = C_BG if ri % 2 == 1 else C_PANEL tf = cell.text_frame tf.margin_left = Cm(0.2) tf.margin_right = Cm(0.2) tf.vertical_anchor = MSO_ANCHOR.MIDDLE p = tf.paragraphs[0] p.alignment = PP_ALIGN.LEFT if ci == 0 else PP_ALIGN.CENTER set_run( p.add_run(), text, body_size, bold=(ci == 0) or is_highlight, color=HIGHLIGHT_BORDER if is_highlight else C_TEXT, ) def blank_slide(prs): return prs.slides.add_slide(prs.slide_layouts[6]) def add_arrow(slide, x1, y1, x2, y2, *, color=C_MUTED, width=1.5): line = slide.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, x1, y1, x2, y2) line.line.color.rgb = color line.line.width = Pt(width) return line # ─────────────────────── slides ─────────────────────── def slide_01_cover(prs, total): s = blank_slide(prs) add_notes(s, ( "발표 시작 — 인사 + 자기소개 짧게.\n\n" "오늘 주제 한 줄 — Spring Boot auth-server 를 Kubernetes(K3s) 위에서 사이드카 없이, " "운영자가 Vault 만 보고도 시크릿이 흐르게 어떻게 묶었는가.\n\n" "발표 흐름 — Auth boundary → Application → Data/Migration → Secret(Vault·VSO) → " "Runtime(K8s·Traefik) → 다음 검증 항목.\n\n" "시간 안내 (약 30 분 + Q&A). 질문은 중간에 끊어도 OK." )) # 한 줄 메인 카피 — 동사 + 결과 add_text_box( s, Cm(2), Cm(5.6), SLIDE_W - Cm(4), Cm(2.2), "Spring 인증 서버를 K3s 위에 올리기", size=44, bold=True, color=C_TEXT, ) add_text_box( s, Cm(2), Cm(8.1), SLIDE_W - Cm(4), Cm(1.2), "사이드카 없이, 운영자가 Vault 만 보고도 시크릿이 흐르게", size=22, color=C_MUTED, ) # accent bar bar = s.shapes.add_shape(MSO_SHAPE.RECTANGLE, Cm(2), Cm(10.3), Cm(2.5), Cm(0.18)) bar.fill.solid() bar.fill.fore_color.rgb = C_APP bar.line.fill.background() bar.shadow.inherit = False # 발표 범위 키워드 — 4 개만, 동등 위계 keywords = ["Auth Boundary", "Clean Architecture", "Vault → VSO", "K3s Runtime"] kw_top = Cm(11.3) kw_gap = Cm(0.4) kw_w = (SLIDE_W - Cm(4) - kw_gap * (len(keywords) - 1)) // len(keywords) for i, kw in enumerate(keywords): x = Cm(2) + (kw_w + kw_gap) * i add_panel(s, x, kw_top, kw_w, Cm(0.95), fill=C_PANEL, line=C_BORDER, line_w=0.6) add_text_box( s, x, kw_top + Cm(0.22), kw_w, Cm(0.55), kw, size=13, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER, ) add_text_box( s, Cm(2), Cm(14.4), SLIDE_W - Cm(4), Cm(0.8), "발표자 · DongHyeonka · Backend / Platform", size=16, bold=True, color=C_TEXT, ) add_text_box( s, Cm(2), Cm(15.3), SLIDE_W - Cm(4), Cm(0.7), "2026-05-16 · 약 30분 + Q&A", size=13, color=C_MUTED, ) add_slide_chrome(s, 1, total) def slide_02_goals(prs, total): s = blank_slide(prs) add_notes(s, ( "발표 범위 — 청중에게 '어떤 슬라이드들이 어떤 순서로 나오는지' 미리 보여주는 장.\n\n" "5 개 축을 좌→우 흐름으로 보여줍니다. Auth → Application → Data → Secret → Runtime. " "이 순서가 곧 슬라이드 순서이기도 합니다.\n\n" "'다음 검증 항목'은 여기서 다루지 않고 마무리 슬라이드에서 통합해서 다룹니다 — " "청중이 '현재 한 일' 과 '다음 할 일' 을 한 호흡으로 끊어서 받게 하기 위함." )) add_title(s, "발표 범위 — 5 축", "Auth → App → Data → Secret → Runtime 순서로 진행") axes = [ ("Auth\nBoundary", "oauth2-proxy 가 Ingress 단 로그인,\nauth-server 는 JWT 재검증", C_APP), ("Application\nArchitecture", "Clean Architecture 5 모듈,\nArchUnit 로 의존 방향 자동 검증", C_APP), ("Data\n& Migration", "Postgres 도메인 저장소,\nFlyway = ConfigMap + Job", C_DATA), ("Secret\nManagement", "Vault → VSO → K8s Secret,\nPod 는 Vault 를 모름", C_SECRET), ("K8s\nRuntime", "Kustomize base/overlay,\nPSS + NetworkPolicy default-deny", C_APP), ] n = len(axes) gap = Cm(0.45) card_w = (content_w() - gap * (n - 1)) // n card_top = Cm(4.6) card_h = Cm(6.6) for i, (head, body, color) in enumerate(axes): x = MARGIN_X + (card_w + gap) * i # number badge add_panel(s, x, card_top, card_w, card_h, fill=C_BG, line=color, line_w=1.6) add_text_box( s, x, card_top + Cm(0.35), card_w, Cm(0.7), f"0{i + 1}", size=22, bold=True, color=color, align=PP_ALIGN.CENTER, ) add_text_box( s, x + Cm(0.2), card_top + Cm(1.4), card_w - Cm(0.4), Cm(1.9), head, size=18, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER, ) add_text_box( s, x + Cm(0.3), card_top + Cm(3.6), card_w - Cm(0.6), Cm(2.7), body, size=11, color=C_MUTED, align=PP_ALIGN.CENTER, ) add_text_box( s, MARGIN_X, Cm(12.0), content_w(), Cm(0.7), "이번 발표는 '운영 완료' 가 아니라 '구성 정리' — 마지막에 다음 검증 항목으로 닫음", size=14, color=C_MUTED, align=PP_ALIGN.CENTER, ) add_caption_band(s, "5 축을 순서대로 — 같은 흐름이 슬라이드 순서이기도") add_slide_chrome(s, 2, total, "들어가며") def slide_03_overall(prs, total): s = slide_diagram_only( prs, "01-overall.png", 3, total, "전체 아키텍처", title="전체 아키텍처 — 요청 ⊥ 시크릿", subtitle="요청은 좌→우 3 계층, 시크릿은 하단에서 위로 — Pod 는 Vault 를 모름", caption="oauth2-proxy 가 Ingress 단 로그인, auth-server 가 JWT 재검증 — Edge / Resource Server 이중 경계", ) add_notes(s, ( "전체 흐름은 두 축 + 시크릿 한 축으로 보면 됩니다.\n\n" "■ 요청 흐름 (좌→우)\n" " 사용자 요청은 Traefik Ingress 로 들어오고, oauth2-proxy 가 로그인 세션을 확인합니다. " "로그인이 필요하면 Keycloak 으로 redirect 하고, 통과한 요청은 auth-server 로 전달됩니다.\n\n" "■ 검증 경계\n" " auth-server 는 oauth2-proxy 를 신뢰해서 끝내는 것이 아니라, Keycloak 이 발급한 JWT 를 " "Spring Security Resource Server 로 다시 검증합니다. 즉 Ingress 게이트와 Resource Server " "검증의 이중 경계.\n\n" "■ 시크릿 흐름 (하단 → 위)\n" " 운영자가 Vault 에 값을 넣고, VSO 가 Kubernetes Secret 으로 동기화합니다. " "Pod 는 Vault 를 직접 알지 않고 평범한 Kubernetes Secret 을 envFrom 으로 받습니다.\n\n" "①~⑤ 번호 따라 흐름 짚기:\n" "① Traefik (kube-system) — Ingress 진입\n" "② oauth2-proxy (mnt) — 로그인/세션 게이트\n" "③ auth-server — Spring Boot · CA · MDC traceId · JWT 재검증\n" "④ Postgres / MinIO — 데이터 + 객체 저장\n" "⑤ Vault → VSO → K8s Secret — Pod 는 envFrom 으로 받음\n\n" "강조 — Pod 는 Vault 를 모르고 평범한 K8s Secret 만 본다.\n" "단, VSO 방식에서는 Secret 이 Kubernetes Secret 으로 존재하므로 etcd 보호가 필요합니다 " "(다음 액션의 etcd encryption 항목으로 연결)." )) def slide_04_auth(prs, total): s = slide_diagram_only( prs, "02-auth-flow.png", 4, total, "세부 #1 · 인증 경계", title="① 인증 경계 — 이중 검증", subtitle="Edge(oauth2-proxy) 가 세션 게이트, App(Spring Resource Server) 이 JWT 재검증", caption="검증 경계 2 곳 — Edge 에서 미인증 차단 + App 에서 issuer / signature / exp / role claim 재검증", ) add_notes(s, ( "Key Message — oauth2-proxy 는 로그인/세션 게이트를 담당하고, auth-server 는 Keycloak " "JWT 를 다시 검증한다.\n\n" "여기서 중요한 건 인증을 한 번만 한다는 뜻이 아닙니다.\n" "브라우저 로그인, redirect, callback, 세션 확인은 oauth2-proxy 가 담당합니다. " "반면 auth-server 는 백엔드 API 서버이기 때문에 전달된 Keycloak JWT 의 issuer, signature, " "expiration, role claim 을 다시 검증합니다.\n\n" "그래서 이 구조는 'Ingress 에서 끝나는 인증' 이 아니라, " "Ingress 게이트와 Resource Server 검증을 분리한 구조입니다.\n\n" "Input: 브라우저의 GET /api/v1/auth/me (세션 쿠키 있을 수도, 없을 수도)\n" "Output: 검증된 principal + 권한이 채워진 요청 → 컨트롤러 도달\n\n" "흐름:\n" "A. 미인증 → oauth2-proxy 가 302 redirect → Keycloak 로그인 → 세션 쿠키 발급\n" "B. 인증된 요청 → oauth2-proxy 가 요청을 통과시키고 필요한 인증 정보를 전달\n" "C. auth-server → Authorization Bearer JWT 를 Resource Server 로 검증 " "(issuer-uri 환경변수, JWKS 자동 fetch + 캐시)\n\n" "왜 분리하는가 — App 코드는 로그인 플로우를 직접 다루지 않고, 검증된 principal 과 " "권한 조건을 전제로 HTTP 계약에 집중. callback / redirect / 쿠키 관리는 oauth2-proxy 가 담당.\n\n" "JwtAuthenticationConverter 가 Keycloak realm role → Spring Authority 변환." )) def slide_05_ca(prs, total): s = blank_slide(prs) add_notes(s, ( "Key Message — 레이어 의존 방향을 사람 리뷰가 아니라 ArchUnit 테스트로 검증한다.\n\n" "Gradle multi-module 5 개. 의존은 한 방향만 — presentation/infrastructure → application → domain. " "bootstrap 은 composition root — 모든 모듈 참조 OK.\n\n" "ArchUnit 규칙은 슬라이드 표면에 명시 (외울 필요 X — 어떤 종류의 위반이 자동 차단되는지 보면 됨).\n\n" "왜 테스트로? — 사람 리뷰는 빠질 수 있음. 테스트 실패는 PR 단계에서 차단. CI 연결 시 main 반영 전 자동." )) add_title(s, "② 의존 방향을 테스트로 잠근다", "Clean Architecture 5 모듈 + ArchUnit 자동 검증") # 좌측: 모듈 의존 다이어그램 diag_left = MARGIN_X diag_top = CONTENT_TOP_WIDE diag_w = Cm(15.0) diag_h = Cm(11.0) add_panel(s, diag_left, diag_top, diag_w, diag_h, fill=C_PANEL, line=C_BORDER, line_w=0.75) # 4 boxes vertically: presentation, infrastructure / application / domain box_w = Cm(6.0) box_h = Cm(1.5) center_x = diag_left + diag_w // 2 # row 1: presentation + infrastructure (top, 2 columns) row1_y = diag_top + Cm(1.0) pres_x = center_x - box_w - Cm(0.4) infra_x = center_x + Cm(0.4) add_panel(s, pres_x, row1_y, box_w, box_h, fill=C_BG, line=C_APP, line_w=1.2) add_text_box(s, pres_x, row1_y + Cm(0.45), box_w, Cm(0.6), "presentation", size=15, bold=True, mono=True, color=C_APP, align=PP_ALIGN.CENTER) add_panel(s, infra_x, row1_y, box_w, box_h, fill=C_BG, line=C_APP, line_w=1.2) add_text_box(s, infra_x, row1_y + Cm(0.45), box_w, Cm(0.6), "infrastructure", size=15, bold=True, mono=True, color=C_APP, align=PP_ALIGN.CENTER) # row 2: application (centered) row2_y = row1_y + Cm(2.8) app_x = center_x - box_w // 2 add_panel(s, app_x, row2_y, box_w, box_h, fill=C_BG, line=C_APP, line_w=1.2) add_text_box(s, app_x, row2_y + Cm(0.45), box_w, Cm(0.6), "application", size=15, bold=True, mono=True, color=C_APP, align=PP_ALIGN.CENTER) # row 3: domain (centered, bold border) row3_y = row2_y + Cm(2.8) dom_x = center_x - box_w // 2 add_panel(s, dom_x, row3_y, box_w, box_h, fill=C_APP_FILL, line=C_APP, line_w=2.0) add_text_box(s, dom_x, row3_y + Cm(0.45), box_w, Cm(0.6), "domain", size=17, bold=True, mono=True, color=C_APP, align=PP_ALIGN.CENTER) # arrows downward add_arrow(s, pres_x + box_w // 2, row1_y + box_h, app_x + box_w // 2, row2_y, color=C_APP) add_arrow(s, infra_x + box_w // 2, row1_y + box_h, app_x + box_w // 2, row2_y, color=C_APP) add_arrow(s, app_x + box_w // 2, row2_y + box_h, dom_x + box_w // 2, row3_y, color=C_APP) add_text_box( s, diag_left, row3_y + box_h + Cm(0.5), diag_w, Cm(0.7), "의존 방향은 한 방향만 — domain 은 그 누구도 모름", size=13, bold=True, color=C_APP, align=PP_ALIGN.CENTER, ) add_text_box( s, diag_left, row3_y + box_h + Cm(1.2), diag_w, Cm(0.6), "bootstrap (composition root) 만이 모든 모듈을 참조", size=11, color=C_MUTED, align=PP_ALIGN.CENTER, ) # 우측: ArchUnit 규칙 rules_left = diag_left + diag_w + Cm(0.5) rules_w = SLIDE_W - rules_left - MARGIN_X add_text_box( s, rules_left, diag_top, rules_w, Cm(0.7), "ArchUnit 이 차단하는 위반", size=15, bold=True, color=C_GOOD, ) rules = [ ("domain", "Spring / Hibernate / JPA / servlet 의존 0"), ("application", "presentation / infrastructure / Spring web 의존 0"), ("presentation", "domain / infrastructure 직접 의존 0"), ("presentation", "SecurityContext / Authentication 직접 접근 0"), ] rule_top = diag_top + Cm(1.0) for i, (layer, body) in enumerate(rules): y = rule_top + Cm(i * 2.1) add_panel(s, rules_left, y, rules_w, Cm(1.85), fill=C_BG, line=C_BORDER, line_w=0.75) add_text_box(s, rules_left + Cm(0.3), y + Cm(0.2), rules_w - Cm(0.6), Cm(0.55), layer, size=12, bold=True, mono=True, color=C_GOOD) add_text_box(s, rules_left + Cm(0.3), y + Cm(0.8), rules_w - Cm(0.6), Cm(1.0), body, size=12, color=C_TEXT) add_caption_band(s, "사람 리뷰는 놓친다 — 테스트로 잠그면 PR 단계에서 자동 차단") add_slide_chrome(s, 5, total, "세부 #2 · Clean Architecture") def slide_06_exception_policy(prs, total): """6 — 내부 에러 코드가 클라이언트 응답으로 나가지 못하게 타입으로 막는다.""" s = blank_slide(prs) add_notes(s, ( "Key Message — 내부 분류 코드와 외부 응답 코드를 sealed 타입으로 분리해서, " "응답 경로에 내부 코드가 새려고 하면 빌드가 실패하게 만들었다.\n\n" "런타임 if 검사가 아니라 컴파일 실패가 핵심.\n\n" "[backup] 구체 이름 — ErrorCode (sealed) → ClientFacingErrorCode / ExternalErrorCode. " "응답 매퍼는 ClientFacingErrorCode 만 인자로 받음." )) add_title(s, "내부 코드를 응답에 새지 않게 — 타입으로 잠근다", "런타임 검사 X · 컴파일 실패로 막는다") # 좌측: 분리된 코드 카테고리 2 카드 card_top = CONTENT_TOP_WIDE card_h = Cm(3.5) left_card_w = Cm(15.0) right_card_w = SLIDE_W - MARGIN_X * 2 - left_card_w - Cm(0.6) right_card_x = MARGIN_X + left_card_w + Cm(0.6) # 좌측 카드 1: Client-facing (응답에 나가는 코드) add_panel(s, MARGIN_X, card_top, left_card_w, card_h, fill=C_GOOD_FILL, line=C_GOOD, line_w=1.6) add_text_box(s, MARGIN_X + Cm(0.4), card_top + Cm(0.3), left_card_w - Cm(0.8), Cm(0.6), "응답에 나가는 코드", size=13, bold=True, color=C_GOOD) add_text_box(s, MARGIN_X + Cm(0.4), card_top + Cm(0.95), left_card_w - Cm(0.8), Cm(0.7), "Client-facing", size=24, bold=True, color=C_TEXT) add_text_box(s, MARGIN_X + Cm(0.4), card_top + Cm(2.0), left_card_w - Cm(0.8), Cm(1.3), "Common · Auth · Presentation 도메인의 안정된 계약 코드만.\n" "클라이언트가 보고 다음 액션을 결정할 수 있는 코드.", size=12, color=C_TEXT) # 좌측 카드 2 (아래): Internal-only card2_top = card_top + card_h + Cm(0.4) add_panel(s, MARGIN_X, card2_top, left_card_w, card_h, fill=C_DANGER_FILL, line=C_DANGER, line_w=1.6) add_text_box(s, MARGIN_X + Cm(0.4), card2_top + Cm(0.3), left_card_w - Cm(0.8), Cm(0.6), "내부 분류 전용", size=13, bold=True, color=C_DANGER) add_text_box(s, MARGIN_X + Cm(0.4), card2_top + Cm(0.95), left_card_w - Cm(0.8), Cm(0.7), "Internal-only", size=24, bold=True, color=C_TEXT) add_text_box(s, MARGIN_X + Cm(0.4), card2_top + Cm(2.0), left_card_w - Cm(0.8), Cm(1.3), "Infrastructure / 외부 어댑터 실패 원인 분류.\n" "운영 트리아지에만 쓰이고, 응답 경로 통과 시 빌드 실패.", size=12, color=C_TEXT) # 우측: 응답 매퍼 시그니처 박스 mapper_h = card_h * 2 + Cm(0.4) add_panel(s, right_card_x, card_top, right_card_w, mapper_h, fill=C_PANEL, line=C_TEXT, line_w=1.0) add_text_box(s, right_card_x + Cm(0.4), card_top + Cm(0.4), right_card_w - Cm(0.8), Cm(0.6), "응답 매퍼 시그니처", size=13, bold=True, color=C_MUTED) add_text_box(s, right_card_x + Cm(0.4), card_top + Cm(1.4), right_card_w - Cm(0.8), Cm(1.4), "map(\n ClientFacing\n) → HttpStatus", size=18, bold=True, mono=True, color=C_GOOD) add_text_box(s, right_card_x + Cm(0.4), card_top + Cm(4.6), right_card_w - Cm(0.8), Cm(2.5), "Internal-only 코드를 인자로 넘기면\n→ 컴파일 실패\n" "→ CI 가 PR 단계에서 차단", size=12, color=C_TEXT) add_text_box( s, MARGIN_X, Cm(13.2), content_w(), Cm(0.8), "내부 분류는 운영을 위해 남기되, 응답 계약에는 노출하지 않는다", size=17, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER, ) add_caption_band(s, "타입이 정책이다 — 컴파일러가 실수를 먼저 잡는다") add_slide_chrome(s, 6, total, "세부 #2-A · Exception 정책") def slide_06a_exception_layers(prs, total): """7 — 예외 발생 위치를 기준으로 핸들러 책임을 나눈다.""" s = blank_slide(prs) add_notes(s, ( "Key Message — 예외가 '어디에서' 발생했는가에 따라 잡는 위치가 다르다. 운영 시 원인 분류 목적.\n\n" "표는 클래스 이름이 아니라 '실패 지점' 과 '응답 의미' 만 노출. 청중이 클래스 5 개를 외울 이유는 없음.\n\n" "[backup] 실제 핸들러 — InfrastructureExceptionHandler / SecurityResponseExceptionHandler / " "ValidationExceptionHandler / RequestExceptionHandler / ApplicationExceptionHandler. " "필터 체인은 SecurityExceptionHandler, advice 밖은 ApiErrorController.\n\n" "advice 밖 = sendError / 이중 폴트 / 우회 경로. 이런 경로도 응답 envelope 통일." )) add_title(s, "실패 지점별로 잡는 곳을 분리", "어디서 실패했는지가 보이게 — 다음 조치가 빨라진다") headers = ["실패 지점", "응답 / 운영 의미"] rows = [ ("DB / Vault / 외부 HTTP 어댑터", "내부 원인은 숨기고 COMMON-999 로 정규화"), ("Spring Security 필터", "인증 상태에 따라 401 / 403 분기 (다음 슬라이드)"), ("Bean Validation", "필드 단위 errors map 으로 입력 오류 반환"), ("Spring MVC 입력 파싱", "파라미터 / body / upload 등 요청 형식 오류"), ("Application use case", "BusinessException + 최후 안전망"), ] add_table( s, MARGIN_X, CONTENT_TOP_WIDE, headers, rows, col_widths_cm=[13.0, 18.5], row_h_cm=1.25, body_size=13, header_size=14, ) # advice 밖 안전망 — 핵심 인사이트 panel_top = Cm(10.6) add_panel(s, MARGIN_X, panel_top, content_w(), Cm(2.4), fill=C_WARN_FILL, line=C_WARN_LINE, line_w=1.4) add_text_box(s, MARGIN_X + Cm(0.5), panel_top + Cm(0.3), Cm(12), Cm(0.6), "advice 가 못 보는 경로도 envelope 통일", size=15, bold=True, color=C_WARN_TEXT) add_text_box(s, MARGIN_X + Cm(0.5), panel_top + Cm(1.05), content_w() - Cm(1.0), Cm(1.2), "필터 체인 인증/인가 예외 + sendError / 이중 폴트 / 우회 경로 — " "전용 안전망 핸들러로 같은 응답 형식 보장", size=12, color=C_TEXT) add_caption_band(s, "클래스 이름이 아니라 실패 지점 — 운영 트리아지의 1차 분류") add_slide_chrome(s, 7, total, "세부 #2-B · Exception 계층") def slide_06b_envelope(prs, total): """8 — 실패 응답 계약: envelope, status 보존, 401/403 분기.""" s = blank_slide(prs) add_notes(s, ( "Key Message — 응답 형식은 통일하되, HTTP 의미는 뭉개지 않는다.\n\n" "3 카드:\n" "① envelope — success/code/message/data/errors/traceId/timestamp. data 와 errors 분리, null 직렬화 제외.\n" "② 401 vs 403 — 익명에게 403 을 주면 클라이언트가 재로그인 플로우를 시작 못 함. 그래서 익명=401, 인증된=403.\n" "③ status 보존 — /error 에서 404→500 둔갑 방지. LB 헬스체크와 SDK 재시도 정책 보호.\n\n" "[backup] 실제 envelope 클래스 = ApiResult. 검증 근거 = JaCoCo Tier1 95%+, PIT app 94% / pres 77%, jqwik 속성 테스트." )) add_title(s, "실패 응답 3 계약 — 클라이언트가 다음 행동을 결정 가능하게", "형식은 통일, HTTP 의미는 보존") n = 3 gap = Cm(0.5) col_w = (content_w() - gap * (n - 1)) // n card_top = CONTENT_TOP_WIDE card_h = Cm(7.4) cards = [ ("01", "envelope 통일", "{ success, code, message,\n data, errors,\n traceId, timestamp }", "data 와 errors 분리 — 성공/실패 응답이 섞이지 않음"), ("02", "401 vs 403", "익명 → 401\n인증된 사용자 → 403", "익명에게 403 주면 클라이언트가 로그인 / 재발급 플로우 시작 불가"), ("03", "status 보존", "404 → 404\n500 → 500", "/error 에서 컨테이너 결정 status 유지 — LB · SDK 정책 보호"), ] for i, (num, head, mid, body) in enumerate(cards): x = MARGIN_X + (col_w + gap) * i add_panel(s, x, card_top, col_w, card_h, fill=C_BG, line=C_APP, line_w=1.4) add_text_box(s, x, card_top + Cm(0.3), col_w, Cm(0.6), num, size=14, bold=True, color=C_MUTED, align=PP_ALIGN.CENTER) add_text_box(s, x + Cm(0.3), card_top + Cm(1.0), col_w - Cm(0.6), Cm(0.8), head, size=18, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER) add_panel(s, x + Cm(0.4), card_top + Cm(2.1), col_w - Cm(0.8), Cm(2.6), fill=C_APP_FILL, line=C_APP, line_w=0.8) add_text_box(s, x + Cm(0.5), card_top + Cm(2.3), col_w - Cm(1.0), Cm(2.3), mid, size=13, bold=True, mono=True, color=C_APP, align=PP_ALIGN.CENTER) add_text_box(s, x + Cm(0.5), card_top + Cm(5.0), col_w - Cm(1.0), Cm(2.2), body, size=11, color=C_TEXT, align=PP_ALIGN.CENTER) add_text_box( s, MARGIN_X, Cm(13.0), content_w(), Cm(0.8), "검증 근거 — JaCoCo Tier1 95%+ · PIT app 94% / pres 77% · jqwik 속성 테스트", size=12, color=C_MUTED, align=PP_ALIGN.CENTER, ) add_caption_band(s, "응답 형식은 통일, HTTP 의미는 뭉개지 않는다") add_slide_chrome(s, 8, total, "세부 #2-C · Exception envelope") def slide_07_logging_flow(prs, total): """9 — traceId 하나로 응답과 앱 로그를 묶는다.""" s = blank_slide(prs) add_notes(s, ( "Key Message — 같은 traceId 값이 응답 헤더와 앱 로그에 동시에 박혀, 운영 트리아지 시 한 개 값으로 좁혀간다.\n\n" "MDC 가 비면 응답에 sentinel '-' 를 노출 → '필터 누락' 자체를 숨기지 않음.\n\n" "[backup] 실제 컴포넌트 — TraceIdFilter (servlet filter) → MDC.put(\"traceId\", ...). " "logback 패턴은 %X{traceId}. RequestBoundApiResultFactory 가 응답 envelope 에 같은 값을 채움.\n" "프로파일 appender 4 종 — local/dev CONSOLE, prod logstash JSON, audit-file RollingFileAppender." )) add_title(s, "traceId 하나로 응답과 로그를 묶는다", "MDC 가 비면 sentinel '-' 로 노출 — 필터 누락을 숨기지 않음") # 가로 흐름: 요청 → 필터 → MDC → 출력 2 개 flow_top = Cm(5.5) node_h = Cm(1.7) # 좌측 3 박스 (요청 / 필터 / MDC) upstream = [ ("HTTP 요청", "", C_APP, False), ("traceId 발급", "(servlet filter)", C_APP, True), ("MDC", "(per-request context)", C_APP, True), ] up_x = Cm(1.2) up_w = Cm(6.0) up_gap = Cm(0.6) for i, (head, sub, color, mono) in enumerate(upstream): x = up_x + (up_w + up_gap) * i add_panel(s, x, flow_top, up_w, node_h, fill=C_BG, line=color, line_w=1.4) add_text_box(s, x, flow_top + Cm(0.3), up_w, Cm(0.7), head, size=17, bold=True, mono=mono, color=color, align=PP_ALIGN.CENTER) if sub: add_text_box(s, x, flow_top + Cm(1.0), up_w, Cm(0.55), sub, size=10, color=C_MUTED, align=PP_ALIGN.CENTER) if i > 0: prev_x = up_x + (up_w + up_gap) * (i - 1) add_arrow(s, prev_x + up_w, flow_top + node_h // 2, x, flow_top + node_h // 2, color=C_APP) # 우측 2 출력 박스 (응답 헤더 / 앱 로그) out_x = up_x + (up_w + up_gap) * 3 + Cm(0.6) out_w = SLIDE_W - out_x - MARGIN_X outputs = [ ("응답 헤더", "X-Trace-Id", "클라이언트가 동일 ID 로 추적"), ("앱 로그", "%X{traceId}", "운영자가 같은 ID 로 요청 흐름 검색"), ] out_h = Cm(1.7) out_gap = Cm(0.4) mdc_x = up_x + (up_w + up_gap) * 2 for i, (label, code, sub) in enumerate(outputs): y = flow_top - Cm(0.5) + (out_h + out_gap) * i + (Cm(0.5) if i == 0 else Cm(0.5)) # adjust: spread vertically around flow_top y = flow_top - Cm(0.7) + (out_h + out_gap) * i + (out_h + out_gap) * 0 add_panel(s, out_x, y, out_w, out_h, fill=C_APP_FILL, line=C_APP, line_w=1.2) add_text_box(s, out_x + Cm(0.3), y + Cm(0.18), out_w - Cm(0.6), Cm(0.45), label, size=11, bold=True, color=C_MUTED) add_text_box(s, out_x + Cm(0.3), y + Cm(0.65), out_w - Cm(0.6), Cm(0.55), code, size=15, bold=True, mono=True, color=C_APP) add_text_box(s, out_x + Cm(0.3), y + Cm(1.18), out_w - Cm(0.6), Cm(0.5), sub, size=10, color=C_MUTED) add_arrow(s, mdc_x + up_w, flow_top + node_h // 2, out_x, y + out_h // 2, color=C_APP) # 강조 패널 — MDC empty → '-' sentinel_top = Cm(10.0) add_panel(s, MARGIN_X, sentinel_top, content_w(), Cm(2.5), fill=C_WARN_FILL, line=C_WARN_LINE, line_w=1.4) add_text_box(s, MARGIN_X + Cm(0.5), sentinel_top + Cm(0.3), Cm(20), Cm(0.6), "필터 누락도 숨기지 않는다", size=15, bold=True, color=C_WARN_TEXT) add_text_box(s, MARGIN_X + Cm(0.5), sentinel_top + Cm(1.05), content_w() - Cm(1.0), Cm(1.3), "MDC 가 비어 있으면 응답 traceId 필드에 sentinel \"-\" 노출 — " "JSON null 로 가리지 않아 'TraceIdFilter 가 빠졌다' 가 즉시 보임", size=12, color=C_TEXT) add_caption_band(s, "운영 트리아지는 traceId 한 개로 요청 단위 흐름을 좁혀간다") add_slide_chrome(s, 9, total, "세부 #2-D · Logging traceId") def slide_08_data(prs, total): s = slide_diagram_only( prs, "04-data.png", 10, total, "세부 #3 · 데이터 레이어", title="③ 객체 저장소를 1 개로 통일 — MinIO", subtitle="docker-registry 는 자기 PV 0 — REGISTRY_STORAGE=s3 로 MinIO 에 위임", caption="앱 데이터 = Postgres · 객체 = MinIO 한 곳. 백업 / 용량 / 모니터링이 객체 저장소 1 군데로 모임", ) add_notes(s, ( "Key Message — PostgreSQL 은 auth-server 데이터, MinIO 는 object storage 와 registry " "backend 를 담당한다.\n\n" "발표 멘트 — auth-server 관점에서 핵심 데이터 저장소는 PostgreSQL 입니다. " "사용자 식별 정보와 Flyway migration 대상 schema 가 여기에 있습니다.\n\n" "반면 MinIO 는 auth-server 의 도메인 저장소라기보다는 infra 쪽 object storage 입니다. " "Docker Registry 가 자체 PV 를 갖지 않고 MinIO S3 backend 를 사용하도록 구성했습니다.\n\n" "즉 애플리케이션 데이터와 플랫폼 object storage 책임을 분리한 구조입니다.\n\n" "Postgres — schema: auth, 5 마이그레이션 (V1~V5). Flyway Job 이 별도 적용 (다음 슬라이드).\n" "MinIO Tenant — MinIO Operator 로 띄움. buckets: auth · docker-registry.\n\n" "registry 는 자체 PV 0 — REGISTRY_STORAGE=s3 + REGISTRY_STORAGE_S3_REGIONENDPOINT. " "객체 저장소 인프라 1 개로 통일해 백업 / 용량 / 모니터링을 한 군데에서.\n\n" "이미지 push / containerd image pull 은 별도 슬라이드 (S08-B, 발표 시간 따라)." )) def slide_09_flyway(prs, total): s = slide_diagram_only( prs, "05-flyway-evolution.png", 11, total, "세부 #4 · Flyway 진화", title="④ Flyway = ConfigMap + Job (이미지 빌드 사이클 제거)", subtitle="현재 ~2 KB · 회귀 기준 = 1 MiB · 도달 시 이미지 내장 또는 별도 artifact 로 전환", caption="SQL 한 줄 변경 = ConfigMap 갱신 + 표준 Flyway Job 재실행 (registry 빌드/push 없음)", ) add_notes(s, ( "Key Message — 단순함이 우선인 단계엔 ConfigMap. 단, 1 MiB 한계 (회귀 기준) 명시.\n\n" "Before — SQL → migration-jar → 이미지 빌드 + push → Job pull. SQL 1 줄 변경마다 빌드.\n" "After — Git SQL → ConfigMap mount → 공식 flyway:10.20.1 이미지 + Job. SQL 변경 = ConfigMap 갱신.\n\n" "지금 ~2 KB 라 여유 충분. 회귀 조건이 명확해야 결정이 가벼움." )) def slide_10_vault_compare(prs, total): """12 — Vault Injector vs VSO: 3 단점을 한 화면에 통합""" s = blank_slide(prs) add_notes(s, ( "Key Message — Vault Injector 를 안 고른 3 가지 이유. 다음 슬라이드(VSO 진화) 의 전제.\n\n" "이건 Injector 적대가 아니라, '우리 환경(단일 노드 K3s dev) 기준' 의 선택. " "대규모 운영팀 / 풍부한 메모리 / 사이드카 운영 경험 충분한 환경에서는 Injector 가 정답일 수도.\n\n" "3 단점:\n" "① 의존 사슬 — auto-unseal(KMS) → Vault → K8s Secret → Pod 모두 직렬. KMS 한 점이 클러스터 전체 영향.\n" " 더 깊게 — auto-unseal token 자체가 K8s Secret 으로 저장되어 etcd 에 남음. Vault 도입 동기와 충돌.\n" "② 사이드카 비용 — 로컬 측정 156 Mi/Pod (vault-agent 1.17.2, idle, kubectl top 3 회 동일). " " *주의* 표본 1, idle baseline. 정상 운영 시 +20~50 Mi 가능. 정확한 절대값보다 'Pod N = 사이드카 N' 의 선형성 메시지.\n" "③ K8s 네이티브 X — Pod 어노테이션 기반. GitOps(ArgoCD/Flux)가 sync 됐는지 자체를 검증 불가.\n" " VSO 는 CRD 기반이라 status 필드로 reconcile 결과를 외부에 노출.\n\n" "결론 — 우리 환경에서는 VSO. (다음 슬라이드)" )) add_title(s, "⑤ Vault Injector 를 안 고른 3 가지 이유", "우리 환경(단일 노드 K3s dev) 기준 — 대규모 운영에서는 Injector 도 정답일 수 있음") # 3 카드 가로 배치 n = 3 gap = Cm(0.5) col_w = (content_w() - gap * (n - 1)) // n card_top = CONTENT_TOP_WIDE card_h = Cm(8.4) cards = [ { "num": "01", "head": "의존 사슬", "viz": "KMS → Vault → K8s Secret\n→ Pod → 모든 워크로드", "body": "auto-unseal 채택 시 KMS 한 점이 전체 직렬화.\nunseal token 도 K8s Secret 으로 etcd 에 남음 — 도입 동기 자기충돌", }, { "num": "02", "head": "사이드카 비용 (선형)", "viz": "≈ 156 Mi / Pod", "body": "vault-agent 1.17.2 · idle · 로컬 단일 측정.\n표본 1 · 정상 운영 +20~50 Mi 가능 — 절대값보다 'Pod N = 사이드카 N' 메시지가 핵심", }, { "num": "03", "head": "K8s 네이티브 X", "viz": "Pod annotation\n(K8s 1급 객체 X)", "body": "Injector 는 어노테이션 기반 동적 주입.\nGitOps(ArgoCD/Flux)가 sync 결과를 검증 못 함 — VSO 는 CRD status 로 공개", }, ] for i, c in enumerate(cards): x = MARGIN_X + (col_w + gap) * i add_panel(s, x, card_top, col_w, card_h, fill=C_BG, line=C_DANGER, line_w=1.4) add_text_box(s, x, card_top + Cm(0.3), col_w, Cm(0.6), c["num"], size=14, bold=True, color=C_DANGER, align=PP_ALIGN.CENTER) add_text_box(s, x + Cm(0.3), card_top + Cm(1.0), col_w - Cm(0.6), Cm(0.8), c["head"], size=18, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER) # viz block add_panel(s, x + Cm(0.4), card_top + Cm(2.2), col_w - Cm(0.8), Cm(2.8), fill=C_DANGER_FILL, line=C_DANGER, line_w=0.8) add_text_box(s, x + Cm(0.5), card_top + Cm(2.5), col_w - Cm(1.0), Cm(2.3), c["viz"], size=13, bold=True, mono=True, color=C_DANGER, align=PP_ALIGN.CENTER) # body add_text_box(s, x + Cm(0.5), card_top + Cm(5.3), col_w - Cm(1.0), Cm(2.9), c["body"], size=11, color=C_TEXT, align=PP_ALIGN.LEFT) # 다음 슬라이드 연결 한 줄 add_text_box( s, MARGIN_X, Cm(13.0), content_w(), Cm(0.8), "→ 다음 슬라이드: 같은 Vault 를 유지하면서 Pod-Vault 접점만 바꾼 해법 (VSO)", size=14, bold=True, color=C_GOOD, align=PP_ALIGN.CENTER, ) add_caption_band(s, "이건 Injector 적대가 아니라 우리 환경 기준의 선택") add_slide_chrome(s, 12, total, "세부 #5 · Vault 운영 비교") def slide_11_vault_vso(prs, total): s = slide_diagram_only( prs, "06-vault-evolution.png", 13, total, "세부 #5 · VSO 진화", title="⑤ VSO — Pod 와 Vault 사이 접점만 바꾼다", subtitle="사이드카 0 · Pod 는 평범한 K8s Secret 만 envFrom · 다음 경계 = etcd", caption="운영자는 Vault 를 source of truth 로 · 애플리케이션은 K8s Secret 만 소비", ) add_notes(s, ( "Key Message — Vault 를 빼는 게 아니라 'Pod ↔ Vault' 접점만 바꾼다. Pod 는 Vault 를 모름.\n\n" "CRD 3 종 — VaultConnection (연결), VaultAuth (K8s auth), VaultStaticSecret (KV → K8s Secret 매핑, 현재 7 개+).\n\n" "트레이드오프 — VSO 가 만드는 K8s Secret 은 etcd 에 base64 로 저장. 다음 슬라이드(etcd 결정 트리)에서 닫음.\n\n" "이 슬라이드 단일 메시지 = '접점만 바꿨다'. 사이드카 / 어노테이션 / 비용 비교는 이전 슬라이드(12)에서 마쳤음 — 여기선 반복 X." )) def slide_12_etcd_encryption(prs, total): """14 — etcd encryption-at-rest: 환경별 결정 트리 + 닭달걀 경고""" s = blank_slide(prs) add_notes(s, ( "Key Message — VSO 채택의 부수 책임 = etcd 보호. 환경별 권장 옵션이 다르고, " "Vault transit 으로 etcd 를 암호화하면 닭달걀 (Vault Pod 가 K8s Secret 을 etcd 에서 읽어야 시작).\n\n" "현재 상태 — dev 환경에서 K3s --secrets-encryption 은 *다음 액션*. 아직 미적용. " "마무리 슬라이드의 다음 검증 항목으로 연결.\n\n" "[backup] 옵션별 키 관리:\n" "• K3s --secrets-encryption — AES-CBC 자동, 키는 노드 디스크\n" "• kubeadm EncryptionConfiguration — KMS provider 와 결합\n" "• 클라우드 KMS — AWS KMS / GCP KMS provider" )) add_title(s, "⑤-C etcd 보호 — 환경별 결정", "Vault transit 으로 etcd 암호화 시 닭달걀 — 단일 Vault 에선 별도 provider 필수") # 결정 카드 3 개 n = 3 gap = Cm(0.5) col_w = (content_w() - gap * (n - 1)) // n card_top = CONTENT_TOP_WIDE card_h = Cm(6.0) cards = [ ("dev / 단일 노드", "K3s\n--secrets-encryption", "한 줄 옵션 · AES-CBC 자동 · 추가 인프라 0", "현재 다음 액션 후보", C_GOOD), ("표준 kubeadm", "EncryptionConfiguration\n+ KMS provider", "외부 KMS 와 결합 · 키 회전 정책 관리 필요", "운영 환경 표준", C_APP), ("클라우드", "AWS KMS / GCP KMS\nprovider", "클라우드 관리형 키 · IAM 위임 · 가장 손쉬움", "managed 환경 정답", C_APP), ] for i, (env, opt, body, hint, color) in enumerate(cards): x = MARGIN_X + (col_w + gap) * i add_panel(s, x, card_top, col_w, card_h, fill=C_BG, line=color, line_w=1.4) add_text_box(s, x, card_top + Cm(0.25), col_w, Cm(0.6), env, size=12, bold=True, color=C_MUTED, align=PP_ALIGN.CENTER) add_text_box(s, x + Cm(0.3), card_top + Cm(0.9), col_w - Cm(0.6), Cm(1.6), opt, size=15, bold=True, mono=True, color=color, align=PP_ALIGN.CENTER) add_text_box(s, x + Cm(0.4), card_top + Cm(2.8), col_w - Cm(0.8), Cm(2.0), body, size=11, color=C_TEXT, align=PP_ALIGN.CENTER) add_text_box(s, x + Cm(0.4), card_top + Cm(4.9), col_w - Cm(0.8), Cm(0.7), hint, size=11, bold=True, color=color, align=PP_ALIGN.CENTER) # 닭달걀 경고 패널 — 핵심 인사이트 chick_top = Cm(11.0) add_panel(s, MARGIN_X, chick_top, content_w(), Cm(2.4), fill=C_WARN_FILL, line=C_WARN_LINE, line_w=1.5) add_text_box(s, MARGIN_X + Cm(0.5), chick_top + Cm(0.25), Cm(20), Cm(0.6), "닭과 달걀 — Vault 로 etcd 를 직접 풀 수 없다", size=15, bold=True, color=C_WARN_TEXT) add_text_box(s, MARGIN_X + Cm(0.5), chick_top + Cm(0.95), content_w() - Cm(1.0), Cm(1.4), "Vault Pod 도 K8s Secret(설정 · TLS · token) 을 etcd 에서 읽어야 시작 → " "etcd 복호화 = Vault 호출 = Vault 가 다시 etcd 필요. " "단일 Vault 환경에선 별도 provider Vault 또는 다른 KMS 필수.", size=12, color=C_TEXT) add_caption_band(s, "VSO 가 K8s Secret 을 만들면 etcd 보호가 다음 경계 — 닭달걀 함정 인지") add_slide_chrome(s, 14, total, "세부 #5-C · etcd 보호") def slide_13_k8s_decisions(prs, total): s = blank_slide(prs) add_notes(s, ( "Key Message — '기본은 막고 필요한 것만 연다'. 4 개 결정은 모두 '변경 영향 면 축소' 목적.\n\n" "시간 짧으면 #1, #3 만. base/overlay = 환경 차이 격리, NetworkPolicy default-deny = 보안 표면 코드화.\n\n" "[backup] YAML 예시 — podSelector: {} + Ingress/Egress 양쪽 빈 정책 = 완전 차단. " "그 위에 컴포넌트별 allow 만 추가. 파일 위치 = " "gitops/policies/baseline/overlays/lab/networkpolicy.yaml." )) add_title(s, "⑥ K8s 설정 4 결정 — 기본 deny, 명시적 allow", "환경 차이는 overlay 로, 보안 표면은 코드로 정의") headers = ["#", "결정", "줄어드는 것"] rows = [ ("1", "base / overlay 분리", "환경 차이를 한 곳에만 — base 수정 1 번 = 모든 환경 자동 반영"), ("2", "PSS Restricted enforce", "runAsNonRoot · readOnlyRootFilesystem · capabilities=DROP ALL — 위반 시 admission 차단"), ("3", "NetworkPolicy default-deny", "기본 거부 + 명시 허용 — 신규 Pod 의 의도하지 않은 통신 가능성"), ("4", "ingressClassName 명시", "annotation 의존 X — 다중 ingress 환경에서 라우팅 모호성"), ] add_table( s, MARGIN_X, CONTENT_TOP_WIDE, headers, rows, col_widths_cm=[1.5, 8.5, 21.5], header_fill=C_PANEL, row_h_cm=1.4, body_size=13, header_size=14, ) # 핵심 결정 #3 강조 — 라이트 코드 패널 code_top = Cm(10.0) code_w = content_w() add_text_box( s, MARGIN_X, code_top, code_w, Cm(0.6), "#3 default-deny — 한 매니페스트가 전체 통신을 막고, allow 만 추가하게 한다", size=12, bold=True, color=C_APP, ) add_panel(s, MARGIN_X, code_top + Cm(0.7), code_w, Cm(3.4), fill=C_PANEL, line=C_APP, line_w=1.0) add_text_box( s, MARGIN_X + Cm(0.4), code_top + Cm(0.85), code_w - Cm(0.8), Cm(3.2), "kind: NetworkPolicy\n" "spec:\n" " podSelector: {} # 모든 Pod\n" " policyTypes: [Ingress, Egress] # 양쪽 모두 명시\n" " # ingress / egress 둘 다 비어있음 → 완전 차단", size=12, mono=True, color=C_TEXT, ) add_caption_band(s, "보안 표면을 코드로 정의 — drift 도, 실수도 동시에 줄어든다") add_slide_chrome(s, 15, total, "세부 #6 · K8s 결정") def slide_14_traefik(prs, total): s = slide_diagram_only( prs, "07-traefik-pipeline.png", 16, total, "세부 #7 · Traefik", title="⑦ Traefik — 운영 정책을 Middleware 한 줄 토글", subtitle="K3s packaged Traefik 유지 · 코어 manifest 0 수정 · HelmChartConfig overlay 로 GitOps", caption="dev 는 셀프사인 인증서 발급 이슈로 HTTP 운영 중 — cert-manager + 외부 DNS 가 다음 액션", ) add_notes(s, ( "Key Message — Traefik 자체를 새로 설치하지 않는다. K3s 기본 Traefik 을 유지하면서 " "운영 정책만 HelmChartConfig + Middleware 로 GitOps 화.\n\n" "dev TLS 비활성 컨텍스트 — 셀프사인 인증서로 oauth2-proxy ForwardAuth 셋업 중 신뢰 체인 이슈가 있어, " "dev 단계에선 HTTP 로 운영. 운영 환경 표준은 cert-manager + 외부 DNS + Let's Encrypt (마무리 슬라이드 다음 액션).\n\n" "[backup] Middleware 체인 — https-redirect / security-headers / (registry 만) BasicAuth. " "TLSOption modern-tls = TLS 1.2+ strict SNI. " "라우터 annotation = traefik.ingress.kubernetes.io/router.middlewares 에 체인 명시. " "namespace prefix 주의 — kube-system-* (Traefik 위치) / mnt-* (워크로드 위치)." )) def slide_16_next(prs, total): s = blank_slide(prs) add_notes(s, ( "Closing — 한 줄 메시지: 백엔드는 애플리케이션 내부만으로 완성되지 않는다. " "인증 → 시크릿 → 런타임 순서로 경계를 나누고 닫는 작업이 같이 가야 운영 가능한 구조가 된다.\n\n" "다음 액션은 우선순위 3 개로 추렸음:\n" "• cert-manager + 외부 DNS — dev TLS 셀프사인 이슈 해소, ssl_insecure_skip_verify 제거\n" "• ForwardAuth E2E 검증 — 실제 사용자 세션 / 로그인 / 로그아웃 / 차단 흐름 통과\n" "• etcd encryption-at-rest — K3s --secrets-encryption 부터\n\n" "(시간 있으면) backup 3 — registries.yaml 자동화 · CORS 책임 위치 · presentation-http/grpc 분리.\n\n" "Q&A 안내 — 질문 받음. 예상 질문:\n" "• '왜 K3s 인가' → dev 학습 환경, 단일 노드 적합, 같은 K8s API.\n" "• 'ArchUnit 빌드 시간' → 테스트 단계만, +수 초.\n" "• 'Vault 단일 노드 prod 가능?' → file backend 라 HA 불가. raft 전환 필요." )) add_title(s, "한 줄 정리 + 다음 액션") # 단일 closing 메시지 — 중앙 큰 박스 msg_top = Cm(3.6) msg_h = Cm(3.2) add_panel(s, MARGIN_X, msg_top, content_w(), msg_h, fill=C_APP_FILL, line=C_APP, line_w=1.6) add_text_box( s, MARGIN_X + Cm(0.5), msg_top + Cm(0.4), content_w() - Cm(1.0), Cm(1.0), "백엔드는 애플리케이션 내부만으로 완성되지 않는다", size=22, bold=True, color=C_TEXT, align=PP_ALIGN.CENTER, ) add_text_box( s, MARGIN_X + Cm(0.5), msg_top + Cm(1.5), content_w() - Cm(1.0), Cm(1.5), "인증 → 시크릿 → 런타임 순서로 경계를 나누고 닫아야 운영 가능한 구조가 된다", size=15, color=C_MUTED, align=PP_ALIGN.CENTER, ) # 다음 액션 — top 3 만 act_top = Cm(7.8) add_text_box( s, MARGIN_X, act_top, content_w(), Cm(0.7), "다음 액션 — 우선순위 3", size=14, bold=True, color=C_DANGER, ) actions = [ ("01", "cert-manager + 외부 DNS", "dev TLS 셀프사인 이슈 해소 · ssl_insecure_skip_verify 제거"), ("02", "ForwardAuth E2E 검증", "실제 사용자 로그인 / 로그아웃 / 차단 흐름 통과 확인"), ("03", "etcd encryption-at-rest", "K3s --secrets-encryption 부터 (VSO 채택의 다음 경계)"), ] n = 3 gap = Cm(0.4) a_w = (content_w() - gap * (n - 1)) // n a_top = act_top + Cm(0.85) a_h = Cm(3.6) for i, (num, head, body) in enumerate(actions): x = MARGIN_X + (a_w + gap) * i add_panel(s, x, a_top, a_w, a_h, fill=C_BG, line=C_DANGER, line_w=1.2) add_text_box(s, x + Cm(0.3), a_top + Cm(0.2), a_w - Cm(0.6), Cm(0.55), num, size=11, bold=True, color=C_DANGER) add_text_box(s, x + Cm(0.3), a_top + Cm(0.8), a_w - Cm(0.6), Cm(0.7), head, size=15, bold=True, color=C_TEXT) add_text_box(s, x + Cm(0.3), a_top + Cm(1.7), a_w - Cm(0.6), Cm(1.8), body, size=11, color=C_MUTED) # backup 3 — 한 줄 작게 add_text_box( s, MARGIN_X, Cm(13.0), content_w(), Cm(0.6), "backup — registries.yaml 자동화 · CORS 책임 위치 · presentation-http/grpc 분리", size=11, color=C_MUTED, align=PP_ALIGN.CENTER, ) add_caption_band(s, "Q & A — 질문 받겠습니다") add_slide_chrome(s, 17, total, "마무리") # ─────────────────────── build ─────────────────────── def build(): prs = Presentation() prs.slide_width = SLIDE_W prs.slide_height = SLIDE_H total = 17 # 들어가며 slide_01_cover(prs, total) slide_02_goals(prs, total) # ① Auth boundary slide_03_overall(prs, total) slide_04_auth(prs, total) # ② Application slide_05_ca(prs, total) slide_06_exception_policy(prs, total) slide_06a_exception_layers(prs, total) slide_06b_envelope(prs, total) slide_07_logging_flow(prs, total) # ③ Data slide_08_data(prs, total) slide_09_flyway(prs, total) # ④ Secret (Vault Injector 단점 → VSO → etcd) slide_10_vault_compare(prs, total) slide_11_vault_vso(prs, total) slide_12_etcd_encryption(prs, total) # ⑤ Runtime slide_13_k8s_decisions(prs, total) slide_14_traefik(prs, total) # 마무리 slide_16_next(prs, total) OUT.parent.mkdir(parents=True, exist_ok=True) prs.save(OUT) print(f"saved → {OUT}") if __name__ == "__main__": build()