// 이 파일이 하는 일: "트랜잭션과 격리 수준" 코스 — 동시에 밀려드는 요청 속에서
// 돈과 데이터를 지키는 규칙을 7개 섹션으로 안내하는 정적 학습 페이지.
// 프레이밍은 "서비스를 만들어 본 사람의 다음 고민" — 기능은 도는데, 두 사용자가
// 같은 잔고를 '동시에' 건드리면 무슨 일이 벌어지는가? ACID에서 출발해
// 동시성 이상 현상 → 격리 수준 4단계 → 잠금 → 데드락 → 스프링 실전 → 분산 환경의
// 한계까지, 포트폴리오 매수 로직을 실험대 삼아 끝까지 걸어간다.
// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.

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

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

const CODE_ACID = `트랜잭션(Transaction) = "전부 되거나, 전부 안 되거나"의 작업 묶음

A · 원자성(Atomicity)    묶음 안의 작업은 쪼개지지 않는다.
                         중간에 실패하면? 전부 없던 일로(롤백).
C · 일관성(Consistency)  트랜잭션 전후로 규칙(잔고>=0, 제약조건)이 깨지지 않는다.
I · 격리성(Isolation)    동시에 도는 트랜잭션끼리 서로의 '중간 상태'를 보지 않는다.
                         ← 이 코스의 주인공. 완벽한 격리는 비싸서 '수준'을 고른다.
D · 지속성(Durability)   커밋된 결과는 서버가 꺼져도 남는다(WAL 로그 → 디스크).

커밋(COMMIT)  = "이 묶음, 확정" — 이후엔 누구나 이 결과를 본다.
롤백(ROLLBACK) = "이 묶음, 전부 취소" — 처음부터 없었던 것처럼.`;

const CODE_BUY = `// 포트폴리오 매수 — @Transactional 경계가 곧 '원자 단위'
@Service
public class PortfolioService {

    // 학습 포인트: 이 메서드 전체가 하나의 트랜잭션.
    // ①②③ 중 하나라도 예외가 터지면 셋 다 롤백된다.
    @Transactional
    public void buy(Long userId, String ticker, int qty, long price) {
        Account acc = accountRepository.findByUserId(userId);

        long cost = (long) qty * price;
        if (acc.getBalance() < cost) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잔고 부족");
        }

        acc.withdraw(cost);                          // ① 잔고 차감
        positionRepository.addQuantity(userId, ticker, qty); // ② 보유 수량 증가
        tradeLogRepository.save(TradeLog.buy(userId, ticker, qty, price)); // ③ 체결 기록
    } // ← 여기서 정상 반환하면 커밋, 예외가 통과하면 롤백
}`;

const CODE_ANOMALY = `-- 동시성 3대 이상 현상: 트랜잭션 A와 B가 '동시에' 돌 때
-- (시간은 위에서 아래로 흐른다)

[더티 리드 Dirty Read] — 커밋 안 된 값을 읽어 버림
A: UPDATE account SET balance = 0 WHERE id = 7;   -- 아직 커밋 전!
B: SELECT balance FROM account WHERE id = 7;      -- 0으로 보임
A: ROLLBACK;                                      -- 없던 일이 됨
→ B는 '세상에 존재한 적 없는 값'으로 매수 가능 여부를 판단했다.

[논-리피터블 리드 Non-Repeatable Read] — 같은 행을 두 번 읽으니 값이 다름
B: SELECT balance ...;   -- 100만원
A: UPDATE ... balance = 30만원; COMMIT;
B: SELECT balance ...;   -- 30만원 (아까랑 다르다!)
→ B가 "잔고 확인 → 차감" 사이에 발밑이 바뀌었다.

[팬텀 리드 Phantom Read] — 같은 조건으로 두 번 세니 '행 개수'가 다름
B: SELECT count(*) FROM position WHERE user_id = 7;  -- 3건
A: INSERT INTO position ...; COMMIT;
B: SELECT count(*) ...;                              -- 4건 (유령 등장)
→ "보유 종목 10개 제한" 같은 개수 기반 규칙이 뚫린다.`;

