Skip to content

perf: replace audit log OFFSET paging with cursors - #4

Merged
Seongbin-Choo merged 2 commits into
mainfrom
audit-paging-performance
Sep 8, 2026
Merged

Seongbin-Choo merged 2 commits into
mainfrom
audit-paging-performance

Conversation

@Seongbin-Choo

Copy link
Copy Markdown
Owner

문제

관리자 감사 로그 조회는 Page 기반 OFFSET 페이징이었고, 정렬 키 (created_at, id)를 지원하는 인덱스도 없었습니다. 로컬 MariaDB 12.3.2에 짧은 마스킹 감사 로그 100만 건을 넣어 측정하니 첫 페이지부터 100만 행을 스캔했고, 별도 COUNT(*)도 매 요청마다 실행됐습니다.

작업 개선 전
OFFSET 0 232.50ms
OFFSET 100,000 270.46ms
OFFSET 500,000 295.26ms
OFFSET 999,980 333.38ms
COUNT(*) 126.46ms

판단

  1. 감사 로그는 최신 항목부터 순서대로 검토하는 용도라 임의 페이지 이동보다 깊이에 무관한 조회 비용을 우선했습니다.
  2. 요청 이력에서 검증한 것과 동일하게 (created_at, id)를 정렬·커서 키로 사용했습니다. 같은 생성 시각이 여러 건이어도 id DESC가 순서를 결정합니다.
  3. MariaDB가 복합 인덱스 범위로 처리하는 created_at < ? OR (created_at = ? AND id < ?) 형태를 사용했습니다.
  4. size + 1건만 읽어 hasNext를 판단하므로 전체 개수용 count 쿼리를 제거했습니다.
  5. 응답은 기존 요청 이력과 동일한 {items, nextCursor, hasNext}로 맞췄고 size를 1~100으로 제한했습니다.

결과

V6 적용 후 실제 엔티티 조회 칼럼과 같은 원시 SQL을 각 위치에서 7회 실행해 최소값을 기록했습니다(LIMIT 20).

위치 최소 응답 전체 스캔 정렬 인덱스 역방향 이동
첫 페이지 0.333ms 0 0 19
깊이 100,000 0.250ms 0 0 19
깊이 500,000 0.127ms 0 0 19
깊이 999,980 0.238ms 0 0 19

실제 API는 hasNext를 위해 size=20일 때 최대 21건을 읽습니다. 위 19회는 원시 SQL이 첫 위치를 찾은 뒤 추가로 이동한 횟수입니다.

API 변경

  • 이전: GET /api/v1/audit?page=...&size=... → Spring Page
  • 이후: GET /api/v1/audit?cursor=...&size=... → {items,nextCursor,hasNext}
  • 관리자 인증과 POST/PATCH 계약은 변경하지 않았습니다.
  • 현재 저장소의 플러그인과 관리자 정적 UI에는 이 GET API 소비자가 없습니다.

마이그레이션 및 Upgrade note

V6는 parsing_audit_log.created_at을 NOT NULL로 바꾸고 idx_audit_created_at_id(created_at,id)를 생성합니다.

MODIFY ... NOT NULL은 큰 MariaDB 테이블을 재구성해 잠금 또는 쓰기 지연을 만들 수 있습니다. 적용 전에 NULL 존재 여부와 백업을 확인하고 배포 창을 확보해야 합니다. 로컬 100만 건 테이블에는 NULL이 없었고 V6 두 문장 적용에 2.601초가 걸렸지만, 운영 예상치로 그대로 사용하지 않습니다.

Docker Compose MariaDB 11.4.13에서도 V6 적용, Hibernate 스키마 검증, 백엔드 헬스체크를 확인했습니다.

버린 선택지

  • OFFSET 유지 + 인덱스만 추가: 깊은 페이지에서는 앞 행을 계속 읽고 버리고, count 쿼리도 남습니다.
  • (created_at, id) < (?, ?) 행값 비교: 요청 이력 측정에서 MariaDB가 이를 효율적인 범위 조건으로 변환하지 않아 OR 형태를 유지했습니다.
  • 전체 개수 유지: 감사 로그가 누적될수록 매 요청의 count 비용이 커지므로 최신부터 순회하는 관리자 용도에서는 제외했습니다.

검증

  • ./gradlew test bootJar 성공
  • 총 61개 테스트, 실패 0
  • ParsingAuditCursorPagingTest 4개: 동점 시각 전체 순회, 잘못된 cursor, size 범위, 비숫자 size
  • H2 Flyway V1~V6 + Hibernate validate 성공
  • 로컬 MariaDB 12.3.2: 100만 행 V6/기동/실행계획 측정
  • Docker MariaDB 11.4.13: V6/기동/헬스체크 성공
  • git diff --check 성공

@Query("""
select audit from ParsingAuditLog audit
where audit.createdAt < :createdAt
or (audit.createdAt = :createdAt and audit.id < :id)

@Seongbin-Choo Seongbin-Choo Sep 8, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

행값 비교 (created_at, id) < (?, ?) 대신 OR 조건을 쓴 이유는 MariaDB 실측에서만 드러났습니다. 동일 인덱스에서도 행값 비교는 깊이만큼 이동했지만 이 형태는 type=range와 index condition으로 모든 깊이에서 19회 이동을 유지했습니다.

@@ -0,0 +1,2 @@
ALTER TABLE parsing_audit_log MODIFY created_at DATETIME(6) NOT NULL;

@Seongbin-Choo Seongbin-Choo Sep 8, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

created_at NOT NULL은 커서 정렬 키에서 NULL 행이 순회 밖으로 빠지는 것을 막지만, 대용량 MariaDB에서는 테이블 재구성이 발생할 수 있습니다. 그래서 PR Upgrade note에 NULL 사전 점검·백업·배포 창 필요성을 명시했고, 100만 행 로컬 적용 시간 2.601초도 운영 예상치로 일반화하지 않았습니다.

@Seongbin-Choo
Seongbin-Choo merged commit 4e0f002 into main Sep 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant