조인 쿼리가 갑자기 느려졌을 때 — 추정이 어긋난 첫 노드 찾기

쿼리도 인덱스도 그대로인데 리포트 쿼리가 어느 날부터 100배 느려졌어요. 조인 순서가 뒤집힌 거예요. 옵티마이저가 순서를 잘못 고른 지점을 실행 계획에서 찾아 바로잡는 순서를 담았어요.

여러 테이블을 조인하는 리포트 쿼리가 어느 날부터 100배 느려졌어요. 쿼리도 그대로고 인덱스도 그대로예요.

EXPLAIN 을 비교해 보니 조인 순서가 바뀌어 있어요. 예전에는 작은 테이블을 먼저 좁히고 큰 테이블과 만났는데, 지금은 큰 테이블부터 훑고 있어요.

옵티마이저가 순서를 정하는 기준을 모르면 여기서 손을 못 대요. 힌트로 강제하고 싶어지는데, 그건 다음에 데이터가 또 바뀌면 반대 방향으로 틀리는 선택이고요.

순서가 왜 뒤집혔는지를 봐야 해요. 그리고 그 답은 대개 조인 자체가 아니라 추정에 있어요. 옵티마이저는 통계로 크기를 예측해 순서를 정하니까요.

왜 그런가

조인의 비용은 순서에 크게 좌우돼요. 중간 결과의 크기가 순서에 따라 완전히 달라지기 때문이에요.

세 테이블을 조인한다고 해 볼게요. A 를 먼저 좁혀 100행으로 만든 뒤 B 와 조인하면 중간 결과가 작아요. 반대로 B 와 C 를 먼저 조인해서 100만 행을 만든 뒤 A 로 좁히면, 그 100만 행을 만들고 버리는 비용을 다 낸 거예요.

그래서 원칙은 "빨리 좁혀라"예요. 결과를 가장 많이 줄이는 조인을 먼저 하는 게 좋아요.

옵티마이저는 이 순서를 통계로 추정해서 정해요. 각 조인의 결과 크기를 예측하고, 가능한 순서들의 비용을 비교해서 고르죠. 테이블이 많아지면 조합이 폭발하니 일정 개수를 넘으면 완전 탐색을 포기하고 발견적 방법으로 넘어가요.

여기서 앞 편들이 다시 나와요. 추정이 틀리면 순서가 틀려요. 통계가 낡았거나, 컬럼 간 상관을 모르거나, 조건이 복잡해서 선택도를 못 잡으면 옵티마이저가 잘못된 순서를 고릅니다.

그리고 조인 알고리즘 선택도 함께 갈려요. 중첩 루프는 한쪽이 아주 작을 때 좋고, 해시 조인은 큰 것끼리 등호로 만날 때 좋고, 병합 조인은 양쪽이 정렬돼 있을 때 좋아요. 순서가 바뀌면 알고리즘도 따라 바뀝니다.

코드로 보면

추정이 순서를 정한다:

코드
EXPLAIN (ANALYZE) SELECT ...
  FROM orders o
  JOIN users u ON u.id = o.user_id
  JOIN items i ON i.order_id = o.id
 WHERE u.grade = 'vip' AND o.created_at >= '2026-08-01';

· 계획을 안쪽부터 읽으며 중간 결과 크기를 본다
 Nested Loop (rows=50)  actual rows=1200000
 → 50 을 예상하고 중첩 루프를 골랐는데 120만이 나왔다
 → 120만 번의 반복. 여기가 100배의 정체다

· 원인은 대개 추정이다 — 통계부터
 ANALYZE users; ANALYZE orders;

· 컬럼 간 상관이 있으면 알려 준다
 CREATE STATISTICS s (dependencies) ON grade, region FROM users;

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

진단 순서가 있어요.

먼저 계획에서 추정과 실제가 크게 벌어진 첫 지점을 찾으세요. 조인은 아래에서 위로 쌓이니, 아래쪽에서 어긋나면 그 위는 전부 어긋나요. 맨 위의 큰 오차를 보고 거기를 고치려 하면 헛돕니다.

