회원 한 명 조회가 전체 예약 41,135건을 집계하고 있었다
WHERE 1=1 파생테이블은 바깥 필터와 무관하게 테이블 전체를 materialize 합니다. 필터를 안으로 밀어 넣었습니다
운영 장애로 슬로우 쿼리 로그를 켜고 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.85s | 3.39s (×1.14) |
| 재작성 쿼리 | 0.85s | 0.30s |
인덱스 단독은 기존 쿼리에 거의 무효였습니다. 주범이 "전체 materialize" 라는 구조라서 인덱스가 건드릴 수 있는 부분이 아니었기 때문입니다. 재작성이 주범을 제거한 뒤에야 인덱스가 남은 풀스캔을 없애며 효과를 냈습니다. 느린 쿼리에 인덱스부터 던져 보고 "인덱스 무용" 이라 결론 내리기 쉬운데, 순서가 반대였던 겁니다.
검증 — 순서까지 결과의 일부
이 쿼리는 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=<테이블 전체 행수>— 파생테이블이 필터 없이 전체를 굽고 있다는 신호- 인덱스를 걸어도 거의 안 빨라짐 — 구조(전체 집계)가 주범이면 재작성이 먼저