제조 데이터 · 설비 자동화

현장에서 얻은 데이터를
쓸 수 있는 정보로 정리합니다.

PLC, Python, SQL, Tableau와 예지보전을 실제 설비기술 업무의 흐름 안에서 기록합니다.

MSSQL IN과 EXISTS 차이 — 결과·성능·사용 기준 [MSSQL 26]

지난 초급 9편에서 바깥 행마다 다시 계산되는 상관 서브쿼리를 배웠습니다. 이번 편의 주인공 EXISTS는 그 상관 서브쿼리가 가장 자연스럽게 쓰이는 자리입니다. 같은 질문을 IN과 EXISTS 양쪽으로 풀어 결과가 똑같음을 확인하고, 실무에서 조용히 사고를 내는 NOT IN + NULL 함정을 직접 재현해 보겠습니다.

시리즈 안내 · 이 글은 MSSQL 26입니다.
SQL·MES 전체 글 보기 →

복습 — "주문한 회원"을 IN으로

초급 8편에서 배운 IN 서브쿼리부터 다시 꺼내 보겠습니다. "주문한 적 있는 회원"을 조회해 볼까요?

SELECT MemberID, Name, City
FROM Member
WHERE MemberID IN (SELECT MemberID FROM Orders)
ORDER BY MemberID;

Orders에 등장하는 MemberID는 1, 2, 3, 5, 6, 8 — 회원 8명 중 6명이 나왔습니다. 김민준(1)은 주문이 3건이지만 결과에는 한 번만 나옵니다. IN은 "목록에 있는가"만 판단하므로 주문 건수와 무관하게 회원당 한 행입니다.

같은 질문을 EXISTS로

이번에는 똑같은 질문을 EXISTS로 풀어 보겠습니다. EXISTS의 문법은 단순합니다 — 괄호 안 서브쿼리가 행을 하나라도 반환하면 TRUE, 한 행도 없으면 FALSE입니다.

SELECT MemberID, Name, City
FROM Member AS M
WHERE EXISTS (SELECT 1 FROM Orders AS O
              WHERE O.MemberID = M.MemberID)
ORDER BY MemberID;

결과를 확인해 볼까요? IN으로 풀었을 때와 완전히 같은 6행입니다. 동작 순서는 이렇습니다. 바깥 쿼리가 Member의 행을 하나 잡으면, 서브쿼리가 O.MemberID = M.MemberID인 주문을 찾아봅니다. 한 행이라도 찾는 순간 TRUE가 확정되어 더 뒤지지 않고 멈춥니다. SELECT 1이라고 쓴 이유는 EXISTS가 행의 존재만 볼 뿐 열 내용은 보지 않기 때문입니다(SELECT *도 결과는 같습니다).

두 형태의 변환 규칙은 기계적입니다.

  • IN: 바깥열 IN (SELECT 안쪽열 FROM ...)
  • EXISTS: EXISTS (SELECT 1 FROM ... WHERE 안쪽열 = 바깥열)

IN에서 비교하던 두 열이, EXISTS에서는 서브쿼리 안쪽의 상관 조건으로 자리를 옮긴 것뿐입니다.

NOT IN의 함정 — 재현 준비

여기까지는 평화롭습니다. 문제는 부정형에서 터집니다. BookMart에 "비회원(게스트) 주문" 기능이 생겼다고 가정해 보겠습니다. 회원이 아닌 손님의 주문은 MemberID가 NULL로 남는 상황입니다. 원본 Orders는 건드리지 않도록, 연습용 복사 테이블 OrdersPractice에 이 시나리오를 만들어 보겠습니다.

SELECT * INTO OrdersPractice FROM Orders;
​
ALTER TABLE OrdersPractice ALTER COLUMN MemberID int NULL;
​
INSERT INTO OrdersPractice (OrderID, MemberID, OrderDate, Status)
VALUES (1011, NULL, '2025-07-05 15:20:00', N'주문접수');

복사본의 MemberID는 원본을 따라 NOT NULL로 만들어지기 때문에, 초급 1편에서 배운 ALTER COLUMN으로 NULL 허용으로 바꾼 뒤 게스트 주문 한 건을 넣었습니다. 상태를 확인해 보겠습니다.

SELECT OrderID, MemberID, Status
FROM OrdersPractice
WHERE OrderID >= 1009
ORDER BY OrderID;

OrderIDMemberIDStatus

1009 8 배송중
1010 5 주문접수
1011 NULL 주문접수

주문 11건 중 1011번의 MemberID가 NULL — 준비 완료입니다.

함정 발동 — 0행이 반환됩니다

이제 "주문하지 않은 회원"을 NOT IN으로 구해 보겠습니다. 주문 이력이 없는 최수아(4)·윤지호(7) 두 명이 나와야 정상입니다.

SELECT MemberID, Name, City
FROM Member
WHERE MemberID NOT IN (SELECT MemberID FROM OrdersPractice)
ORDER BY MemberID;

0행입니다. 최수아도 윤지호도 사라졌습니다. 더 무서운 점은 오류도 경고도 없다는 것입니다. 쿼리는 "성공"했고, 그저 조용히 빈 결과를 돌려줍니다. "주문 안 한 회원이 없나 보다"라고 잘못 결론 내리기 딱 좋은 상황이죠.

