[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 BY에 m.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' 해시태그가 붙은 책을 조회한다. (book ↔ book_hash_tag ↔ hash_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 방식 |
|---|---|---|
| 페이지 이동 | 번호로 직접 이동 가능 | 이전/다음만 가능 |
| 성능 | 뒤 페이지일수록 느려짐 | 항상 일정한 성능 |
| 데이터 중복 | 새 데이터 추가 시 중복 발생 | 중복 없음 |
| 구현 복잡도 | 단순 | 복잡 (특히 복합 정렬 시) |
| 적합한 경우 | 정적 데이터, 백오피스 | 피드, 댓글, 무한스크롤 |
쿼리 실행 흐름
내가 작성한 쿼리가 실제 서버에서 어떻게 동작하는지 전체 흐름을 보면 이렇다.
- 클라이언트 요청 — 사용자가 버튼 클릭이나 스크롤로 요청을 보낸다. (
GET /books?tag=dev&page=2) - 서버에서 요청 처리 — URL과 쿼리 파라미터를 읽어 SQL 변수로 매핑한다.
- DB 쿼리 실행 — DB 커넥션을 통해 SQL을 실행하고, DB가 조건에 맞는 데이터를 반환한다.
- JSON 응답으로 변환 — 불필요한 칼럼 제거, 추가 계산 후 JSON으로 변환해 프론트엔드에 전달한다.
- 클라이언트 화면 렌더링 — 프론트엔드가 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가 필요하다.