const CODE_LEVELS = `격리 수준 4단계 — 위로 갈수록 빠르고, 아래로 갈수록 안전하다

수준                  더티 리드   논-리피터블   팬텀 리드
─────────────────────────────────────────────────────
READ UNCOMMITTED      발생        발생          발생
READ COMMITTED        차단        발생          발생      ← Postgres 기본값
REPEATABLE READ       차단        차단          차단*     ← MySQL(InnoDB) 기본값
SERIALIZABLE          차단        차단          차단

* 표준은 REPEATABLE READ에서 팬텀을 허용하지만, Postgres는
  스냅샷(MVCC) 방식이라 이 수준에서 팬텀까지 막힌다.
* Postgres는 READ UNCOMMITTED를 골라도 내부적으로
  READ COMMITTED로 동작한다 — 더티 리드는 아예 불가능.`;

const CODE_TRY = `-- 직접 실험: psql 두 개를 띄워 놓고 (터미널 A / 터미널 B)

-- [터미널 A]
BEGIN;
UPDATE account SET balance = balance - 500000 WHERE user_id = 7;
-- 아직 COMMIT 하지 않고 멈춰 둔다

-- [터미널 B]
BEGIN;
SELECT balance FROM account WHERE user_id = 7;
-- READ COMMITTED: A가 커밋 전이므로 '옛 값'이 보인다 (더티 리드 차단)

-- [터미널 A]
COMMIT;

-- [터미널 B]
SELECT balance FROM account WHERE user_id = 7;
-- 값이 바뀌어 보인다! 같은 트랜잭션 안에서 두 번 읽은 값이 다름
-- = 논-리피터블 리드를 눈으로 확인

-- 이제 B를 REPEATABLE READ로 다시:
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 같은 실험을 반복하면, A가 커밋해도 B에겐 '처음 스냅샷'이 유지된다.`;

const CODE_LOCK = `-- 비관적 락: "어차피 충돌할 테니, 먼저 잠그고 시작한다"
SELECT balance FROM account
 WHERE user_id = 7
   FOR UPDATE;   -- 이 행에 배타락. 다른 트랜잭션의 FOR UPDATE는 여기서 '대기'

-- 공유락(FOR SHARE): 읽기끼리는 동시에 OK, 쓰기만 막는다.
-- 배타락(FOR UPDATE): 읽기용 잠금조차 못 들어온다. 나 혼자 쓴다.

// JPA에서는 이렇게 쓴다
@Lock(LockModeType.PESSIMISTIC_WRITE)   // → SELECT ... FOR UPDATE
@Query("select a from Account a where a.userId = :userId")
Account findForUpdate(Long userId);`;

const CODE_VERSION = `// 낙관적 락: "충돌은 드물 테니, 일단 진행하고 저장할 때 검사한다"
@Entity
public class Account {
    @Id private Long id;
    private long balance;

    @Version                 // ← 이 한 줄이 핵심
    private long version;    // UPDATE 때마다 자동으로 +1
}

// JPA가 실제로 날리는 SQL:
// UPDATE account SET balance = ?, version = version + 1
//  WHERE id = ? AND version = ?   ← 내가 읽었을 때의 버전과 같아야만 성공
//
// 그 사이 누가 먼저 고쳤다면? version이 달라져 0건 갱신
// → OptimisticLockException → 잡아서 재시도하거나 사용자에게 알린다.

// 선택 기준:
// 충돌이 잦다(인기 종목 잔고, 재고 1개) → 비관적 락: 기다리는 게 싸다
// 충돌이 드물다(내 프로필, 내 메모)     → 낙관적 락: 안 잠그는 게 싸다`;

