안녕하세요, 여러분의 든든한 IT 길잡이 데브프리입니다.
백엔드 개발자로 일하다 보면 서비스 런칭 초기에는 전혀 문제가 없다가, 데이터가 100만 건, 1,000만 건 단위로 쌓이기 시작하면서 갑자기 특정 API가 3~5초씩 걸리는 기현상을 마주하게 됩니다. APM(성능 모니터링) 툴을 열어보면 어김없이 '게시글 목록 조회', '주문 내역 조회' 같은 페이징(Pagination) 쿼리에서 붉은색 슬로우 쿼리(Slow Query) 경보가 울리고 있죠.
"페이징 처리에 인덱스도 잘 태웠는데 도대체 왜 느려진 걸까요?"
이 현상은 우리가 무심코 사용하던 Spring Data JPA의 기본 Page 객체와 OFFSET 쿼리가 가진 치명적인 태생적 한계 때문입니다. 오늘은 대용량 트래픽과 데이터를 다루는 실무에서 오프셋(Offset) 페이징이 서버를 마비시키는 원인을 객관적으로 분석하고, 이를 타파하는 커서 기반 페이징(Cursor-based / No-Offset Pagination), 그리고 최신 Spring Boot 3.x의 Scroll API를 활용한 가장 우아한 최적화 코드를 짚어드리겠습니다.
1. 실전 트러블슈팅: 오프셋(Offset) 페이징의 덫
보통 게시판을 만들 때 Pageable을 넘겨서 아래와 같은 쿼리를 발생시킵니다.
SQL
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 1000000;
"최신 주문 순으로 정렬해서, 앞에 100만 개는 건너뛰고(OFFSET), 그다음 10개(LIMIT)만 가져와 줘!"라는 뜻이죠. 논리적으로는 아주 완벽해 보입니다.
왜 서버가 뻗었을까? (버려지는 100만 개의 데이터)
데이터베이스(MySQL, PostgreSQL 등)는 OFFSET을 처리할 때 우리가 생각하는 것처럼 100만 번째 데이터로 '마법처럼 순간이동'을 하지 못합니다.
DB 입장에서는 저 10개를 반환하기 위해, 앞에 있는 100만 개의 데이터를 일단 메모리로 전부 다 읽어 들인 다음, 100만 개를 쓰레기통에 버리고 마지막 10개만 남겨서 돌려줍니다. 만약 사용자가 무한 스크롤을 계속 내려서 뒤쪽 페이지로 갈수록, DB가 읽고 버려야 하는 데이터의 양은 기하급수적으로 늘어납니다. CPU와 디스크 I/O가 폭발하며 서버가 완전히 멈춰버리는(Stall) 병목의 주범이 바로 이것입니다.

2. 해결 베스트 프랙티스: 커서(Cursor / No-Offset) 기반 페이징
이 끔찍한 낭비를 막기 위해 실무에서 사용하는 아키텍처가 바로 커서 기반 페이징(Cursor-based Pagination), 흔히 No-Offset 페이징이라 부르는 기법입니다. (인스타그램이나 페이스북의 무한 스크롤이 모두 이 방식을 사용합니다.)
원리는 아주 단순합니다. "앞에 몇 개를 건너뛰어라"가 아니라, "내가 마지막으로 본 데이터의 고유 식별자(Cursor)를 줄 테니, 딱 그 뒤부터 10개만 줘!"라고 요청하는 것입니다.
SQL
-- 커서 기반 페이징의 쿼리 예시
SELECT * FROM orders
WHERE id < 1000000 -- 💡 클라이언트가 넘겨준 마지막 조회 ID (인덱스를 타고 즉시 이동!)
ORDER BY id DESC
LIMIT 10;
이렇게 조회하면 DB는 버려야 할 앞선 100만 개의 데이터를 읽을 필요가 전혀 없습니다. B-Tree 인덱스를 타고 id = 999999 위치로 0.001초 만에 꽂아 내려간 뒤, 거기서부터 순서대로 딱 10개만 읽고 깔끔하게 끝납니다. 페이지가 아무리 뒤로 가도 조회 속도는 항상 일정하게 유지되죠.
3. 최신 실무 트렌드: Spring Boot 3.x Scroll API 도입
과거에는 이 커서 페이징을 구현하려면 QueryDSL을 떡칠하거나 복잡한 분기문을 직접 짜야 했습니다. 하지만 Spring Data JPA 3.1 (Spring Boot 3.2+) 버전부터는 Scroll API (Keyset Scrolling)라는 이름으로 커서 기반 페이징을 공식 지원하기 시작했습니다.
이제는 복잡한 라이브러리 없이 순수 스프링 프레임워크 기능만으로 아주 우아하게 커서 페이징을 구현할 수 있습니다.
실전 코드 스니펫
Java
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Window;
public interface OrderRepository extends JpaRepository<Order, Long> {
// 💡 Page 대신 Window를, Pageable 대신 ScrollPosition을 파라미터로 받습니다.
Window<Order> findTop10ByOrderByIdDesc(ScrollPosition position);
}
Java
@Service
public class OrderService {
private final OrderRepository orderRepository;
public Window<Order> getOrders(ScrollPosition cursorPosition) {
// 클라이언트에서 커서(Keyset)를 넘겨주면, 스프링이 알아서 WHERE id < ? 쿼리를 만들어줍니다.
// 첫 요청 시에는 ScrollPosition.keyset() 을 넘겨주면 됩니다.
return orderRepository.findTop10ByOrderByIdDesc(cursorPosition);
}
}
스프링이 응답으로 돌려주는 Window 객체 안에는 데이터뿐만 아니라 positionAt(window.size() - 1) 메서드를 통해 클라이언트에게 넘겨줄 '다음 커서(Next Cursor)' 정보까지 완벽하게 패키징되어 있습니다. 프론트엔드는 이 커서 값을 저장해 두었다가 다음 API 호출 시 파라미터로 넘겨주기만 하면 됩니다.

