// 이 파일이 하는 일: "메시지 큐와 이벤트 드리븐 — 시스템을 느슨하게 잇는 법" 코스 —
// 동기 REST 체인의 한계에서 출발해, 큐/토픽 모델, Kafka의 파티션·오프셋·컨슈머 그룹,
// 전달 보장과 멱등 컨슈머, 이벤트 드리븐 설계, DLQ·랙 모니터링, 이벤트 소싱·CQRS까지
// 7개 섹션으로 안내하는 정적 학습 페이지. 프레이밍은 "대규모 시스템을 설계하는 시야" —
// traderbox의 audit.events 토픽과 Valkey Pub/Sub이라는 실전 사례를 다리 삼아,
// '시스템을 느슨하게 잇는' 비동기 메시징의 사고방식을 익힌다.
// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.

import { Link } from 'react-router-dom';

// ── 예제 코드 상수들 ──

const CODE_SYNC_CHAIN = `[동기 REST 체인]  회원가입 요청 하나가 4개 서비스를 줄줄이 기다린다

사용자 → 가입API → 이메일서비스 → 포인트서비스 → 통계서비스 → 응답
          50ms   +   300ms      +   80ms       +   200ms     = 630ms

문제 ① 지연 전파: 제일 느린 놈(이메일 300ms)이 전체 응답 시간을 정한다
문제 ② 연쇄 장애: 통계서비스가 죽으면? 가입 자체가 실패한다 (통계 때문에!)
문제 ③ 강한 결합: 알림 채널을 하나 더 붙이려면 가입API 코드를 고쳐야 한다

[비동기 메시징]  가입API는 "가입됐다"는 사실만 남기고 즉시 응답한다

사용자 → 가입API → (UserSignedUp 이벤트 발행) → 응답  = 60ms
                        ↓ 브로커(Kafka)
          이메일서비스 ── 포인트서비스 ── 통계서비스   ← 각자 알아서 구독
          (느려도 OK)    (죽어도 가입은 성공, 살아나면 밀린 것 처리)`;

const CODE_QUEUE_VS_TOPIC = `[포인트-투-포인트 큐]  "일감 나눠 갖기" — 메시지 하나 = 일 하나 = 처리자 하나

  생산자 → [ 큐 ] → 워커A (메시지1 가져감·삭제)
                  → 워커B (메시지2 가져감·삭제)
  · 워커를 늘리면 처리량이 는다 (작업 분배)
  · 한 메시지는 딱 한 워커만 본다 — 이미지 리사이징, 메일 발송 같은 '작업'에 적합

[발행-구독 토픽]  "방송하기" — 메시지 하나 = 사실 하나 = 듣는 쪽 여럿

  생산자 → [ 토픽 ] → 이메일서비스 (전부 받음)
                    → 포인트서비스 (전부 받음)
                    → 통계서비스   (전부 받음)
  · 구독자를 늘려도 서로 영향 없음 — "가입했다"는 사실의 방송
  · Kafka는 여기에 '로그'를 더했다: 읽어도 지우지 않고, 각자 어디까지
    읽었는지(오프셋)만 기억한다 → 나중에 온 구독자도 과거를 읽을 수 있다`;

const CODE_KAFKA_ANATOMY = `토픽 audit.events (파티션 3개라고 가정)

  파티션 0:  [0][1][2][3][4][5] ←새 메시지는 끝에만 붙는다(append-only)
  파티션 1:  [0][1][2][3]
  파티션 2:  [0][1][2][3][4]
              ↑
        오프셋(offset): 파티션 안에서의 순번. 컨슈머는 "나 파티션0의
        3번까지 읽었어"를 브로커에 기록(커밋)해 두고 이어 읽는다.

순서 보장의 범위: 같은 파티션 안에서만 순서가 보장된다.
  → 파티션 키를 userId로 주면: 같은 사용자의 이벤트는 같은 파티션에
    들어가 순서 유지, 다른 사용자끼리는 병렬 처리. (키 선택 = 설계 결정)

컨슈머 그룹: 같은 groupId를 가진 컨슈머들이 파티션을 나눠 맡는다.
  그룹 [welcome-noti]  컨슈머A → 파티션 0, 1 담당
                       컨슈머B → 파티션 2 담당
  · 컨슈머를 3대로 늘리면 → 1대당 파티션 1개 (처리량 UP)
  · 4대로 늘리면? → 1대는 논다. 파티션 수가 병렬성의 상한이다.
  · 컨슈머가 죽거나 늘면 파티션 재배정(리밸런싱)이 일어난다 — 이 순간
    잠깐 소비가 멈추고, 처리 중이던 메시지가 다시 배정될 수 있다.`;