왜 0행일까 — 3값 논리로 따져 보기

입문 9편에서 NULL과의 비교는 참도 거짓도 아닌 UNKNOWN이라고 배웠습니다. 그 원리가 여기서 그대로 작동합니다. 서브쿼리가 돌려주는 목록은 중복을 걷어내면 (1, 2, 3, 5, 6, 8, NULL)이고, NOT IN은 내부적으로 이렇게 펼쳐집니다.

MemberID <> 1 AND MemberID <> 2 AND ... AND MemberID <> NULL

최수아(MemberID=4)의 행을 따라가 보겠습니다.

비교결과

4 <> 1 TRUE
4 <> 2 TRUE
4 <> 3, 5, 6, 8 모두 TRUE
4 <> NULL UNKNOWN
전체 AND UNKNOWN

TRUE AND UNKNOWN은 UNKNOWN이고, WHERE절은 TRUE인 행만 통과시킵니다. 최수아·윤지호는 UNKNOWN이라 탈락, 주문한 회원들은 김민준의 1 <> 1 같은 비교에서 FALSE라 탈락 — 결국 모든 행이 탈락해 0행이 되는 것입니다. 목록에 NULL이 단 하나만 있어도 NOT IN 전체가 무력화됩니다.

NOT EXISTS로 올바르게

같은 질문을 NOT EXISTS로 풀어 보겠습니다.

SELECT MemberID, Name, City
FROM Member AS M
WHERE NOT EXISTS (SELECT 1 FROM OrdersPractice AS O
                  WHERE O.MemberID = M.MemberID)
ORDER BY MemberID;

이번에는 최수아·윤지호 2행이 정확히 나옵니다. NOT EXISTS가 안전한 이유는 비교 방식에 있습니다. 상관 조건 O.MemberID = M.MemberID에서 게스트 주문의 NULL은 어떤 회원 번호와도 같다고 판정되지 않으므로(UNKNOWN → 매칭 안 됨), 그냥 아무에게도 속하지 않는 행으로 취급될 뿐 존재 판정을 뒤흔들지 못합니다.

NOT IN을 꼭 써야 한다면 서브쿼리에서 NULL을 걸러 주면 됩니다.

WHERE MemberID NOT IN (SELECT MemberID FROM OrdersPractice
                       WHERE MemberID IS NOT NULL)

이렇게 고치면 NOT EXISTS와 같은 2행이 나옵니다.

연습 테이블 정리 — 원상 복구

실습이 끝났으니 복사 테이블을 정리하겠습니다. 원본 Orders와 Member는 한 번도 손대지 않았으므로 BookMart는 처음 그대로입니다.

DROP TABLE OrdersPractice;

확인 삼아 OrdersPractice를 다시 SELECT하면 아래 오류가 납니다(문구는 버전·언어 설정에 따라 다를 수 있습니다). 테이블이 깨끗이 정리되었다는 뜻입니다.

개체 이름 'OrdersPractice'이(가) 잘못되었습니다.

실무에서는 어느 쪽을 쓸까

  • 긍정형(IN vs EXISTS): 결과가 같고, SQL Server 옵티마이저도 두 형태를 비슷한 방식으로 처리하는 경우가 많아 성능보다 가독성으로 고르면 됩니다. IN (1, 3, 9)처럼 짧은 고정 목록이면 IN이 읽기 쉽고, "다른 테이블에 존재하는가"라는 의도라면 EXISTS가 뜻이 잘 드러납니다.
  • 부정형은 NOT EXISTS가 기본값입니다. NOT IN은 서브쿼리 컬럼이 NULL 허용인지부터 확인해야 하고, 쓰더라도 IS NOT NULL을 습관처럼 붙이세요.
  • 존재 여부만 필요한데 JOIN으로 풀면 주문이 3건인 김민준이 3행으로 불어나 DISTINCT가 필요해집니다. EXISTS는 애초에 회원당 한 행이라 그런 고민이 없습니다.

오늘의 정리

  • EXISTS는 서브쿼리가 행을 하나라도 반환하는지만 확인하며, 열 내용은 보지 않으므로 SELECT 1로 충분합니다.
  • IN에서 비교하던 열을 서브쿼리 안의 상관 조건으로 옮기면 EXISTS가 되고, 결과는 동일합니다.
  • NOT IN은 서브쿼리 결과에 NULL이 하나라도 있으면 0행 — <> NULL이 UNKNOWN이 되고 WHERE는 TRUE만 통과시키기 때문입니다.
  • 부정 조건은 NOT EXISTS가 안전하며, NOT IN을 쓸 때는 IS NOT NULL을 함께 쓰는 습관이 필요합니다.
  • 긍정형은 가독성으로 선택, 부정형은 NOT EXISTS — 이것이 실무 기본 원칙입니다.

다음 편 예고

다음 초급 11편에서는 서브쿼리가 WHERE절을 넘어 어디까지 갈 수 있는지 살펴봅니다. SELECT절의 스칼라 서브쿼리로 회원 목록에 주문 건수를 붙이고, FROM절의 파생 테이블로 집계 결과를 다시 조회하는 기법을 다룹니다.