MySQL EXISTS는 정말 모든 행을 확인할까?
쿼리를 작성하다 보면 이런 코드를 만날 때가 있다.
SELECT u.id, u.name, u.email, u.phone, u.created_at
FROM user u
WHERE u.user_type = 2
AND u.is_deleted = 0
AND NOT EXISTS (
SELECT 1 FROM task t
WHERE t.assigned_user_id = u.id
);
태스크가 할당되지 않은 유저를 찾는 쿼리다. 그런데 EXISTS 를 보면 뭔가 찜찜하다. "존재하는지 확인한다"는 의미니까, 혹시 user 테이블의 모든 행에 대해 task 테이블을 뒤지는 건 아닐까? 테이블 크기가 크면 엄청 느려지는 거 아닐까?
결론부터 말하자면, 그렇지 않다. EXISTS는 생각보다 똑똑하게 동작한다.
EXISTS가 실제로 동작하는 방식
많은 사람들이 오해하는 부분이 바로 이거다. EXISTS가 user 테이블의 모든 행 에 대해 실행된다고 생각하는 것. 하지만 실제로는 WHERE 절의 다른 조건들이 먼저 필터링을 한다.
위 쿼리의 실행 순서를 생각해보자.
u.user_type = 2조건으로 user 테이블을 필터링u.is_deleted = 0조건으로 추가 필터링- 남은 행들에 대해서만 EXISTS 서브쿼리 실행
만약 user 테이블에 100만 건이 있는데, user_type = 2이고 is_deleted = 0인 유저가 1000명이라면? EXISTS는 1000번만 실행된다. 100만 번이 아니라.
이게 가능한 이유는 MySQL 옵티마이저가 WHERE 절의 조건들을 효율적인 순서로 재배치하고, 가능한 한 빨리 행을 걸러내기 때문이다. 인덱스가 잘 걸려있다면 user_type과 is_deleted 조건으로 대부분의 행을 빠르게 제외할 수 있다.
Short-circuit 평가의 힘
EXISTS의 또 다른 장점은 short-circuit 평가를 한다는 것이다. 서브쿼리에서 첫 번째 매칭되는 행을 찾으면 즉시 true를 반환하고 나머지는 확인하지 않는다.
NOT EXISTS (
SELECT 1 FROM task t
WHERE t.assigned_user_id = u.id
)
이 서브쿼리는 assigned_user_id = u.id인 task가 하나라도 있는지만 확인하면 된다. task가 0개면 false, 1개 이상이면 true. 그래서 SELECT 1을 쓰는 것이다. 실제 데이터가 필요한 게 아니라 존재 여부만 확인하면 되니까.
IN 절과 비교해보면 차이가 명확하다.
-- IN은 모든 값을 가져와서 비교
WHERE u.id IN (SELECT assigned_user_id FROM task)
-- EXISTS는 존재 여부만 확인
WHERE EXISTS (SELECT 1 FROM task WHERE task.assigned_user_id = u.id)
IN은 서브쿼리 결과를 모두 메모리에 올려놓고 비교하지만, EXISTS는 그럴 필요가 없다. 특히 NOT EXISTS 같은 경우는 더욱 효율적이다.
인덱스가 있으면 더 빠르다
EXISTS의 진가는 인덱스와 만났을 때 발휘된다. task.assigned_user_id에 인덱스가 걸려있다면, MySQL은 B-Tree를 탐색해서 O(log n) 시간에 해당 유저의 task가 있는지 확인할 수 있다.
인덱스가 없으면? 그때는 Full Table Scan이 발생한다. task 테이블의 모든 행을 순회하면서 assigned_user_id = u.id인지 확인해야 하니까. 이 경우에는 정말 느려질 수 있다.
-- 인덱스 생성
CREATE INDEX idx_task_assigned_user ON task(assigned_user_id);
이 한 줄만 추가해도 쿼리 성능이 극적으로 개선될 수 있다.
실제로 확인하는 방법
쿼리가 어떻게 실행되는지 궁금하면 EXPLAIN을 사용하면 된다.
EXPLAIN SELECT u.id, u.name, u.email, u.phone, u.created_at
FROM user u
WHERE u.user_type = 2
AND u.is_deleted = 0
AND NOT EXISTS (
SELECT 1 FROM task t
WHERE t.assigned_user_id = u.id
);
EXPLAIN 결과에서 확인할 포인트는 이렇다.
- type:
index또는ref면 인덱스를 사용하는 것.ALL이면 Full Table Scan - rows: 실제로 스캔하는 행의 수 추정치
- Extra:
Using where,Using index같은 추가 정보
만약 task 테이블에서 type이 ALL로 나온다면, 인덱스를 추가하는 걸 고려해야 한다.
정리하면
EXISTS는 단순히 "존재하는지 확인"하는 것처럼 보이지만, 실제로는 꽤 최적화된 방식으로 동작한다.
- WHERE 절의 다른 조건들이 먼저 필터링을 하고, 남은 행에 대해서만 EXISTS가 실행된다
- Short-circuit 평가로 첫 번째 매칭을 찾으면 즉시 멈춘다
- 인덱스가 있으면 B-Tree 탐색으로 O(log n)에 결과를 찾는다
쿼리가 느리다고 느껴진다면, EXISTS 자체의 문제보다는 인덱스가 제대로 걸려있는지 먼저 확인해보는 게 좋다. EXPLAIN으로 실행 계획을 보면 병목이 어디인지 금방 알 수 있다.