
6초 응답 시간을 0.3초로 단축한 Keyset Pagination의 페이징 성능 최적화
MSA 서비스의 게시글 목록 조회 기능은 데이터가 10만 건 이상 누적되면서 심각한 성능 저하를 겪기 시작했습니다.
특히, 오래된 게시글을 조회하는 마지막 페이지에서는 응답 시간이 6초 이상 소요되어 사용자 경험 저하와 업무 지연을 유발했습니다.
포스팅은 기존의 비효율적인 ROW_NUMBER() 기반 페이징을 커서 기반 페이징으로 전환하여, 응답 시간을 95% (6초 → 0.3초) 개선한 과정을 상세히 공유합니다.
ROW_NUMBER() 기반 페이징의 문제점| 페이지 위치 | 응답 시간 | 성능 평가 |
|---|---|---|
| 첫 페이지 | 0.3초 | 양호 |
| 중간 페이지 (2,500번째) | 2.1초 | 느림 |
| 마지막 페이지 (5,000번째) | 6.0초 | 매우 느림 (심각) |
-- 기존 쿼리 : Oracle의 ROWNUM 또는 ROW_NUMBER() 사용
SELECT A2.* FROM (
SELECT ROW_NUMBER() OVER(ORDER BY cd_dt DESC, file_id DESC) row_no
, CEIL((ROW_NUMBER() OVER(...)) / 20) pagenum
, ...
, A1.* FROM (
SELECT t1.*
FROM file t1
WHERE use_yn = 'Y'
ORDER BY cd_dt DESC, file_id DESC
) A1
) A2
WHERE pagenum = 5000; -- 마지막 페이지
ROW_NUMBER() OVER(ORDER BY ...) 함수는 페이지를 불문하고 조건에 맞는 전체 10만 건의 데이터를 메모리에서 정렬해야 합니다.| Id | Operation Name | Rows | Cost |
|---|---|---|---|
| 0 | SELECT STATEMENT | 20 | 15234 |
| 1 | VIEW | 99000 | 15234 |
| 2 | WINDOW SORT PUSHED RANK | 99000 | 15234 |
| 3 | TABLE ACCESS FULL | 99000 | 1234 |
커서 기반 페이징은 기존의 페이지 번호나 OFFSET 대신, 이전 페이지의 마지막 행이 가진 정렬 키를 사용하여 다음 페이지의 시작 범위를 WHERE 조건으로 직접 지정하는 방식입니다.
이 방식을 사용하면 데이터베이스가 인덱스를 활용하여 필요한 범위 20건 만 우선 탐색하고 정렬하므로, 데이터가 증가하더라도 페이지 위치에 관계없이 일정한 성능을 유지할 수 있습니다.
정렬 기준인 (cd_dt, file_id)를 커서 값으로 활용합니다.
-- 개선된 쿼리
SELECT t1.*
FROM file t1
WHERE t1.use_yn = 'Y'
AND (
t1.cd_dt < TO_DATE(#{lastdt}, 'YYYYMMDD') -- 이전 커서보다 작거나
OR (t1.cd_dt = TO_DATE(#{lastdt}, 'YYYYMMDD') -- 같을 경우 (동일 날짜)
AND t1.file_id < #{lfileId}) -- 다음 정렬 키로 비교
)
ORDER BY t1.cd_dt DESC, t1.file_id DESC
FETCH FIRST 20 ROWS ONLY; -- 필요한 건수만 가져옴
(cd_dt DESC, file_id DESC)를 직접 사용하도록 유도하여, 대규모 데이터 스캔을 회피합니다.ROW_NUMBER나 OFFSET이 없으므로, 20건을 찾으면 즉시 종료됩니다.정렬 기준과 WHERE 조건에 맞는 복합 인덱스를 생성합니다.
-- 커서 페이징 최적화용 복합 인덱스
CREATE INDEX idx_file_cursor
ON file(cd_dt DESC, file_id DESC);
-- WHERE 조건 최적화용 인덱스 (use_yn이 선택도가 낮다면 추가 고려)
CREATE INDEX idx_file_use_yn
ON file(use_yn);
<select id="selectListCursor" parameterType="map" resultType="map">
SELECT
t1.file_id, t1.file_name, t1.original_name, t1.cd_dt, t1.irb_mastinfo
FROM file t1
WHERE t1.use_yn = 'Y'
<if test="lastdt != null and lfileId != null">
AND (
t1.cd_dt < TO_DATE(#{lastdt}, 'YYYYMMDD')
OR (t1.cd_dt = TO_DATE(#{lastdt}, 'YYYYMMDD')
AND t1.file_id < #{lfileId})
)
</if>
ORDER BY t1.cd_dt DESC, t1.file_id DESC
FETCH FIRST #{rows} ROWS ONLY
</select>
조회된 결과에서 다음 페이지 조회를 위한 **커서(마지막 행의 정렬 키)**를 추출하여 응답에 포함합니다.
public Map<String, Object> getListWithCursor(Map<String, Object> pMap) {
List<Map<String, Object>> result = mapper.selectListCursor(pMap);
Map<String, Object> response = new HashMap<>();
response.put("rows", result);
response.put("hasMore", result.size() == (Integer) pMap.get("rows"));
// 다음 페이지를 위한 커서 값 (마지막 행의 cd_dt와 file_id) 추출
if (!result.isEmpty() && result.size() == (Integer) pMap.get("rows")) {
Map<String, Object> lastRow = result.get(result.size() - 1);
Map<String, Object> mastInfo = new HashMap<>();
nextCursor.put("lastdt", lastRow.get("cd_dt"));
nextCursor.put("lfileId", lastRow.get("file_id"));
response.put("nextmastInfo", mastInfo);
}
return response;
}
| 페이지 위치 | Before (ROW_NUMBER) | After (Cursor) | 개선 시간 | 개선율 |
|---|---|---|---|---|
| 첫 페이지 (1) | 175.36ms | 175.36ms | 0ms | - |
| 중간 페이지 (2,500) | 2,100ms (추정) | ~250ms | 88% 이상 | - |
| 마지막 페이지 (5,000) | 6,000ms | 300ms | 5,700ms | 95% 개선 |
실제 로그에서는 마지막 페이지 시간이 221.46ms로 측정되어 95%를 훨씬 상회하는 압도적인 개선을 보여주었으며, 보수적으로 300ms로 가정하여도 극적인 효과를 입증했습니다.
| Id | Operation Name | Rows | Cost |
|---|---|---|---|
| 0 | SELECT STATEMENT | 20 | 45 |
| 1 | INDEX RANGE SCAN | 20 | 45 |
| 지표 | Before | After | 개선 내용 |
|---|---|---|---|
| 응답 시간 | 6초 | 0.3초 | 95% 단축 |
| 쿼리 | 15,234 | 45 | 99.7% 감소 |
| 처리 건수 | 100,000건 | 20건 | 99.98% 감소 |
커서 기반 페이징을 도입함으로써, 대규모 데이터 환경에서 가장 큰 성능 문제였던 마지막 페이지 조회 시간을 6초에서 0.3초로 극적으로 단축했습니다.
이 방식은 데이터 증가에도 불구하고 모든 페이지에서 일정한 응답 속도를 보장하여 시스템의 확장성과 사용자 경험을 크게 향상시켰습니다.
| 특징 | ROW_NUMBER / OFFSET | Keyset Pagination |
|---|---|---|
| 랜덤 접근 | 페이지 번호로 이동 | 순차 탐색만 가능 |
| 대용량 | 페이지 번호에 따라 성능 저하 | 페이지 위치에 관계없이 일정 |
| 구현 난이도 | 쉬움 | 정렬 키를 이용한 커서 구현 필요 |
ROW_NUMBER 방식을 유지하거나 두 가지 방식을 혼합하여 사용하는 하이브리드 전략을 고려해야 합니다.========================================
테스트 데이터 생성 시작
총 건수: 100000건
...
데이터 생성 완료!
총 소요 시간: 6.99초
========================================
========================================
[Before] ROW_NUMBER() 기반 페이징 (첫 페이지)
실행 시간: 390.35ms
반환 건수: 0건
========================================