문제
FetchMyAlertsService.execute()(alert/service/FetchMyAlertsService.kt:21-23)가 alertPersistencePort.findAllByUserIdOrderByCreatedAtDesc(userId)를 호출해 해당 유저의 알림을 전량 조회합니다. GraphQL 스키마(alert.graphqls)도 myAlerts: [Alert!]!로 인자가 없어, LIMIT·오프셋·커서 어느 것도 없습니다.
alert_tb는 삭제가 단건 deleteAlert뿐인 사실상 append-only 테이블이라, 오래 사용한 유저일수록 1회 조회에 읽는 행 수가 선형으로 증가합니다. 알림함을 열 때마다:
- 유저의 전체 알림 행을 정렬 순서로 읽고
- 행마다
score_tb를 nested loop로 조인 조회하고 (알림 N건이면 score 조회도 N회)
- 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에서 파생 (인덱스 이슈로 접수됐으나 실제 원인은 페이지네이션 부재)
문제
FetchMyAlertsService.execute()(alert/service/FetchMyAlertsService.kt:21-23)가alertPersistencePort.findAllByUserIdOrderByCreatedAtDesc(userId)를 호출해 해당 유저의 알림을 전량 조회합니다. GraphQL 스키마(alert.graphqls)도myAlerts: [Alert!]!로 인자가 없어,LIMIT·오프셋·커서 어느 것도 없습니다.alert_tb는 삭제가 단건deleteAlert뿐인 사실상 append-only 테이블이라, 오래 사용한 유저일수록 1회 조회에 읽는 행 수가 선형으로 증가합니다. 알림함을 열 때마다:score_tb를 nested loop로 조인 조회하고 (알림 N건이면 score 조회도 N회)캐싱 레이어도 없어 매 요청이 그대로 DB로 갑니다.
참고 - 쿼리 자체는 문제 없음 (#109에서 확인)
#109(오진으로 close)를 검증하며 MySQL 8.0에서 EXPLAIN을 확인한 결과:
idx_alert_user_id_created_at (user_id, created_at)복합 인덱스는 이미 존재 (V4)Using filesort/Using temporary없음즉 병목은 인덱스나 정렬이 아니라 결과 집합 크기에 상한이 없다는 점입니다. 인덱스를 어떻게 바꿔도 "전량 읽기"라는 구조는 그대로입니다.
제안하는 개선
myAlerts에 커서 기반 페이지네이션 도입: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로 이 범위 스캔이 그대로 커버됨 → 추가 인덱스 불필요참고