그 지점의 추정이 왜 틀렸는지가 다음 질문이에요. 통계가 낡았는지, 컬럼 간 상관이 있는지, 조건이 표현식이라 추정이 안 되는지. 앞 편들에서 본 원인들이 그대로 적용돼요.

추정을 고칠 수 없는 경우도 있어요. 그때는 쿼리 구조를 바꿔서 옵티마이저가 헷갈릴 여지를 줄여요. 서브쿼리로 먼저 좁혀서 작은 결과를 만들고 그것과 조인하는 방식이요. 임시 테이블에 중간 결과를 담고 통계를 갱신한 뒤 조인하는 방법도 있어요 — 이러면 옵티마이저가 정확한 크기를 알고 시작합니다.

조인 개수가 많으면 탐색 자체가 문제예요. 일정 개수를 넘으면 옵티마이저가 완전 탐색을 포기해요. 조인이 열 개가 넘는 쿼리는 계획 품질이 불안정해지니, 쿼리를 나누는 걸 검토해야 해요.

외부 조인은 순서를 바꿀 자유가 적어요. 왼쪽 외부 조인은 의미상 순서가 고정되는 부분이 있어서 옵티마이저의 선택지가 줄어요. 그래서 불필요한 외부 조인을 내부 조인으로 바꾸면 계획이 좋아지는 경우가 있습니다.

힌트로 순서를 강제하는 건 마지막 수단이에요. 지금은 맞아도 데이터 분포가 바뀌면 최악의 선택으로 굳어요. 쓸 거면 이유와 재검토 시점을 주석에 남기세요.

처방

  1. 추정과 실제가 벌어진 첫 지점을 찾으세요. 위쪽 오차는 아래쪽 오차의 결과일 뿐입니다.
  2. 통계를 갱신하고 다시 보세요. 이것만으로 순서가 제자리를 찾는 경우가 많아요.
  3. 컬럼 간 상관이 있으면 확장 통계로 알려 주세요. 조건이 여러 개 붙는 테이블에서 특히 효과가 큽니다.
  4. 서브쿼리로 먼저 좁히세요. 작은 중간 결과를 만들어 조인하면 옵티마이저의 선택지가 단순해져요.
  5. 정말 복잡하면 임시 테이블에 중간 결과를 담고 ANALYZE 한 뒤 조인하세요. 옵티마이저가 정확한 크기를 알고 시작합니다.
  6. 조인 개수를 줄이세요. 열 개가 넘으면 계획 품질이 불안정해집니다.
  7. 불필요한 외부 조인을 내부 조인으로 바꾸세요. 옵티마이저의 순서 선택 자유가 늘어납니다.
  8. 힌트는 마지막에, 이유와 재검토 시점을 함께 남기고 쓰세요.
  9. 조인 컬럼의 타입이 양쪽 같은지 확인하세요. 정수와 문자열을 조인하면 형변환이 인덱스를 죽이고 알고리즘 선택도 나빠집니다.

지금 확인해 보세요

어긋난 첫 지점을 찾는다:

코드
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

· 안쪽(들여쓰기 깊은 곳)부터 위로 읽으며
· 추정 대비 실제가 처음 크게 벌어진 노드를 찾는다
 rows=50  actual rows=1200000  ← 여기가 원인
 그 위의 오차는 이것의 결과일 뿐이다

· 그 노드의 테이블 통계를 확인한다
SELECT relname, n_mod_since_analyze, last_analyze
  FROM pg_stat_user_tables WHERE relname IN ('users','orders');

· 조인 알고리즘도 같이 본다
 Nested Loop  — 한쪽이 아주 작을 때만 좋다
 Hash Join    — 큰 것끼리 등호로 만날 때
 Merge Join   — 양쪽이 정렬돼 있을 때

· 조인 컬럼의 타입이 같은지
 정수 ↔ 문자열 조인은 형변환으로 인덱스를 죽인다

핵심 한 줄조인은 빨리 좁히는 쪽이 이긴다. 순서가 틀렸다면 추정이 틀린 것이다.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

인덱스·쿼리 성능 글 20편 모두 보기 →