// 이 파일이 하는 일: "N+1과 쿼리 최적화" 코스 — 목록 하나 여는데 쿼리 수백 개가 나가는
// ORM의 조용한 재앙(N+1)을 눈으로 확인하는 것에서 출발해, fetch join·프로젝션·집계 최적화·
// 읽기 부하 분산·측정 도구·실전 리팩터링 흐름까지 7개 섹션으로 안내하는 정적 학습 페이지.
// 프레이밍은 "이미 서비스를 만들어 본 사람"이 대상인 고급 과정 — 돌아가는 코드를 넘어,
// 데이터가 쌓여도 버티는 코드를 만드는 감각을 기른다.
// 학습 포인트: API 호출이 없는 '내용 고정' 페이지다. 스타일은 styles/global.css의
// 가이드 전용 클래스(step-card, code-block, tip 등)를 재사용하고 색은 var(--*)만 쓴다(하드코딩 금지).
// 예제 코드는 JSX 중괄호/백틱 충돌을 피하려고 파일 상단의 백틱 문자열 상수로 정의한다.

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

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

const CODE_NPLUS1_LOG = `-- 게시글 목록 "20개"를 여는 순간, 하이버네이트 로그에 찍힌 것
select p.id, p.title, p.author_id from post p limit 20;   -- ① 목록 조회 1번

select u.* from users u where u.id = 1;    -- ② 1행의 작성자 조회
select u.* from users u where u.id = 7;    --    2행의 작성자 조회
select u.* from users u where u.id = 3;    --    3행의 작성자 조회
... (행마다 반복, 총 20번)

-- 합계: 1 + 20 = 21번. 목록이 100개면 101번, 500개면 501번.
-- 처음(1번) 조회 뒤에 행 수(N)만큼 추가 쿼리가 터진다 — 그래서 'N+1'.
-- 코드에는 반복문 한 줄뿐인데, DB에는 폭탄이 떨어지고 있습니다.`;

const CODE_LAZY = `// 범인은 버그가 아니라 '지연 로딩(LAZY)'이라는 정상 동작이에요
@Entity
public class Post {
  @ManyToOne(fetch = FetchType.LAZY)   // 작성자는 '실제로 쓸 때' 가져온다
  private User author;
}

// 목록을 돌며 작성자 이름을 꺼내는 순간마다 —
for (Post p : posts) {
  rows.add(new PostRow(p.getTitle(), p.getAuthor().getName()));
  //                                  ↑ 여기서 매번 select 1번씩 나간다
}

// LAZY 자체는 좋은 기본값입니다(안 쓰면 안 가져오니까).
// 문제는 "목록 + 반복문 + 연관 접근"이라는 조합 — 이때만 N+1로 돌변해요.`;

const CODE_SHOW_SQL = `# application.yml — 개발 환경에서 나가는 쿼리를 '눈으로' 보기
spring:
  jpa:
    properties:
      hibernate:
        format_sql: true                  # SQL을 읽기 좋게 줄바꿈
logging:
  level:
    org.hibernate.SQL: debug              # 실행되는 SQL 전부 출력
    org.hibernate.orm.jdbc.bind: trace    # 바인딩되는 파라미터 값까지

# 최적화의 첫걸음은 튜닝이 아니라 '관찰'입니다.
# 로그를 켜고 목록 화면을 한 번 열어 보세요 — 스크롤이 밀려 내려가는
# select의 행렬을 본 순간, N+1은 더 이상 남의 얘기가 아니게 됩니다.`;

const CODE_FETCH_JOIN = `// ① fetch join — "목록 가져올 때 작성자도 같이 실어 와" (쿼리 1번)
@Query("select p from Post p join fetch p.author where p.board = :board")
List<Post> findWithAuthor(@Param("board") String board);
// → select p.*, u.* from post p join users u on u.id = p.author_id
//   연관이 처음부터 채워져 있으니 반복문에서 추가 쿼리가 안 나간다.

// ② @EntityGraph — 메서드 이름 쿼리를 유지하면서 함께 로딩
@EntityGraph(attributePaths = "author")
List<Post> findByBoard(String board);
// JPQL을 직접 쓰기 아까운 단순 조회라면 이쪽이 깔끔해요.`;

