donghakim.dev — zsh
← ls ../blog

회원 한 명 조회가 전체 예약 41,135건을 집계하고 있었다

WHERE 1=1 파생테이블은 바깥 필터와 무관하게 테이블 전체를 materialize 합니다. 바깥의 회원 필터를 파생테이블 안에도 추가했습니다.

backend · cover image

운영 장애로 슬로우 쿼리 로그를 활성화하고 pt-query-digest 로 순위를 집계해 보니, 마이페이지 예약목록 조회 하나가 전체 슬로우 시간의 97% 를 점유하고 있었습니다. 회당 약 3.8초가 소요됐고 Rows_examined 는 수십만이었습니다. 회원 한 명의 예약 몇십 건을 표시하는 화면입니다.

원인: 필터가 파생테이블 밖에 있었습니다

쿼리 골격은 이렇습니다. 여정 정보를 생성하는 파생테이블 S 가 있고, 바깥에서 회원 번호로 필터링합니다.

FROM (
    SELECT ..., (상관 서브쿼리 7개) ...
    FROM segment                     -- 예약 세그먼트 테이블
    WHERE 1=1                        -- ← 필터 없음
    GROUP BY book_no, book_sno
) S
JOIN booking B ON B.book_no = S.book_no ...
WHERE O.cstmr_no = ?                 -- ← 회원 필터는 여기서야 걸림

파생테이블은 바깥 WHERE 를 인지하지 못합니다. 그래서 조회 회원이 누구든 전체 예약을 GROUP BY 해서 임시 테이블로 materialize 한 뒤, 바깥 조인이 그중 해당 회원의 행만 사용하고 나머지는 폐기합니다. EXPLAIN ANALYZE 결과에서 이 동작이 그대로 확인됩니다.

-> Index lookup on S using <auto_key1>
   -> Materialize (actual time=3432..3432 rows=41135)   ← 전체 예약 41,135건
      -> Group aggregate: rows=41135
      -> Select #7~#13 (dependent subquery) loops=41135  ← 상관 서브쿼리 7개 × 전체

회원이 55건을 조회하는데 41,135건을 집계합니다. 데이터가 누적될수록 콜당 비용이 선형으로 증가하고, 동시 접속이 중첩되면 reader CPU 사용량이 함께 상승하는 구조입니다.

해결: 바깥 필터를 파생테이블 안에 넣기

파생테이블 내부 로직과 정렬은 변경하지 않고, WHERE 1=1 자리에 바깥과 동일한 동적 필터를 추가했습니다.

FROM segment OSB
WHERE (OSB.book_no, OSB.book_sno) IN (
    SELECT FB.book_no, FB.book_sno
    FROM booking FB
    LEFT JOIN orders FO ON FO.ord_no = FB.ord_no
    WHERE FB.last_txn_yn = 'Y'
      AND FO.cstmr_no = ?          -- 바깥과 같은 동적 필터를 그대로 복제
)
GROUP BY book_no, book_sno

결과가 변하지 않는 근거는 단순합니다. 남는 각 (book_no, book_sno) 그룹의 내부 행 구성은 그대로이고, 제외되는 그룹은 바깥 조인이 어차피 폐기하던 다른 회원의 그룹뿐입니다. 그룹 내부의 행을 변경하지 않으므로 집계값도, GROUP BY 대상이 아닌 컬럼을 그룹 안의 임의의 행에서 추출하는 동작도 변하지 않습니다.

한 가지 예외를 설정했습니다. 날짜 범위 필터는 파생테이블이 생성한 컬럼(S.*)에 의존해서 안으로 추가할 수 없습니다. 이런 필터는 바깥에 그대로 유지하면 됩니다. 안에 추가한 필터가 결과의 superset 을 생성하고 바깥 필터가 나머지를 제외하므로 결과가 같습니다.

인덱스만으로는 안 됐습니다

같은 쿼리에 회원→예약 탐색 인덱스 두 개도 필요했는데, 적용 순서에 따라 효과가 크게 달랐습니다.

인덱스 전인덱스 후
기존 쿼리3.85s3.39s (×1.14)
재작성 쿼리0.85s0.30s

인덱스만 추가했을 때는 기존 쿼리가 거의 빨라지지 않았습니다. 주원인이 "전체 materialize" 라는 쿼리 구조라서 인덱스로 절감할 수 있는 비용이 아니었기 때문입니다. 재작성으로 전체 materialize 를 제거한 뒤에야 인덱스가 남은 풀스캔을 제거해 0.85s 를 0.30s 로 단축했습니다. 인덱스만 먼저 적용해 보고 판단했다면 인덱스가 효과가 없다고 결론 내렸을 것입니다. 이 쿼리에서는 재작성을 먼저 하고 인덱스를 추가하는 순서가 적절했습니다.

검증: 순서까지 결과의 일부

이 쿼리는 ORDER BY 가 있어서 정렬 순서도 결과입니다. 집합 비교로는 부족해서, 원본과 재작성 결과를 정렬 없이 전체 덤프해 byte diff 했습니다. 그 전에 원본을 두 번 실행해 self-diff 로 결정성부터 확인했습니다. NOW() 에 의존하는 컬럼이 실행 시점에 따라 값이 변경되면 이는 원본 쿼리 자체의 비결정성이므로, 값을 고정한 뒤 비교해야 합니다. 왕복·유아 동반·취소·특수 좌석 같은 엣지케이스 회원과 운영 상위 20명 전건이 byte-identical 인 걸 확인하고 배포했습니다.

같은 모양이 세 군데 있었습니다

배포 후 슬로우 로그를 다시 집계하니 남은 슬로우의 91% 가 같은 매퍼의 다른 쿼리였고, 똑같은 WHERE 1=1 파생테이블이었습니다. 포인트 내역 쿼리에도 하나 더 있었습니다. 같은 pushdown 을 적용한 결과는 다음과 같습니다.

  • 예약목록 (회원 필터): 3.85s → 0.30s, 약 13배
  • 체크인 조회 (PNR 필터): 3,592ms → 2.5~5.7ms, 약 630~1,400배. PNR 이 단일 예약을 특정하는 선택도가 매우 높은 조건이라 materialize 가 41,477행 → 1행으로 감소했고, 재작성만으로 이미 51배 빨라졌습니다
  • 포인트 내역 (회원 필터): 257ms → 0.28ms, 약 900배 (러닝토탈 O(n²) 재작성 포함)

같은 패턴이 복사되어 여러 쿼리에 포함돼 있었습니다. 하나를 수정한 뒤 WHERE 1=1 과 GROUP BY 가 같이 있는 파생테이블을 grep 으로 검색하면, 슬로우 로그에 기록되기 전에 같은 패턴을 발견할 수 있습니다.

진단 시그니처

  • 특정 화면 조회가 데이터 증가에 따라 꾸준히 느려지고, Rows_examined 가 결과 행수와 자릿수부터 다릅니다.
  • EXPLAIN ANALYZE 에 Materialize rows=<테이블 전체 행수> 가 확인됩니다. 파생테이블이 필터 없이 테이블 전체를 materialize 하고 있다는 신호입니다.
  • 인덱스를 추가해도 거의 빨라지지 않습니다. 구조(전체 집계)가 주원인이면 쿼리 재작성을 먼저 해야 합니다.
#aurora#derived-table#explain-analyze#index#mysql#performance#query-optimization#troubleshooting