const CODE_PRODUCER = `// service-auth 쪽 — 회원가입이 '성공한 뒤' 이벤트를 발행한다
// 학습 포인트: 이벤트 이름은 과거형(UserSignedUp). "이미 일어난 사실"이기 때문.
public record UserSignedUpEvent(
        String eventId,     // UUID — 컨슈머가 중복 제거에 쓸 유일 키
        Long userId,
        String email,
        Instant occurredAt  // 사실이 '일어난' 시각 (발행 시각과 구분)
) implements AuditEvent

@Service
public class SignupEventPublisher

    private final KafkaTemplate<String, String> kafka;

    public void publish(User user)
        var event = new UserSignedUpEvent(
                UUID.randomUUID().toString(),
                user.getId(), user.getEmail(), Instant.now());
        // 파티션 키 = userId → 같은 사용자의 이벤트는 순서 보장
        kafka.send("user.events", String.valueOf(user.getId()), toJson(event));

// traderbox의 AuditEventPublisher가 audit.events 토픽에 하는 일과 같은 구조다.
// 발행자는 "누가 듣는지" 모른다 — 알 필요도 없다. 그게 느슨한 결합이다.`;

const CODE_IDEMPOTENT_CONSUMER = `// 웰컴 알림 컨슈머 — 같은 이벤트가 '두 번' 와도 알림은 '한 번'만
// 학습 포인트: at-least-once 세상에서 컨슈머의 기본 예의는 멱등(idempotent)이다.
@Component
public class WelcomeNotiConsumer

    private final ProcessedEventRepository processed;  // 처리한 eventId 저장소
    private final NotificationSender sender;

    @KafkaListener(topics = "user.events", groupId = "welcome-noti")
    public void on(String message)
        var event = fromJson(message, UserSignedUpEvent.class);

        // ① 이미 처리한 이벤트인가? — eventId로 중복 제거
        if (processed.existsById(event.eventId()))
            return;   // 두 번째 도착: 조용히 무시. 이게 멱등 처리의 전부다.

        // ② 실제 작업
        sender.sendWelcome(event.userId(), event.email());

        // ③ 처리 완료 기록 — ②와 ③을 같은 DB 트랜잭션으로 묶으면 더 안전
        processed.save(new ProcessedEvent(event.eventId(), Instant.now()));

// 왜 중복이 오나? 컨슈머가 알림을 보낸 '직후, 오프셋 커밋 직전'에 죽으면
// 브로커는 "아직 안 읽었네" 하고 같은 메시지를 다시 준다. 이건 버그가 아니라
// at-least-once의 정상 동작이다. 그래서 방어는 언제나 컨슈머 몫이다.`;

const CODE_OUTBOX = `// 트랜잭셔널 아웃박스 — "DB 저장과 이벤트 발행"을 사실상 하나로 묶는 패턴
// 문제: DB에 회원을 저장하고 → Kafka에 발행하는 사이에 서버가 죽으면?
//       회원은 있는데 이벤트는 없다. (반대로 발행 먼저 하면 유령 이벤트가 생긴다)

// 해법: 이벤트를 Kafka가 아니라 '같은 DB의 outbox 테이블'에 먼저 쓴다.
@Transactional
public void signup(SignupRequest req)
    var user = userRepository.save(newUser(req));          // ① 회원 저장
    outboxRepository.save(OutboxRow.of(                    // ② 이벤트도 같은
            "UserSignedUp", toJson(eventOf(user))));       //    트랜잭션으로 저장
    // 커밋 → 둘 다 저장되거나, 둘 다 안 되거나. 어긋날 틈이 없다.

// ③ 별도의 릴레이(폴러 또는 CDC)가 outbox 테이블을 읽어 Kafka로 발행하고
//    발행 성공한 행을 지운다. 릴레이가 죽어도 행은 남아 있으니 재시도하면 그만.

// '정확히 한 번'처럼 보이는 것의 정체:
//   아웃박스(발행 유실 방지) + 멱등 컨슈머(중복 소비 무해화)
//   = 유실도 없고 중복의 '효과'도 없음. 브로커 설정 하나로 되는 마법이 아니라
//     생산자와 소비자 양쪽의 설계로 만들어 내는 성질이다.`;

const CODE_EVENT_SCHEMA = `// 이벤트 스키마와 하위 호환 — 이벤트는 '발행되는 순간 공공재'가 된다
// v1 구독자가 돌고 있는데 발행자만 v2로 올라가는 상황은 일상이다.

// v1                                v2 (호환 OK — 필드 추가)
"eventId": "...",                   "eventId": "...",
"userId": 42,                       "userId": 42,
"email": "a@b.dev",                 "email": "a@b.dev",
"occurredAt": "..."                 "occurredAt": "...",
                                    "referrer": "friend-invite"  // 새 필드

→ v1 컨슈머는 모르는 필드(referrer)를 그냥 무시하면 된다. 안전.

// v2' (호환 깨짐 — 필드 삭제/의미 변경)
"eventId": "...",
"userId": 42,                       // email 삭제!
"occurredAt": "..."

→ email을 쓰던 웰컴 알림 컨슈머가 그 자리에서 깨진다.
   규칙: 필드 추가는 자유, 삭제·이름변경·타입변경은 금지(정 필요하면
   새 버전 토픽이나 마이그레이션 기간을 두고 단계적으로).`;

