[Node.js / Express 5] 실전 SQL과 페이지네이션

 

SQL 워밍업

도서관 ERD(member, book, book_category, rent, book_likes)를 기준으로 연습했다.

기본 조회

"소설" 카테고리의 모든 책을 조회한다.

SELECT b.name, b.description
FROM book b
JOIN book_category bc ON b.book_category_id = bc.id
WHERE bc.name = '소설';

집계 함수

회원별 대여 횟수 상위 5명을 조회한다.

SELECT m.name, COUNT(*) AS rent_count
FROM member m
JOIN rent r ON m.id = r.member_id
GROUP BY m.id, m.name
ORDER BY rent_count DESC
LIMIT 5;

GROUP BYm.id, m.name을 모두 명시하는 이유가 있다. SELECT에서 name을 조회하기 때문이다. 집계되지 않은 칼럼은 GROUP BY에 반드시 포함해야 한다.

복합 JOIN

각 회원이 좋아요를 누른 책의 카테고리별 분포를 조회한다.

SELECT m.name, bc.name AS category, COUNT(*) AS like_count
FROM member m
JOIN book_likes bl ON m.id = bl.member_id
JOIN book b        ON bl.book_id = b.id
JOIN book_category bc ON b.book_category_id = bc.id
GROUP BY m.id, m.name, bc.id, bc.name;

시작점은 member 테이블이다. book_likes -> book -> book_category 순으로 접근하고, GROUP BY로 회원+카테고리 조합별로 그룹화한다.

서브쿼리 vs 복합 JOIN

같은 문제를 CROSS JOIN과 서브쿼리 조합으로 구현하면 이렇게 된다.

SELECT m.name, bc.name AS category,
  (SELECT COUNT(*)
   FROM book_likes bl
   JOIN book b ON bl.book_id = b.id
   WHERE bl.member_id = m.id AND b.book_category_id = bc.id) AS like_count
FROM member m
CROSS JOIN book_category bc
WHERE (SELECT COUNT(*)
       FROM book_likes bl
       JOIN book b ON bl.book_id = b.id
       WHERE bl.member_id = m.id AND b.book_category_id = bc.id) > 0;

CROSS JOIN으로 모든 회원×카테고리 조합을 먼저 만든 뒤 필터링하고, 동일한 서브쿼리가 두 번 실행된다. 이 경우는 복합 JOIN 방식이 더 효율적이다.


실전 요구사항 쿼리

좋아요 개수 집계

likes 칼럼이 있는 경우와 book_likes 테이블에서 집계하는 경우를 비교하면 이렇다.

SELECT likes FROM book;

SELECT COUNT(*) FROM book_likes
WHERE book_id = 3;

매핑 테이블에서 COUNT로 집계하는 방식이 요구사항 변경에 유연하게 대응할 수 있다.

차단 사용자 좋아요 제외

책 ID가 3이고 내 ID가 2일 때, 내가 차단한 사용자의 좋아요를 제외하고 집계한다.

NOT IN 서브쿼리 방식

SELECT COUNT(*)
FROM book_likes
WHERE book_id = 3
  AND user_id NOT IN (SELECT target_id FROM block WHERE owner_id = 2);

LEFT JOIN 방식

SELECT COUNT(*)
FROM book_like AS bl
LEFT JOIN block AS b ON bl.user_id = b.target_id AND b.owner_id = 2
WHERE bl.book_id = 3
  AND b.target_id IS NULL;

LEFT JOIN 후 b.target_id IS NULL은 오른쪽 테이블(block)에 매칭이 없는 행, 즉 차단 목록에 없는 사용자만 필터링한다.

커뮤니티 앱에서 신고/차단 기능이 없으면 앱스토어 reject 사례가 있다. 처음 설계할 때부터 고려하는 것이 좋다.

N:M 해시태그 검색

'dev' 해시태그가 붙은 책을 조회한다. (bookbook_hash_taghash_tag)

SELECT b.*
FROM book AS b
INNER JOIN book_hash_tag AS bht ON b.id = bht.book_id
INNER JOIN hash_tag AS ht       ON bht.hash_tag_id = ht.id
WHERE ht.name = 'dev';

좋아요 순 정렬

book 테이블에 likes 칼럼이 없을 때, 좋아요 수 기준으로 정렬한다.