const CODE_DEADLOCK = `-- 데드락의 전형: 서로가 가진 것을 서로 기다린다
[트랜잭션 A]                       [트랜잭션 B]
UPDATE account WHERE id = 1;       UPDATE account WHERE id = 2;
  (1번 행 잠금 획득)                 (2번 행 잠금 획득)
UPDATE account WHERE id = 2;       UPDATE account WHERE id = 1;
  → B가 잡고 있어 대기...            → A가 잡고 있어 대기...

둘 다 영원히 기다린다 = 데드락(교착 상태).
Postgres는 이를 감지해 한쪽을 강제 롤백시킨다:
ERROR: deadlock detected

-- 예방 1순위: 잠금 순서 일관성
-- "계좌를 여러 개 잠글 땐 항상 id 오름차순으로" 같은 규칙 하나면
-- 위 시나리오는 원천적으로 불가능해진다 (둘 다 1번부터 잡으므로).

-- 보조 수단: 타임아웃 + 재시도
SET lock_timeout = '3s';          -- 잠금 대기 3초 넘으면 포기
-- 애플리케이션에선 데드락/타임아웃 예외를 잡아
-- 짧은 무작위 대기 후 트랜잭션 전체를 재시도한다.`;

const CODE_PROPAGATION = `// 전파(Propagation): "이미 트랜잭션이 도는 중에 또 @Transactional을 만나면?"

@Transactional  // 기본값 = REQUIRED
public void buy(...) {
    ...
    auditService.record("BUY", ...);   // 이 안의 트랜잭션은 어떻게 될까?
}

// REQUIRED (기본): 이미 있으면 '합류', 없으면 새로 시작.
//   → buy()와 record()가 한 배를 탄다. 하나 터지면 둘 다 롤백.
@Transactional(propagation = Propagation.REQUIRED)
public void record(String action, ...) { ... }

// REQUIRES_NEW: 바깥을 잠시 멈추고 '독립된 새 트랜잭션'을 판다.
//   → 매수가 롤백돼도 감사 기록은 남는다. "실패도 기록해야 하는" 로그에 적합.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String action, ...) { ... }

// readOnly: 쓰지 않는 조회는 선언하자.
//   플러시 생략·스냅샷 비보관으로 가벼워지고, 실수로 쓰면 막아 준다.
@Transactional(readOnly = true)
public PortfolioSummary summary(Long userId) { ... }`;

const CODE_SELF = `// 함정: self-invocation — 같은 클래스 안에서 자기 메서드를 부르면
// @Transactional이 '조용히' 무시된다
@Service
public class PortfolioService {

    public void buyAll(List<Order> orders) {
        for (Order o : orders) {
            this.buy(o);   // ← 프록시를 거치지 않는 내부 호출!
        }                  //    buy()의 @Transactional은 적용되지 않는다
    }

    @Transactional
    public void buy(Order o) { ... }
}

// 왜? 스프링 트랜잭션은 '프록시(대리인)'가 메서드 앞뒤에서
// BEGIN/COMMIT을 감싸 주는 방식. 바깥에서 들어온 호출만 프록시를
// 통과하고, this.buy()는 대리인을 건너뛰고 본체를 직접 부른다.
//
// 해법: 트랜잭션이 필요한 메서드를 '다른 빈'으로 분리해
// 주입받아 호출한다 — 호출이 반드시 프록시를 지나가게.`;

