OR 조건이 인덱스를 못 타는 이유 — UNION·IN 으로 나누기

검색 조건에 OR 하나를 더했을 뿐인데 쿼리가 갑자기 느려져요. AND 로 바꾸면 멀쩡하고요. 어떤 OR 은 괜찮고 어떤 OR 만 인덱스를 놓치는지, 그 기준과 고쳐 쓰는 법을 알 수 있어요.

검색 조건에 OR 하나를 추가했더니 쿼리가 갑자기 느려졌어요. 두 조건 각각은 인덱스가 잘 걸려 있고, 따로 실행하면 둘 다 밀리초 단위로 끝나요.

그런데 OR 로 묶으니 순차 스캔이에요. 있는 인덱스를 하나도 안 씁니다.

더 이상한 건 AND 로 바꾸면 정상으로 돌아온다는 거예요. 같은 컬럼, 같은 인덱스, 연산자만 바뀌었는데 계획이 완전히 달라집니다.

이 차이를 이해하면 OR 을 다루는 방법도 같이 보여요. 그리고 왜 어떤 OR 은 멀쩡하고 어떤 OR 만 문제인지도 갈립니다. 그리고 그 기준은 생각보다 단순해요 — 같은 컬럼이냐 다른 컬럼이냐입니다.

왜 그런가

인덱스는 정렬된 구조라 "한 구간"을 찾는 데 강해요. AND 는 구간을 좁히니 인덱스와 궁합이 좋아요. 조건을 더할수록 범위가 줄어드니까요.

OR 은 반대예요. 구간을 넓혀요. 그것도 서로 떨어진 여러 구간으로요.

같은 컬럼에 대한 OR 이면 그나마 나아요. status = 'a' OR status = 'b' 는 IN 과 같으니 인덱스에서 두 구간을 각각 찾으면 돼요. 옵티마이저도 대개 잘 처리합니다.

문제는 다른 컬럼에 대한 OR 이에요. name = '김' OR email = 'x@y.z' 는 두 개의 서로 다른 인덱스를 봐야 해요. 하나의 인덱스로는 절대 해결이 안 되고요.

이걸 처리하는 방법이 있긴 해요. 각 인덱스로 해당하는 행 위치를 찾아 비트맵으로 만들고, 그 비트맵들을 합집합으로 합친 뒤 테이블을 읽는 방식이요. 그런데 이건 각 조건의 결과가 충분히 적을 때만 이득이에요. 하나라도 결과가 많으면 비트맵을 만드는 비용이 커져서 옵티마이저가 순차 스캔을 고릅니다.

그래서 OR 은 "인덱스를 못 쓴다"가 아니라 "인덱스를 쓰기 어렵고, 조건 중 하나만 나빠도 전체가 무너진다"가 정확한 표현이에요.

코드로 보면

합집합은 UNION 으로 명시한다:

코드
 SELECT * FROM users
 WHERE name = '김철수' OR email = 'a@b.c';
 -- 두 인덱스를 한 번에 못 쓴다 → 순차 스캔

 SELECT * FROM users WHERE name = '김철수'
 UNION
 SELECT * FROM users WHERE email = 'a@b.c';
· 각각 자기 인덱스를 탄다. 중복 제거가 필요 없으면 UNION ALL

· 같은 컬럼이면 IN 으로 — 옵티마이저가 더 잘 다룬다
 WHERE status = 'a' OR status = 'b'
 WHERE status IN ('a', 'b')

· NULL 을 OR 로 처리하고 있다면 조건을 없애는 게 낫다
 WHERE deleted_at IS NULL OR deleted_at > now()
 컬럼 설계를 바꿔 NULL 자체를 없앤다

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

실전에서는

해결의 방향이 셋이에요.

첫째, UNION 으로 나누기. 각 가지가 자기 인덱스를 타고, 결과를 합쳐요. 중복이 없거나 상관없으면 UNION ALL 이 훨씬 싸요. 중복 제거는 정렬이나 해시를 요구하거든요. 다만 쿼리가 길어지고 정렬이 필요하면 각 가지에서 정렬해야 해서 복잡해집니다.