SELECT *
FROM book AS b
JOIN (
  SELECT book_id, COUNT(*) AS like_count
  FROM book_likes
  GROUP BY book_id
) AS likes ON b.id = likes.book_id
ORDER BY likes.like_count DESC;

FROM 절의 서브쿼리(인라인 뷰)로 책별 좋아요 수를 먼저 집계하고, 그 결과를 book 테이블과 JOIN한다.


페이지네이션

데이터가 100만 건이라면 한 번에 전부 가져올 수 없다. DB에서 끊어서 가져오는 Paging이 필수다. 방식은 크게 두 가지가 있다.

Offset 기반 페이지네이션

페이지 번호로 직접 이동하는 방식이다. 일반 게시판의 1, 2, 3... 페이지 형태에 해당한다.

기본 공식

SELECT * FROM book
ORDER BY created_at DESC
LIMIT 15 OFFSET (x - 1) * 15;

x는 페이지 번호, 15는 페이지당 데이터 수다. 1페이지면 OFFSET이 0이 되므로 (x-1)을 곱한다.

좋아요 순 + 페이지네이션

SELECT *
FROM book AS b
JOIN (
  SELECT book_id, COUNT(*) AS like_count
  FROM book_likes
  GROUP BY book_id
) AS likes ON b.id = likes.book_id
ORDER BY likes.like_count DESC
LIMIT 15 OFFSET (n - 1) * 15;

Offset 방식의 단점

문제 설명
성능 저하 OFFSET 값이 클수록 앞 데이터를 모두 스캔한 뒤 버린다. 페이지가 뒤로 갈수록 점점 느려진다.
데이터 중복 1페이지 -> 2페이지로 이동 중 새 데이터가 추가되면, 기존 데이터들이 뒤로 밀려 2페이지에서 1페이지 데이터가 다시 보인다.

Cursor 기반 페이지네이션

마지막으로 조회한 데이터의 식별자(cursor)를 기준으로 다음 n개를 가져오는 방식이다.

기본 구조

SELECT * FROM book ORDER BY created_at DESC LIMIT 15;

SELECT * FROM book
WHERE created_at < (SELECT created_at FROM book WHERE id = {cursor_id})
ORDER BY created_at DESC
LIMIT 15;

첫 번째 요청은 그냥 조회하고, 이후 요청부터 마지막 조회 book의 created_at을 커서로 사용한다.

복합 정렬 시 중복값 문제

좋아요가 0개인 게시글이 400개이고 마지막 조회 책의 좋아요도 0개라면, likes.like_count < 0 조건에 해당하는 데이터가 없어 다음 페이지가 비어버린다.

해결: 복합 커서값 (CONCAT + LPAD)

좋아요 수와 id를 하나의 고유 문자열로 엮어 커서로 사용한다.

좋아요 23, id 45 => "0000000023" + "0000000045" => "00000000230000000045"
좋아요 5,  id 99 => "0000000005" + "0000000099" => "00000000050000000099"

문자열 비교 시 좋아요 수가 먼저 비교되고, 같으면 id로 구분한다. LPAD 없이 "23" vs "199"를 비교하면 문자열 기준으로 "199" < "23"이 되어 잘못된 결과가 나온다. LPAD로 고정 길이 문자열을 만들어야 비교가 올바르게 작동한다.

SELECT b.*,
       CONCAT(LPAD(likes.like_count, 10, '0'), LPAD(b.id, 10, '0')) AS cursor_value
FROM book AS b
JOIN (
  SELECT book_id, COUNT(*) AS like_count
  FROM book_likes
  GROUP BY book_id
) AS likes ON b.id = likes.book_id
WHERE CONCAT(LPAD(likes.like_count, 10, '0'), LPAD(b.id, 10, '0')) <
  (SELECT CONCAT(LPAD(lc.like_count, 10, '0'), LPAD(lc.book_id, 10, '0'))
   FROM (SELECT book_id, COUNT(*) AS like_count FROM book_likes GROUP BY book_id) AS lc
   WHERE lc.book_id = {cursor_id})
ORDER BY likes.like_count DESC, b.id DESC
LIMIT 15;

Offset vs Cursor 비교

항목 Offset 방식 Cursor 방식
페이지 이동 번호로 직접 이동 가능 이전/다음만 가능
성능 뒤 페이지일수록 느려짐 항상 일정한 성능
데이터 중복 새 데이터 추가 시 중복 발생 중복 없음
구현 복잡도 단순 복잡 (특히 복합 정렬 시)
적합한 경우 정적 데이터, 백오피스 피드, 댓글, 무한스크롤

