
백엔드 개발을 공부하다 보면 SQL 문법 자체보다도, “이걸 실제로 왜 써야 하지?” 에서 막히는 경우가 많습니다.
저도 처음에는 JOIN, LIMIT, OFFSET 같은 문법을 외우듯이 봤는데, 막상 요구사항을 쿼리로 바꾸려고 하니 생각보다 어렵게 느껴졌습니다.
그래서 이번 글에서는 워크북 내용을 바탕으로 JOIN이 왜 필요한지, 그리고 Paging은 왜 반드시 고려해야 하는지를 정리해보려고 합니다.
이번 글에서 다룰 내용은 다음과 같습니다.
- JOIN이 필요한 이유
- JOIN과 Subquery의 차이
- Paging이 필요한 이유
- Offset Paging과 Cursor Paging의 차이
JOIN이란?

JOIN은 여러 테이블에 나뉘어 저장된 데이터를 연결해서 조회하는 기능입니다.
관계형 데이터베이스에서는 데이터를 한 테이블에 몰아서 저장하지 않고, 역할에 따라 여러 테이블로 나누어 관리합니다.
예를 들어 미션 정보를 관리한다고 해도,
- 미션 정보는 mission
- 가게 정보는 store
- 지역 정보는 location
처럼 나뉘어 있을 수 있습니다.
이때 “안암동의 미션들을 조회하고 싶다” 같은 요구사항은 하나의 테이블만 조회해서는 해결하기 어렵습니다.
그래서 필요한 것이 바로 JOIN입니다.
JOIN은 왜 필요할까?
가령 mission 테이블에는 미션 자체 정보만 있고, 지역 이름은 직접 들어있지 않다고 가정해보겠습니다.
그렇다면 안암동 미션을 조회하려면
- 미션이 어떤 가게에 속하는지 확인하고
- 그 가게가 어떤 지역에 속하는지 확인한 뒤
- 지역명이 안암동인 데이터만 골라야 합니다
이 과정을 SQL로 표현하면 테이블을 연결해야 하므로 JOIN이 필요합니다.
예를 들면 이런 식입니다.
select *
from mission
left join store on mission.store_id = store.store_id
left join location on store.location_id = location.location_id
where location.name = '안암동';이 쿼리는 mission을 중심으로 store, location을 차례로 연결한 뒤 최종적으로 지역명이 안암동인 미션만 조회하는 방식입니다.
즉, JOIN은 단순히 문법이 아니라 분리된 데이터를 하나의 의미 있는 결과로 묶어주는 역할을 합니다.
JOIN을 사용할 때 중요한 점
JOIN은 편리하지만, 무조건 많이 붙인다고 좋은 것은 아닙니다.
중요한 것은 어떤 테이블이 기준 테이블인지를 먼저 생각하는 것입니다.
예를 들어 “미션 목록을 보고 싶다”면 mission이 중심이 되고, “리뷰 사진 URL을 보고 싶다”면 review_photo가 중심이 될 수 있습니다.
즉, SQL을 작성할 때는 먼저
내가 최종적으로 무엇을 조회하고 싶은가?
를 기준으로 중심 테이블을 잡고,
필요한 정보만 다른 테이블과 연결하는 방식으로 접근하는 것이 좋습니다.
JOIN과 Subquery는 뭐가 다를까?
같은 요구사항도 JOIN으로 풀 수 있고, Subquery로도 풀 수 있습니다.
예를 들어 별점이 3 이상인 리뷰의 사진 URL을 조회한다고 해보겠습니다.
Subquery를 사용하면 이렇게 작성할 수 있습니다.
select photo_url
from review_photo as rp
where rp.review_id in (
select review_id
from review as r
where r.star >= 3
);반대로 JOIN을 사용하면 이렇게 바꿀 수 있습니다.
select rp.photo_url
from review_photo as rp
left join review as r on rp.review_id = r.review_id
where r.star >= 3;둘 다 같은 결과를 얻을 수 있지만, 읽는 방식에는 차이가 있습니다.
Subquery는 “조건에 맞는 review_id를 먼저 찾고, 그에 해당하는 사진을 가져온다” 는 흐름이고,
JOIN은 “리뷰 사진과 리뷰를 연결한 뒤, 별점 조건으로 필터링한다” 는 흐름입니다.
실제로는 상황에 따라 둘 다 사용할 수 있지만, 테이블 간 관계를 명확하게 보여줘야 하거나 여러 컬럼을 함께 조회해야 할 때는
JOIN이 더 직관적으로 느껴질 때가 많습니다.
조금 더 복잡한 JOIN 예시
단순 조회가 아니라 조건이 여러 개 붙는 경우도 많습니다.
예를 들어
특정 날짜 이후로 완료한 미션 점수 총합을 구하고 싶다
라는 요구사항이 있다고 해보겠습니다.
이 경우에는 미션 정보와 유저-미션 관계 정보를 함께 봐야 하므로 JOIN이 필요합니다.
select sum(m.point)
from mission as m
left join user_mission as um on m.mission_id = um.mission_id
where um.is_complete = 1
and um.user_id = 1
and m.deadline >= '2025-09-08';이 쿼리는
- 어떤 유저가
- 어떤 미션을 완료했고
- 그 미션의 마감일이 조건에 맞는지
를 함께 확인한 뒤, 해당 미션들의 점수를 합산합니다.
이처럼 JOIN은 단순한 테이블 연결을 넘어서 비즈니스 로직을 SQL로 표현하는 핵심 도구라고 느꼈습니다.
Paging이 왜 필요할까?
목록 조회 기능을 만들 때 자주 하는 실수가 전체 데이터를 한 번에 조회하려는 것입니다.
예를 들어 미션이 10개, 100개 정도면 큰 문제가 없어 보일 수 있지만, 실제 서비스에서는 데이터가 수십만 개, 수백만 개가 될 수 있습니다.
이때 목록을 요청할 때마다 전체를 다 가져오면
- 조회 속도가 느려지고
- 불필요한 데이터를 너무 많이 읽게 되고
- 서버와 DB 모두 부담이 커집니다
그래서 필요한 것이 Paging입니다.
Paging은 쉽게 말해
데이터를 일정 개수씩 잘라서 가져오는 방식입니다.
즉, “전체 조회”가 아니라
“지금 필요한 만큼만 조회”하는 방식이라고 볼 수 있습니다.
Offset Paging
가장 익숙한 방식은 Offset Paging입니다.
보통 페이지 번호 기반 페이징에서 많이 사용합니다.
예를 들어 최신 미션 15개를 조회하려면 다음과 같이 작성할 수 있습니다.
select *
from mission
order by mission_id desc
limit 15 offset 0;여기서
- limit 15는 15개만 가져오겠다는 뜻이고
- offset 0은 앞에서 0개를 건너뛴다는 뜻입니다
만약 2페이지라면 앞의 15개를 건너뛰고 다음 15개를 가져오면 됩니다.
select *
from mission
order by mission_id desc
limit 15 offset 15;즉, 페이지 번호 기반으로 보면 일반적으로
limit 페이지크기 offset (페이지번호 - 1) * 페이지크기형태로 생각할 수 있습니다.
처음 배울 때는 가장 이해하기 쉬운 방식이라서
구현 난이도도 비교적 낮은 편입니다.
Offset Paging의 단점
Offset Paging은 직관적이지만 단점도 분명합니다.
첫 번째는 뒤로 갈수록 비효율적일 수 있다는 점입니다.
예를 들어 offset 1000이면 DB는 앞의 1000개를 그냥 마법처럼 건너뛰는 것이 아니라,
실제로 세고 지나가야 합니다. 즉 offset이 커질수록 성능 부담이 커질 수 있습니다.
두 번째는 데이터 정합성 문제입니다.
예를 들어 사용자가 1페이지를 보고 있는 동안 새 글이 앞쪽에 여러 개 추가되면,
2페이지로 넘어갔을 때 이미 본 데이터가 다시 보일 수도 있습니다.
이런 상황이 생기는 이유는 Offset Paging이 “몇 개를 건너뛸지” 기준으로 동작하기 때문입니다.
중간에 데이터가 추가되거나 삭제되면 기준점 자체가 흔들릴 수 있습니다.
즉, Offset Paging은 구현은 쉽지만 데이터가 많거나 실시간 변동이 많은 서비스에서는 한계가 있습니다.
Cursor Paging
이런 문제를 보완하기 위해 사용하는 방식이 Cursor Paging입니다.
Cursor Paging은 페이지 번호 대신 마지막으로 조회한 데이터의 위치를 기준으로 다음 데이터를 가져오는 방식입니다.
예를 들어 리뷰를 최신순으로 15개 조회한다고 하면, 처음에는 이렇게 가져올 수 있습니다.
select *
from review
order by review_id desc
limit 15;그리고 마지막으로 조회한 리뷰 ID가 120이었다면,
그 다음 페이지는 이렇게 조회할 수 있습니다.
select *
from review
where review_id < 120
order by review_id desc
limit 15;즉,
마지막으로 본 데이터 다음부터 가져와
라는 개념에 가깝습니다.
이 방식은 offset처럼 앞의 수많은 데이터를 계속 세지 않아도 되기 때문에
큰 데이터셋에서도 더 안정적으로 동작할 수 있습니다.
Cursor Paging에서 주의할 점
Cursor Paging도 아무 컬럼이나 기준으로 잡으면 안 됩니다.
예를 들어 리뷰를 별점 순으로 정렬한다고 해서
커서를 단순히 star만 사용하면 문제가 생길 수 있습니다.
왜냐하면 별점은 중복될 수 있기 때문입니다.
예를 들어 별점이 3점인 리뷰가 아주 많다면,
마지막 커서 값이 계속 3으로 남게 되어
다음 요청에서도 같은 결과가 반복될 수 있습니다.
그래서 Cursor Paging에서는 보통
정렬 기준 + PK 형태로 커서를 구성합니다.
예를 들면
- 별점 내림차순
- 같은 별점 안에서는 review_id 내림차순
처럼 정렬 기준을 잡고,
커서도 이 두 값을 함께 사용합니다.
SQL로는 이런 형태가 됩니다.
select *
from review as r
where r.star < 4
or (r.star = 4 and r.review_id < 733)
order by r.star desc, r.review_id desc
limit 15;이 방식은 중복 가능한 정렬값만 사용하는 문제를 피할 수 있고,
정렬 순서를 안정적으로 유지할 수 있습니다.
Offset Paging과 Cursor Paging, 어떤 걸 써야 할까?
두 방식은 장단점이 분명합니다.
Offset Paging은
- 구현이 쉽고
- 페이지 번호 UI와 잘 맞고
- 관리자 페이지처럼 단순 목록에 적합합니다
반면 Cursor Paging은
- 대용량 데이터에 더 유리하고
- 중간 데이터 변경에 상대적으로 안정적이고
- 무한 스크롤이나 실시간성 있는 목록에 적합합니다
즉, 어느 한쪽이 무조건 더 좋다기보다는
서비스의 성격에 따라 다르게 선택해야 한다고 느꼈습니다.
마무리
이번 글에서는 SQL에서 자주 등장하는 두 가지 개념인
JOIN과 Paging을 정리해봤습니다.
JOIN은 분리된 테이블의 데이터를 연결해서 의미 있는 결과를 만드는 데 필요한 기능이고,
Paging은 많은 데이터를 한 번에 가져오지 않고 필요한 만큼만 나눠서 조회하기 위한 방식입니다.
처음에는 문법처럼 보이지만, 실제로는 둘 다 요구사항을 데이터 조회 로직으로 바꾸는 핵심 도구라고 생각합니다.
특히 이번 내용을 정리하면서 느낀 건, SQL은 단순히 문장을 쓰는 것이 아니라 서비스 요구사항을 데이터 관점에서 해석하는 과정이라는 점이었습니다.
앞으로는 단순히 쿼리 결과만 보는 것이 아니라,
- 왜 이 테이블을 JOIN해야 하는지
- 왜 이 목록은 Paging이 필요한지
- 어떤 Paging 방식이 더 적절한지
이런 부분까지 같이 고민해보는 연습이 중요할 것 같습니다.