2편에서 검색 인덱스를 정리했다. 이번엔 그 검색 결과를 "어떻게 나눠 주느냐" —
페이징 이야기다. 교과서의 OFFSET 방식을 버리고 커서로 간 기록.
페이징의 기본형은 다들 이렇게 배운다.
SELECT * FROM content ORDER BY id DESC LIMIT 20 OFFSET 10000;
이 쿼리의 함정은 OFFSET의 동작 방식에 있다.
DB는 10,020행을 읽어서 10,000행을 버린다. 건너뛰는 게 아니라 읽고 버린다.
뒤 페이지로 갈수록 느려지는 구조적 비용이고, 무한 스크롤 UI에서는
사용자가 스크롤할수록 서버가 무거워진다는 뜻이 된다.
하나 더 있다. 행이 밀리는 문제. 사용자가 2페이지를 보는 사이 새 콘텐츠가
등록되면 전체 행이 한 칸씩 밀린다. 3페이지를 요청하면 2페이지 마지막 항목이
중복돼 나온다. 무한 스크롤에서 같은 카드가 두 번 보이는 버그의 고전적 원인이다.
커서(keyset) 방식은 질문을 바꾼다.
-- OFFSET: "10,000번째부터 20개 줘"
-- 커서: "id 12345보다 작은 것부터 20개 줘"
WHERE (:cursor IS NULL OR id < :cursor)
ORDER BY id DESC
LIMIT :size
id < :cursor는 인덱스(PK)를 타고 시작점으로 점프한다. 읽고 버리는 게 없다.
1페이지든 500페이지든 비용이 같고, 중간에 행이 끼어들어도
"이 id 다음"이라는 기준은 안 밀리니 중복도 없다.
첫 요청은 커서 없이(:cursor IS NULL) 오고, 응답의 마지막 항목 id가
다음 요청의 커서가 된다. 클라이언트는 "마지막으로 본 것"만 기억하면 된다.
최신순은 id 커서로 끝난다. 문제는 좋아요순 정렬이었다.
좋아요 수는 유일하지 않다. 좋아요 10개짜리가 서른 개면,
likeCount < 10으로 끊는 순간 같은 10개짜리 나머지가 전부 건너뛰어진다.
해법은 복합 커서다. 정렬 기준 + 유일한 타이브레이커(id)를 쌍으로 쓴다.
WHERE (:lastLikeCount IS NULL
OR likeCount < :lastLikeCount
OR (likeCount = :lastLikeCount AND id < :lastId))
ORDER BY likeCount DESC, id DESC
풀어 읽으면: "좋아요가 더 적거나, 좋아요가 같다면 id가 더 작은 것부터".
정렬 순서(likeCount DESC, id DESC)와 커서 조건이 정확히 같은 순서를
표현해야 한다. 이 둘이 어긋나면 중복이나 누락이 조용히 생긴다.
커서 방식의 대가도 여기서 드러난다. "몇 페이지로 점프"가 안 된다.
항상 "다음"만 있다. 무한 스크롤에는 완벽하지만, 페이지 번호 UI가 필요했다면
OFFSET과의 하이브리드를 고민했을 거다. 우리는 스크롤 UI라 고민이 없었다.
커서로 20행을 가볍게 가져와도, 각 행의 연관 엔티티(작성자·장르)를
LAZY 로딩으로 하나씩 긁으면 쿼리가 1 + 20 + 20개가 된다. 악명 높은 N+1.
1차 방어는 fetch join이다. 리스트 쿼리에서 항상 쓰는 연관은 한 방에 가져온다.
SELECT c FROM Content c
LEFT JOIN FETCH c.member
LEFT JOIN FETCH c.genre
WHERE ...
2차 방어는 설정 한 줄이다.
hibernate:
jdbc:
default_batch_fetch_size: 100
fetch join이 못 덮는 경로(다른 서비스에서 재사용될 때, 컬렉션 연관 등)에서
LAZY 로딩이 발생하면, 프록시를 하나씩 조회하는 대신 IN (100개) 쿼리로 묶는다.
N+1이 N/100+1이 되는 안전망이다.
역할을 나누면: fetch join은 "설계된 경로"의 최적화, batch size는
"설계 밖 경로"의 보험. 전자만 믿으면 언젠가 새 코드가 N+1을 다시 만들고,
후자만 믿으면 최적 경로도 쿼리가 2개가 된다. 둘 다 필요하다.
1. OFFSET은 "읽고 버리는" 비용이다.
페이지가 뒤로 갈수록, 데이터가 쌓일수록 선형으로 느려진다.
무한 스크롤이면 커서가 구조적으로 맞다.
2. 커서는 정렬 기준의 유일성이 생명이다.
유일하지 않은 컬럼으로 정렬하면 반드시 타이브레이커(id)를 커서에 포함하고,
WHERE 조건이 ORDER BY와 같은 순서를 표현하는지 검증해야 한다.
3. 페이징 최적화와 N+1 방어는 세트다.
행을 가져오는 비용을 줄여도 행당 추가 쿼리가 남으면 의미가 없다.
fetch join(설계된 경로) + batch fetch size(안전망)의 이중 구조로 막았다.
다음 편은 조회수다. "카운트 1 올리기"라는 사소해 보이는 요구사항에
트랜잭션 이벤트, 비동기 풀, 백프레셔까지 동원하게 된 이유.