const CODE_BATCH = `# ③ default_batch_fetch_size — 지연 로딩을 IN 절로 '묶는' 전역 안전망
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

-- 적용 전: where u.id = 1 / where u.id = 7 / where u.id = 3 ... (N번)
-- 적용 후: where u.id in (1, 7, 3, 12, ...)                    (묶어서 1번)

-- N+1이 '1+1'로 줄어든다. fetch join처럼 극적이진 않지만,
-- 미처 못 잡은 지연 로딩까지 전부 커버하는 보험이라 실무에선 거의 필수.`;

const CODE_PAGING_TRAP = `// 함정: 컬렉션(OneToMany) fetch join + 페이징은 같이 못 간다
@Query("select b from Board b join fetch b.posts")
Page<Board> findAll(Pageable pageable);

// 실행하면 로그에 이 경고가 뜹니다:
// WARN HHH90003004: firstResult/maxResults specified with collection fetch;
//                   applying in memory!
// → "DB에서 못 자르니 '메모리에서' 자르겠다"는 뜻.
//   조인 결과는 자식 수만큼 행이 불어나 limit을 DB에 걸 수 없거든요.
//   전체를 다 퍼 올린 뒤 애플리케이션에서 자르므로, 데이터가 크면 OOM 직행.

// 정리: ManyToOne(단건 연관)은 fetch join + 페이징 OK.
//       OneToMany(컬렉션)의 페이징은 batch_size로 푸는 게 정석입니다.`;

const CODE_PROJECTION = `// 화면엔 제목·작성자·작성일만 쓰는데 엔티티 통째로? — 프로젝션(DTO)으로 끊기
public record PostRow(Long id, String title,
                      String authorName, LocalDateTime createdAt) {}

@Query("""
    select new dev.awesomedev.board.PostRow(
        p.id, p.title, u.displayName, p.createdAt)
    from Post p join p.author u
    where p.board = :board
    order by p.createdAt desc
    """)
Page<PostRow> findRows(@Param("board") String board, Pageable pageable);

// select 절에 딱 4개 컬럼만 명시 —
// 본문(TEXT 컬럼)도, 작성자 엔티티의 나머지 필드도 아예 실려 오지 않는다.
// 연관을 '로딩'하는 게 아니라 필요한 값만 '조회'하니 N+1이 원천 봉쇄돼요.`;

const CODE_COUNT_SPLIT = `// 페이지네이션의 count 쿼리는 따로, 가볍게
@Query(value = """
           select p from Post p
           join fetch p.author
           where p.board = :board
           """,
       countQuery = "select count(p) from Post p where p.board = :board")
Page<Post> findPage(@Param("board") String board, Pageable pageable);

// countQuery를 안 주면 본문 쿼리의 join을 count에도 그대로 끌고 갑니다.
// '전체 몇 건인지' 세는 데 조인은 필요 없는데도요.
// 목록 API가 느릴 때 범인이 본문이 아니라 count 쪽인 경우가 의외로 많아요.`;

const CODE_EXISTS = `-- "댓글이 하나라도 있나?" — 있는지만 궁금한데 전부 세고 있진 않나요?

-- 나쁨: 다 센다 (댓글 10만 개면 10만 행을 훑고 나서야 답한다)
select count(*) from comment where post_id = 42;

-- 좋음: 하나 찾으면 바로 멈춘다
select exists (select 1 from comment where post_id = 42);

// Spring Data JPA라면 메서드 하나로 끝 — 내부적으로 위처럼 최적화됩니다.
boolean existsByPostId(Long postId);`;