const CODE_SAGA = `분산 환경: 하나의 DB 트랜잭션으로 묶을 수 없는 세계

[2PC가 어려운 이유]
서비스 6개가 각자의 스키마를 가진 우리 구조에서, "전원 준비됐나? → 전원
커밋!"을 지휘하는 2단계 커밋(2PC)은 조정자가 죽는 순간 전원이 잠금을
쥔 채 멈춘다. 느리고, 깨지기 쉽고, Kafka·HTTP 경계는 아예 참여 불가.

[그래서 최종 일관성 Eventual Consistency]
auth 서비스: 자기 DB 트랜잭션만 확실히 커밋
     ↓ (커밋 후) Kafka 토픽 audit.events 로 이벤트 발행
log 서비스: 이벤트를 소비해 자기 DB에 반영
→ '지금 이 순간' 둘이 다를 수 있지만, '결국' 같아진다.

[Saga 패턴] 긴 여정을 로컬 트랜잭션의 사슬로 쪼갠다
주문 → 결제 → 배송 각자 커밋. 중간에 실패하면?
거꾸로 '보상 트랜잭션'(결제 취소, 주문 취소)을 실행해 되돌린다.

[아웃박스 패턴] "DB 커밋과 이벤트 발행"을 원자적으로
이벤트를 같은 DB의 outbox 테이블에 INSERT (본 작업과 한 트랜잭션!)
→ 별도 릴레이가 outbox를 읽어 Kafka로 발행
→ "커밋은 됐는데 이벤트가 유실"되는 반쪽 상태를 없앤다.`;

// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. 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: 'ACID' },
  { n: 2, label: '동시성 문제' },
  { n: 3, label: '격리 수준 4단계' },
  { n: 4, label: '잠금(Lock)' },
  { n: 5, label: '데드락' },
  { n: 6, label: '스프링 실전' },
  { n: 7, label: '분산 환경' },
];