const CODE_DLQ = `// 포이즌 메시지와 DLQ — '한 놈'이 전체 라인을 세우지 않게
// 포이즌 메시지: 파싱 불가, 규칙 위반 등으로 몇 번을 재시도해도 실패하는 메시지.
// 이걸 무한 재시도하면? 그 파티션의 뒷 메시지 전부가 영원히 막힌다.

@KafkaListener(topics = "user.events", groupId = "welcome-noti")
public void on(String message)
    try
        handle(message);
    catch (TransientException e)
        throw e;              // 일시 오류(네트워크 등) → 재시도 대상
    catch (Exception e)
        // 영구 오류 → 재시도 N회 후 데드레터 토픽으로 치우고 다음으로 진행
        // (Spring Kafka: DefaultErrorHandler + DeadLetterPublishingRecoverer)
        //   user.events → user.events.DLT
        // DLQ는 '버리는 곳'이 아니라 '나중에 사람이 볼 병원'이다.
        //   → 알람 걸고, 원인 고친 뒤, DLQ에서 본 토픽으로 재발행(재처리)한다.

// 컨슈머 랙(lag) = (파티션의 최신 오프셋) − (내 그룹이 커밋한 오프셋)
//   랙 0~수십     : 실시간으로 따라가는 중. 정상.
//   랙이 계속 증가: 소비 속도 < 생산 속도. 컨슈머 장애이거나 처리량 부족.
//   알람 기준은 절대값보다 '증가 추세 + 지속 시간'으로 잡는 게 실전적이다.
//   (장 마감 직후처럼 원래 몰리는 시간대의 일시적 랙은 정상일 수 있으니까)`;

const CODE_EVENT_SOURCING = `[상태 저장]  계좌 테이블에 '지금 잔고'만 남긴다
  balance = 30,000        ← 어쩌다 3만원이 됐는지는 아무도 모른다

[이벤트 소싱]  '일어난 일'을 전부 남기고, 상태는 그걸 재생해 계산한다
  AccountOpened   (+0)
  MoneyDeposited  (+50,000)
  MoneyWithdrawn  (-30,000)
  MoneyDeposited  (+10,000)
  ──────────────────────── 재생(replay) → balance = 30,000

  회계 장부와 같은 사고방식: 지우지 않고 덧붙인다. 감사(audit)·시점 복원·
  "그때 왜 그랬지?" 추적이 공짜로 따라온다. 대신 조회가 어려워진다 —
  "잔고 10만원 이상 계좌 목록"을 이벤트 더미에서 바로 뽑을 순 없으니까.

[CQRS]  그래서 읽기 모델을 따로 둔다
  쓰기: 커맨드 → 이벤트 저장 (정합성·이력에 최적화)
  읽기: 이벤트를 구독해 조회 전용 테이블/캐시를 갱신 (조회 속도에 최적화)
  대가: 두 모델 사이에 '잠깐의 시차'(최종 일관성)가 생긴다.

판단 기준: 이력 자체가 도메인의 핵심(금융 원장, 감사)이면 강력하다.
그 외 대부분의 팀에는 [아웃박스 + 토픽 + 멱등 컨슈머]가 충분하고,
그게 이 강좌에서 여러분이 이미 다 배운 조합이다.`;

// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. props만 바꿔 재사용.
function Section({ n, title, sub, children }) {
  return (
    <section className="step-card" id={`sec-${n}`}>
      <div className="step-head">
        <span className="step-num">{n}</span>
        <div>
          <h3>{title}</h3>
          {sub && <span className="step-sub">{sub}</span>}
        </div>
      </div>
      <div className="step-body">{children}</div>
    </section>
  );
}

function Code({ children }) {
  return <pre className="code-block"><code>{children}</code></pre>;
}

const SECTIONS = [
  { n: 1, label: '동기 호출의 사슬' },
  { n: 2, label: '큐 vs 토픽' },
  { n: 3, label: 'Kafka 해부' },
  { n: 4, label: '전달 보장' },
  { n: 5, label: '이벤트 드리븐 설계' },
  { n: 6, label: '실패와 지연' },
  { n: 7, label: '이벤트 소싱·CQRS' },
];