const CODE_PREAGG = `-- 무거운 GROUP BY를 요청마다 돌리지 말고, 미리 계산해 두기

-- 매 요청마다: 게시판 목록 열 때마다 글 수를 집계 (글 100만 건이면 매번 풀스캔)
select board_id, count(*) from post group by board_id;

-- 미리 계산: 글을 쓰고 지울 때 카운터 컬럼을 함께 갱신해 둔다
update board set post_count = post_count + 1 where id = :boardId;
-- 조회는 이제 board 테이블 한 줄 읽기로 끝.

-- 트레이드오프: 쓰기가 한 단계 늘고, 카운터가 어긋날 위험을 관리해야 한다.
-- '읽기 1000번 : 쓰기 1번' 같은 비율이라면 남는 장사입니다.`;

const CODE_READONLY = `// 읽기 전용 트랜잭션 — "이 안에선 아무것도 안 바꿀 거야"라고 선언하기
@Transactional(readOnly = true)
public Page<PostRow> list(String board, Pageable pageable) {
  return postRepository.findRows(board, pageable);
}

// 효과 ① JPA가 더티 체킹용 스냅샷을 만들지 않는다 → 메모리·CPU 절약
// 효과 ② 실수로 섞인 쓰기 로직이 플러시되지 않아 안전장치가 된다
// 효과 ③ DB를 주(쓰기)/복제본(읽기)으로 나눈 구조라면,
//        readOnly 트랜잭션을 복제본으로 라우팅해 쓰기 DB의 부하를 덜 수 있다`;

const CODE_P6SPY = `# p6spy — 실제로 나간 쿼리를 파라미터까지 찍어 주는 감청 장치 (개발용)
# build.gradle 의존성 한 줄이면 JDBC 커넥션 사이에 끼어들어 전부 기록해요
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1'

2026-07-20 14:02:11 | took 3ms | select p.id, p.title ... limit 20
2026-07-20 14:02:11 | took 1ms | select u.* from users u where u.id = 1
2026-07-20 14:02:11 | took 1ms | select u.* from users u where u.id = 7
... (같은 모양의 쿼리가 주르륵 = N+1의 지문)

# DB 쪽에서도 볼 수 있어요 — PostgreSQL 느린 쿼리 로그:
# log_min_duration_statement = 200   # 200ms 넘게 걸린 쿼리만 기록`;

const CODE_COUNT_TEST = `// 쿼리 카운트 회귀 테스트 — 고친 N+1이 다시 살아나지 못하게 대못 박기
@Test
void 게시글_목록은_쿼리_2번을_넘지_않는다() {
  SQLStatementCountValidator.reset();

  postService.list("free", PageRequest.of(0, 20));

  SQLStatementCountValidator.assertSelectCount(2);  // 본문 1 + count 1
}

// 몇 달 뒤 누군가 DTO에 필드 하나를 추가하며 무심코 지연 로딩을 건드리면?
// 배포 후 장애가 아니라, CI의 빨간불이 먼저 알려 줍니다.
// "빨라졌다"는 감상이 아니라 assert할 수 있는 숫자로 남기는 것 — 이게 고급의 태도예요.`;

const CODE_FLOW = `실전 리팩터링 5단계 — admin-web 회원 목록 API를 예로 들면

① 측정   p6spy 켜고 /api/admin/users?page=0 호출
          → 쿼리 41번, 응답 480ms 확인 (감이 아니라 숫자)
② 원인   User → 최근 로그인 이력 → 권한 목록을 행마다 지연 로딩
          (1 + 20 + 20 구조)
③ 처방   화면에 쓰는 6개 컬럼만 DTO 프로젝션으로 조회,
          이력은 join으로 최신 1건만
④ 재측정 쿼리 2번(목록 + count), 응답 60ms — 개선을 숫자로 증명
⑤ 고정   assertSelectCount(2) 회귀 테스트 추가 → 재발 방지

순서가 핵심입니다. ③(처방)부터 시작하면 뭐가 나아졌는지 아무도 몰라요.`;

