Msa에서 Pagination 으로 로딩 속도 빠르게하는 교훈

궁금하면 500원·2025년 8월 1일

데이터 저장하기

목록 보기
23/23

6초 응답 시간을 0.3초로 단축한 Keyset Pagination의 페이징 성능 최적화

1. MSA에서 페이징 처리하다.

MSA 서비스의 게시글 목록 조회 기능은 데이터가 10만 건 이상 누적되면서 심각한 성능 저하를 겪기 시작했습니다.
특히, 오래된 게시글을 조회하는 마지막 페이지에서는 응답 시간이 6초 이상 소요되어 사용자 경험 저하와 업무 지연을 유발했습니다.

포스팅은 기존의 비효율적인 ROW_NUMBER() 기반 페이징을 커서 기반 페이징으로 전환하여, 응답 시간을 95% (6초 → 0.3초) 개선한 과정을 상세히 공유합니다.


2. ROW_NUMBER() 기반 페이징의 문제점

2.1. 문제 증상 및 심각성

페이지 위치응답 시간성능 평가
첫 페이지0.3초양호
중간 페이지 (2,500번째)2.1초느림
마지막 페이지 (5,000번째)6.0초매우 느림 (심각)

2.2. 기존 쿼리 구조

-- 기존 쿼리 : 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; -- 마지막 페이지

2.3. 성능 저하의 주요 원인

  1. 전체 데이터에 대한 정렬 :
    • ROW_NUMBER() OVER(ORDER BY ...) 함수는 페이지를 불문하고 조건에 맞는 전체 10만 건의 데이터를 메모리에서 정렬해야 합니다.
  2. OFFSET 비효율성:
    • 마지막 페이지 5,000번째 를 조회하기 위해서는 앞선 약 10만 건의 데이터를 모두 스캔해야 하므로 페이지 번호가 클수록 성능이 기하급수적으로 저하됩니다.

2.4. 실행 계획 분석

IdOperation NameRowsCost
0SELECT STATEMENT2015234
1VIEW9900015234
2WINDOW SORT PUSHED RANK9900015234
3TABLE ACCESS FULL990001234
  • Cost: 15,234
  • TABLE ACCESS FULL: 전체 테이블 스캔
  • WINDOW SORT: 전체 데이터 정렬

3. 커서 기반 페이징 으로 해결해보기

3.1. Keyset Pagination 이란것을 검색해서 해결하게되었습니다.

커서 기반 페이징은 기존의 페이지 번호나 OFFSET 대신, 이전 페이지의 마지막 행이 가진 정렬 키를 사용하여 다음 페이지의 시작 범위를 WHERE 조건으로 직접 지정하는 방식입니다.

이 방식을 사용하면 데이터베이스가 인덱스를 활용하여 필요한 범위 20건 만 우선 탐색하고 정렬하므로, 데이터가 증가하더라도 페이지 위치에 관계없이 일정한 성능을 유지할 수 있습니다.

3.2. 개선된 쿼리 구조

정렬 기준인 (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;  -- 필요한 건수만 가져옴

3.3. 핵심 개선 사항

  • 인덱스 기반 범위 스캔: WHERE 조건이 정렬 기준 복합 인덱스(cd_dt DESC, file_id DESC)를 직접 사용하도록 유도하여, 대규모 데이터 스캔을 회피합니다.
  • 전체 정렬/스캔 불필요: ROW_NUMBER나 OFFSET이 없으므로, 20건을 찾으면 즉시 종료됩니다.
  • 일정한 성능: 페이지 번호에 관계없이 다음 20건을 찾는 비용은 거의 일정합니다.

4. 구현 상세

4.1. 최적화 인덱스 생성

정렬 기준과 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);

4.2. MyBatis Mapper 수정 (커서 조건 적용)

<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 &lt; TO_DATE(#{lastdt}, 'YYYYMMDD')
            OR (t1.cd_dt = TO_DATE(#{lastdt}, 'YYYYMMDD') 
                AND t1.file_id &lt; #{lfileId})
        )
    </if>
    
    ORDER BY t1.cd_dt DESC, t1.file_id DESC
    FETCH FIRST #{rows} ROWS ONLY
</select>

4.3. Java Service 구현 (커서 값 추출)

조회된 결과에서 다음 페이지 조회를 위한 **커서(마지막 행의 정렬 키)**를 추출하여 응답에 포함합니다.

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;
}

5. 성능 테스트 결과 및 개선 효과

5.1. 테스트 환경

  • 데이터 건수: 100,000건
  • 페이지 크기: 20건
  • 테스트 페이지: 첫 페이지, 중간 페이지(2,500번째), 마지막 페이지(5,000번째)

5.2. 성능 비교

페이지 위치Before (ROW_NUMBER)After (Cursor)개선 시간개선율
첫 페이지 (1)175.36ms175.36ms0ms-
중간 페이지 (2,500)2,100ms (추정)~250ms88% 이상-
마지막 페이지 (5,000)6,000ms300ms5,700ms95% 개선

실제 로그에서는 마지막 페이지 시간이 221.46ms로 측정되어 95%를 훨씬 상회하는 압도적인 개선을 보여주었으며, 보수적으로 300ms로 가정하여도 극적인 효과를 입증했습니다.

5.3. 실행 계획 비교 (After)

IdOperation NameRowsCost
0SELECT STATEMENT2045
1INDEX RANGE SCAN2045
  • Cost: 45
  • Operation: 전체 테이블 스캔 대신 인덱스를 통한 범위 탐색으로 전환

5.4. 최종 개선 효과

지표BeforeAfter개선 내용
응답 시간6초0.3초95% 단축
쿼리15,2344599.7% 감소
처리 건수100,000건20건99.98% 감소

6. 결론 및 주의사항

6.1. 결론

커서 기반 페이징을 도입함으로써, 대규모 데이터 환경에서 가장 큰 성능 문제였던 마지막 페이지 조회 시간을 6초에서 0.3초로 극적으로 단축했습니다.
이 방식은 데이터 증가에도 불구하고 모든 페이지에서 일정한 응답 속도를 보장하여 시스템의 확장성과 사용자 경험을 크게 향상시켰습니다.

6.2. 커서 페이징의 한계 및 적용 기준

특징ROW_NUMBER / OFFSETKeyset Pagination
랜덤 접근페이지 번호로 이동순차 탐색만 가능
대용량페이지 번호에 따라 성능 저하페이지 위치에 관계없이 일정
구현 난이도쉬움정렬 키를 이용한 커서 구현 필요
  • 적용 기준: 순차적인 페이지 탐색이 주요 사용 패턴인 게시판, 무한 스크롤, 피드 등에 적합합니다.
  • 제한 사항: 특정 페이지로 바로 이동하는 랜덤 접근이 필수적인 경우에는 기존의 ROW_NUMBER 방식을 유지하거나 두 가지 방식을 혼합하여 사용하는 하이브리드 전략을 고려해야 합니다.

7. 테스트 환경 및 로그

7.1. 테스트 데이터 생성 로그

========================================
테스트 데이터 생성 시작
총 건수: 100000건
...
데이터 생성 완료!
총 소요 시간: 6.99초
========================================

7.2. Before 쿼리 측정 로그

========================================
[Before] ROW_NUMBER() 기반 페이징 (첫 페이지)
실행 시간: 390.35ms
반환 건수: 0건
========================================
profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글