OR 조건과 UNION rewrite: 인덱스 활용도를 높이는 쿼리 변형
MySQL의 OR 조건 처리, Index Merge, UNION rewrite의 의미 보존 조건과 실행 계획 검증 방법을 운영 관점에서 정리한다.
OR는 여러 업무 조건 가운데 하나만 참이면 된다는 요구를 가장 직접적으로 표현한다. 그러나 서로 다른 컬럼, 서로 다른 인덱스, 조인된 여러 테이블을 하나의 OR로 묶으면 옵티마이저가 사용할 수 있는 접근 경로가 복잡해진다. 각 조건은 단독으로 빠른데 합친 쿼리는 table scan이 되거나, Index Merge가 선택되었지만 기대만큼 빨라지지 않는 현상이 대표적이다.
이때 자주 제안되는 해법이 조건별 쿼리를 UNION 또는 UNION ALL로 분리하는 rewrite다. 이 방법은 각 branch에 독립적인 인덱스를 선택할 기회를 주지만, 잘못 적용하면 중복 row, NULL, ORDER BY, LIMIT, lock 범위가 달라진다. 따라서 “OR는 느리므로 UNION으로 바꾼다”는 규칙이 아니라 원래 결과의 집합 의미를 보존하면서 각 접근 경로의 비용을 실제 계획으로 비교하는 절차가 필요하다.
이 글은 MySQL 8.0 이상을 기준으로 OR 처리 경로, Index Merge, 안전한 UNION ALL 분해, 실행 계획 판독법과 운영 점검 절차를 설명한다.
1. OR 조건은 하나의 접근 경로로 수렴해야 한다
다음 조건을 생각해 보자.
WHERE customer_id = 4242
OR status = 'FAILED'
두 predicate는 각각 (customer_id)와 (status) 인덱스를 사용할 수 있다. 하지만 한 row가 어느 한쪽 조건만 만족해도 반환해야 하므로 MySQL은 최종적으로 두 후보 집합을 합치고, 양쪽을 모두 만족하는 row는 한 번만 반환해야 한다. 옵티마이저가 비교할 수 있는 대표 경로는 다음과 같다.
- 전체 table을 한 번 읽으면서 두 조건을 평가한다.
- 한 인덱스로 넓은 후보를 읽고 나머지 조건을 filter로 평가한다.
- 여러 인덱스의 row ID를 합치는
Index Merge를 사용한다. - 같은 컬럼의 범위라면 하나의 range access로 정규화한다.
- 개발자가 조건별 query block으로 분리한
UNION계획을 실행한다.
flowchart TD
Q[OR predicate] --> S{같은 테이블의 조건인가}
S -- 아니오 --> J[조인 순서와 각 테이블 조건을 함께 비용 계산]
S -- 예 --> C{같은 인덱스 선두 컬럼으로 묶을 수 있는가}
C -- 예 --> R[range 또는 IN 형태의 단일 접근]
C -- 아니오 --> M{Index Merge 비용이 유리한가}
M -- 예 --> I[index_merge: union 또는 sort_union]
M -- 아니오 --> T[단일 인덱스 또는 table scan 후 filter]
Q --> U[조건별 UNION rewrite 후보]
U --> P[각 branch 독립 최적화]
P --> D{중복이 가능한가}
D -- 예 --> G[UNION distinct 또는 배타 조건 필요]
D -- 아니오 --> A[UNION ALL 가능]
핵심은 OR 자체가 인덱스를 금지하는 문법은 아니라는 점이다. 같은 컬럼의 equality 조건은 IN 또는 range로 처리될 수 있고, 서로 다른 인덱스도 Index Merge로 합칠 수 있다. 문제는 조건의 선택도, 인덱스 row ID 결합 비용, base row lookup, 중복 제거 비용이 table scan보다 커질 수 있다는 데 있다.
2. MySQL이 사용하는 세 가지 대표 처리 방식
2.1 같은 컬럼의 OR는 하나의 range로 묶일 수 있다
다음 두 조건은 논리적으로 같은 결과를 표현한다.
WHERE status = 'FAILED' OR status = 'PENDING'
WHERE status IN ('FAILED', 'PENDING')
옵티마이저는 첫 번째 형태도 같은 인덱스의 여러 equality range로 다룰 수 있다. IN은 의도를 더 간결하게 보여 주지만, 문법을 바꿨다는 이유만으로 성능이 반드시 개선되는 것은 아니다. 두 SQL의 key, type, rows, 실제 실행 시간이 같은지 확인해야 한다.
2.2 서로 다른 인덱스는 Index Merge 후보가 된다
Index Merge는 한 테이블에 있는 여러 인덱스의 row ID 집합을 결합한다. Traditional EXPLAIN에서는 type=index_merge, key=ix_a,ix_b, Extra=Using union(...) 또는 Using sort_union(...) 같은 신호가 나타날 수 있다.
- union: 여러 index range가 반환하는 row ID를 합치면서 중복을 제거한다.
- sort_union: 즉시 병합하기 어려운 range의 row ID를 모아 정렬한 뒤 합친다.
- intersection: 여러 조건을 모두 만족해야 하는
AND계열에서 row ID 교집합을 구한다.
Index Merge는 여러 인덱스를 사용한다는 점에서 좋아 보이지만, 항상 최선은 아니다. secondary index마다 많은 entry를 읽고, row ID를 합치고, 최종 row를 InnoDB clustered index에서 다시 찾는 작업이 필요하다. 반환 범위가 넓거나 row가 크고 buffer pool hit ratio가 낮으면 순차적인 table scan보다 비쌀 수 있다.
2.3 UNION rewrite는 query block을 분리한다
UNION rewrite의 가장 큰 변화는 “인덱스를 강제로 두 개 쓴다”가 아니다. 하나의 query block 안에서 결합하던 조건을 여러 query block으로 나누어 각 branch의 join order와 access path를 독립적으로 최적화하게 만드는 것이다.
SELECT ... FROM orders WHERE customer_id = 4242
UNION ALL
SELECT ... FROM orders WHERE status = 'FAILED' AND customer_id <> 4242;
첫 branch는 (customer_id), 두 번째 branch는 (status)를 선택할 수 있다. 두 번째에 배타 조건을 둔 이유는 양쪽 조건을 모두 만족하는 row를 한 번만 반환해 원래 OR 의미를 보존하기 위해서다.
3. 재현용 데이터 준비
다음 예제는 절대 성능 benchmark가 아니라 access path와 중복 의미를 관찰하기 위한 축소 실험이다. 주문 10,000건 가운데 FAILED는 200건, PENDING은 800건, 나머지는 PAID가 되도록 구성한다. customer_id는 예제 안에서 고유하지만 운영 schema의 업무 제약을 의미하지는 않는다.
DROP TABLE IF EXISTS or_orders;
CREATE TABLE or_orders (
order_id BIGINT NOT NULL PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(12) NOT NULL,
region VARCHAR(12) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
ordered_at DATETIME NOT NULL,
KEY ix_customer (customer_id),
KEY ix_status (status),
KEY ix_region_date (region, ordered_at)
) ENGINE=InnoDB;
SET SESSION cte_max_recursion_depth = 10000;
INSERT INTO or_orders
(order_id, customer_id, status, region, amount, ordered_at)
WITH RECURSIVE seq AS (
SELECT 1 AS n
UNION ALL
SELECT n + 1 FROM seq WHERE n < 10000
)
SELECT n,
n,
CASE
WHEN MOD(n, 50) = 0 THEN 'FAILED'
WHEN MOD(n, 10) = 0 THEN 'PENDING'
ELSE 'PAID'
END,
CASE MOD(n, 4)
WHEN 0 THEN 'SEOUL'
WHEN 1 THEN 'BUSAN'
WHEN 2 THEN 'DAEJEON'
ELSE 'GWANGJU'
END,
MOD(n * 37, 50000) / 100,
TIMESTAMP('2026-01-01 00:00:00') + INTERVAL n MINUTE
FROM seq;
ANALYZE TABLE or_orders;
SELECT COUNT(*) AS total_rows,
SUM(status = 'FAILED') AS failed_rows,
SUM(status = 'PENDING') AS pending_rows
FROM or_orders;
실행 결과(MySQL 8.0.x):
mysql> CREATE TABLE or_orders (...);
Query OK, 0 rows affected (0.00 sec)
mysql> SET SESSION cte_max_recursion_depth = 10000;
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO or_orders (...) WITH RECURSIVE seq AS (...) SELECT ...;
Query OK, 10000 rows affected (0.07 sec)
Records: 10000 Duplicates: 0 Warnings: 0
mysql> ANALYZE TABLE or_orders;
+---------------------------+---------+----------+----------+
| Table | Op | Msg_type | Msg_text |
+---------------------------+---------+----------+----------+
| mysql_tech_note.or_orders | analyze | status | OK |
+---------------------------+---------+----------+----------+
1 row in set (0.01 sec)
mysql> SELECT COUNT(*) AS total_rows,
-> SUM(status = 'FAILED') AS failed_rows,
-> SUM(status = 'PENDING') AS pending_rows
-> FROM or_orders;
+------------+-------------+--------------+
| total_rows | failed_rows | pending_rows |
+------------+-------------+--------------+
| 10000 | 200 | 800 |
+------------+-------------+--------------+
1 row in set (0.00 sec)
이 데이터에서는 customer_id=4242가 한 건이고, 해당 row의 status는 PAID다. 반면 customer_id=5000은 FAILED이므로 두 조건이 겹치는 중복 사례로 사용할 수 있다. 통계 표본 때문에 EXPLAIN rows 추정치는 검증할 때마다 조금 달라질 수 있으므로, 이후 출력에서는 access type과 선택된 key를 중심으로 읽는다.
4. 서로 다른 컬럼의 OR와 Index Merge 확인
먼저 원래 OR 조건의 계획과 결과 건수를 확인한다.
EXPLAIN
SELECT order_id, customer_id, status, amount
FROM or_orders
WHERE customer_id = 4242
OR status = 'FAILED';
SELECT COUNT(*) AS matched_rows
FROM or_orders
WHERE customer_id = 4242
OR status = 'FAILED';
실행 결과(MySQL 8.0.x):
mysql> EXPLAIN
-> SELECT order_id, customer_id, status, amount
-> FROM or_orders
-> WHERE customer_id = 4242
-> OR status = 'FAILED';
+----+-------------+-----------+------------+-------------+-----------------------+-----------------------+---------+------+------+----------+-------------------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+-------------+-----------------------+-----------------------+---------+------+------+----------+-------------------------------------------------+
| 1 | SIMPLE | or_orders | NULL | index_merge | ix_customer,ix_status | ix_customer,ix_status | 8,50 | NULL | 201 | 100.00 | Using union(ix_customer,ix_status); Using where |
+----+-------------+-----------+------------+-------------+-----------------------+-----------------------+---------+------+------+----------+-------------------------------------------------+
1 row in set (0.00 sec)
mysql> SELECT COUNT(*) AS matched_rows
-> FROM or_orders
-> WHERE customer_id = 4242
-> OR status = 'FAILED';
+--------------+
| matched_rows |
+--------------+
| 201 |
+--------------+
1 row in set (0.01 sec)
이 재현 환경에서는 (customer_id)와 (status)가 모두 후보가 된다. type=index_merge와 Using union(ix_customer,ix_status)가 보이면 두 secondary index에서 row ID를 얻어 합친 뒤 base row를 조회하는 계획이다. 다만 데이터 크기, 통계, projection과 MySQL minor version에 따라 table scan 또는 다른 계획이 선택될 수 있다. Index Merge 출현 자체를 성공 기준으로 삼아서는 안 되며, 실제 환경에서는 EXPLAIN ANALYZE로 iterator별 actual rows와 시간을 확인해야 한다.
Index Merge에서 특히 살펴볼 비용은 다음과 같다.
- 각 index range가 읽는 entry 수
- 두 집합의 overlap과 중복 제거량
- clustered index로 돌아가 base row를 읽는 횟수
- 넓은 projection 때문에 발생하는 random page access
- 추정 선택도와 실제 분포의 차이
5. 배타 조건을 둔 UNION ALL rewrite
다음 rewrite는 첫 branch의 조건을 두 번째 branch에서 제외한다. 따라서 두 branch는 서로 배타적이며 UNION ALL을 사용해도 중복이 생기지 않는다.
EXPLAIN
SELECT order_id, customer_id, status, amount
FROM or_orders
WHERE customer_id = 4242
UNION ALL
SELECT order_id, customer_id, status, amount
FROM or_orders
WHERE status = 'FAILED'
AND customer_id <> 4242;
SELECT COUNT(*) AS rewritten_rows
FROM (
SELECT order_id
FROM or_orders
WHERE customer_id = 4242
UNION ALL
SELECT order_id
FROM or_orders
WHERE status = 'FAILED'
AND customer_id <> 4242
) AS rewritten;
실행 결과(MySQL 8.0.x):
mysql> EXPLAIN
-> SELECT order_id, customer_id, status, amount
-> FROM or_orders
-> WHERE customer_id = 4242
-> UNION ALL
-> SELECT order_id, customer_id, status, amount
-> FROM or_orders
-> WHERE status = 'FAILED'
-> AND customer_id <> 4242;
+----+-------------+-----------+------------+------+-----------------------+-------------+---------+-------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+------+-----------------------+-------------+---------+-------+------+----------+-------------+
| 1 | PRIMARY | or_orders | NULL | ref | ix_customer | ix_customer | 8 | const | 1 | 100.00 | NULL |
| 2 | UNION | or_orders | NULL | ref | ix_customer,ix_status | ix_status | 50 | const | 200 | 100.00 | Using where |
+----+-------------+-----------+------------+------+-----------------------+-------------+---------+-------+------+----------+-------------+
2 rows in set (0.00 sec)
mysql> SELECT COUNT(*) AS rewritten_rows
-> FROM (
-> SELECT order_id
-> FROM or_orders
-> WHERE customer_id = 4242
->
-> UNION ALL
->
-> SELECT order_id
-> FROM or_orders
-> WHERE status = 'FAILED'
-> AND customer_id <> 4242
-> ) AS rewritten;
+----------------+
| rewritten_rows |
+----------------+
| 201 |
+----------------+
1 row in set (0.00 sec)
첫 branch는 ix_customer, 두 번째 branch는 ix_status를 독립적으로 선택할 수 있다. 원래 SQL과 rewrite의 건수는 모두 201건이어야 한다. 그러나 같은 건수만으로 완전한 동치성을 입증할 수는 없다. 운영 변경 전에는 primary key 집합의 양방향 차집합, NULL 처리, 중복 허용 여부까지 검사해야 한다.
배타 조건 방식은 조건이 두 개일 때는 단순하지만 조건이 많아지면 다음 branch마다 앞선 조건의 부정을 누적해야 한다.
A
UNION ALL
B AND NOT A
UNION ALL
C AND NOT A AND NOT B
복잡한 predicate의 부정을 직접 전개하면 NULL의 3-valued logic 때문에 오류가 생기기 쉽다. 이 경우 명확성을 위해 UNION의 distinct 처리를 사용하거나, primary key만 합친 뒤 원본 table을 다시 조인하는 구조를 검토할 수 있다.
6. UNION ALL 중복 함정은 결과 오류를 만든다
OR는 한 row가 양쪽 조건을 모두 만족해도 한 번만 반환한다. 반면 배타 조건 없는 UNION ALL은 branch별 결과를 그대로 이어 붙이므로 같은 row를 두 번 반환한다. customer_id=5000은 FAILED이므로 차이가 명확히 드러난다.
SELECT COUNT(*) AS original_or_rows
FROM or_orders
WHERE customer_id = 5000
OR status = 'FAILED';
SELECT COUNT(*) AS naive_union_all_rows
FROM (
SELECT order_id
FROM or_orders
WHERE customer_id = 5000
UNION ALL
SELECT order_id
FROM or_orders
WHERE status = 'FAILED'
) AS naive_union_all;
SELECT COUNT(*) AS guarded_union_all_rows
FROM (
SELECT order_id
FROM or_orders
WHERE customer_id = 5000
UNION ALL
SELECT order_id
FROM or_orders
WHERE status = 'FAILED'
AND customer_id <> 5000
) AS guarded_union_all;
실행 결과(MySQL 8.0.x):
mysql> SELECT COUNT(*) AS original_or_rows
-> FROM or_orders
-> WHERE customer_id = 5000
-> OR status = 'FAILED';
+------------------+
| original_or_rows |
+------------------+
| 200 |
+------------------+
1 row in set (0.00 sec)
mysql> SELECT COUNT(*) AS naive_union_all_rows
-> FROM (
-> SELECT order_id
-> FROM or_orders
-> WHERE customer_id = 5000
->
-> UNION ALL
->
-> SELECT order_id
-> FROM or_orders
-> WHERE status = 'FAILED'
-> ) AS naive_union_all;
+----------------------+
| naive_union_all_rows |
+----------------------+
| 201 |
+----------------------+
1 row in set (0.00 sec)
mysql> SELECT COUNT(*) AS guarded_union_all_rows
-> FROM (
-> SELECT order_id
-> FROM or_orders
-> WHERE customer_id = 5000
->
-> UNION ALL
->
-> SELECT order_id
-> FROM or_orders
-> WHERE status = 'FAILED'
-> AND customer_id <> 5000
-> ) AS guarded_union_all;
+------------------------+
| guarded_union_all_rows |
+------------------------+
| 200 |
+------------------------+
1 row in set (0.00 sec)
원래 OR와 배타 조건을 둔 rewrite는 200건이지만, 단순 UNION ALL은 order_id=5000을 두 번 포함해 201건이 된다. 이것은 단순한 성능 차이가 아니라 조회 결과의 정확성 결함이다. 합계, 페이지 수, 후속 join에서 오류가 더 크게 증폭될 수 있다.
6.1 UNION은 중복을 제거하지만 비용이 무료가 아니다
UNION은 기본적으로 UNION DISTINCT이며 전체 projection이 같은 row를 중복 제거한다. 다음 차이를 구분해야 한다.
- 원래
OR는 동일한 base row를 한 번 반환한다. UNION은 두 branch가 만든 전체 select list 값이 같은 결과 row를 하나로 줄인다.
Projection에 따라 서로 다른 base row가 우연히 같은 값을 가지면 UNION은 원래 OR보다 적은 row를 반환할 수 있다. 예를 들어 SELECT status만 반환하는 두 branch를 UNION으로 합치면 같은 status 값이 대량으로 축약된다. 안전하게 base row identity를 보존하려면 primary key를 결과에 포함하고 최종 projection 시점을 신중히 설계해야 한다.
또한 distinct 처리는 temporary table, hash 또는 sort 성격의 중복 제거 작업을 요구한다. 중간 결과가 크면 CPU, 메모리와 임시 저장 공간 사용이 늘어난다. 따라서 중복이 불가능함을 업무 규칙과 predicate로 입증할 수 있을 때만 UNION ALL이 자연스러운 선택이다.
7. 같은 컬럼의 OR는 먼저 IN과 range 계획을 확인한다
같은 컬럼에 대한 equality 조건을 무조건 여러 branch로 나누면 query block과 중간 결과 처리만 늘어날 수 있다. 다음 두 SQL의 계획을 비교한다.
EXPLAIN
SELECT order_id, status
FROM or_orders
WHERE status = 'FAILED'
OR status = 'PENDING';
EXPLAIN
SELECT order_id, status
FROM or_orders
WHERE status IN ('FAILED', 'PENDING');
실행 결과(MySQL 8.0.x):
mysql> EXPLAIN
-> SELECT order_id, status
-> FROM or_orders
-> WHERE status = 'FAILED'
-> OR status = 'PENDING';
+----+-------------+-----------+------------+-------+---------------+-----------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+-------+---------------+-----------+---------+------+------+----------+--------------------------+
| 1 | SIMPLE | or_orders | NULL | range | ix_status | ix_status | 50 | NULL | 1000 | 100.00 | Using where; Using index |
+----+-------------+-----------+------------+-------+---------------+-----------+---------+------+------+----------+--------------------------+
1 row in set (0.00 sec)
mysql> EXPLAIN
-> SELECT order_id, status
-> FROM or_orders
-> WHERE status IN ('FAILED', 'PENDING');
+----+-------------+-----------+------------+-------+---------------+-----------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-----------+------------+-------+---------------+-----------+---------+------+------+----------+--------------------------+
| 1 | SIMPLE | or_orders | NULL | range | ix_status | ix_status | 50 | NULL | 1000 | 100.00 | Using where; Using index |
+----+-------------+-----------+------------+-------+---------------+-----------+---------+------+------+----------+--------------------------+
1 row in set (0.00 sec)
두 SQL 모두 ix_status를 사용하는 range access가 가능하다. 이런 형태는 UNION ALL로 나누기 전에 단일 인덱스 range가 이미 충분히 효율적인지 확인하는 것이 우선이다. IN은 조건 추가와 코드 생성이 쉬우며, 한 query block에서 정렬·제한을 적용하기도 단순하다.
다만 IN 목록이 매우 길거나 선택도가 높으면 range access가 항상 유리한 것은 아니다. 옵티마이저가 scan을 선택할 수 있고, 준비 문장과 plan cache, 애플리케이션의 최대 parameter 수, SQL text 크기도 함께 고려해야 한다.
8. rewrite 전후 동치성을 검증하는 방법
건수만 비교하면 같은 개수의 다른 row가 섞인 오류를 놓칠 수 있다. MySQL에는 일반적인 EXCEPT 연산자를 전제로 하지 않고도 primary key 기준 양방향 누락을 확인할 수 있다. 다음은 구조 예시이며 실제 table과 predicate에 맞게 조정해야 한다.
WITH original AS (
SELECT order_id
FROM orders
WHERE condition_a OR condition_b
),
rewritten AS (
SELECT order_id FROM orders WHERE condition_a
UNION ALL
SELECT order_id FROM orders WHERE condition_b AND NOT(condition_a)
)
SELECT 'original_only' AS difference_type, o.order_id
FROM original AS o
LEFT JOIN rewritten AS r ON r.order_id = o.order_id
WHERE r.order_id IS NULL
UNION ALL
SELECT 'rewritten_only', r.order_id
FROM rewritten AS r
LEFT JOIN original AS o ON o.order_id = r.order_id
WHERE o.order_id IS NULL;
검증 시에는 다음 항목을 별도로 확인한다.
- 전체 row count가 같은가?
- Primary key 집합의 양방향 차이가 0건인가?
- 원래 결과가 bag semantics상 중복을 허용했다면 key별 발생 횟수도 같은가?
- Predicate에 nullable 컬럼이 있다면
NOT(condition_a)가 의도대로 동작하는가? ORDER BY와LIMIT가 있다면 전역 적용과 branch별 적용을 혼동하지 않았는가?- Select list의 계산식, collation, implicit conversion이 branch 사이에서 같은가?
8.1 NULL에서는 단순 부정이 배타 집합을 만들지 못할 수 있다
SQL에서 NOT(NULL = 1)은 TRUE가 아니라 UNKNOWN이다. 따라서 두 번째 branch에 AND NOT(condition_a)를 붙이는 방식은 nullable predicate에서 원래 OR와 다르게 row를 제거할 수 있다. MySQL의 null-safe equality 연산자 <=>, 명시적인 IS NULL 분기 또는 UNION DISTINCT가 필요한지 검토해야 한다.
예를 들어 email = 'a@example.com' OR phone = '010...'를 분리할 때 email <> 'a@example.com'만 추가하면 email IS NULL인 row가 두 번째 branch에서 제외된다. 배타 조건은 다음처럼 원래 조건의 3-valued logic을 정확히 반영해야 한다.
WHERE phone = '010...'
AND (email <> 'a@example.com' OR email IS NULL)
9. ORDER BY, LIMIT, pagination에서의 주의점
원래 쿼리가 전역 상위 N건을 요구한다면 각 branch에 LIMIT N을 붙인 뒤 결과를 이어 붙이는 것만으로는 충분하지 않다. 최종 결과 전체에 다시 ORDER BY ... LIMIT N을 적용해야 한다.
SELECT ...
FROM (
(SELECT ... WHERE condition_a ORDER BY ordered_at DESC LIMIT 100)
UNION ALL
(SELECT ... WHERE condition_b AND NOT(condition_a)
ORDER BY ordered_at DESC LIMIT 100)
) AS u
ORDER BY ordered_at DESC, order_id DESC
LIMIT 100;
Branch별 LIMIT 100은 전역 상위 100건을 만들기 위한 후보 수를 줄이는 최적화가 될 수 있지만, 모든 경우에 안전한 것은 아니다. 정렬 key, 동률 처리, branch 수와 배타성에 따라 필요한 후보 개수가 달라진다. 최종 ordering에 안정적인 tie-breaker인 primary key를 포함하지 않으면 페이지 사이에서 row가 누락되거나 중복될 수 있다.
Seek pagination도 같은 의미 검증이 필요하다. 각 branch가 (ordered_at, order_id) cursor 조건을 정확히 적용하고, 합친 뒤 같은 정렬 순서로 제한해야 한다. Offset pagination을 branch별로 각각 적용하면 원래 전역 offset과 다른 결과가 된다.
10. 조인과 OR에서는 rewrite 효과와 위험이 더 커진다
다음처럼 서로 다른 테이블의 조건을 묶은 OR는 한 테이블 안의 Index Merge로 해결할 수 없다.
SELECT o.order_id
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE o.status = 'FAILED'
OR c.vip_flag = 1;
orders.status와 customers.vip_flag는 각각 인덱스가 있어도 join order와 outer row 보존을 동시에 고려해야 한다. UNION으로 분리하면 각 branch가 다른 driving table을 선택할 수 있어 큰 개선이 가능하지만, VIP 고객의 실패 주문은 양쪽 branch에 모두 나타난다. 중복 제거와 join cardinality를 반드시 검증해야 한다.
Outer join에서는 더 조심해야 한다. LEFT JOIN의 ON 조건과 WHERE 조건을 이동하면 null-complemented row가 사라져 inner join처럼 변할 수 있다. 성능 rewrite 전에 원래 join의 보존 측과 filter 적용 시점을 문서화해야 한다.
DML도 단순 치환 대상이 아니다. 하나의 UPDATE ... WHERE A OR B를 두 UPDATE로 나누면 양쪽 조건을 만족하는 row가 두 번 갱신될 수 있고, 두 statement 사이에 다른 transaction이 개입하며, lock 획득 순서와 binlog event도 달라진다. 조회 rewrite에서 확인한 기법을 DML에 그대로 적용해서는 안 된다.
11. 실행 계획과 런타임을 읽는 순서
11.1 access type과 key를 비교한다
Traditional EXPLAIN에서 다음을 나란히 기록한다.
| 항목 | 원래 OR | UNION branch에서 볼 의미 |
|---|---|---|
type |
ALL, range, index_merge 여부 |
각 branch가 ref, range, const로 좁혀지는가 |
key |
결합된 인덱스 또는 단일 인덱스 | predicate별 의도한 인덱스가 선택되는가 |
rows |
전체 후보 추정치 | branch별 추정치 합과 중복 규모가 합리적인가 |
filtered |
residual predicate 비율 | 인덱스 후 많은 row를 버리는 branch가 있는가 |
Extra |
Using union, Using sort_union, Using where |
temporary, filesort, residual filter가 추가되는가 |
rows는 추정치이며 UNION 결과 row 수와 같지 않다. 통계가 부정확하거나 컬럼 사이에 상관관계가 있으면 OR의 합집합 선택도 추정이 크게 틀릴 수 있다.
11.2 EXPLAIN ANALYZE는 실제로 쿼리를 실행한다
읽기 전용 검증 환경에서 EXPLAIN ANALYZE를 사용하면 iterator별 actual rows, loops와 시간을 확인할 수 있다. 운영에서 바로 실행하기 전 다음을 확인한다.
- 쿼리가 끝까지 실행되어도 안전한가?
- 예상보다 넓은 scan이 발생해도 허용 가능한가?
- Buffer pool warm/cold 상태를 구분했는가?
- 한 번의 수치가 아니라 여러 실행의 분포를 비교했는가?
- 결과 전송 비용을 제외하거나 동일하게 통제했는가?
비교할 때는 총 latency 하나만 보지 말고 각 branch의 actual rows, base table lookup 수, sort와 temporary 작업을 함께 읽는다. 테스트 데이터가 너무 작으면 optimizer 선택과 I/O 특성이 운영과 다르므로 운영과 유사한 데이터 분포를 가진 staging 또는 production clone에서 재검증해야 한다.
11.3 Performance Schema로 후보를 찾는다
events_statements_summary_by_digest의 SUM_ROWS_EXAMINED, SUM_ROWS_SENT, COUNT_STAR, 총 실행 시간을 사용하면 많은 row를 읽고 적게 반환하는 SQL을 좁힐 수 있다. 다만 digest에는 literal이 정규화되므로 특정 OR 값의 선택도 차이를 그대로 보여 주지 않는다. 후보를 찾은 뒤 실제 bind value 분포, 실행 계획과 application 호출 문맥을 함께 확보해야 한다.
12. 실패 패턴과 흔한 오해
12.1 “OR는 인덱스를 사용하지 못한다”
같은 컬럼의 range, Index Merge, 일부 조건 단순화로 인덱스를 사용할 수 있다. 실제 EXPLAIN 없이 문법만 보고 판단하면 불필요하게 SQL을 복잡하게 만든다.
12.2 “Index Merge가 보이면 최적화가 끝났다”
여러 secondary index scan과 row ID 결합, clustered lookup 비용이 크면 table scan보다 느릴 수 있다. Using union(...)은 실행 방식의 신호이지 성능 보증이 아니다.
12.3 “UNION ALL이 UNION보다 빠르므로 항상 사용한다”
중복 가능성을 제거하지 않은 UNION ALL은 잘못된 결과를 만든다. 반대로 UNION은 projection 전체를 기준으로 중복 제거하므로 원래 base row 의미를 훼손할 수 있다. 먼저 결과 의미를 결정하고 그다음 비용을 비교해야 한다.
12.4 “각 branch가 빠르면 전체 쿼리도 빠르다”
최종 sort, distinct, temporary table, 네트워크 전송과 base table 재조회가 전체 비용을 지배할 수 있다. Branch만 따로 benchmark하지 말고 최종 SQL 전체를 측정한다.
12.5 FORCE INDEX로 계획을 고정한다
FORCE INDEX는 비교 실험에는 유용하지만 데이터 분포와 버전이 변하면 잘못된 경로를 고정한다. 통계, predicate, composite index와 query shape를 먼저 개선하고, hint에는 적용 근거와 제거 조건을 함께 관리한다.
12.6 낮은 선택도 조건을 무조건 분리한다
status <> 'DONE'처럼 대부분의 row를 반환하는 branch는 인덱스를 사용해도 대량 random lookup을 만들 수 있다. 이런 조건은 scan이 더 효율적일 수 있으며 UNION으로 분리한다고 본질적인 읽기량이 줄지 않는다.
13. Aurora MySQL에서의 운영 해석
Aurora MySQL도 호환 MySQL 옵티마이저를 사용하므로 OR, range access, Index Merge, UNION의 기본 의미는 같다. 다만 운영 판단에는 다음 차이를 반영해야 한다.
- 정확한 계획과 optimizer 개선 사항은 Aurora MySQL engine major/minor version에 종속된다. Community MySQL의 재현 결과를 그대로 가정하지 말고 대상 cluster version에서 확인한다.
- Reader instance에서 비교하면 writer 부하를 피할 수 있지만 replica lag, buffer cache 상태와 인스턴스 class 차이로 결과가 달라질 수 있다.
- Performance Insights는 상위 SQL과 wait를 찾는 데 유용하지만
ORbranch별 access path는EXPLAIN과EXPLAIN ANALYZE로 확인해야 한다. - UNION의 sort·distinct·materialization이 커지면 local temporary storage와 메모리 압력을 관찰한다. 분산 storage 계층이 모든 임시 작업을 대신 처리하는 것은 아니다.
- Parameter group에서 optimizer 관련 설정을 바꾸기 전에 dynamic/static 여부, cluster/instance 범위와 재부팅 필요 여부를 확인한다. 비교 실험은 우선 session 범위에서 수행한다.
- Failover 뒤에는 buffer cache와 workload 배치가 달라질 수 있으므로 단일 시점 latency만으로 rewrite 효과를 확정하지 않는다.
14. 운영 변경 점검표
결과 의미
- 원래
OR -
UNION ALL - Nullable 컬럼에서
NOT(condition)의UNKNOWN -
UNION
실행 계획과 인덱스
- 원래 SQL의
ALL,range,index_merge와Extra - 각 UNION branch가 독립적으로 적절한
key -
Index Merge - 같은 컬럼의
OR는 먼저IN
정렬·제한·조인
-
ORDER BY와LIMIT
배포와 관찰
-
EXPLAIN ANALYZE
15. 정리
OR 최적화의 본질은 문법을 없애는 것이 아니라 여러 후보 집합을 어떤 비용으로 만들고 합칠지 결정하는 데 있다. 같은 인덱스의 조건은 하나의 range로 수렴할 수 있고, 서로 다른 인덱스는 Index Merge로 결합될 수 있다. UNION rewrite는 조건별 query block을 분리하여 독립적인 access path를 열어 주지만 중복 제거, NULL, 정렬, 제한과 join 의미를 새로 책임져야 한다.
실무에서는 먼저 원래 계획의 type, key, Extra와 실제 선택도를 확인한다. 그다음 IN, composite index, Index Merge, 배타적인 UNION ALL, UNION DISTINCT를 결과 동치성 검증과 함께 비교한다. 가장 빠른 SQL보다 먼저 필요한 것은 정확히 같은 결과를 반환하는 SQL이며, 성능 판단은 운영과 유사한 데이터에서 최종 query 전체를 측정해 내려야 한다. 다음 기술노트에서는 여러 predicate의 선택도와 컬럼 상관관계가 cardinality 추정 및 access path 선택에 어떤 오차를 만드는지 이어서 살펴본다.