Files

1240 lines
57 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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<T>. 검증 근거 = 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()