// ── 이 페이지 안에서만 쓰는 작은 부품들 ──
// 학습 포인트: 같은 모양이 반복되면 컴포넌트로 뽑는다. 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+1이란' },
  { n: 2, label: 'JPA에서 잡기' },
  { n: 3, label: '필요한 만큼만' },
  { n: 4, label: '집계·조인' },
  { n: 5, label: '읽기 부하' },
  { n: 6, label: '측정 도구' },
  { n: 7, label: '리팩터링 흐름' },
];

export default function Lv3QueryOptimizationPage() {
  return (
    <div>
      {/* 히어로: 이 코스가 어디서 출발해 어디까지 가는지 */}
      <div className="hero">
        <div className="eyebrow">Course · 고급 과정</div>
        <h1>🐢 N+1과 쿼리 최적화</h1>
        <p>
          목록 하나 여는데 쿼리가 <strong>수백 개</strong> 나가고 있다면 — 코드가 아니라
          ORM이 숨긴 재앙일 가능성이 높아요. 개발 DB에선 멀쩡하다가 데이터가 쌓이면
          기어가는 API, 그 뒤에는 거의 항상 <strong>N+1</strong>이 있습니다. 이 코스에선
          쿼리를 <strong>눈으로 세는 법</strong>부터 fetch join·프로젝션으로 걷어내고,
          회귀 테스트로 재발까지 막는 흐름을 익힙니다.
        </p>
        <div className="chip-row">
          <span className="chip">예상 소요 55분</span>
          <span className="chip">실습: 목록 API 쿼리 수 절반 이하로</span>
          <span className="chip">선수 지식: JPA·SQL 기초</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="N+1이란 무엇인가" sub="1번 조회 뒤 N번의 추가 쿼리가 터지는 구조">
        <p>
          이름부터 풀어 봅시다. 목록을 가져오는 쿼리가 <strong>1번</strong>, 그리고 목록의{' '}
          <strong>행 수(N)만큼</strong> 연관 데이터를 가져오는 추가 쿼리가 터지는 패턴 —
          그래서 <strong>N+1 문제</strong>라고 불러요. 20건짜리 목록이면 21번, 500건이면
          501번의 쿼리가 DB로 날아갑니다.
        </p>
        <Code>{CODE_NPLUS1_LOG}</Code>
        <p>
          무서운 점은 이게 <strong>버그가 아니라는 것</strong>이에요. JPA의{' '}
          <span className="icode">FetchType.LAZY</span>(지연 로딩)는 "연관 데이터는 실제로
          쓸 때 가져온다"는 합리적인 기본값인데, <strong>목록 + 반복문 + 연관 접근</strong>이라는
          조합을 만나는 순간 행마다 쿼리를 쏘는 폭탄으로 돌변합니다. 코드는 어떤 예외도 던지지
          않고, 화면도 멀쩡히 뜨죠. 그래서 '조용한 재앙'입니다.
        </p>
        <Code>{CODE_LAZY}</Code>
        <p>
          잡으려면 먼저 <strong>보여야</strong> 합니다. 개발 환경에서 SQL 로그를 켜고 목록
          화면을 한 번 열어 보세요. 같은 모양의 <span className="icode">select</span>가
          주르륵 이어지는 광경 — 그게 N+1의 지문이에요.
        </p>
        <Code>{CODE_SHOW_SQL}</Code>
        <div className="tip">
          <b>개발 DB에선 왜 안 느릴까</b> 로컬 DB는 데이터가 수십 건이고 네트워크 왕복이
          0.1ms 수준이라 쿼리 100번도 티가 안 나요. 운영에선 데이터가 수만 건, DB까지의
          왕복이 수 ms — <strong>같은 코드가 다르게 늙습니다.</strong> "지금 빠르다"가 아니라
          "쿼리가 몇 번 나가나"로 판단하는 습관이 고급 과정의 출발점이에요.
        </div>
      </Section>

      <Section n={2} title="JPA에서 N+1 잡기" sub="fetch join · @EntityGraph · batch_size, 그리고 페이징 함정">
        <p>
          처방은 세 갈래입니다. 첫째, <strong>fetch join</strong> — JPQL에서{' '}
          <span className="icode">join fetch</span>를 쓰면 "목록을 가져올 때 연관도 같이
          실어 와"가 되어, N+1이 <strong>쿼리 1번</strong>으로 접힙니다. 둘째,{' '}
          <strong>@EntityGraph</strong> — JPQL을 직접 쓰지 않는 메서드 이름 쿼리에
          같은 효과를 얹는 애너테이션이에요.
        </p>
        <Code>{CODE_FETCH_JOIN}</Code>
        <p>
          셋째는 <strong>batch_size</strong>. 지연 로딩이 일어나긴 하되, 한 건씩이 아니라{' '}
          <span className="icode">where id in (...)</span>으로 <strong>묶어서</strong> 가져오게
          하는 전역 설정입니다. N+1이 '1+1'로 줄어들죠. fetch join만큼 극적이진 않지만,
          미처 못 찾은 지연 로딩까지 전부 커버하는 <strong>보험</strong>이라 실무에선 거의
          기본으로 깔아 둡니다.
        </p>
        <Code>{CODE_BATCH}</Code>
        <p>
          단, fetch join엔 유명한 함정이 하나 있어요. <strong>컬렉션(OneToMany) fetch join과
          페이징은 같이 못 갑니다.</strong> 조인하면 부모 행이 자식 수만큼 불어나{' '}
          <span className="icode">limit</span>을 DB에 걸 수 없거든요. 하이버네이트는 경고를
          찍고 <strong>전체를 메모리로 퍼 올린 뒤</strong> 애플리케이션에서 자릅니다 —
          데이터가 크면 그대로 메모리 장애입니다.
        </p>
        <Code>{CODE_PAGING_TRAP}</Code>
        <div className="warn">
          <b>즉시 로딩(EAGER)은 해법이 아니에요</b> "LAZY가 문제라면 EAGER로 바꾸면
          되잖아?" — 함정입니다. EAGER는 그 연관을 <strong>안 쓰는 화면에서도</strong> 항상
          가져오게 만들고, 쿼리가 어디서 몇 번 나가는지 예측을 더 어렵게 해요. 기본은 LAZY로
          두고, <strong>필요한 조회에만</strong> fetch join·EntityGraph를 골라 쓰는 것이
          정석입니다.
        </div>
      </Section>

      <Section n={3} title="필요한 만큼만 가져오기" sub="SELECT *의 대가, 프로젝션, count 분리">
        <p>
          N+1을 잡았다면 다음 질문은 이겁니다. <strong>"가져온 것 중에 실제로 쓰는 건
          얼마나 되지?"</strong> 엔티티로 조회하면 모든 컬럼이 실려 옵니다 — 목록 화면엔
          제목·작성자·날짜 3개만 쓰는데, 수십 KB짜리 본문(TEXT)까지 매 행마다 네트워크를
          타고 오는 거죠. <span className="icode">SELECT *</span>의 대가는 전송량만이 아니라
          DB의 읽기량, JPA의 영속성 컨텍스트 관리 비용까지 포함입니다.
        </p>
        <p>
          해법은 <strong>프로젝션(DTO 조회)</strong> — select 절에 화면이 쓰는 컬럼만 명시해서
          곧바로 DTO로 받는 방식이에요. 연관 엔티티를 '로딩'하는 게 아니라 필요한 값만
          '조회'하니, N+1이 구조적으로 생길 수 없다는 보너스까지 따라옵니다.
        </p>
        <Code>{CODE_PROJECTION}</Code>
        <p>
          목록 API의 숨은 비용이 하나 더 있어요. 페이지네이션의 <strong>count 쿼리</strong>입니다.{' '}
          <span className="icode">Page</span>를 반환하면 전체 건수를 세는 쿼리가 자동으로 한 번 더
          나가는데, 본문 쿼리의 join을 count까지 끌고 가면 집계가 필요 이상으로 무거워져요.{' '}
          <span className="icode">countQuery</span>를 분리해 가볍게 만들어 줍니다.
        </p>
        <Code>{CODE_COUNT_SPLIT}</Code>
        <div className="tip">
          <b>판단 기준: 화면이 정한다</b> "엔티티 조회 vs 프로젝션"의 정답은 화면에 있어요.
          상세 페이지처럼 <strong>대부분의 필드를 쓰고 수정도 하는</strong> 곳은 엔티티가 자연스럽고,
          목록·통계처럼 <strong>일부 컬럼만 읽는</strong> 곳은 프로젝션이 맞습니다. admin-web의
          목록 화면들을 떠올려 보세요 — 테이블에 뜨는 컬럼은 대여섯 개뿐입니다.
        </div>
      </Section>

      <Section n={4} title="집계·조인 최적화" sub="조인은 어디서, 집계는 언제, 존재 확인은 얼마에">
        <p>
          연관 데이터를 합치는 방법은 두 가지예요. <strong>DB 조인</strong>(SQL{' '}
          <span className="icode">join</span>으로 DB가 합쳐서 반환)과{' '}
          <strong>애플리케이션 조인</strong>(두 번 조회한 뒤 자바 코드에서{' '}
          <span className="icode">Map</span>으로 합치기). 기본은 DB 조인이 유리합니다 —
          인덱스를 타고, 네트워크를 한 번만 타니까요. 하지만 조인 결과가 크게 불어나는 경우나,
          상대가 <strong>다른 서비스의 DB</strong>라 조인 자체가 불가능한 경우(마이크로서비스!)엔
          애플리케이션 조인이 답이 됩니다. 이때도 행마다 조회하면 N+1이니, 반드시{' '}
          <span className="icode">where id in (...)</span>으로 <strong>한 번에</strong> 가져와서
          합치세요.
        </p>
        <p>
          <strong>무거운 집계는 미리 계산</strong>해 둘 수 있습니다. 게시판마다 글 수를 보여
          주려고 요청마다 <span className="icode">GROUP BY</span>를 돌리는 대신, 글을 쓰고
          지울 때 카운터 컬럼을 함께 갱신해 두면 조회는 한 줄 읽기로 끝나요.
        </p>
        <Code>{CODE_PREAGG}</Code>
        <p>
          마지막으로 아주 흔한 낭비 하나 — <strong>존재 여부만 궁금한데 전부 세는 것</strong>.
          "댓글이 하나라도 있나?"에 <span className="icode">count(*)</span>를 쓰면 DB는 조건에
          맞는 행을 끝까지 다 세고 나서야 답합니다. <span className="icode">EXISTS</span>는
          첫 행을 찾는 순간 멈추죠.
        </p>
        <Code>{CODE_EXISTS}</Code>
        <div className="warn">
          <b>미리 계산의 청구서</b> 카운터 컬럼 같은 사전 집계는 <strong>쓰기 경로가 복잡해지고,
          값이 어긋날 위험</strong>을 떠안는 거래예요. 동시에 여러 요청이 카운터를 갱신하면
          경합도 생기고요. "읽기가 쓰기보다 압도적으로 많은가?"를 먼저 확인하고, 어긋남을
          바로잡는 보정(배치 재계산 등) 수단까지 준비된 뒤에 도입하세요.
        </div>
      </Section>

      <Section n={5} title="읽기 부하 줄이기" sub="캐시 · 읽기 전용 트랜잭션 · 비정규화라는 타협">
        <p>
          여기까지는 쿼리 하나하나를 다듬는 얘기였다면, 이번엔 시야를 넓혀 봅시다. 대부분의
          서비스에서 트래픽의 압도적 다수는 <strong>읽기</strong>이고, 그중 상당수는{' '}
          <strong>방금 다른 사용자가 읽은 것과 똑같은 데이터</strong>예요. 자주 읽히고 덜
          바뀌는 데이터라면 — 매번 DB에 갈 이유가 없습니다. 결과를 <strong>캐시</strong>에
          올려 두고 DB를 아예 건너뛰는 거죠. 이 주제는 깊어서 다음 강좌(캐싱 전략) 하나를
          통째로 씁니다. 여기선 "쿼리를 아무리 갈아도 안 되면, 쿼리를 안 하는 방법이 있다"는
          출구를 기억해 두세요.
        </p>
        <p>
          당장 적용할 수 있는 것은 <strong>읽기 전용 트랜잭션</strong>입니다. 조회 서비스에{' '}
          <span className="icode">@Transactional(readOnly = true)</span>를 선언하면 JPA가
          변경 감지(더티 체킹)용 스냅샷을 만들지 않아 메모리와 CPU를 아끼고, 나중에 DB를
          주/복제본으로 나눌 때 <strong>읽기를 복제본으로 흘려보내는 라우팅 기준</strong>이
          되어 줍니다.
        </p>
        <Code>{CODE_READONLY}</Code>
        <p>
          그리고 <strong>비정규화</strong> — 정규화 원칙을 알면서도 <strong>의도적으로</strong>{' '}
          중복을 허용하는 타협입니다. 댓글 테이블에 작성자 이름을 복사해 두면 목록 조회에서
          조인이 사라지죠. 대가는 명확해요. 작성자가 이름을 바꾸면 복사된 모든 행을 따라
          고치거나, "작성 당시 이름"이라는 정책으로 안 고치기로 <strong>결정</strong>해야
          합니다. 섹션 4의 카운터 컬럼도 사실 비정규화의 한 형태예요.
        </p>
        <div className="tip">
          <b>순서가 있어요</b> 비정규화·복제본은 강력하지만 되돌리기 어려운 구조 변경입니다.
          순서는 이렇게: <strong>① N+1 제거 → ② 프로젝션·인덱스 → ③ readOnly →
          ④ 캐시 → ⑤ 비정규화·복제본.</strong> 앞 단계를 건너뛰고 뒤를 먼저 하면, 느린
          쿼리를 여러 곳에 복사해 두는 꼴이 됩니다.
        </div>
      </Section>

      <Section n={6} title="측정 도구" sub="세어 보고, 증명하고, 재발을 막기">
        <p>
          "최적화했더니 빨라진 것 같아요"는 고급 과정에선 통하지 않는 문장이에요. 필요한 건{' '}
          <strong>숫자</strong>입니다 — 요청 하나에 쿼리가 몇 번 나갔고, 몇 ms 걸렸는지.
          그 숫자를 주는 도구가 <strong>p6spy</strong>(애플리케이션 쪽에서 실제 나간 쿼리를
          파라미터까지 기록)와 <strong>느린 쿼리 로그</strong>(DB 쪽에서 기준 시간을 넘긴
          쿼리만 기록)입니다.
        </p>
        <Code>{CODE_P6SPY}</Code>
        <p>
          그런데 측정보다 중요한 게 있어요. <strong>재발 방지</strong>입니다. N+1은 고쳐도
          다시 생깁니다 — 몇 달 뒤 누군가 DTO에 필드 하나를 추가하면서 무심코 지연 로딩을
          건드리는 식으로요. 그래서 <strong>쿼리 카운트 회귀 테스트</strong>를 남깁니다.
          "이 API는 쿼리 2번을 넘지 않는다"를 테스트로 박아 두면, 재발은 장애가 아니라
          CI의 빨간불로 먼저 나타나요.
        </p>
        <Code>{CODE_COUNT_TEST}</Code>
        <div className="tip">
          <b>전후 비교는 같은 조건에서</b> 개선 전 측정과 개선 후 측정은 <strong>같은 데이터,
          같은 페이지 크기</strong>로 해야 의미가 있어요. 그리고 첫 요청은 커넥션 풀·JIT
          워밍업 때문에 유독 느리니, 몇 번 호출해 본 뒤의 값으로 비교하세요. "41번 → 2번,
          480ms → 60ms"처럼 <strong>쿼리 수와 시간을 함께</strong> 기록하는 습관이 좋습니다.
        </div>
      </Section>

      <Section n={7} title="실전 리팩터링 흐름" sub="측정 → 원인 → 처방 → 재측정 → 고정">
        <p>
          이제 배운 걸 하나의 흐름으로 엮어 봅시다. 실습 과제는 이렇습니다 —{' '}
          <strong>admin-web이 부르는 목록 API 하나를 골라, 요청당 쿼리 수를 세고,
          fetch join·프로젝션으로 절반 이하로 줄이기.</strong> 순서가 절대적으로 중요해요.
          처방부터 시작하면 뭐가 나아졌는지 증명할 수 없으니까요.
        </p>
        <Code>{CODE_FLOW}</Code>
        <p>
          리팩터링 중에 과거의 유산을 만날 수 있어요. 예전에 누군가 N+1을 급히 막느라{' '}
          <span className="icode">FetchType.EAGER</span>를 여기저기 박아 둔 경우입니다.
          당장은 동작해도, 그 엔티티를 쓰는 <strong>모든</strong> 조회가 연관을 끌고 다니게
          되죠. 이런 즉시 로딩은 LAZY로 되돌리고, 정말 필요한 조회에만 fetch join을 달아{' '}
          <strong>균형을 되찾는 것</strong>까지가 이번 실습의 범위예요.
        </p>
        <p>
          마지막으로, <strong>멈출 지점</strong>을 정합시다. 쿼리 41번을 2번으로 줄이는 건
          큰 성과지만, 2번을 1번으로 줄이려고 코드를 비틀기 시작하면 얻는 건 몇 ms고 잃는 건
          가독성입니다. 최적화는 <strong>측정된 병목</strong>이 있을 때만 계속하고, 목표
          숫자(예: 요청당 쿼리 3번 이하, p95 100ms 이하)에 닿으면 멈추는 것 — 그것도
          설계 감각입니다.
        </p>
        <div className="warn">
          <b>하나만 골라서, 끝까지</b> 실습에서 여러 API를 조금씩 건드리고 싶은 유혹이
          올 거예요. 참으세요. <strong>API 하나</strong>를 골라 측정 → 처방 → 재측정 → 회귀
          테스트까지 <strong>5단계를 완주</strong>하는 것이, 다섯 API를 어중간하게 만지는
          것보다 백 배 남습니다. 완주 경험이 곧 다음 API의 속도가 돼요.
        </div>
      </Section>

      <div className="step-card">
        <div className="step-body">
          <h3 style={{ marginBottom: 6 }}>🐢 여기까지 왔다면</h3>
          <p className="muted">
            이제 목록 API가 느릴 때 "DB가 느린가 봐요" 대신, 로그를 켜 <strong>쿼리를 세고</strong>,
            fetch join과 프로젝션으로 <strong>걷어내고</strong>, 회귀 테스트로 <strong>대못을
            박는</strong> 흐름이 손에 잡혔을 거예요. 거북이(🐢)는 쿼리 습관의 문제였지, DB의
            잘못이 아니었던 거죠. 그런데 섹션 5에서 열어 둔 출구가 하나 남았습니다 — 아무리
            다듬어도 매 요청마다 DB에 가는 것 자체가 아까운 데이터는요? 다음{' '}
            <Link to="/learn/lv3-caching-strategy"><strong>캐싱 전략 — 로컬과 Redis</strong></Link>{' '}
            강좌에서, DB 앞에 기억을 두는 기술과 그 대가(무효화라는 난제)를 이어서 다룹니다.
          </p>
        </div>
      </div>
    </div>
  );
}
