개발을 하다 보면 특정 조건에 해당하지 않는 데이터를 조회해야 할 때가 많습니다. 이럴 때 흔히 사용하는 게 바로 NOT IN이죠. 예를 들어 다음과 같은 형태입니다:
SELECT * FROM users WHERE status NOT IN ('inactive', 'banned');
혹은 MongoDB에서는 이렇게도 사용하죠:
db.users.find({ status: { $nin: ["inactive", "banned"] } });
딱 봐도 직관적이고 쓰기 편해 보입니다. 하지만 실무에서는 조심해서 써야 할 몇 가지 함정이 존재합니다. 이번 글에서는 제가 겪은 실제 사례를 바탕으로 NOT IN 쿼리의 문제점과 이를 최적화하는 방법까지 정리해보려고 합니다.
⚠️ NOT IN이 느려지는 이유
- 인덱스를 타지 못하는 경우가 많다
- IN은 인덱스를 잘 타지만, NOT IN은 대부분의 경우 인덱스를 무시합니다. 조건 자체가 "해당하지 않는 것"을 찾는 것이기 때문에, DB는 전체 데이터를 스캔할 수밖에 없게 되죠.
- NULL 값 포함 여부에 따라 예외가 발생할 수 있다 (특히 SQL)
SELECT * FROM users WHERE id NOT IN (1, 2, NULL);
NULL은 비교 불가이기 때문에, SQL에서는 NOT IN이 제대로 작동하지 않는 예외가 생깁니다.
NOT IN의 리스트에 NULL이 포함되어 있으면, 결과가 아예 안 나오는 경우도 있습니다. 예를 들어 다음 쿼리는 아무 것도 반환하지 않을 수 있습니다:
- 복잡한 서브쿼리와 함께 쓰일 경우 성능 저하
SELECT * FROM users WHERE id NOT IN ( SELECT user_id FROM blocked_users WHERE reason = 'spam' );
SELECT * FROM users WHERE id NOT IN (1, 2, NULL);
그럼 어떻게 최적화할 수 있을까?
1. NOT EXISTS 또는 LEFT JOIN ... IS NULL 로 변경 (SQL)
성능 이슈가 있는 경우에는 NOT EXISTS나 LEFT JOIN 방식으로 바꿔보는 걸 추천합니다:
NOT EXISTS SELECT * FROM users u WHERE NOT EXISTS (
SELECT 1
FROM blocked_users b
WHERE b.user_id = u.id AND b.reason = 'spam'
);
LEFT JOIN 방식
SELECT u.*
FROM users u LEFT JOIN blocked_users b ON b.user_id = u.id AND b.reason = 'spam'
WHERE b.user_id IS NULL;
이 방식은 보통 옵티마이저가 더 효율적인 실행 계획을 만들 수 있도록 도와줍니다.
2. MongoDB에서는 $nin을 $expr이나 $lookup으로 대체 고려
MongoDB에서도 $nin 쿼리가 인덱스를 타지 않아서 느린 경우가 많습니다. 이럴 때는 $lookup으로 조인해서 존재 여부로 필터링하는 방법을 사용할 수 있습니다:
db.users.aggregate([
{
$lookup: {
from: "blocked_users",
localField: "_id",
foreignField: "user_id",
as: "blocked"
}
},
{
$match: { blocked: { $size: 0 } }
}
])
단, lookup은 오히려 비싸질 수도 있으니 쿼리 조건/데이터 크기에 따라 트레이드오프를 잘 따져야 합니다.
3. 사전 필터링 + 캐싱
차라리 아예 NOT IN으로 쓸 값들을 메모리나 Redis에 캐싱해두고, 어플리케이션 레벨에서 필터링하거나 조건을 바꿔서 IN 조건으로 재구성하는 방식도 고려할 수 있습니다.
마치며
NOT IN은 편하긴 하지만, 잘못 쓰면 인덱스도 못 타고 예외도 발생하며, 성능도 크게 저하될 수 있는 잠재적인 성능 폭탄입니다. 실제로 저희 팀에서도 MongoDB $nin이 들어간 쿼리 때문에 병목이 생겨서, $lookup으로 바꾸고 나서야 응답 시간이 절반 이하로 줄었던 경험이 있습니다.
꼭 필요한 경우가 아니라면, 대체 쿼리 방식이나 데이터 구조 개선을 고민해보는 걸 추천드립니다. 혹시 여러분도 유사한 이슈를 겪으셨다면, 댓글로 공유해 주세요. 👇
'database' 카테고리의 다른 글
Docker Mysql 테스트서버 DB분리 (0) | 2024.06.07 |
---|---|
Statement보다 PreparedStatement를 사용해야하는이유 (0) | 2024.06.06 |
mysql index를 타지않는 경우 (0) | 2024.03.17 |
Querydsl 방언 사용하기 (2) | 2024.02.12 |
mysql DB 데이터 백업/복구(export/import) dump뜨는법 (1) | 2024.01.06 |