💡 [데브프리의 심층 면접 꿀팁: 정렬 기준이 '조회수'나 '가격'이라면 벌어지는 대참사 (Tie-breaker)]
면접관이 "커서 기반 페이징에서 정렬 기준이 고유 식별자(id)가 아니라 '가격(price)'이라면 어떤 문제가 생기나요?"라고 묻는다면 100점짜리 압박 질문입니다!
가격이나 조회수는 중복될 수 있습니다. 만약 10,000원짜리 상품이 50개인데, 첫 페이지의 마지막 상품 가격이 10,000원이었다고 가정해 봅시다. 클라이언트가 price < 10000으로 다음 페이지를 요청하면 어떻게 될까요? 아직 화면에 나오지 못한 나머지 10,000원짜리 상품 40개가 통째로 누락(Skip)되어 영원히 조회되지 않는 대참사가 벌어집니다!
이를 막으려면 반드시 고유한 값(id 등)을 두 번째 정렬 기준으로 추가해 주는 'Tie-breaker(동점자 처리)' 로직이 필수입니다. 즉, ORDER BY price DESC, id DESC 처럼 복합 정렬을 걸어주어야 하며, 쿼리 속도를 유지하기 위해 데이터베이스의 인덱스 역시 (price, id)로 묶인 복합 인덱스(Composite Index)를 생성해야 한다는 점을 잊지 마세요!
4. 전문가의 확고한 결론 (Verdict)
커서 기반(No-Offset) 페이징, 실무에 언제 도입하고 언제 피해야 할까요?
- 👍 확실한 도입 타이밍:
- 모바일 앱의 무한 스크롤(Infinite Scroll) 피드, 수백만 건의 데이터를 조회해야 하는 대규모 로깅/히스토리 시스템, 모듈 간 대량의 데이터를 배치(Batch)로 끊어서 읽어가야 할 때 무조건 도입해야 합니다. 데이터가 쌓일수록 느려지는 시한폭탄인 OFFSET을 제거하고, 서버 리소스와 DB 쿼리 타임을 드라마틱하게 최적화할 수 있는 유일한 해결책입니다.
- 🛑 도입을 피해야 할 때:
- 관리자(Admin) 백오피스 페이지처럼 화면 하단에 [ 1 ] [ 2 ] [ 3 ] ... [ 10 ] 형태의 정확한 페이지 번호 네비게이션이 비즈니스 요구사항으로 강제되는 경우에는 커서 페이징을 사용할 수 없습니다. 커서 페이징은 '다음/이전'으로만 이동이 가능하며, 한 번에 100페이지를 건너뛰는 동작은 불가능하기 때문입니다. 이런 B2B 관리자 페이지에서는 기존의 OFFSET 방식을 쓰되, 전체 개수를 세는 count 쿼리를 분리하여 캐싱하거나 커버링 인덱스(Covering Index)를 태우는 방식으로 별도의 튜닝을 진행하는 것이 올바른 방향입니다.
"테스트 서버에선 빨랐는데 실서버에선 왜 이러지?"라는 고민의 80%는 DB와 쿼리의 스케일링을 고려하지 않은 설계에서 나옵니다. 오늘 알려드린 최신 Scroll API를 여러분의 프로젝트에 바로 적용해 보시고, 무한 스크롤을 내려도 0.05초 만에 응답하는 쾌적한 백엔드 API를 완성해 보시기 바랍니다.
항상 팩트에 기반한 객관적인 실무 가이드로 여러분의 삽질 시간을 줄여드리겠습니다. 다음 포스팅에서도 개발자들에게 유용한 아키텍처 꿀팁으로 찾아뵙겠습니다!
'🚀 데브 노트 (IT 지식 & 팁)' 카테고리의 다른 글
| 배포만 하면 502 에러가 뜬다고요? 스프링 부트 무중단 배포의 완성, Graceful Shutdown 완벽 가이드 (0) | 2026.08.09 |
|---|---|
| DB 부하를 줄이는 마법? 레플리케이션(Replication) 읽기/쓰기 분리와 복제 지연(Lag) 트러블슈팅 (0) | 2026.08.08 |
| 회원가입 API가 5초나 걸린다고요? @TransactionalEventListener로 스파게티 코드 분리하기 (0) | 2026.08.06 |
| API가 3초간 멈춘다고요? 스프링 부트 OSIV(Open Session In View)의 배신과 최적화 가이드 (0) | 2026.08.05 |
| 429 에러 방어막! 대용량 트래픽을 견디는 API 처리율 제한(Rate Limiting) 알고리즘 완벽 가이드 (0) | 2026.08.04 |