쿼리 실행 흐름

내가 작성한 쿼리가 실제 서버에서 어떻게 동작하는지 전체 흐름을 보면 이렇다.

  1. 클라이언트 요청 — 사용자가 버튼 클릭이나 스크롤로 요청을 보낸다. (GET /books?tag=dev&page=2)
  2. 서버에서 요청 처리 — URL과 쿼리 파라미터를 읽어 SQL 변수로 매핑한다.
  3. DB 쿼리 실행 — DB 커넥션을 통해 SQL을 실행하고, DB가 조건에 맞는 데이터를 반환한다.
  4. JSON 응답으로 변환 — 불필요한 칼럼 제거, 추가 계산 후 JSON으로 변환해 프론트엔드에 전달한다.
  5. 클라이언트 화면 렌더링 — 프론트엔드가 JSON 데이터를 받아 화면을 렌더링한다.

핵심 키워드 정리

인덱스

인덱스는 조회 성능을 높이기 위한 데이터 구조다. 책의 목차처럼 원하는 데이터를 빠르게 찾을 수 있다.

CREATE INDEX idx_book_created_at ON book(created_at);
  내용
장점 조회 속도 향상. WHERE, ORDER BY, JOIN ON 절이 빨라진다.
단점 INSERT/UPDATE/DELETE 성능 저하. 추가 저장 공간 필요.
적용 기준 자주 WHERE, ORDER BY, JOIN ON에 사용되는 칼럼에 적용한다.

ORM vs Raw SQL

항목 ORM (Prisma, Sequelize) Raw SQL
생산성 높음 (코드 자동 생성) 낮음 (직접 작성)
성능 최적화 어려움 세밀한 최적화 가능
복잡한 쿼리 한계 있음 자유롭게 작성 가능
타입 안정성 Prisma는 타입 제공 직접 관리

실무 전략은 기본적으로 ORM을 사용하되, 복잡한 집계 쿼리나 커서 페이지네이션처럼 ORM으로 표현하기 어려운 경우에는 Raw SQL을 직접 작성한다.


미션 풀이

이전에 설계한 데이터베이스(지역, 식당, 미션, 리뷰)를 기반으로 작성한 쿼리다.

내가 진행중/완료한 미션 목록 (페이징 포함)

SELECT
  m.title AS mission_title,
  r.name AS restaurant_name,
  um.status,
  um.created_at
FROM user_mission um
JOIN mission m    ON um.mission_id = m.id
JOIN restaurant r ON m.restaurant_id = r.id
WHERE um.user_id = {내 user_id}
  AND um.status IN ('진행중', '완료')
ORDER BY um.created_at DESC
LIMIT 15 OFFSET (n - 1) * 15;

리뷰 작성 쿼리

INSERT INTO review (user_id, restaurant_id, score, body, created_at)
VALUES ({user_id}, {restaurant_id}, {score}, {body}, NOW());

홈 화면 — 현재 지역에서 도전 가능한 미션 목록 (페이징 포함)

SELECT
  m.id, m.title, m.reward,
  r.name AS restaurant_name,
  r.category
FROM mission m
JOIN restaurant r ON m.restaurant_id = r.id
WHERE r.region_id = {region_id}
  AND m.id NOT IN (
    SELECT mission_id FROM user_mission WHERE user_id = {user_id}
  )
ORDER BY m.created_at DESC
LIMIT 15 OFFSET (n - 1) * 15;

마이 페이지 화면

SELECT
  u.nickname, u.email, u.point,
  COUNT(DISTINCT r.id) AS review_count,
  COUNT(DISTINCT um.id) AS mission_count
FROM user u
LEFT JOIN review r       ON u.id = r.user_id
LEFT JOIN user_mission um ON u.id = um.user_id
WHERE u.id = {user_id}
GROUP BY u.id, u.nickname, u.email, u.point;

COUNT(DISTINCT r.id)는 리뷰 수를, COUNT(DISTINCT um.id)는 미션 수를 각각 집계한다. LEFT JOIN이 여러 개면 카테시안 곱으로 COUNT가 부풀 수 있으므로 DISTINCT가 필요하다.