export default function Lv4MessageQueueEventDrivenPage() {
  return (
    <div>
      {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
      <div className="hero">
        <div className="eyebrow">Course · 특급 과정</div>
        <h1>메시지 큐와 이벤트 드리븐<br />— 시스템을 느슨하게 잇는 법 📨</h1>
        <p>
          서비스가 서비스를 직접 부르는 <strong>동기 호출</strong>은 직관적이지만, 시스템이
          커질수록 "하나가 느려지면 다 느려지는" 사슬이 됩니다. 이 코스에서는 그 사슬을{' '}
          <strong>Kafka 같은 메시지 브로커</strong>로 끊어 내고, "요청과 응답" 대신{' '}
          <strong>"일어난 사실(이벤트)"을 중심으로 사고하는 법</strong>을 배웁니다. 파티션과
          오프셋, 멱등 컨슈머, 아웃박스, DLQ까지 — 대규모 분산 환경을 설계하는 시야를 함께
          길러 봅시다.
        </p>
        <div className="chip-row">
          <span className="chip">예상 소요 85분</span>
          <span className="chip">실습: 가입 이벤트 발행 + 멱등 웰컴 컨슈머</span>
          <span className="chip">선수 지식: REST API·DB 트랜잭션</span>
        </div>
      </div>

      <Section n={1} title="동기 호출의 사슬 — 하나가 느려지면 다 느려진다" sub="왜 큰 시스템은 비동기로 기우는가">
        <p>
          회원가입 하나를 생각해 봅시다. 가입 처리 후에 <strong>웰컴 이메일</strong>을 보내고,{' '}
          <strong>가입 포인트</strong>를 적립하고, <strong>통계</strong>도 갱신해야 한다면?
          가장 직관적인 방법은 가입 API가 세 서비스를 <strong>차례로 REST 호출</strong>하는
          거예요. 작을 땐 이걸로 충분합니다. 그런데 시스템이 커지면 이 사슬이 세 가지 방식으로
          발목을 잡습니다.
        </p>
        <Code>{CODE_SYNC_CHAIN}</Code>
        <p>
          첫째, <strong>지연 전파</strong> — 응답 시간이 사슬에 묶인 서비스들의 <strong>합</strong>이
          되고, 제일 느린 서비스가 전체의 하한을 정합니다. 둘째, <strong>연쇄 장애</strong> —
          통계서비스가 죽었을 뿐인데 회원가입이 실패해요. 부가 기능의 장애가 핵심 기능을
          무너뜨리는 거죠. 셋째, <strong>강한 결합</strong> — 알림 채널 하나 추가할 때마다
          가입 API 코드를 열어야 하니, 서비스들이 서로의 존재를 너무 잘 압니다.
        </p>
        <p>
          <strong>비동기 메시징</strong>은 이 셋을 한 번에 풀어요. 가입 API는 브로커에
          "가입됐다"는 메시지만 남기고 즉시 응답합니다(<strong>시간적 분리</strong>). 트래픽이
          몰려도 브로커가 버퍼가 되어 컨슈머는 제 속도로 소화하고(<strong>부하 흡수</strong>),
          컨슈머 하나가 죽어도 메시지는 브로커에 남아 있다가 복구 후 처리됩니다
          (<strong>장애 격리</strong>).
        </p>
        <div className="tip">
          <b>그래도 동기가 맞는 경우</b> 모든 걸 비동기로 바꾸라는 얘기가 아니에요.{' '}
          <strong>호출한 쪽이 결과를 알아야 다음 단계로 갈 수 있는 일</strong> — 잔고 조회,
          비밀번호 검증, 주문 가능 여부 확인 — 은 동기가 맞습니다. 기준은 하나예요.
          "이 응답을 <strong>지금 당장</strong> 알아야 하는가?" 예스면 동기, 나중에 처리돼도
          되면 비동기. 회원가입의 '가입'은 동기, '웰컴 메일'은 비동기인 이유입니다.
        </div>
      </Section>

      <Section n={2} title="큐 vs 토픽 — 메시징의 두 가지 모델" sub="일감 나누기와 방송하기는 다른 문제다">
        <p>
          "메시지 큐"라고 뭉뚱그려 부르지만, 그 안엔 목적이 다른 두 모델이 있어요.{' '}
          <strong>포인트-투-포인트 큐</strong>는 <strong>작업 분배</strong>가 목적입니다.
          메시지 하나는 일감 하나고, 워커 중 <strong>딱 하나</strong>가 가져가 처리하면
          큐에서 사라져요. 반면 <strong>발행-구독(pub/sub) 토픽</strong>은{' '}
          <strong>사실의 방송</strong>이 목적이라, 구독자 <strong>전원</strong>이 같은 메시지를
          받습니다.
        </p>
        <Code>{CODE_QUEUE_VS_TOPIC}</Code>
        <p>
          브로커의 구조도 갈립니다. <strong>RabbitMQ류</strong>는 전통적 큐 모델 — 브로커가
          메시지를 워커에게 밀어 주고, 처리 확인(ack)을 받으면 <strong>지웁니다</strong>.
          라우팅이 유연하고 작업 큐에 강해요. <strong>Kafka류</strong>는{' '}
          <strong>로그 기반</strong> — 메시지를 지우지 않고 순서대로 쌓아 두고, 컨슈머가
          "어디까지 읽었는지"만 각자 기억합니다. 그래서 같은 데이터를 여러 팀이 각자의
          속도로 읽을 수 있고, 나중에 합류한 컨슈머도 <strong>과거부터</strong> 읽을 수 있죠.
        </p>
        <p>
          우리가 참고하는 실전 사례, traderbox를 보면 이 구분이 선명해요. <strong>실시간
          시세</strong>는 Valkey Pub/Sub(<span className="icode">market.ticks</span> 채널)로
          흘립니다 — 시세는 "지금 이 순간"이 전부라, 놓친 틱을 나중에 읽을 이유가 없거든요.
          가볍고 빠른 인메모리 방송이면 충분합니다. 반면 <strong>감사 로그</strong>는
          Kafka(<span className="icode">audit.events</span> 토픽)로 보내요 — 감사 기록은{' '}
          <strong>하나도 잃으면 안 되고</strong>, 나중에 다시 읽을 수도 있어야 하니까요.
        </p>
        <div className="tip">
          <b>선택 기준 한 줄</b> "놓친 메시지를 나중에 읽어야 하는가?" — 아니오(시세, 채팅
          프레즌스)면 가벼운 Pub/Sub, 예(감사, 정산, 이벤트 이력)면 로그 기반 브로커.
          같은 데이터를 두 채널로 흘리지 않는 것도 중요한 원칙이에요. 채널마다 보장이
          다르면 "어느 쪽이 진실이냐"는 골치 아픈 질문이 생기니까요.
        </div>
      </Section>

      <Section n={3} title="Kafka 해부 — 파티션, 오프셋, 컨슈머 그룹" sub="로그를 지우지 않는 브로커의 내부">
        <p>
          Kafka의 심장은 <strong>"지우지 않는 로그"</strong>입니다. 토픽은 여러 개의{' '}
          <strong>파티션</strong>으로 쪼개져 있고, 각 파티션은 메시지가 끝에만 붙는
          (append-only) 파일이에요. 파티션 안의 순번이 <strong>오프셋</strong>이고, 컨슈머는
          "나 여기까지 읽었어"라는 오프셋만 커밋해 둡니다. 메시지를 지우는 게 아니라{' '}
          <strong>책갈피를 옮기는</strong> 방식이라, 여러 그룹이 같은 토픽을 각자 읽어도 서로
          방해가 없어요.
        </p>
        <Code>{CODE_KAFKA_ANATOMY}</Code>
        <p>
          여기서 특급 개발자의 첫 설계 결정이 나옵니다. <strong>파티션 키</strong>를 뭘로
          잡을 것인가. Kafka는 <strong>같은 파티션 안에서만</strong> 순서를 보장하는데, 같은
          키의 메시지는 항상 같은 파티션으로 가요. 키를 <span className="icode">userId</span>로
          주면 "한 사용자의 이벤트는 순서대로, 사용자끼리는 병렬로"가 되고, 키를 안 주면
          순서는 포기하는 대신 부하가 고르게 퍼집니다. <strong>순서 보장과 부하 분산을 한 번에
          결정하는 한 줄</strong>인 셈이죠.
        </p>
        <p>
          <strong>컨슈머 그룹</strong>은 소비 쪽의 병렬화 장치예요. 같은{' '}
          <span className="icode">groupId</span>의 컨슈머들이 파티션을 나눠 맡고, 컨슈머를
          늘리면 파티션이 재배정(<strong>리밸런싱</strong>)되며 처리량이 늘어요. 단, 파티션
          수가 병렬성의 상한이라 파티션 3개짜리 토픽에 컨슈머 4대를 붙이면 1대는 놉니다.
          그리고 리밸런싱 중엔 소비가 잠깐 멈추고, 처리 중이던 메시지가 다른 컨슈머로 다시
          갈 수 있어요 — 섹션 4의 "중복 소비"가 여기서도 태어납니다.
        </p>
        <div className="tip">
          <b>실전 구성 읽기</b> traderbox의 <span className="icode">AuditEventPublisher</span>는
          각 서비스에서 감사 이벤트를 <span className="icode">audit.events</span> 토픽으로
          발행하고, log 서비스의 <span className="icode">AuditEventConsumer</span>가 자기
          그룹으로 구독해 DB에 적재합니다. 발행하는 서비스들은 log 서비스가 죽었는지조차
          모릅니다 — 죽어 있는 동안의 이벤트도 토픽에 쌓여 있다가, 살아나면 밀린 오프셋부터
          따라잡아요. "지우지 않는 로그"가 주는 장애 격리를 눈으로 볼 수 있는 구성이에요.
        </div>
      </Section>

      <Section n={4} title="전달 보장 — 최소 한 번, 최대 한 번, 정확히 한 번" sub="중복은 버그가 아니라 기본값이다">
        <p>
          메시징 시스템의 전달 보장은 세 등급으로 말해요. <strong>at-most-once</strong>(최대
          한 번 — 유실 가능, 중복 없음), <strong>at-least-once</strong>(최소 한 번 — 유실
          없음, 중복 가능), <strong>exactly-once</strong>(정확히 한 번). 실무의 기본값은{' '}
          <strong>at-least-once</strong>입니다. 왜냐하면 "처리했다"는 확인이 오가는 분산
          환경에서는, 확인이 유실됐을 때 <strong>다시 보내는 것</strong> 말고는 유실을 막을
          방법이 없거든요. 다시 보내면? 중복이 생기죠. 즉 <strong>유실을 막는 대가가
          중복</strong>이고, 중복은 장애가 아니라 <strong>설계에 반영해야 할 전제</strong>입니다.
        </p>
        <p>
          그래서 컨슈머의 기본 소양이 <strong>멱등(idempotent) 처리</strong>예요. 같은 이벤트가
          두 번 와도 <strong>효과는 한 번</strong>이게 만드는 것. 가장 정직한 방법은 이벤트마다
          유일한 <span className="icode">eventId</span>를 실어 보내고, 컨슈머가 "처리한 ID
          장부"를 확인하는 겁니다. 이번 실습의 핵심이 바로 이거예요.
        </p>
        <Code>{CODE_IDEMPOTENT_CONSUMER}</Code>
        <p>
          그럼 <strong>exactly-once</strong>는요? 흔한 오해가 "브로커 설정 하나 켜면 되는 것"
          인데, 브로커 혼자서는 <strong>못 만듭니다</strong>. 브로커 바깥 — 여러분의 DB, 이메일
          발송, 외부 API — 까지 걸치는 순간, 보장은 양 끝단의 설계 몫이 돼요. 생산 쪽의 고전적
          해법이 <strong>트랜잭셔널 아웃박스</strong> 패턴입니다.
        </p>
        <Code>{CODE_OUTBOX}</Code>
        <div className="tip">
          <b>공식으로 기억하세요</b> 실무에서 "정확히 한 번<strong>처럼</strong>"은{' '}
          <strong>아웃박스(발행 유실 방지) + at-least-once 전달 + 멱등 컨슈머(중복 무해화)</strong>
          의 합으로 만듭니다. 셋 중 하나라도 빠지면 어딘가에서 유실되거나 두 번 실행돼요.
          이번 실습에서 이 세 조각을 전부 손으로 만져 봅니다.
        </div>
      </Section>

      <Section n={5} title="이벤트 드리븐 아키텍처 설계" sub="이벤트는 과거형 사실, 커맨드는 요청">
        <p>
          도구를 알았으니 이제 <strong>설계 언어</strong>를 다듬을 차례예요. 첫 구분은{' '}
          <strong>이벤트 vs 커맨드</strong>입니다. <span className="icode">UserSignedUp</span>
          (가입<strong>했다</strong>)은 이벤트 — 이미 일어난 <strong>과거형 사실</strong>이고,
          발행자는 누가 듣는지 모르며, 듣는 쪽이 뭘 할지는 듣는 쪽 마음입니다.{' '}
          <span className="icode">SendEmail</span>(보내<strong>라</strong>)은 커맨드 —
          특정 수신자에게 <strong>특정 행동을 요구</strong>하는 요청이죠. 이벤트로 설계하면
          발행자가 홀가분해지고(결합 감소), 커맨드로 설계하면 의도가 명확해집니다(책임 명시).
          가입 API가 <span className="icode">SendWelcomeEmail</span> 커맨드를 쏘는 순간,
          "이메일을 보내야 한다"는 지식이 다시 가입 API로 돌아온다는 점을 눈여겨보세요 —
          동기 호출 시절의 결합이 모양만 바꿔 부활한 겁니다.
        </p>
        <p>
          두 번째는 <strong>스키마 호환성</strong>이에요. 이벤트는 발행되는 순간, 지금 있는
          컨슈머뿐 아니라 <strong>미래의 컨슈머</strong>와 <strong>토픽에 쌓인 과거</strong>까지
          상대하는 공공재가 됩니다. 발행자와 구독자는 따로 배포되니, 스키마 변경은 언제나
          "옛 컨슈머가 새 메시지를 읽는" 상황을 견뎌야 해요.
        </p>
        <Code>{CODE_EVENT_SCHEMA}</Code>
        <p>
          세 번째는 <strong>흐름의 지휘 방식</strong>입니다. <strong>코레오그래피
          (choreography)</strong>는 지휘자 없이 각 서비스가 앞 이벤트에 반응해 자기 일을 하고
          다음 이벤트를 내는 방식 — 결합이 가장 느슨하지만, "주문 하나가 지금 어디까지
          진행됐지?"를 아무도 한눈에 모릅니다. <strong>오케스트레이션(orchestration)</strong>은
          지휘자(오케스트레이터)가 순서를 관리하며 각 단계를 호출하는 방식 — 흐름이 한 곳에
          보이는 대신 지휘자가 결합점이자 단일 장애점이 되기 쉬워요. 단계가 적고 독립적이면
          코레오그래피, 단계가 많고 보상 처리(취소·환불)가 얽히면 오케스트레이션 쪽이
          관리하기 쉽다는 게 통례입니다.
        </p>
        <div className="tip">
          <b>이름 짓기가 설계다</b> 이벤트 이름을 지을 때 과거형 동사로 안 써지면 —{' '}
          <span className="icode">ProcessPayment</span>처럼 — 그건 이벤트가 아니라 커맨드일
          가능성이 높아요. 이름을 과거형으로 바꿔 보려는 시도 자체가 "이건 사실인가,
          요청인가"를 강제로 묻게 하는 좋은 설계 훈련입니다.
        </div>
      </Section>

      <Section n={6} title="실패와 지연 다루기 — DLQ, 재처리, 랙 모니터링" sub="비동기의 운영은 '안 보이는 실패'와의 싸움">
        <p>
          동기 호출은 실패하면 호출자가 <strong>바로</strong> 압니다. 비동기는 반대예요 —
          발행자는 이미 응답을 끝냈으니, 컨슈머의 실패는 <strong>아무도 모르게</strong>
          쌓입니다. 그래서 비동기 시스템의 운영은 "실패를 어디에 모아 두고, 지연을 어떻게
          감지하느냐"의 문제가 됩니다.
        </p>
        <p>
          첫 번째 도구가 <strong>데드레터 큐(DLQ)</strong>예요. 몇 번을 재시도해도 실패하는{' '}
          <strong>포이즌 메시지</strong>를 무한 재시도하면, 같은 파티션 뒤의 멀쩡한 메시지
          전부가 막힙니다. 그래서 "N회 실패하면 별도 토픽(<span className="icode">.DLT</span>)
          으로 치우고 다음으로 진행"이 표준이에요. 단, 치우기 전에{' '}
          <strong>일시 오류(재시도하면 될 일)와 영구 오류(백번 해도 안 될 일)를 구분</strong>
          해야 합니다 — 네트워크 순단까지 DLQ로 보내면 멀쩡한 메시지를 버리는 셈이니까요.
        </p>
        <Code>{CODE_DLQ}</Code>
        <p>
          두 번째 도구가 <strong>컨슈머 랙(lag)</strong> 모니터링입니다. 랙은 "생산된 최신
          오프셋과 내가 커밋한 오프셋의 차이" — 즉 <strong>밀린 일감의 양</strong>이에요.
          랙이 0 근처면 실시간, <strong>계속 증가하면</strong> 컨슈머가 죽었거나 처리량이
          모자란 겁니다. 비동기 시스템에서 랙은 사실상 유일한 조기 경보라, "웰컴 메일이 안
          와요"라는 고객 문의보다 몇 시간 먼저 문제를 알려 줍니다.
        </p>
        <p>
          마지막으로 <strong>재처리(replay)</strong> — 로그 기반 브로커의 특권입니다. 컨슈머
          버그로 일주일치 이벤트를 잘못 처리했다면? 오프셋을 되감아 다시 읽으면 돼요. 단,
          조건이 있습니다. <strong>① 컨슈머가 멱등할 것</strong>(다시 읽으면 이미 처리한
          이벤트도 다시 오니까), <strong>② 토픽 보존 기간(retention) 안일 것</strong>,{' '}
          <strong>③ 이벤트에 처리에 필요한 정보가 자족적으로 담겨 있을 것</strong>(발행 시점의
          외부 상태에 기대면 재생 시점엔 그 상태가 없어요). 섹션 4에서 만든 멱등 컨슈머가
          여기서 다시 효자가 됩니다.
        </p>
        <div className="tip">
          <b>알람 기준 잡기</b> 랙 알람을 "랙 &gt; 1000" 같은 절대값으로만 걸면, 트래픽이
          원래 몰리는 시간대(장 마감 직후 등)마다 거짓 알람이 울려요. <strong>증가 추세가
          N분 이상 지속</strong>되는지를 함께 보는 게 실전적입니다. 알람이 자주 거짓말하면
          사람이 알람을 끄게 되고, 그날부터 모니터링은 없는 것과 같아요.
        </div>
      </Section>

      <Section n={7} title="이벤트 소싱과 CQRS 맛보기" sub="장부처럼 기록하는 사고방식, 그리고 도입 판단">
        <p>
          마지막으로, 이벤트 중심 사고를 끝까지 밀어붙인 패턴을 구경해 봅시다.{' '}
          <strong>이벤트 소싱</strong>은 "현재 상태"를 저장하는 대신 <strong>일어난 이벤트
          전부를 저장</strong>하고, 상태는 이벤트를 처음부터 재생해 계산하는 방식이에요.
          회계 장부와 똑같습니다 — 잔고를 고쳐 쓰는 게 아니라 거래를 덧붙이고, 잔고는
          장부를 합산해 얻죠.
        </p>
        <Code>{CODE_EVENT_SOURCING}</Code>
        <p>
          얻는 것: 완벽한 감사 이력, 임의 시점 복원, "왜 이 상태가 됐는지"에 대한 완전한
          답. 잃는 것: <strong>조회의 단순함</strong>입니다. 이벤트 더미에서 "잔고 10만원
          이상 계좌 목록"을 바로 뽑을 순 없으니, 조회용 모델을 따로 두게 되고 — 그게{' '}
          <strong>CQRS</strong>(Command Query Responsibility Segregation, 쓰기 모델과 읽기
          모델의 분리)입니다. 쓰기는 정합성과 이력에, 읽기는 조회 속도에 각각 최적화하는
          대신, 두 모델 사이에 <strong>최종 일관성</strong>(잠깐의 시차)을 받아들이는 거래예요.
        </p>
        <p>
          도입 판단은 냉정하게 하세요. 이벤트 소싱 + CQRS는 <strong>이력 자체가 도메인의
          핵심</strong>인 곳 — 금융 원장, 감사 추적, 재고 이동 — 에서 빛나지만, 이벤트 스키마
          버전 관리·재생 성능·스냅샷 같은 운영 비용이 만만치 않습니다. 대부분의 팀에는 이
          강좌에서 배운 <strong>아웃박스 + 토픽 + 멱등 컨슈머</strong> 조합이면 충분해요.
          패턴의 이름값이 아니라, "우리 도메인이 이력을 얼마나 필요로 하는가"가 기준입니다.
        </p>
        <div className="tip">
          <b>실습 과제</b> 이제 손으로 만들 차례예요. <strong>① 회원가입 성공 시{' '}
          <span className="icode">UserSignedUp</span> 이벤트를 Kafka 토픽으로 발행</strong>
          하고(파티션 키는 userId, 이벤트에 <span className="icode">eventId</span> 포함),{' '}
          <strong>② 웰컴 알림 컨슈머</strong>를 별도 컨슈머 그룹으로 만들어 구독한 뒤,{' '}
          <strong>③ 같은 이벤트를 일부러 두 번 발행해도 알림이 한 번만 가는지</strong>{' '}
          <span className="icode">eventId</span> 장부로 멱등 처리해 증명하세요. 컨슈머를
          끈 채 이벤트를 쌓았다가 켜서 따라잡는 것까지 확인하면, 이 강좌의 절반을 몸으로
          이해한 겁니다.
        </div>
      </Section>

      <div className="step-card">
        <div className="step-body">
          <h3 style={{ marginBottom: 6 }}>📨 여기까지 왔다면</h3>
          <p className="muted">
            이제 서비스 사이의 화살표를 볼 때 "이건 지금 답을 알아야 하는 호출인가, 나중에
            처리해도 되는 사실인가"를 먼저 묻게 됐을 거예요. 동기 사슬을 끊는 브로커, 순서와
            병렬성을 함께 정하는 파티션 키, 중복을 전제로 한 멱등 컨슈머, 유실을 막는
            아웃박스, 실패를 모아 두는 DLQ까지 — <strong>느슨하게 잇는 도구와 사고방식</strong>을
            한 바퀴 돌았습니다. 그런데 이 도구들이 진짜 필요해지는 순간은, 하나였던 시스템을{' '}
            <strong>여러 조각으로 쪼갤 때</strong>예요. 쪼개면 정말 좋아질까요, 아니면 분산
            시스템의 고통만 사 오는 걸까요? 다음{' '}
            <Link to="/learn/lv4-monolith-vs-microservices"><strong>모놀리스 vs 마이크로서비스
            — 쪼개는 것의 진짜 비용</strong></Link> 강좌에서, 그 판단의 저울을 함께 들어 봅시다.
          </p>
        </div>
      </div>
    </div>
  );
}
