Skip to content

[alert] 내 알림 목록 조회 - 페이지네이션 부재로 전량 조회 #127

Description

@minwoo-3

문제

FetchMyAlertsService.execute()(alert/service/FetchMyAlertsService.kt:21-23)가 alertPersistencePort.findAllByUserIdOrderByCreatedAtDesc(userId)를 호출해 해당 유저의 알림을 전량 조회합니다. GraphQL 스키마(alert.graphqls)도 myAlerts: [Alert!]!로 인자가 없어, LIMIT·오프셋·커서 어느 것도 없습니다.

alert_tb는 삭제가 단건 deleteAlert뿐인 사실상 append-only 테이블이라, 오래 사용한 유저일수록 1회 조회에 읽는 행 수가 선형으로 증가합니다. 알림함을 열 때마다:

  1. 유저의 전체 알림 행을 정렬 순서로 읽고
  2. 행마다 score_tb를 nested loop로 조인 조회하고 (알림 N건이면 score 조회도 N회)
  3. N개를 전부 도메인 객체로 변환해 응답

캐싱 레이어도 없어 매 요청이 그대로 DB로 갑니다.

참고 - 쿼리 자체는 문제 없음 (#109에서 확인)

#109(오진으로 close)를 검증하며 MySQL 8.0에서 EXPLAIN을 확인한 결과:

  • idx_alert_user_id_created_at (user_id, created_at) 복합 인덱스는 이미 존재 (V4)
  • 실제 쿼리는 Backward index scan + nested loop join(score_tb eq_ref PK) 으로 실행되며 Using filesort / Using temporary 없음
  • score_tb가 비어 있을 때만 hash join이 선택돼 사후 정렬이 붙었는데, 운영처럼 score 데이터가 있으면 발생하지 않음

즉 병목은 인덱스나 정렬이 아니라 결과 집합 크기에 상한이 없다는 점입니다. 인덱스를 어떻게 바꿔도 "전량 읽기"라는 구조는 그대로입니다.

제안하는 개선

myAlerts에 커서 기반 페이지네이션 도입:

  • GraphQL: myAlerts(size: Int, cursorAlertId: ID): [Alert!]! (또는 Relay 스타일 AlertConnection)
  • AlertPersistencePort: findAllByUserIdOrderByCreatedAtDesc(userId)findPageByUserId(userId, size, cursor) 형태로 변경
  • 쿼리 조건: WHERE user_id = ? AND (created_at, alert_id) < (:cursorCreatedAt, :cursorAlertId) ORDER BY created_at DESC, alert_id DESC LIMIT :size
    • 기존 (user_id, created_at) 인덱스 + 암묵 PK로 이 범위 스캔이 그대로 커버됨 → 추가 인덱스 불필요
  • 무한 스크롤이므로 오프셋 방식보다 커서 방식이 적합 (뒤 페이지 성능 일정, 신규 알림 추가 시 항목 밀림 없음)

참고

  • 프런트엔드 무한 스크롤 대응이 필요해 프런트 협의 후 진행
  • #109에서 파생 (인덱스 이슈로 접수됐으나 실제 원인은 페이지네이션 부재)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions