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)