export default function Lv3TransactionIsolationPage() {
  return (
    <div>
      {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
      <div className="hero">
        <div className="eyebrow">Course · 고급 과정</div>
        <h1>🔒 트랜잭션과<br />격리 수준</h1>
        <p>
          기능은 이미 돌아가요. 문제는 <strong>동시에</strong> 돌아갈 때 시작됩니다.
          두 요청이 같은 잔고를 0.001초 차이로 읽고 쓰면, 돈이 두 번 빠지거나
          있지도 않은 돈으로 매수가 체결되죠. 이 코스는 <strong>동시에 밀려드는 요청 속에서
          돈과 데이터를 지키는 규칙</strong> — 트랜잭션, 격리 수준, 잠금, 그리고 그 규칙이
          닿지 않는 분산 환경까지를 다룹니다.
        </p>
        <div className="chip-row">
          <span className="chip">예상 소요 60분</span>
          <span className="chip">실습: 매수 로직에 동시 요청 걸기</span>
          <span className="chip">선수 지식: SQL·Spring 기초</span>
        </div>
      </div>

      {/* 섹션 바로가기 */}
      <nav className="pill-nav">
        {SECTIONS.map((s) => (
          <a key={s.n} href={`#sec-${s.n}`}>
            <b>{s.n}</b>
            {s.label}
          </a>
        ))}
      </nav>

      <Section n={1} title="트랜잭션의 ACID" sub="전부 되거나, 전부 안 되거나">
        <p>
          "잔고를 차감했는데 보유 수량을 늘리기 직전에 서버가 죽으면?" — 이 질문에 답하려고
          트랜잭션이 있어요. 트랜잭션은 여러 개의 읽기·쓰기를 <strong>쪼개지지 않는 하나의
          묶음</strong>으로 만드는 약속이고, 그 약속의 내용이 네 글자 <strong>ACID</strong>입니다.
        </p>
        <Code>{CODE_ACID}</Code>
        <p>
          스프링에서 이 묶음의 <strong>경계</strong>를 긋는 도구가{' '}
          <span className="icode">@Transactional</span>이에요. 메서드에 붙이면 "이 메서드의
          시작이 BEGIN, 정상 반환이 COMMIT, 예외 통과가 ROLLBACK"이 됩니다. 즉{' '}
          <strong>@Transactional 경계가 곧 원자 단위</strong> — 잔고 차감·수량 증가·체결 기록이
          함께 살거나 함께 죽어요.
        </p>
        <Code>{CODE_BUY}</Code>
        <div className="tip">
          <b>비유</b> 트랜잭션은 <strong>계약서에 도장 찍기</strong>예요. 조항을 아무리 많이
          고쳐 써도(쿼리 실행) 도장(COMMIT)을 찍기 전엔 효력이 없고, 마음이 바뀌면 찢어 버리면
          (ROLLBACK) 그만입니다. 도장을 찍는 순간부터는 — 지속성(D) — 불이 나도 계약은 남아요.
        </div>
      </Section>

      <Section n={2} title="동시성이 일으키는 문제" sub="더티 리드 · 논-리피터블 리드 · 팬텀 리드">
        <p>
          트랜잭션이 <strong>하나씩 차례로</strong>만 돈다면 격리(I)를 고민할 일이 없어요.
          하지만 그러면 서버는 한 번에 한 명만 상대하는 창구가 됩니다. 그래서 DB는 여러
          트랜잭션을 <strong>겹쳐서</strong> 돌리고, 그 순간 세 가지 유령이 나타납니다.
        </p>
        <Code>{CODE_ANOMALY}</Code>
        <p>
          이게 왜 <strong>포트폴리오·잔고에서 치명적</strong>일까요? 매수 로직의 뼈대는 늘{' '}
          <strong>"읽고(잔고 확인) → 판단하고(충분한가?) → 쓴다(차감)"</strong>예요. 그런데 읽기와
          쓰기 <strong>사이의 빈틈</strong>에 다른 트랜잭션이 끼어들면 — 잔고 100만원인 계좌에
          80만원짜리 매수 요청 두 개가 동시에 들어와, 둘 다 "잔고 충분"이라 읽고 둘 다
          차감해 버립니다. 결과는 잔고 <strong>-60만원</strong>. 코드 어디에도 버그는 없는데
          돈이 새는, 동시성 특유의 사고죠.
        </p>
        <div className="warn">
          <b>테스트가 못 잡는 버그</b> 이런 문제는 요청을 하나씩 보내는 단위 테스트로는 절대
          재현되지 않아요. <strong>타이밍이 곧 재현 조건</strong>이라, 운영에서 트래픽이 몰리는
          날 처음 터집니다. 그래서 이 코스의 실습이 "동시 요청을 일부러 걸어 보는 것"이에요 —
          유령은 불러내 봐야 잡는 법을 배웁니다.
        </div>
      </Section>

      <Section n={3} title="격리 수준 4단계" sub="안전과 동시성은 시소를 탄다">
        <p>
          유령을 셋 다 막는 완벽한 격리(SERIALIZABLE)가 있는데 왜 전부 그걸 쓰지 않을까요?
          <strong> 격리를 올릴수록 동시성을 잃기 때문</strong>입니다. 더 많이 잠그고, 더 자주
          기다리고, 더 자주 "충돌했으니 다시 하세요"가 발생해요. 그래서 SQL 표준은 격리를
          4단계로 나눠 <strong>어디까지 감수할지 고르게</strong> 했습니다.
        </p>
        <Code>{CODE_LEVELS}</Code>
        <p>
          <strong>Postgres의 기본값이 READ COMMITTED인 이유</strong>도 이 시소에 있어요.
          더티 리드만 막아도 "존재한 적 없는 값" 사고는 사라지는데, 그 이상은 MVCC 스냅샷
          유지·직렬화 검사 비용이 붙거든요. 대부분의 조회 트래픽에는 READ COMMITTED가
          충분히 안전하면서 가장 빠른 균형점입니다. 대신 <strong>"읽고 → 판단하고 → 쓰는"
          돈 계산 구간</strong>은 이 수준만으로 못 지켜요 — 그건 다음 섹션의 잠금이 맡습니다.
        </p>
        <p>
          <strong>REPEATABLE READ</strong>는 트랜잭션 시작 시점의 스냅샷을 끝까지 유지해
          "읽을 때마다 값이 다른" 문제를 없애고, <strong>SERIALIZABLE</strong>은 아예 "모든
          트랜잭션이 한 줄로 선 것과 같은 결과"를 보장합니다. 대가는? 동시에 건드린 트랜잭션
          중 하나가 <strong>직렬화 오류로 강제 롤백</strong>될 수 있어, 애플리케이션에 재시도
          코드가 필수가 돼요. 공짜 안전은 없습니다.
        </p>
        <Code>{CODE_TRY}</Code>
        <div className="tip">
          <b>실습 포인트</b> 위 psql 실험을 그대로 해 보세요. 같은 SELECT가 격리 수준에 따라
          다른 값을 보여 주는 걸 <strong>눈으로</strong> 확인하는 순간, 격리 수준은 암기 항목이
          아니라 손에 잡히는 스위치가 됩니다. 그다음 매수 API에 동시 요청을 걸어(간단히는
          같은 curl 두 개를 &amp;로 병렬 실행) 잔고가 어떻게 무너지는지 재현해 보세요.
        </div>
      </Section>

      <Section n={4} title="잠금(Lock) 이해" sub="비관과 낙관, 두 가지 태도">
        <p>
          격리 수준이 <strong>DB 전체의 기본 방어선</strong>이라면, 잠금은 위험한 구간에만
          거는 <strong>정밀 방어</strong>예요. 종류는 두 축으로 나뉩니다. 먼저{' '}
          <strong>무엇을 허용하나</strong>: <strong>공유락</strong>(읽기끼리는 동시 허용, 쓰기만
          차단)과 <strong>배타락</strong>(나 말고 전부 차단). 다음은{' '}
          <strong>어디를 잠그나</strong>: 행 하나만 잠그는 <strong>행 잠금</strong>과 테이블
          전체를 잠그는 <strong>테이블 잠금</strong> — 좁게 잠글수록 다른 요청이 덜 기다립니다.
        </p>
        <Code>{CODE_LOCK}</Code>
        <p>
          그런데 잠금엔 <strong>태도</strong>의 문제가 하나 더 있어요. "충돌이 날 것이다"라고
          보고 미리 잠그는 <strong>비관적 락</strong>과, "충돌은 드물 것이다"라고 보고 저장
          시점에 검사만 하는 <strong>낙관적 락</strong>. 낙관적 락은 DB 잠금을 아예 쓰지 않고{' '}
          <strong>버전 컬럼</strong> 하나로 구현합니다.
        </p>
        <Code>{CODE_VERSION}</Code>
        <div className="tip">
          <b>비유</b> 비관적 락은 <strong>회의실 예약</strong> — 들어가기 전에 잡아 두니 충돌은
          없지만, 내가 안 쓰는 동안에도 남들이 못 씁니다. 낙관적 락은{' '}
          <strong>위키 문서 편집</strong> — 자유롭게 고치되 저장할 때 "누가 먼저 고쳤네요,
          다시 확인하세요"를 받는 방식이죠. 트래픽의 성격(충돌 빈도)이 어느 쪽이 싼지를 정합니다.
        </div>
      </Section>

      <Section n={5} title="데드락과 싸우기" sub="서로를 기다리다 함께 멈춘다">
        <p>
          잠금을 배우면 곧바로 잠금의 부작용을 만나요. 트랜잭션 A가 1번 계좌를 잡고 2번을
          기다리는데, B는 2번을 잡고 1번을 기다린다면? 둘 다 <strong>영원히</strong> 기다립니다.
          이게 <strong>데드락(교착 상태)</strong> — 여러 행을 잠그는 로직(이체, 일괄 매수,
          리밸런싱)에서 나오는 전형적인 사고예요.
        </p>
        <Code>{CODE_DEADLOCK}</Code>
        <p>
          예방의 왕도는 <strong>잠금 순서 일관성</strong>입니다. "여러 자원을 잠글 땐 언제나
          같은 순서(예: id 오름차순)로"라는 팀 규칙 하나면, '서로 엇갈려 기다리는' 구조 자체가
          만들어질 수 없어요. 데드락은 코드 한 줄의 버그가 아니라 <strong>두 코드 경로의
          조합</strong>에서 나오므로, 개인기가 아닌 규칙으로 막는 게 맞습니다.
        </p>
        <p>
          그래도 새는 경우를 위한 안전망이 <strong>타임아웃과 재시도</strong>예요. 잠금 대기에
          상한(<span className="icode">lock_timeout</span>)을 두고, 데드락 감지로 롤백당한
          트랜잭션은 <strong>짧은 무작위 대기 후 전체를 재시도</strong>합니다. 무작위를 섞는
          이유는, 둘이 동시에 재시도하면 같은 충돌이 반복되기 때문이에요(엘리베이터 앞에서
          양보하다 또 마주치는 그 상황).
        </p>
        <div className="warn">
          <b>재시도의 전제 = 멱등성</b> 트랜잭션 전체가 롤백된 뒤의 재시도는 안전하지만,
          트랜잭션 <strong>바깥</strong>의 부수 효과(푸시 알림 발송, 외부 API 호출)까지 함께
          재실행되면 알림이 두 번 갑니다. 재시도 루프를 짜기 전에 "이 묶음, 두 번 돌아도
          같은 결과인가?"를 반드시 물어보세요.
        </div>
      </Section>

      <Section n={6} title="스프링 트랜잭션 실전" sub="전파 · readOnly · 그리고 프록시의 함정">
        <p>
          이제 스프링으로 내려와 봅시다. 실전에서 가장 자주 갈리는 선택이{' '}
          <strong>전파(propagation)</strong> — "트랜잭션이 도는 중에 또 다른 @Transactional
          메서드를 부르면 합류시킬 것인가, 새로 팔 것인가"입니다.
        </p>
        <Code>{CODE_PROPAGATION}</Code>
        <p>
          기준은 <strong>운명을 같이해야 하는가</strong>예요. 잔고 차감과 체결 기록은 한 배
          (REQUIRED)를 타야 하지만, "매수 시도가 있었다"는 감사 기록은 매수가{' '}
          <strong>실패해도 남아야</strong> 하니 독립된 배(REQUIRES_NEW)가 맞습니다. 한편{' '}
          <span className="icode">readOnly = true</span>는 조회 전용 선언인데, 주의할 함정이
          하나 — 트랜잭션이 메서드 끝에서 닫히므로, 반환한 엔티티의 <strong>지연 로딩</strong>
          필드를 컨트롤러에서 뒤늦게 건드리면 세션이 이미 없어 예외가 터져요. 필요한 데이터는
          트랜잭션 <strong>안에서</strong> 다 채워(fetch join, DTO 변환) 나가야 합니다.
        </p>
        <p>
          마지막으로, 스프링 트랜잭션 최대의 함정 — <strong>self-invocation</strong>입니다.
        </p>
        <Code>{CODE_SELF}</Code>
        <div className="tip">
          <b>원리로 기억하기</b> <span className="icode">@Transactional</span>은 마법이 아니라{' '}
          <strong>프록시(대리인) 객체</strong>가 메서드 호출을 가로채 BEGIN/COMMIT을 감싸 주는
          것뿐이에요. 그래서 대리인을 거치지 않는 <span className="icode">this.메서드()</span>
          호출엔 아무 일도 일어나지 않습니다. "왜 롤백이 안 되지?"의 상당수가 이 한 줄에서
          나와요 — 어노테이션이 안 먹는 것 같으면 <strong>호출 경로부터</strong> 의심하세요.
        </div>
      </Section>

      <Section n={7} title="분산 환경의 한계" sub="트랜잭션이 닿지 않는 곳, 최종 일관성">
        <p>
          여기까지의 무기는 전부 <strong>한 DB 안</strong>에서 통해요. 그런데 서비스가
          커지면 — 우리처럼 auth·market·log가 <strong>각자의 스키마</strong>를 갖는 마이크로서비스
          구조라면 — "auth의 커밋과 log의 커밋을 한 트랜잭션으로" 묶을 방법이 마땅치 않습니다.
          교과서의 답인 <strong>2단계 커밋(2PC)</strong>은 모든 참여자가 잠금을 쥔 채 조정자의
          신호를 기다리는 구조라, 조정자 장애 = 전원 멈춤이고 Kafka·HTTP 같은 경계는 아예
          참여할 수도 없어요.
        </p>
        <Code>{CODE_SAGA}</Code>
        <p>
          그래서 분산 세계의 현실적 선택은 <strong>최종 일관성(eventual consistency)</strong>
          입니다. 각 서비스는 <strong>자기 DB 트랜잭션만</strong> 확실히 지키고, 서비스 사이는
          이벤트로 잇죠. 우리 플랫폼이 감사 로그를 auth에서 직접 log DB에 쓰지 않고{' '}
          <strong>Kafka 토픽 <span className="icode">audit.events</span></strong>로 발행 →
          log 서비스가 소비해 저장하는 것이 정확히 이 패턴이에요. 두 DB가 '지금 이 순간'은
          다를 수 있어도 '결국' 같아집니다.
        </p>
        <p>
          더 긴 여정(주문→결제→배송)은 <strong>Saga 패턴</strong> — 로컬 트랜잭션의 사슬 +
          실패 시 거꾸로 되돌리는 <strong>보상 트랜잭션</strong>으로 풀고, "DB 커밋은 됐는데
          이벤트 발행 직전에 죽으면?"이라는 반쪽 상태는 <strong>아웃박스 패턴</strong> —
          이벤트를 본 작업과 <strong>같은 트랜잭션</strong>으로 outbox 테이블에 넣고 별도
          릴레이가 발행 — 으로 막습니다. 이번엔 맛보기지만, 이름과 문제의식만 쥐고 있어도
          설계 회의에서 길을 잃지 않아요.
        </p>
        <div className="warn">
          <b>격리 감각의 확장</b> 최종 일관성을 택했다면 "아직 반영 전인 순간"이 정상 상태라는
          뜻이에요. 그 순간을 사용자에게 어떻게 보여 줄지(처리 중 표시, 잠깐의 지연 안내)까지가
          설계입니다. 트랜잭션 공부의 끝은 결국 <strong>"어디까지의 불일치를 허용할 것인가"를
          스스로 정하는 감각</strong>이에요.
        </div>
      </Section>

      <div className="step-card">
        <div className="step-body">
          <h3 style={{ marginBottom: 6 }}>🔒 여기까지 왔다면</h3>
          <p className="muted">
            이제 "동시에 들어오면 어떻게 되지?"라는 질문에 층위별 답을 갖게 됐어요.
            묶음의 경계는 트랜잭션(ACID)이, 기본 방어는 격리 수준이, 정밀 방어는 잠금이,
            잠금의 부작용은 순서 규칙과 재시도가, 그리고 DB 하나를 넘어서는 순간은
            최종 일관성과 Saga·아웃박스가 맡습니다. 그런데 격리와 잠금으로 <strong>정확해진</strong>{' '}
            쿼리가, 이번엔 <strong>느려서</strong> 문제를 일으키기 시작해요. 조회 하나에 쿼리가
            수백 번 나가는 N+1, 인덱스를 타지 않는 조건절 — 다음{' '}
            <Link to="/learn/lv3-query-optimization"><strong>N+1과 쿼리 최적화</strong></Link>{' '}
            코스에서, 정확함 위에 빠름을 얹는 법을 이어서 익혀 봅시다.
          </p>
        </div>
      </div>
    </div>
  );
}
