791 lines
51 KiB
Swift
791 lines
51 KiB
Swift
import AppKit
|
||
import CoreGraphics
|
||
import Foundation
|
||
|
||
let outPath = CommandLine.arguments.count > 1
|
||
? CommandLine.arguments[1]
|
||
: "docs/backend-advanced-concepts-ko.pdf"
|
||
|
||
let W: CGFloat = 595
|
||
let H: CGFloat = 842
|
||
let margin: CGFloat = 44
|
||
|
||
let navy = NSColor(calibratedRed: 0.055, green: 0.086, blue: 0.16, alpha: 1)
|
||
let ink = NSColor(calibratedRed: 0.10, green: 0.13, blue: 0.18, alpha: 1)
|
||
let muted = NSColor(calibratedRed: 0.37, green: 0.42, blue: 0.50, alpha: 1)
|
||
let paper = NSColor(calibratedRed: 0.975, green: 0.98, blue: 0.99, alpha: 1)
|
||
let line = NSColor(calibratedRed: 0.86, green: 0.88, blue: 0.92, alpha: 1)
|
||
let blue = NSColor(calibratedRed: 0.16, green: 0.39, blue: 0.93, alpha: 1)
|
||
let cyan = NSColor(calibratedRed: 0.10, green: 0.69, blue: 0.74, alpha: 1)
|
||
let green = NSColor(calibratedRed: 0.10, green: 0.63, blue: 0.39, alpha: 1)
|
||
let orange = NSColor(calibratedRed: 0.94, green: 0.47, blue: 0.12, alpha: 1)
|
||
let red = NSColor(calibratedRed: 0.88, green: 0.25, blue: 0.28, alpha: 1)
|
||
let purple = NSColor(calibratedRed: 0.48, green: 0.32, blue: 0.89, alpha: 1)
|
||
|
||
func pdfRect(_ r: CGRect) -> CGRect {
|
||
CGRect(x: r.minX, y: H - r.maxY, width: r.width, height: r.height)
|
||
}
|
||
|
||
func pdfPoint(_ p: CGPoint) -> CGPoint {
|
||
CGPoint(x: p.x, y: H - p.y)
|
||
}
|
||
|
||
func font(_ size: CGFloat, _ weight: NSFont.Weight = .regular) -> NSFont {
|
||
NSFont(name: "Apple SD Gothic Neo", size: size)
|
||
?? NSFont.systemFont(ofSize: size, weight: weight)
|
||
}
|
||
|
||
func mono(_ size: CGFloat) -> NSFont {
|
||
NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||
}
|
||
|
||
func attrs(_ size: CGFloat, color: NSColor = ink, weight: NSFont.Weight = .regular,
|
||
align: NSTextAlignment = .left, lineSpacing: CGFloat = 3) -> [NSAttributedString.Key: Any] {
|
||
let p = NSMutableParagraphStyle()
|
||
p.alignment = align
|
||
p.lineSpacing = lineSpacing
|
||
p.lineBreakMode = .byWordWrapping
|
||
return [.font: font(size, weight), .foregroundColor: color, .paragraphStyle: p]
|
||
}
|
||
|
||
func drawText(_ text: String, _ rect: CGRect, size: CGFloat = 11, color: NSColor = ink,
|
||
weight: NSFont.Weight = .regular, align: NSTextAlignment = .left,
|
||
lineSpacing: CGFloat = 3) {
|
||
NSAttributedString(string: text, attributes: attrs(size, color: color, weight: weight,
|
||
align: align, lineSpacing: lineSpacing))
|
||
.draw(with: pdfRect(rect), options: [.usesLineFragmentOrigin, .usesFontLeading])
|
||
}
|
||
|
||
func rounded(_ rect: CGRect, radius: CGFloat = 12, fill: NSColor = .white,
|
||
stroke: NSColor? = line, width: CGFloat = 1) {
|
||
let p = NSBezierPath(roundedRect: pdfRect(rect), xRadius: radius, yRadius: radius)
|
||
fill.setFill(); p.fill()
|
||
if let stroke { stroke.setStroke(); p.lineWidth = width; p.stroke() }
|
||
}
|
||
|
||
func pill(_ text: String, x: CGFloat, y: CGFloat, w: CGFloat, color: NSColor) {
|
||
rounded(CGRect(x: x, y: y, width: w, height: 25), radius: 12.5,
|
||
fill: color.withAlphaComponent(0.12), stroke: nil)
|
||
drawText(text, CGRect(x: x, y: y + 5, width: w, height: 16), size: 9.5,
|
||
color: color, weight: .semibold, align: .center)
|
||
}
|
||
|
||
func arrow(_ from: CGPoint, _ to: CGPoint, color: NSColor = muted) {
|
||
let from = pdfPoint(from), to = pdfPoint(to)
|
||
let p = NSBezierPath(); p.move(to: from); p.line(to: to)
|
||
color.setStroke(); p.lineWidth = 1.8; p.stroke()
|
||
let a = atan2(to.y - from.y, to.x - from.x)
|
||
let l: CGFloat = 7
|
||
let h = NSBezierPath()
|
||
h.move(to: to)
|
||
h.line(to: CGPoint(x: to.x - l * cos(a - .pi / 6), y: to.y - l * sin(a - .pi / 6)))
|
||
h.line(to: CGPoint(x: to.x - l * cos(a + .pi / 6), y: to.y - l * sin(a + .pi / 6)))
|
||
h.close(); color.setFill(); h.fill()
|
||
}
|
||
|
||
func node(_ title: String, _ sub: String, rect: CGRect, color: NSColor) {
|
||
rounded(rect, radius: 10, fill: color.withAlphaComponent(0.10),
|
||
stroke: color.withAlphaComponent(0.55), width: 1.2)
|
||
drawText(title, CGRect(x: rect.minX + 8, y: rect.minY + 10, width: rect.width - 16, height: 18),
|
||
size: 10.5, color: color, weight: .bold, align: .center)
|
||
drawText(sub, CGRect(x: rect.minX + 8, y: rect.minY + 31, width: rect.width - 16, height: rect.height - 36),
|
||
size: 8.5, color: muted, align: .center, lineSpacing: 1)
|
||
}
|
||
|
||
func sectionTitle(_ number: String, _ title: String, _ subtitle: String, color: NSColor) {
|
||
pill(number, x: margin, y: 48, w: 34, color: color)
|
||
drawText(title, CGRect(x: 86, y: 46, width: 450, height: 30), size: 22,
|
||
color: navy, weight: .bold)
|
||
drawText(subtitle, CGRect(x: margin, y: 82, width: W - 2 * margin, height: 26),
|
||
size: 10.5, color: muted)
|
||
}
|
||
|
||
func footer(_ page: Int, _ label: String = "O2O Negosium · Backend Concepts") {
|
||
let p = NSBezierPath()
|
||
p.move(to: CGPoint(x: margin, y: H - 34)); p.line(to: CGPoint(x: W - margin, y: H - 34))
|
||
line.setStroke(); p.lineWidth = 0.7; p.stroke()
|
||
drawText(label, CGRect(x: margin, y: H - 28, width: 350, height: 14), size: 7.5, color: muted)
|
||
drawText("\(page)", CGRect(x: W - margin - 35, y: H - 28, width: 35, height: 14),
|
||
size: 8, color: muted, align: .right)
|
||
}
|
||
|
||
func callout(_ title: String, _ body: String, rect: CGRect, color: NSColor) {
|
||
rounded(rect, radius: 12, fill: color.withAlphaComponent(0.08),
|
||
stroke: color.withAlphaComponent(0.35))
|
||
rounded(CGRect(x: rect.minX, y: rect.minY, width: 5, height: rect.height),
|
||
radius: 2.5, fill: color, stroke: nil)
|
||
drawText(title, CGRect(x: rect.minX + 16, y: rect.minY + 12,
|
||
width: rect.width - 28, height: 20),
|
||
size: 11, color: color, weight: .bold)
|
||
drawText(body, CGRect(x: rect.minX + 16, y: rect.minY + 37,
|
||
width: rect.width - 28, height: rect.height - 45),
|
||
size: 9.5, color: ink, lineSpacing: 3)
|
||
}
|
||
|
||
func comparison(_ leftTitle: String, _ left: String, _ rightTitle: String, _ right: String,
|
||
y: CGFloat, color: NSColor) {
|
||
let gap: CGFloat = 14
|
||
let cw = (W - 2 * margin - gap) / 2
|
||
callout(leftTitle, left, rect: CGRect(x: margin, y: y, width: cw, height: 126), color: red)
|
||
callout(rightTitle, right, rect: CGRect(x: margin + cw + gap, y: y, width: cw, height: 126), color: color)
|
||
}
|
||
|
||
func codeBox(_ title: String, _ path: String, _ code: String, rect: CGRect, accent: NSColor) {
|
||
rounded(rect, radius: 10, fill: navy, stroke: nil)
|
||
drawText(title, CGRect(x: rect.minX + 14, y: rect.minY + 11,
|
||
width: rect.width - 28, height: 17),
|
||
size: 10, color: .white, weight: .bold)
|
||
drawText(path, CGRect(x: rect.minX + 14, y: rect.minY + 30,
|
||
width: rect.width - 28, height: 14),
|
||
size: 7.5, color: accent)
|
||
let p = NSMutableParagraphStyle(); p.lineSpacing = 2; p.lineBreakMode = .byClipping
|
||
NSAttributedString(string: code, attributes: [.font: mono(7.8), .foregroundColor: NSColor(calibratedWhite: 0.88, alpha: 1), .paragraphStyle: p])
|
||
.draw(with: pdfRect(CGRect(x: rect.minX + 14, y: rect.minY + 51,
|
||
width: rect.width - 28, height: rect.height - 60)),
|
||
options: [.usesLineFragmentOrigin])
|
||
}
|
||
|
||
func beginPage(_ ctx: CGContext, page: Int, label: String = "O2O Negosium · Backend Concepts") {
|
||
ctx.beginPDFPage(nil)
|
||
ctx.saveGState()
|
||
NSGraphicsContext.saveGraphicsState()
|
||
NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false)
|
||
paper.setFill(); NSBezierPath(rect: pdfRect(CGRect(x: 0, y: 0, width: W, height: H))).fill()
|
||
footer(page, label)
|
||
}
|
||
|
||
func endPage(_ ctx: CGContext) {
|
||
NSGraphicsContext.restoreGraphicsState()
|
||
ctx.restoreGState()
|
||
ctx.endPDFPage()
|
||
}
|
||
|
||
var mediaBox = CGRect(x: 0, y: 0, width: W, height: H)
|
||
guard let consumer = CGDataConsumer(url: URL(fileURLWithPath: outPath) as CFURL),
|
||
let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
|
||
fatalError("PDF context 생성 실패")
|
||
}
|
||
|
||
// 1 — Cover
|
||
beginPage(ctx, page: 1, label: "O2O Negosium · Backend Field Guide")
|
||
rounded(CGRect(x: 0, y: 0, width: W, height: H), radius: 0, fill: navy, stroke: nil)
|
||
for i in 0..<7 {
|
||
let x = CGFloat(50 + i * 78)
|
||
let c = [blue, cyan, green, orange, purple][i % 5]
|
||
rounded(CGRect(x: x, y: 85 + CGFloat((i % 3) * 28), width: 48, height: 48),
|
||
radius: 24, fill: c.withAlphaComponent(0.35), stroke: nil)
|
||
}
|
||
drawText("BACKEND", CGRect(x: margin, y: 190, width: 507, height: 35), size: 15,
|
||
color: cyan, weight: .bold)
|
||
drawText("어려운 개념 5가지,\n코드로 이해하기", CGRect(x: margin, y: 228, width: 507, height: 118),
|
||
size: 36, color: .white, weight: .bold, lineSpacing: 7)
|
||
drawText("분산 시스템 · 트랜잭션/동시성 · 멀티테넌시\n캐시 정합성 · 스케줄러/배치",
|
||
CGRect(x: margin, y: 370, width: 507, height: 62), size: 15,
|
||
color: NSColor(calibratedWhite: 0.80, alpha: 1), lineSpacing: 8)
|
||
rounded(CGRect(x: margin, y: 485, width: 507, height: 154), radius: 18,
|
||
fill: NSColor.white.withAlphaComponent(0.07),
|
||
stroke: NSColor.white.withAlphaComponent(0.15))
|
||
drawText("이 문서는 이렇게 읽어요", CGRect(x: 66, y: 510, width: 455, height: 25),
|
||
size: 14, color: .white, weight: .bold)
|
||
drawText("① 일상 비유로 개념 잡기\n② 실제 서비스 흐름을 그림으로 보기\n③ 프로젝트 코드에서 구현 확인하기\n④ 없을 때 생기는 문제와 비교하기",
|
||
CGRect(x: 66, y: 548, width: 455, height: 78), size: 11.5,
|
||
color: NSColor(calibratedWhite: 0.88, alpha: 1), lineSpacing: 6)
|
||
drawText("Generated from the current repository · 2026-07-29",
|
||
CGRect(x: margin, y: 758, width: 507, height: 18), size: 8.5,
|
||
color: NSColor(calibratedWhite: 0.62, alpha: 1))
|
||
endPage(ctx)
|
||
|
||
// 2 — Architecture map
|
||
beginPage(ctx, page: 2)
|
||
drawText("먼저, 서비스 지도를 봅시다", CGRect(x: margin, y: 48, width: 507, height: 34),
|
||
size: 24, color: navy, weight: .bold)
|
||
drawText("다섯 개념은 따로 노는 것이 아니라, 한 요청이 여러 서비스와 저장소를 지나면서 함께 작동합니다.",
|
||
CGRect(x: margin, y: 88, width: 507, height: 32), size: 10.5, color: muted)
|
||
node("사용자", "브라우저", rect: CGRect(x: 44, y: 170, width: 90, height: 62), color: purple)
|
||
node("Backend", "채팅·공급사", rect: CGRect(x: 184, y: 145, width: 102, height: 72), color: blue)
|
||
node("Agent", "AI 협상", rect: CGRect(x: 348, y: 145, width: 102, height: 72), color: orange)
|
||
node("Negodata", "견적·관리", rect: CGRect(x: 184, y: 270, width: 102, height: 72), color: cyan)
|
||
node("LPS", "최저가 Worker", rect: CGRect(x: 348, y: 270, width: 102, height: 72), color: green)
|
||
node("PostgreSQL", "업무 원본", rect: CGRect(x: 184, y: 405, width: 130, height: 72), color: purple)
|
||
node("Redis", "앵커링 캐시", rect: CGRect(x: 368, y: 405, width: 100, height: 72), color: red)
|
||
arrow(CGPoint(x: 134, y: 200), CGPoint(x: 184, y: 182), color: purple)
|
||
arrow(CGPoint(x: 286, y: 180), CGPoint(x: 348, y: 180), color: blue)
|
||
arrow(CGPoint(x: 235, y: 217), CGPoint(x: 235, y: 270), color: cyan)
|
||
arrow(CGPoint(x: 286, y: 304), CGPoint(x: 348, y: 304), color: green)
|
||
arrow(CGPoint(x: 235, y: 342), CGPoint(x: 235, y: 405), color: purple)
|
||
arrow(CGPoint(x: 399, y: 342), CGPoint(x: 415, y: 405), color: red)
|
||
callout("① 경계가 생기면 ‘분산 시스템’", "서비스 A가 서비스 B를 네트워크로 호출하는 순간, 지연·타임아웃·부분 실패를 다뤄야 합니다.",
|
||
rect: CGRect(x: margin, y: 525, width: 246, height: 105), color: blue)
|
||
callout("② 여러 실행자가 만나면 ‘동시성’", "사용자 클릭과 스케줄러가 같은 견적을 동시에 마감할 수 있어, DB가 최종 심판 역할을 합니다.",
|
||
rect: CGRect(x: 305, y: 525, width: 246, height: 105), color: orange)
|
||
callout("③ 빠르게 읽되 원본을 지키면 ‘캐시’", "Redis와 프로세스 메모리는 복사본입니다. PostgreSQL과 설정 파일이 원본입니다.",
|
||
rect: CGRect(x: margin, y: 650, width: 246, height: 105), color: red)
|
||
callout("④ 회사별 경계를 지키면 ‘멀티테넌시’", "요청 헤더에서 회사 ID를 결정하고, 회사별 설정·엔진을 선택합니다.",
|
||
rect: CGRect(x: 305, y: 650, width: 246, height: 105), color: purple)
|
||
endPage(ctx)
|
||
|
||
// 3 — Distributed systems: concept first
|
||
beginPage(ctx, page: 3)
|
||
sectionTitle("3", "분산 시스템 — 개념부터", "여러 독립 실행 단위가 네트워크를 통해 하나의 업무를 완성하는 시스템", color: blue)
|
||
callout("정확한 정의", "프로세스·컨테이너·서버가 각자 메모리와 실행 상태를 가지고, HTTP나 메시지로 통신하는 구조입니다. 한 서비스의 함수 호출과 달리 상대의 상태를 직접 볼 수 없고, 네트워크 응답만으로 결과를 추론해야 합니다.",
|
||
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: blue)
|
||
drawText("왜 어려운가: 네트워크에는 네 가지 결과가 있습니다", CGRect(x: margin, y: 244, width: 507, height: 24),
|
||
size: 13.5, color: navy, weight: .bold)
|
||
let distCases: [(String, String, NSColor)] = [
|
||
("성공", "상대가 처리했고 응답도 받음", green),
|
||
("명확한 실패", "상대가 오류 응답을 보냄", red),
|
||
("연결 실패", "상대에게 요청이 도착하지 않음", orange),
|
||
("애매한 타임아웃", "처리는 됐지만 응답만 늦었을 수도 있음", purple),
|
||
]
|
||
for (i, c) in distCases.enumerated() {
|
||
let col = i % 2, row = i / 2
|
||
let x = margin + CGFloat(col) * 260
|
||
let y = CGFloat(286 + row * 86)
|
||
callout(c.0, c.1, rect: CGRect(x: x, y: y, width: 247, height: 70), color: c.2)
|
||
}
|
||
drawText("대표적인 대응 수단", CGRect(x: margin, y: 475, width: 507, height: 24),
|
||
size: 13.5, color: navy, weight: .bold)
|
||
callout("Timeout", "얼마나 기다릴지 상한을 둡니다. 짧으면 정상 요청도 실패하고, 길면 자원이 오래 묶입니다.",
|
||
rect: CGRect(x: margin, y: 515, width: 159, height: 92), color: blue)
|
||
callout("Retry", "일시 실패를 다시 시도합니다. 단, 중복 처리에 안전한 작업에서만 제한적으로 사용합니다.",
|
||
rect: CGRect(x: 218, y: 515, width: 159, height: 92), color: orange)
|
||
callout("Idempotency", "같은 요청을 여러 번 보내도 결과가 한 번 처리한 것과 같도록 만듭니다.",
|
||
rect: CGRect(x: 392, y: 515, width: 159, height: 92), color: purple)
|
||
callout("Fallback", "주 서비스가 실패하면 대체 경로·기본값·이전 데이터를 사용합니다. 대체 결과가 업무적으로 허용될 때만 가능합니다.",
|
||
rect: CGRect(x: margin, y: 630, width: 247, height: 92), color: green)
|
||
callout("Circuit breaker", "실패가 계속되는 서비스를 잠시 호출하지 않아 연쇄 장애를 막습니다. 현재 프로젝트에는 명시적 구현이 없습니다.",
|
||
rect: CGRect(x: 304, y: 630, width: 247, height: 92), color: red)
|
||
endPage(ctx)
|
||
|
||
// 4 distributed concept
|
||
beginPage(ctx, page: 4)
|
||
sectionTitle("3", "분산 시스템과 장애 대응", "한 프로그램이 아니라 여러 서비스가 네트워크로 협력하는 구조", color: blue)
|
||
callout("쉬운 비유", "한 식당 안에서 주방과 홀 직원이 말로 협업하는 것이 단일 시스템이라면, 분산 시스템은 서로 다른 건물의 팀이 전화로 협업하는 것입니다. 전화는 늦거나 끊길 수 있고, 상대가 일을 끝냈는데 답만 못 받을 수도 있습니다.",
|
||
rect: CGRect(x: margin, y: 130, width: 507, height: 105), color: blue)
|
||
drawText("프로젝트의 대표 흐름", CGRect(x: margin, y: 266, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
node("Backend", "사용자 채팅 요청", rect: CGRect(x: 52, y: 315, width: 110, height: 74), color: blue)
|
||
node("HTTPX", "timeout 설정", rect: CGRect(x: 242, y: 315, width: 110, height: 74), color: cyan)
|
||
node("Agent", "협상 턴 계산", rect: CGRect(x: 432, y: 315, width: 110, height: 74), color: orange)
|
||
arrow(CGPoint(x: 162, y: 352), CGPoint(x: 242, y: 352), color: blue)
|
||
arrow(CGPoint(x: 352, y: 352), CGPoint(x: 432, y: 352), color: cyan)
|
||
drawText("성공", CGRect(x: 394, y: 414, width: 80, height: 18), size: 9, color: green, weight: .bold)
|
||
arrow(CGPoint(x: 485, y: 389), CGPoint(x: 485, y: 458), color: green)
|
||
node("응답 반영", "채팅 상태 저장", rect: CGRect(x: 430, y: 458, width: 112, height: 65), color: green)
|
||
drawText("타임아웃/실패", CGRect(x: 185, y: 414, width: 105, height: 18), size: 9, color: red, weight: .bold)
|
||
arrow(CGPoint(x: 297, y: 389), CGPoint(x: 297, y: 458), color: red)
|
||
node("안전한 실패", "ok=false 반환", rect: CGRect(x: 241, y: 458, width: 112, height: 65), color: red)
|
||
callout("중요한 함정: 타임아웃 ≠ 상대가 아무 일도 안 함", "Agent가 DB 상태를 이미 전진시킨 직후 응답만 늦었을 수 있습니다. 그래서 무조건 재시도하면 같은 턴을 두 번 처리할 위험이 있습니다. 코드가 timed_out을 따로 표시하는 이유입니다.",
|
||
rect: CGRect(x: margin, y: 566, width: 507, height: 105), color: orange)
|
||
comparison("이 장치가 없으면", "Agent가 느린 순간 Backend 요청도 끝없이 대기합니다. 무작정 재시도하면 협상 step이 두 번 전진할 수 있습니다.",
|
||
"현재 방식", "HTTP timeout을 두고 성공/일반 실패/타임아웃을 구분합니다. 호출 경계에서 예외를 응답 객체로 변환합니다.",
|
||
y: 695, color: blue)
|
||
endPage(ctx)
|
||
|
||
// 5 distributed code
|
||
beginPage(ctx, page: 5)
|
||
sectionTitle("3", "분산 시스템 — 실제 코드", "서비스 경계마다 timeout, fallback, best-effort 정책이 다릅니다.", color: blue)
|
||
codeBox("Agent 호출: 타임아웃을 별도 상태로 반환",
|
||
"backend/services/agent_client.py · lines 81–91",
|
||
"""
|
||
async with httpx.AsyncClient(
|
||
base_url=agent_config.base_url,
|
||
timeout=agent_config.timeout_sec,
|
||
) as cli:
|
||
resp = await cli.post("/v1/chat", json=body, headers=headers)
|
||
|
||
except httpx.TimeoutException as ex:
|
||
# Agent가 이미 처리했을 수 있어 단순 재시도는 위험
|
||
return AgentTurn(ok=False, timed_out=True)
|
||
except Exception:
|
||
return AgentTurn(ok=False)
|
||
""",
|
||
rect: CGRect(x: margin, y: 130, width: 507, height: 245), accent: cyan)
|
||
codeBox("카탈로그 변경 알림: 핵심 업무를 막지 않는 best-effort",
|
||
"negodata/backend/services/agent_notify.py · lines 15–26",
|
||
"""
|
||
try:
|
||
async with httpx.AsyncClient(timeout=3.0) as cli:
|
||
await cli.post(f"{base}/v1/catalog-refresh-all")
|
||
except Exception as ex:
|
||
# 알림 실패는 카드 작업을 막지 않는다
|
||
LOG.w(f"[agent_notify] 변경 알림 실패(무시): {ex}")
|
||
""",
|
||
rect: CGRect(x: margin, y: 395, width: 507, height: 160), accent: green)
|
||
callout("어떻게 정책을 고르나요?", "결제처럼 반드시 성공해야 하는 작업은 실패를 호출자에게 알려 재처리해야 합니다. 반면 ‘캐시 무효화 알림’처럼 보조적인 작업은 실패해도 핵심 카드 변경을 성공시킬 수 있습니다. 모든 외부 호출을 똑같이 재시도하면 안 됩니다.",
|
||
rect: CGRect(x: margin, y: 580, width: 507, height: 105), color: blue)
|
||
drawText("기억할 단어", CGRect(x: margin, y: 712, width: 120, height: 20), size: 12,
|
||
color: navy, weight: .bold)
|
||
pill("timeout", x: 150, y: 707, w: 82, color: blue)
|
||
pill("partial failure", x: 242, y: 707, w: 102, color: orange)
|
||
pill("best-effort", x: 354, y: 707, w: 92, color: green)
|
||
pill("idempotency", x: 456, y: 707, w: 90, color: purple)
|
||
endPage(ctx)
|
||
|
||
// 6 — Transactions and concurrency: concept first
|
||
beginPage(ctx, page: 6)
|
||
sectionTitle("4", "트랜잭션·동시성 — 개념부터", "데이터의 일관성을 지키는 작업 단위와, 동시에 실행되는 요청을 제어하는 방법", color: orange)
|
||
drawText("트랜잭션의 ACID", CGRect(x: margin, y: 126, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
let acid: [(String, String, NSColor)] = [
|
||
("A · Atomicity", "전부 성공하거나 전부 취소", orange),
|
||
("C · Consistency", "규칙을 만족하는 상태로 이동", green),
|
||
("I · Isolation", "동시 작업의 중간 상태를 서로 숨김", purple),
|
||
("D · Durability", "COMMIT된 결과는 장애 후에도 보존", blue),
|
||
]
|
||
for (i, a) in acid.enumerated() {
|
||
let x = margin + CGFloat(i % 2) * 260
|
||
let y = CGFloat(165 + (i / 2) * 84)
|
||
callout(a.0, a.1, rect: CGRect(x: x, y: y, width: 247, height: 68), color: a.2)
|
||
}
|
||
drawText("동시성 문제는 어떻게 생기나?", CGRect(x: margin, y: 352, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
node("요청 A", "status=OPEN 읽음", rect: CGRect(x: 48, y: 401, width: 116, height: 64), color: blue)
|
||
node("요청 B", "status=OPEN 읽음", rect: CGRect(x: 48, y: 500, width: 116, height: 64), color: purple)
|
||
node("둘 다 처리", "중복 마감·중복 메일", rect: CGRect(x: 250, y: 449, width: 130, height: 70), color: red)
|
||
arrow(CGPoint(x: 164, y: 433), CGPoint(x: 250, y: 471), color: blue)
|
||
arrow(CGPoint(x: 164, y: 532), CGPoint(x: 250, y: 497), color: purple)
|
||
callout("해결 ① 비관적 잠금", "SELECT ... FOR UPDATE로 먼저 행을 잠급니다. 명확하지만 잠금 대기와 deadlock을 관리해야 합니다.",
|
||
rect: CGRect(x: 408, y: 391, width: 143, height: 92), color: orange)
|
||
callout("해결 ② 조건부 갱신", "UPDATE ... WHERE status=OPEN 후 rowcount를 확인합니다. 상태 검사와 변경이 원자적으로 일어납니다.",
|
||
rect: CGRect(x: 408, y: 497, width: 143, height: 92), color: green)
|
||
callout("격리 수준과 잠금은 만능이 아님", "격리를 높이면 안전성은 커지지만 동시 처리량이 줄고 대기·교착 가능성이 커집니다. 업무 규칙에 맞는 최소 범위의 트랜잭션과 조건부 상태 전이가 실용적입니다.",
|
||
rect: CGRect(x: margin, y: 624, width: 507, height: 92), color: orange)
|
||
callout("COMMIT / ROLLBACK", "COMMIT은 변경 확정, ROLLBACK은 현재 트랜잭션의 미확정 변경 취소입니다. 외부 이메일 발송은 DB rollback으로 되돌릴 수 없다는 점도 중요합니다.",
|
||
rect: CGRect(x: margin, y: 720, width: 507, height: 72), color: red)
|
||
endPage(ctx)
|
||
|
||
// 7 transaction concept
|
||
beginPage(ctx, page: 7)
|
||
sectionTitle("4", "DB 트랜잭션과 동시성 제어", "여러 작업을 하나로 묶고, 동시에 온 요청 중 한 명만 통과시키는 기술", color: orange)
|
||
callout("트랜잭션이란?", "은행 이체에서 ‘내 계좌 차감’과 ‘상대 계좌 증가’가 둘 다 성공하거나 둘 다 취소되어야 하듯, 관련 DB 변경을 하나의 작업 단위로 묶는 것입니다. 중간에 실패하면 rollback합니다.",
|
||
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: orange)
|
||
drawText("동시 마감 문제", CGRect(x: margin, y: 252, width: 200, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
node("사용자 클릭", "마감 요청 A", rect: CGRect(x: 50, y: 300, width: 110, height: 66), color: blue)
|
||
node("스케줄러", "마감 요청 B", rect: CGRect(x: 50, y: 405, width: 110, height: 66), color: purple)
|
||
node("조건부 UPDATE", "status != CLOSED", rect: CGRect(x: 243, y: 350, width: 120, height: 74), color: orange)
|
||
node("PostgreSQL", "원자적으로 판정", rect: CGRect(x: 430, y: 350, width: 112, height: 74), color: green)
|
||
arrow(CGPoint(x: 160, y: 333), CGPoint(x: 243, y: 376), color: blue)
|
||
arrow(CGPoint(x: 160, y: 438), CGPoint(x: 243, y: 399), color: purple)
|
||
arrow(CGPoint(x: 363, y: 387), CGPoint(x: 430, y: 387), color: orange)
|
||
callout("승자", "영향받은 행 수(rowcount) = 1\n마감 판정 권한 획득",
|
||
rect: CGRect(x: 76, y: 518, width: 205, height: 88), color: green)
|
||
callout("패자/재요청", "rowcount = 0\n이미 닫혔으므로 추가 처리 중단",
|
||
rect: CGRect(x: 314, y: 518, width: 205, height: 88), color: red)
|
||
comparison("단순 SELECT 후 UPDATE", "두 요청이 동시에 OPEN을 읽으면 둘 다 마감·낙찰 로직을 실행할 수 있습니다. 이메일도 두 번 발송될 수 있습니다.",
|
||
"조건부 UPDATE", "DB가 상태 검사와 변경을 한 문장으로 처리합니다. 먼저 성공한 요청만 rowcount=1을 받습니다.",
|
||
y: 640, color: orange)
|
||
endPage(ctx)
|
||
|
||
// 8 transaction code
|
||
beginPage(ctx, page: 8)
|
||
sectionTitle("4", "트랜잭션·동시성 — 실제 코드", "애플리케이션의 if문보다 DB의 원자적 UPDATE가 강한 최종 방어선입니다.", color: orange)
|
||
codeBox("조건부 상태 전이: 마감 권한 선점",
|
||
"negodata/backend/crud/quotation_crud.py · lines 482–500",
|
||
"""
|
||
query = (
|
||
update(quotations)
|
||
.where(
|
||
quotations.qt_id == qt_id,
|
||
quotations.status != QuotationStatus.CLOSED.value,
|
||
quotations.deleted == False,
|
||
)
|
||
.values(
|
||
status=QuotationStatus.CLOSED.value,
|
||
updated_at=GTime.UTC(),
|
||
)
|
||
)
|
||
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
|
||
""",
|
||
rect: CGRect(x: margin, y: 130, width: 507, height: 235), accent: orange)
|
||
codeBox("트랜잭션 실패 시 rollback",
|
||
"negodata/backend/common/database/db_session_manager.py · lines 116–127",
|
||
"""
|
||
try:
|
||
await db.commit()
|
||
return ErrorType.SUCCESS
|
||
except IntegrityError:
|
||
await db.rollback()
|
||
return ErrorType.DB_ALREADY_SAME_KEY
|
||
except Exception:
|
||
await db.rollback()
|
||
raise
|
||
""",
|
||
rect: CGRect(x: margin, y: 390, width: 507, height: 175), accent: red)
|
||
callout("왜 rowcount를 보나요?", "UPDATE가 에러 없이 실행됐다는 사실만으로는 내가 상태를 바꿨는지 알 수 없습니다. WHERE 조건에 맞는 행이 없으면 SQL은 정상 실행되지만 변경 행은 0개입니다. 그래서 1이면 승자, 0이면 이미 다른 요청이 처리한 것으로 판단합니다.",
|
||
rect: CGRect(x: margin, y: 592, width: 507, height: 108), color: orange)
|
||
callout("실무 체크", "트랜잭션은 짧게 유지하고, 외부 HTTP·이메일처럼 오래 걸리는 작업을 DB 트랜잭션 안에 오래 붙잡아 두지 않습니다.",
|
||
rect: CGRect(x: margin, y: 720, width: 507, height: 65), color: purple)
|
||
endPage(ctx)
|
||
|
||
// 9 — Multitenancy: concept first
|
||
beginPage(ctx, page: 9)
|
||
sectionTitle("5", "멀티테넌시 — 개념부터", "하나의 애플리케이션을 여러 고객사가 공유하면서 논리적으로 격리하는 설계", color: purple)
|
||
callout("Tenant란?", "서비스를 사용하는 독립 고객 단위입니다. 이 프로젝트에서는 주로 ‘회사’가 tenant입니다. 같은 API와 서버를 쓰더라도 회사별 데이터, 설정, 권한, 협상 정책이 섞이면 안 됩니다.",
|
||
rect: CGRect(x: margin, y: 126, width: 507, height: 88), color: purple)
|
||
drawText("대표적인 데이터 격리 모델", CGRect(x: margin, y: 242, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
callout("DB 분리", "회사마다 별도 DB\n격리 강함 · 운영비 높음",
|
||
rect: CGRect(x: margin, y: 282, width: 159, height: 88), color: blue)
|
||
callout("Schema 분리", "한 DB 안에서 schema 분리\n중간 수준의 격리와 비용",
|
||
rect: CGRect(x: 218, y: 282, width: 159, height: 88), color: cyan)
|
||
callout("Row 공유", "같은 테이블 + tenant_id\n효율적 · 쿼리 누락 위험",
|
||
rect: CGRect(x: 392, y: 282, width: 159, height: 88), color: orange)
|
||
drawText("격리는 DB만의 문제가 아닙니다", CGRect(x: margin, y: 404, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
let tenantAxes: [(String, String)] = [
|
||
("식별", "이 요청이 어느 회사 것인지 신뢰할 수 있게 결정"),
|
||
("인가", "그 사용자가 해당 회사 자원에 접근 가능한지 확인"),
|
||
("데이터", "모든 조회·수정 쿼리에 회사 범위 적용"),
|
||
("설정", "회사별 정책·브랜딩·카드 선택"),
|
||
("캐시", "캐시 key에 tenant를 포함해 회사 간 충돌 방지"),
|
||
("자원", "한 회사의 과부하가 다른 회사에 미치는 영향 제한"),
|
||
]
|
||
for (i, t) in tenantAxes.enumerated() {
|
||
let x = margin + CGFloat(i % 2) * 260
|
||
let y = CGFloat(444 + (i / 2) * 74)
|
||
rounded(CGRect(x: x, y: y, width: 247, height: 58), radius: 9,
|
||
fill: purple.withAlphaComponent(0.06), stroke: purple.withAlphaComponent(0.25))
|
||
drawText(t.0, CGRect(x: x + 12, y: y + 10, width: 52, height: 18), size: 10,
|
||
color: purple, weight: .bold)
|
||
drawText(t.1, CGRect(x: x + 66, y: y + 9, width: 168, height: 38), size: 8.5, color: ink)
|
||
}
|
||
callout("가장 흔한 사고", "쿼리의 WHERE tenant_id 조건 누락, 공유 캐시 key에 tenant_id 누락, 사용자가 body로 보낸 tenant_id를 그대로 신뢰하는 경우입니다. 그래서 tenant context를 요청 초기에 확정하고 자동 전달하는 구조가 중요합니다.",
|
||
rect: CGRect(x: margin, y: 680, width: 507, height: 105), color: red)
|
||
endPage(ctx)
|
||
|
||
// 10 multitenancy concept
|
||
beginPage(ctx, page: 10)
|
||
sectionTitle("5", "멀티테넌시", "하나의 시스템을 여러 회사가 쓰되, 설정과 데이터의 경계를 지키는 구조", color: purple)
|
||
callout("쉬운 비유", "한 오피스 빌딩을 여러 회사가 함께 사용하지만 출입카드가 자기 회사 층만 열어주는 구조입니다. 서버는 공유하되, 요청마다 ‘어느 회사의 요청인지’를 먼저 확정해야 합니다.",
|
||
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: purple)
|
||
drawText("요청이 회사별 엔진을 찾는 과정", CGRect(x: margin, y: 252, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
node("HTTP 요청", "X-Tenant-ID", rect: CGRect(x: 45, y: 310, width: 100, height: 68), color: blue)
|
||
node("Middleware", "존재·등록 검증", rect: CGRect(x: 195, y: 310, width: 105, height: 68), color: purple)
|
||
node("request.state", "tenant_id 보관", rect: CGRect(x: 350, y: 310, width: 105, height: 68), color: cyan)
|
||
arrow(CGPoint(x: 145, y: 344), CGPoint(x: 195, y: 344), color: blue)
|
||
arrow(CGPoint(x: 300, y: 344), CGPoint(x: 350, y: 344), color: purple)
|
||
node("Registry", "회사별 엔진 선택", rect: CGRect(x: 195, y: 445, width: 105, height: 68), color: orange)
|
||
node("TenantEngine", "회사별 정책·카드", rect: CGRect(x: 350, y: 445, width: 105, height: 68), color: green)
|
||
arrow(CGPoint(x: 402, y: 378), CGPoint(x: 275, y: 445), color: cyan)
|
||
arrow(CGPoint(x: 300, y: 479), CGPoint(x: 350, y: 479), color: orange)
|
||
callout("보안 핵심", "tenant_id를 요청 body에서 받으면 사용자가 다른 회사 ID를 넣어 위조할 수 있습니다. 이 프로젝트는 헤더/경로에서 결정한 값을 middleware가 request.state에 넣고, 뒤의 코드가 그것만 사용합니다.",
|
||
rect: CGRect(x: margin, y: 558, width: 507, height: 105), color: red)
|
||
comparison("멀티테넌시 경계가 약하면", "A회사 요청이 B회사 카드·설정·협상 엔진을 사용할 수 있습니다. 이는 단순 버그가 아니라 데이터 유출 사고입니다.",
|
||
"현재 방식", "요청 시작점에서 tenant를 검증하고, Registry가 해당 회사의 설정과 엔진을 해석합니다.",
|
||
y: 687, color: purple)
|
||
endPage(ctx)
|
||
|
||
// 11 multitenancy code
|
||
beginPage(ctx, page: 11)
|
||
sectionTitle("5", "멀티테넌시 — 실제 코드", "식별 → 검증 → request.state 전달 → 회사별 엔진 선택의 4단계", color: purple)
|
||
codeBox("Middleware: tenant를 요청 경계에서 확정",
|
||
"agent/router/middleware/tenant_middleware.py · lines 35–69",
|
||
"""
|
||
tenant_id = request.headers.get("X-Tenant-ID")
|
||
|
||
if not tenant_id:
|
||
return JSONResponse(status_code=400, ...)
|
||
|
||
if not tenant_registry.is_registered(tenant_id):
|
||
return JSONResponse(status_code=404, ...)
|
||
|
||
request.state.tenant_id = tenant_id
|
||
return await call_next(request)
|
||
""",
|
||
rect: CGRect(x: margin, y: 130, width: 507, height: 205), accent: purple)
|
||
codeBox("Dependency: 검증된 tenant로 엔진 조회",
|
||
"agent/router/deps.py · lines 13–21",
|
||
"""
|
||
async def get_tenant_engine(request: Request) -> TenantEngine:
|
||
tenant_id = getattr(request.state, "tenant_id", None)
|
||
if not tenant_id:
|
||
raise EXCEPTION_TENANT_HEADER_MISSING
|
||
return await tenant_registry.get_engine(tenant_id)
|
||
""",
|
||
rect: CGRect(x: margin, y: 360, width: 507, height: 165), accent: cyan)
|
||
codeBox("Registry: 프로세스 메모리에서 회사별 엔진 재사용",
|
||
"agent/tenancy/registry.py · lines 85–98",
|
||
"""
|
||
cached = self._engines.get(tenant_id)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
async with self._locks[tenant_id]:
|
||
cached = self._engines.get(tenant_id)
|
||
if cached is not None:
|
||
return cached
|
||
engine = await self._build(tenant_id)
|
||
self._engines[tenant_id] = engine
|
||
return engine
|
||
""",
|
||
rect: CGRect(x: margin, y: 550, width: 507, height: 205), accent: orange)
|
||
endPage(ctx)
|
||
|
||
// 12 — Cache consistency: concept first
|
||
beginPage(ctx, page: 12)
|
||
sectionTitle("8", "캐시 정합성 — 개념부터", "비싼 계산·DB·외부 호출의 결과를 가까운 곳에 복사해 재사용하는 기술", color: red)
|
||
drawText("기본 용어", CGRect(x: margin, y: 126, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
let cacheTerms: [(String, String, NSColor)] = [
|
||
("Hit", "캐시에 값이 있어 원본을 읽지 않음", green),
|
||
("Miss", "값이 없어 원본을 읽고 캐시를 채움", blue),
|
||
("TTL", "값이 자동 만료될 때까지의 시간", orange),
|
||
("Stale", "원본은 바뀌었지만 캐시는 옛 값인 상태", red),
|
||
]
|
||
for (i, t) in cacheTerms.enumerated() {
|
||
let x = margin + CGFloat(i % 2) * 260
|
||
let y = CGFloat(165 + (i / 2) * 75)
|
||
callout(t.0, t.1, rect: CGRect(x: x, y: y, width: 247, height: 60), color: t.2)
|
||
}
|
||
drawText("대표적인 읽기·쓰기 패턴", CGRect(x: margin, y: 338, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
callout("Cache-aside", "앱이 캐시를 먼저 조회하고 miss이면 DB를 읽어 캐시에 저장합니다. 단순하고 가장 흔하지만 무효화를 앱이 책임집니다.",
|
||
rect: CGRect(x: margin, y: 378, width: 247, height: 92), color: blue)
|
||
callout("Write-through", "쓰기 때 캐시와 원본을 함께 갱신합니다. 읽기는 안정적이지만 쓰기 지연과 두 저장소의 부분 실패를 다뤄야 합니다.",
|
||
rect: CGRect(x: 304, y: 378, width: 247, height: 92), color: purple)
|
||
callout("Write-behind", "캐시에 먼저 쓰고 DB는 나중에 반영합니다. 빠르지만 캐시 장애 시 데이터 유실 위험이 있어 업무 원본에는 신중해야 합니다.",
|
||
rect: CGRect(x: margin, y: 486, width: 247, height: 92), color: orange)
|
||
callout("Negative cache", "‘결과 없음’도 잠깐 저장합니다. 반복 실패 비용을 줄이지만 너무 긴 TTL은 새로 생긴 데이터를 늦게 발견하게 합니다.",
|
||
rect: CGRect(x: 304, y: 486, width: 247, height: 92), color: green)
|
||
drawText("캐시에서 자주 생기는 문제", CGRect(x: margin, y: 610, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
callout("Invalidation", "원본 변경 후 어떤 key를 언제 삭제·갱신할지 결정하기 어렵습니다.",
|
||
rect: CGRect(x: margin, y: 650, width: 159, height: 82), color: red)
|
||
callout("Stampede", "인기 key가 만료되는 순간 많은 요청이 동시에 DB로 몰립니다.",
|
||
rect: CGRect(x: 218, y: 650, width: 159, height: 82), color: orange)
|
||
callout("Key 설계", "tenant·버전 등이 빠지면 서로 다른 데이터가 같은 key를 공유합니다.",
|
||
rect: CGRect(x: 392, y: 650, width: 159, height: 82), color: purple)
|
||
endPage(ctx)
|
||
|
||
// 13 cache concept
|
||
beginPage(ctx, page: 13)
|
||
sectionTitle("8", "캐시 정합성과 무효화", "빠른 복사본이 원본과 다른 값을 갖지 않도록 관리하는 문제", color: red)
|
||
callout("캐시는 복사본", "도서관 검색대의 메모가 캐시이고, 원본 장부가 DB라고 생각하면 쉽습니다. 메모는 빠르지만 오래된 정보일 수 있습니다. 정합성이란 메모와 장부가 의미상 같은 상태를 유지하는 것입니다.",
|
||
rect: CGRect(x: margin, y: 130, width: 507, height: 95), color: red)
|
||
drawText("앵커링 값의 저장·조회 순서", CGRect(x: margin, y: 252, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
node("1. DB 저장", "조정 이력 COMMIT", rect: CGRect(x: 46, y: 310, width: 118, height: 70), color: purple)
|
||
node("2. Redis SET", "최신값 + TTL 7일", rect: CGRect(x: 238, y: 310, width: 118, height: 70), color: red)
|
||
node("3. 다음 조회", "Redis 우선", rect: CGRect(x: 430, y: 310, width: 118, height: 70), color: blue)
|
||
arrow(CGPoint(x: 164, y: 345), CGPoint(x: 238, y: 345), color: purple)
|
||
arrow(CGPoint(x: 356, y: 345), CGPoint(x: 430, y: 345), color: red)
|
||
drawText("Redis 실패", CGRect(x: 240, y: 417, width: 110, height: 18), size: 9, color: red, weight: .bold)
|
||
arrow(CGPoint(x: 297, y: 380), CGPoint(x: 297, y: 465), color: red)
|
||
node("DB Fallback", "업무는 계속", rect: CGRect(x: 238, y: 465, width: 118, height: 70), color: green)
|
||
callout("stale 데이터란?", "DB에는 새 값 60이 저장됐는데 Redis SET이 실패해 캐시에 옛 값 55가 남은 상태입니다. 캐시 miss와 달리 값이 존재하므로 더 위험합니다. TTL과 주간 re-SET으로 회복합니다.",
|
||
rect: CGRect(x: margin, y: 574, width: 507, height: 95), color: orange)
|
||
comparison("캐시만 믿으면", "Redis 장애가 업무 장애가 되고, 오래된 값이 실제 제안가를 왜곡할 수 있습니다. Redis 유실 시 원본도 사라집니다.",
|
||
"원본 DB + 파생 캐시", "Redis 장애 시 DB를 읽고, TTL과 reconciliation으로 오래된 복사본을 교정합니다.",
|
||
y: 693, color: red)
|
||
endPage(ctx)
|
||
|
||
// 14 cache code
|
||
beginPage(ctx, page: 14)
|
||
sectionTitle("8", "캐시 정합성 — 실제 코드", "Cache-aside, TTL, DB fallback, reconciliation이 한 세트로 작동합니다.", color: red)
|
||
codeBox("Cache-aside: Redis miss → DB → Redis backfill",
|
||
"schedules/anchoring/src/anchoring/reader.py · lines 33–42",
|
||
"""
|
||
cached = await get_value(company_id, supplier_type, price_range)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
value = await get_latest_adjusted_value(
|
||
db, company_id, supplier_type, price_range
|
||
)
|
||
if value is None:
|
||
value = get_base_anchoring_value(price_range)
|
||
|
||
await set_value(..., value, nx=True)
|
||
return value
|
||
""",
|
||
rect: CGRect(x: margin, y: 130, width: 507, height: 225), accent: red)
|
||
codeBox("Redis 장애는 cache miss로 취급",
|
||
"schedules/anchoring/src/anchoring/redis_client.py · lines 69–84",
|
||
"""
|
||
try:
|
||
raw = await _client.get(anchor_key(...))
|
||
if raw is None:
|
||
return None
|
||
return int(raw)
|
||
except Exception as ex:
|
||
_note_failure("get", anchor_key(...), ex)
|
||
return None # 호출측이 DB fallback
|
||
""",
|
||
rect: CGRect(x: margin, y: 380, width: 507, height: 180), accent: orange)
|
||
callout("두 겹의 회복 장치", "TTL 7일은 오래된 키가 영원히 남는 것을 막습니다. 주간 reconciliation은 DB의 최신 조정값을 Redis에 다시 SET하여, DB commit 뒤 Redis 갱신에 실패했던 값도 교정합니다.",
|
||
rect: CGRect(x: margin, y: 585, width: 507, height: 100), color: red)
|
||
callout("현재 견적 생성 경로의 예외", "Negodata의 실제 견적 생성은 Redis를 사용하지 않고 PostgreSQL의 anchoring.current_values View를 일괄 조회합니다. Redis는 현재 anchoring 배치 내부 캐시입니다.",
|
||
rect: CGRect(x: margin, y: 705, width: 507, height: 72), color: cyan)
|
||
endPage(ctx)
|
||
|
||
// 15 — Scheduler and batch: concept first
|
||
beginPage(ctx, page: 15)
|
||
sectionTitle("9", "스케줄러·배치 — 개념부터", "시간 규칙으로 작업을 시작하고, 많은 데이터를 사용자 요청 밖에서 처리하는 방식", color: green)
|
||
callout("둘의 차이", "스케줄러는 ‘언제 실행할지’를 결정합니다. 배치 Job은 ‘무엇을 어떻게 처리할지’를 구현합니다. CronTrigger가 알람시계라면 close_expired_quotations는 알람이 울렸을 때 수행할 실제 업무입니다.",
|
||
rect: CGRect(x: margin, y: 126, width: 507, height: 92), color: green)
|
||
drawText("Job의 생명주기", CGRect(x: margin, y: 248, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
node("Trigger", "실행 시각 도달", rect: CGRect(x: 45, y: 297, width: 94, height: 66), color: green)
|
||
node("Select", "처리 대상 조회", rect: CGRect(x: 181, y: 297, width: 94, height: 66), color: blue)
|
||
node("Process", "개별 업무 수행", rect: CGRect(x: 317, y: 297, width: 94, height: 66), color: orange)
|
||
node("Checkpoint", "결과·진행점 기록", rect: CGRect(x: 453, y: 297, width: 94, height: 66), color: purple)
|
||
arrow(CGPoint(x: 139, y: 330), CGPoint(x: 181, y: 330), color: green)
|
||
arrow(CGPoint(x: 275, y: 330), CGPoint(x: 317, y: 330), color: blue)
|
||
arrow(CGPoint(x: 411, y: 330), CGPoint(x: 453, y: 330), color: orange)
|
||
drawText("운영에서 반드시 결정할 것", CGRect(x: margin, y: 405, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
let jobIssues: [(String, String, NSColor)] = [
|
||
("중복 실행", "이전 Job이 안 끝났는데 다음 시각이 오면?", red),
|
||
("Misfire", "서버가 꺼져 실행 시각을 놓쳤다면?", orange),
|
||
("부분 실패", "100건 중 73번째가 실패하면 어디부터 재개?", purple),
|
||
("재시도", "즉시 재시도, 다음 tick, 운영자 재처리 중 무엇?", blue),
|
||
("멱등성", "같은 대상을 다시 처리해도 중복 효과가 없는가?", green),
|
||
("관측성", "처리량·실패 대상·소요시간을 로그와 지표로 남기는가?", cyan),
|
||
]
|
||
for (i, j) in jobIssues.enumerated() {
|
||
let x = margin + CGFloat(i % 2) * 260
|
||
let y = CGFloat(445 + (i / 2) * 73)
|
||
callout(j.0, j.1, rect: CGRect(x: x, y: y, width: 247, height: 58), color: j.2)
|
||
}
|
||
callout("스케줄러만으로 정확성은 보장되지 않음", "max_instances=1은 한 프로세스 안의 중복을 막을 뿐입니다. 서버가 여러 대면 각 서버가 Job을 실행할 수 있으므로 DB 조건부 갱신, 분산 락, 전용 Worker 같은 추가 방어가 필요합니다.",
|
||
rect: CGRect(x: margin, y: 680, width: 507, height: 105), color: red)
|
||
endPage(ctx)
|
||
|
||
// 16 scheduler concept
|
||
beginPage(ctx, page: 16)
|
||
sectionTitle("9", "스케줄러와 배치 안정성", "사용자 요청 없이 정해진 시간마다 반복 업무를 수행하는 백그라운드 실행", color: green)
|
||
callout("쉬운 비유", "API가 손님이 주문할 때 움직이는 직원이라면, 스케줄러는 매 5분마다 마감 시간이 지난 주문을 확인하는 당직자입니다. 사람이 요청하지 않아도 시간이 되면 일을 시작합니다.",
|
||
rect: CGRect(x: margin, y: 130, width: 507, height: 92), color: green)
|
||
drawText("5분 tick의 세 가지 작업", CGRect(x: margin, y: 252, width: 507, height: 24),
|
||
size: 14, color: navy, weight: .bold)
|
||
node("CronTrigger", "매 5분", rect: CGRect(x: 48, y: 315, width: 105, height: 68), color: green)
|
||
node("잡 ①", "기한 지난 견적 마감", rect: CGRect(x: 225, y: 280, width: 135, height: 62), color: orange)
|
||
node("잡 ②", "협상 완료 견적 마감", rect: CGRect(x: 225, y: 365, width: 135, height: 62), color: purple)
|
||
node("잡 ③", "LPS 결과 증분 반영", rect: CGRect(x: 225, y: 450, width: 135, height: 62), color: cyan)
|
||
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 311), color: green)
|
||
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 396), color: green)
|
||
arrow(CGPoint(x: 153, y: 349), CGPoint(x: 225, y: 481), color: green)
|
||
node("PostgreSQL", "조건부 처리·기록", rect: CGRect(x: 430, y: 365, width: 115, height: 72), color: blue)
|
||
arrow(CGPoint(x: 360, y: 311), CGPoint(x: 430, y: 385), color: orange)
|
||
arrow(CGPoint(x: 360, y: 396), CGPoint(x: 430, y: 401), color: purple)
|
||
arrow(CGPoint(x: 360, y: 481), CGPoint(x: 430, y: 420), color: cyan)
|
||
callout("중복 실행 방지 장치", "SCHEDULER_ENABLED=1인 프로세스 하나만 잡을 등록합니다. 각 잡은 max_instances=1이고, 밀린 실행은 coalesce=True로 한 번만 실행합니다. 그래도 다중 서버 가능성을 고려해 DB의 조건부 UPDATE가 마지막 방어선입니다.",
|
||
rect: CGRect(x: margin, y: 565, width: 507, height: 115), color: green)
|
||
comparison("안정 장치가 없으면", "서버 Worker 수만큼 같은 잡이 실행되고, 같은 견적을 여러 번 마감하거나 알림을 중복 발송할 수 있습니다.",
|
||
"현재 방식", "실행 프로세스 제한 + 잡 중복 제한 + DB 동시성 가드를 겹쳐 사용합니다.",
|
||
y: 704, color: green)
|
||
endPage(ctx)
|
||
|
||
// 17 scheduler code
|
||
beginPage(ctx, page: 17)
|
||
sectionTitle("9", "스케줄러·배치 — 실제 코드", "‘언제 실행할지’와 ‘무엇을 안전하게 처리할지’를 분리합니다.", color: green)
|
||
codeBox("APScheduler 등록: 5분, 중복 방지, 지연 허용",
|
||
"negodata/backend/scheduler/__init__.py · lines 37–69",
|
||
"""
|
||
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||
_scheduler.add_job(
|
||
jobs.close_expired_quotations,
|
||
CronTrigger(minute="*/5"),
|
||
id="close_expired_quotations",
|
||
coalesce=True, # 밀린 실행은 1번만
|
||
misfire_grace_time=600, # 10분 내 지연 실행 허용
|
||
max_instances=1, # 같은 잡 동시 실행 금지
|
||
)
|
||
""",
|
||
rect: CGRect(x: margin, y: 130, width: 507, height: 215), accent: green)
|
||
codeBox("Job: 대상 조회와 개별 마감 처리를 분리",
|
||
"negodata/backend/scheduler/jobs.py · lines 39–61",
|
||
"""
|
||
err_type, qt_ids = await DB_SESSION_MNG.execute_lambda(
|
||
quotations.DBType(),
|
||
DBWRType.DB_READ.value,
|
||
lambda s: crud.list_due_for_close(s, now),
|
||
)
|
||
if err_type != ErrorType.SUCCESS:
|
||
return 0
|
||
|
||
results = await _close_each(service, qt_ids)
|
||
return sum(results.values())
|
||
""",
|
||
rect: CGRect(x: margin, y: 370, width: 507, height: 190), accent: cyan)
|
||
callout("멱등성(idempotency)", "같은 잡을 두 번 실행해도 최종 결과가 한 번 실행한 것과 같도록 만드는 성질입니다. 대상 조회가 중복될 수 있어도 claim_for_close의 조건부 UPDATE가 두 번째 처리를 rowcount=0으로 막습니다.",
|
||
rect: CGRect(x: margin, y: 585, width: 507, height: 105), color: purple)
|
||
callout("실패한 tick은 어떻게 되나요?", "LPS 동기화는 watermark 기반 증분 처리라 실패한 회차의 데이터가 다음 5분 tick에서 다시 대상이 됩니다. 스케줄러 자체 재시도보다 데이터 설계를 통해 회복합니다.",
|
||
rect: CGRect(x: margin, y: 710, width: 507, height: 72), color: green)
|
||
endPage(ctx)
|
||
|
||
// 18 combined scenario
|
||
beginPage(ctx, page: 18)
|
||
drawText("다섯 기술이 한 장면에서 만나는 순간", CGRect(x: margin, y: 48, width: 507, height: 34),
|
||
size: 23, color: navy, weight: .bold)
|
||
drawText("예: 협상이 모두 끝난 견적을 스케줄러가 자동 마감하는 동안 담당자가 수동 마감을 클릭했다.",
|
||
CGRect(x: margin, y: 88, width: 507, height: 30), size: 10.5, color: muted)
|
||
let rows: [(String, String, NSColor)] = [
|
||
("1", "멀티테넌시: 요청의 X-Tenant-ID로 어느 회사의 협상 엔진과 데이터인지 결정", purple),
|
||
("2", "분산 시스템: Backend가 Agent를 HTTP로 호출할 때 timeout과 부분 실패를 구분", blue),
|
||
("3", "스케줄러: 5분 tick이 동일 견적을 마감 대상으로 발견", green),
|
||
("4", "동시성 제어: 수동 요청과 스케줄러 중 조건부 UPDATE를 먼저 성공한 쪽만 처리", orange),
|
||
("5", "트랜잭션: 관련 상태 변경을 commit하거나, 실패하면 rollback", cyan),
|
||
("6", "캐시 정합성: 원본 DB commit 후 파생 캐시를 갱신하고 실패 시 다음 회차에 회복", red),
|
||
]
|
||
for (i, item) in rows.enumerated() {
|
||
let y = CGFloat(145 + i * 91)
|
||
rounded(CGRect(x: margin, y: y, width: 507, height: 70), radius: 12,
|
||
fill: item.2.withAlphaComponent(0.08), stroke: item.2.withAlphaComponent(0.30))
|
||
rounded(CGRect(x: 58, y: y + 15, width: 40, height: 40), radius: 20,
|
||
fill: item.2, stroke: nil)
|
||
drawText(item.0, CGRect(x: 58, y: y + 24, width: 40, height: 20), size: 13,
|
||
color: .white, weight: .bold, align: .center)
|
||
drawText(item.1, CGRect(x: 116, y: y + 15, width: 415, height: 42), size: 10.2,
|
||
color: ink, weight: .medium, lineSpacing: 3)
|
||
if i < rows.count - 1 {
|
||
arrow(CGPoint(x: 78, y: y + 70), CGPoint(x: 78, y: y + 90), color: item.2)
|
||
}
|
||
}
|
||
callout("핵심 관점", "어려운 백엔드 기술은 ‘라이브러리 이름’보다 경계와 실패를 다루는 방법입니다. 네트워크 경계, 회사 경계, 트랜잭션 경계, 캐시의 원본 경계를 명확하게 설계하는 것이 핵심입니다.",
|
||
rect: CGRect(x: margin, y: 708, width: 507, height: 78), color: navy)
|
||
endPage(ctx)
|
||
|
||
// 19 glossary
|
||
beginPage(ctx, page: 19)
|
||
drawText("초보자를 위한 한 줄 사전", CGRect(x: margin, y: 48, width: 507, height: 34),
|
||
size: 24, color: navy, weight: .bold)
|
||
let glossary: [(String, String)] = [
|
||
("분산 시스템", "여러 프로세스·서버가 네트워크로 협력하는 시스템"),
|
||
("부분 실패", "전체 중 일부 서비스만 실패한 상태"),
|
||
("Timeout", "응답을 무한히 기다리지 않고 정해진 시간에 포기하는 제한"),
|
||
("Best-effort", "실패해도 핵심 업무는 성공시키는 보조 작업 정책"),
|
||
("트랜잭션", "여러 DB 변경을 모두 성공 또는 모두 취소하는 작업 단위"),
|
||
("Rollback", "실패했을 때 트랜잭션의 변경을 되돌리는 것"),
|
||
("Race condition", "실행 순서에 따라 결과가 달라지는 동시성 문제"),
|
||
("원자적 연산", "중간 상태가 보이지 않도록 한 번에 처리되는 연산"),
|
||
("멀티테넌시", "한 시스템을 여러 고객사가 격리된 상태로 공유하는 구조"),
|
||
("Cache-aside", "캐시를 먼저 보고, miss이면 원본 조회 후 캐시를 채우는 패턴"),
|
||
("TTL", "캐시 값이 자동 만료되기까지의 시간"),
|
||
("Stale", "원본보다 오래되어 현재와 맞지 않는 캐시 상태"),
|
||
("Invalidation", "원본 변경 시 캐시를 삭제하거나 무효화하는 것"),
|
||
("Reconciliation", "원본과 복사본을 비교·재적재해 다시 맞추는 작업"),
|
||
("Scheduler", "정해진 시간 규칙에 따라 작업을 실행하는 도구"),
|
||
("Batch", "사용자 요청과 별개로 데이터 묶음을 주기적으로 처리하는 작업"),
|
||
("멱등성", "같은 작업을 반복해도 최종 결과가 달라지지 않는 성질"),
|
||
]
|
||
for (i, g) in glossary.enumerated() {
|
||
let col = i < 9 ? 0 : 1
|
||
let row = col == 0 ? i : i - 9
|
||
let x = margin + CGFloat(col) * 260
|
||
let y = CGFloat(128 + row * 69)
|
||
drawText(g.0, CGRect(x: x, y: y, width: 230, height: 19), size: 10.5,
|
||
color: [blue, orange, purple, red, green][i % 5], weight: .bold)
|
||
drawText(g.1, CGRect(x: x, y: y + 23, width: 230, height: 36), size: 8.8,
|
||
color: ink, lineSpacing: 2)
|
||
}
|
||
callout("추천 복습 순서", "트랜잭션·동시성 → 캐시 정합성 → 스케줄러 → 멀티테넌시 → 분산 시스템 순으로 다시 보면, 작은 DB 작업에서 전체 서비스 구조로 이해가 확장됩니다.",
|
||
rect: CGRect(x: margin, y: 718, width: 507, height: 74), color: blue)
|
||
endPage(ctx)
|
||
|
||
ctx.closePDF()
|
||
print(outPath)
|