둘째, 조건을 없애기. OR 이 붙은 이유가 대개 있어요. "회원 검색인데 이름이나 이메일 중 아무거나" 같은 요구요. 그런데 UI 에서 무엇으로 검색하는지 알 수 있다면, 앱에서 분기해서 하나의 조건만 보내면 돼요. OR 을 SQL 이 아니라 코드에서 처리하는 거죠.

셋째, 인덱스를 조건에 맞게 다시 만들기. 같은 컬럼에 대한 OR 이면 IN 으로 바꾸고, 자주 함께 오는 OR 조합이면 표현식 인덱스나 생성 컬럼으로 하나의 조건으로 만들 수 있어요. 예를 들어 이름이나 이메일 중 아무거나 검색한다면, 둘을 합친 검색용 컬럼을 만들어 거기에 인덱스를 거는 방식이요.

주의할 게 하나 있어요. OR 조건 중 하나가 인덱스를 못 쓰는 종류면 나머지가 아무리 좋아도 소용없어요. 앞이 열린 패턴 하나가 섞여 있으면 전체가 순차 스캔이 돼요. 그래서 OR 가지 각각을 따로 실행해 보는 게 진단의 첫 단계예요.

그리고 조인 조건에 OR 이 들어가면 훨씬 나빠요. 조인 알고리즘 중 해시 조인과 병합 조인은 등호 조건이 필요한데, OR 이 섞이면 못 써요. 남는 건 중첩 루프뿐이고 그건 큰 테이블에서 재앙입니다.

처방

  1. OR 가지를 각각 따로 실행해 보세요. 하나라도 느리면 그게 원인이에요. 진단의 첫 단계입니다.
  2. 같은 컬럼에 대한 OR 은 IN 으로 바꾸세요. 옵티마이저가 훨씬 잘 다룹니다.
  3. 다른 컬럼이면 UNION 으로 나누세요. 중복 제거가 필요 없으면 UNION ALL 을 쓰세요.
  4. 앱에서 분기할 수 있는지 보세요. 검색 종류를 알 수 있다면 조건 하나만 보내는 게 가장 깔끔해요.
  5. 자주 쓰이는 OR 조합이면 생성 컬럼이나 표현식 인덱스로 하나의 조건으로 만드세요.
  6. 조인 조건에는 OR 을 넣지 마세요. 해시·병합 조인을 못 쓰게 되어 중첩 루프만 남습니다. 필요하면 조인을 나눠 UNION 하세요.
  7. NOT IN 이나 부정 조건도 같은 성격이에요. 범위를 넓히는 조건은 인덱스와 궁합이 나쁩니다.
  8. 옵티마이저가 알아서 잘 처리하는 경우도 있으니 무조건 바꾸지 말고 재 보세요. 조건들이 전부 선택도가 높으면 비트맵 합집합으로 잘 돌아갑니다.

지금 확인해 보세요

가지별로 따로 재 본다:

코드
· 1) 각 가지를 따로 실행 — 하나라도 느리면 그게 범인
EXPLAIN (ANALYZE) SELECT * FROM users WHERE name = '김철수';
EXPLAIN (ANALYZE) SELECT * FROM users WHERE email = 'a@b.c';

· 2) 합친 것과 비교
EXPLAIN (ANALYZE) SELECT * FROM users
 WHERE name = '김철수' OR email = 'a@b.c';
 Seq Scan 이면 인덱스를 하나도 못 썼다
 BitmapOr → Bitmap Heap Scan 이면 둘 다 썼다

· 3) UNION 으로 바꿨을 때
EXPLAIN (ANALYZE)
SELECT * FROM users WHERE name='김철수'
UNION ALL
SELECT * FROM users WHERE email='a@b.c';

· ORM 이 실제로 만든 SQL 을 찍어서 확인한다
 동적 필터가 OR 로 붙는 경우가 흔하다

핵심 한 줄OR 은 인덱스가 싫어하는 방향이다. 나누거나 없애라.

브루니가 적어요

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

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