세미조인과 안티조인으로 중복 없는 SQL 조회 설계하기

세미조인과 안티조인의 동작 방식, EXISTS·IN·NOT EXISTS·NOT IN 선택 기준, NULL 처리와 SQL 쿼리 최적화 방법을 정리합니다.

2026-08-14 · 최초 발행 2025-08-10

조인 결과가 아니라 존재 여부만 필요할 때

세미조인(Semi-Join)은 두 관계 R과 S 사이의 조인 조건을 검사하되, 결과에는 R의 튜플만 남기는 연산이다. S는 R의 행을 걸러내기 위한 기준으로만 쓰이며 S의 속성 값은 결과 집합에 포함되지 않는다.

일반적인 내부조인처럼 양쪽 테이블의 컬럼을 결합할 필요가 없고, 오른쪽 테이블에서 여러 행이 일치하더라도 왼쪽 행을 중복해 반환하지 않는다. SQL에 세미조인 전용 연산자는 없지만 EXISTSIN으로 같은 의도를 표현할 수 있다.

EXISTS와 IN으로 세미조인 표현하기

EXISTS는 일치하는 행이 존재하는지를 확인한다. 서브쿼리의 SELECT 1은 실제 반환값이 아니라 존재 여부만 필요하다는 뜻이다.

SELECT * FROM R
WHERE EXISTS (
    SELECT 1 FROM S
    WHERE R.key = S.key
);

IN도 S의 키 목록에 포함되는 R의 행만 선택한다.

SELECT * FROM R
WHERE R.key IN (
    SELECT S.key FROM S
);

두 방식은 유사한 결과를 만들지만 처리 방식에는 차이가 있다. IN은 서브쿼리 결과 집합을 먼저 생성한 뒤 비교하고, EXISTS는 행별로 서브쿼리를 평가한다.

안티조인이 찾는 행

세미조인이 조인 조건을 충족하는 레코드를 반환한다면, 안티조인(Anti-Join)은 그 조건을 충족하지 않는 레코드를 찾는다. 관계가 없는 고객, 활동 기록이 없는 사용자처럼 제외 대상을 확인해야 할 때 사용한다.

NOT EXISTS로는 일치 행이 없는 R의 행을 선택할 수 있다.

SELECT * FROM R
WHERE NOT EXISTS (
    SELECT 1 FROM S
    WHERE R.key = S.key
);

NOT IN도 같은 목적에 사용할 수 있다.

SELECT * FROM R
WHERE R.key NOT IN (
    SELECT S.key FROM S
);

내부조인과 결과 형태가 달라지는 이유

내부조인은 조인 조건을 만족하는 양쪽 테이블의 컬럼을 결합한다. 오른쪽 테이블에 일치 행이 여러 개 있으면 같은 왼쪽 행이 여러 결과 행으로 나타날 수 있다. 세미조인은 왼쪽 테이블의 컬럼만 반환하므로 이런 중복을 만들지 않는다.

세미조인 결과R1R2내부조인 결과R1 + S1R1 + S2R2 + S3

이 특성은 필요한 속성만 결과에 담고, 중복 행에 대한 후처리를 줄이는 데 유용하다. 분산 데이터베이스에서는 네트워크를 통한 데이터 전송량을 줄일 수 있으며, 조인 조건을 만족하는 레코드만 처리해 연산 비용과 메모리 사용량도 낮출 수 있다.

주문과 구매 이력에서의 사용

주문 내역이 있는 고객만 필요하다면 주문 테이블의 컬럼을 가져오지 않고 고객 행만 필터링할 수 있다.

-- 세미조인 방식 (EXISTS 사용)
SELECT customer_id, name, email
FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.customer_id
);

고객별 주문이 여러 건이어도 결과에는 각 고객이 한 번만 포함된다.

특정 카테고리 상품을 구매한 고객을 찾는 경우에도 세미조인 형태가 적합하다.

-- 세미조인 방식 (IN 사용)
SELECT customer_id, name
FROM customers
WHERE customer_id IN (
    SELECT o.customer_id
    FROM orders o
    JOIN order_items oi ON o.order_id = oi.order_id
    JOIN products p ON oi.product_id = p.product_id
    WHERE p.category = 'Electronics'
);

이 쿼리는 전자제품 카테고리 상품을 구매한 고객을 중복 없이 반환한다.

EXISTS와 IN을 고르는 기준

서브쿼리 결과 집합이 작고 외부 쿼리 테이블이 큰 경우, 그리고 서브쿼리의 조인 컬럼에 인덱스가 있을 때는 IN 사용이 유리하다.

반대로 서브쿼리 결과 집합이 크고 외부 쿼리 테이블이 작은 경우, 외부 쿼리의 조인 컬럼에 인덱스가 있는 경우에는 EXISTS 사용이 유리하다.

조인 조건에 쓰이는 컬럼에 인덱스를 두는 일은 세미조인 성능에 직접 영향을 준다. 특히 EXISTS는 인덱스를 효과적으로 활용할 수 있는 구조다. IN 내부의 서브쿼리가 복잡하다면 임시 테이블이나 뷰로 분리하는 방식도 고려할 수 있다.

필터를 먼저 적용해 검사 범위 줄이기

EXISTS 조건과 함께 R 테이블의 필터가 있다면, 먼저 대상 행을 줄여 서브쿼리 검사량을 낮출 수 있다.

-- 최적화 전
SELECT * FROM R
WHERE EXISTS (
    SELECT 1 FROM S
    WHERE R.key = S.key
) AND R.value > 100;

-- 최적화 후
SELECT * FROM R
WHERE R.value > 100 AND EXISTS (
    SELECT 1 FROM S
    WHERE R.key = S.key
);

안티조인에서 확인할 NULL과 대용량 처리

주문이 없는 고객은 NOT EXISTS로 조회할 수 있다.

-- NOT EXISTS 사용
SELECT customer_id, name, email
FROM customers c
WHERE NOT EXISTS (
    SELECT 1 FROM orders o
    WHERE o.customer_id = c.customer_id
);

특정 기간 동안 활동하지 않은 사용자도 NOT IN으로 찾을 수 있다.

-- NOT IN 사용
SELECT user_id, username
FROM users
WHERE user_id NOT IN (
    SELECT DISTINCT user_id
    FROM user_activities
    WHERE activity_date > '2023-01-01'
);

다만 NOT IN은 서브쿼리 결과에 NULL이 포함되면 전체 결과가 빈 집합이 될 수 있다. 이 경우에는 NOT EXISTS가 더 안전하다.

-- 서브쿼리에 NULL 값이 있을 경우 결과가 없음
SELECT * FROM R
WHERE R.key NOT IN (
    SELECT S.key FROM S -- S.key에 NULL 포함
);

복잡한 서브쿼리는 실행 계획에 큰 영향을 미치므로 단순화하거나 인덱스를 활용하도록 구성해야 한다. 대용량 테이블에서 NOT IN 사용으로 성능 저하가 심각할 수 있다면 NOT EXISTS 또는 LEFT JOIN + IS NULL 패턴을 고려할 수 있다.

-- LEFT JOIN + IS NULL 방식의 안티조인
SELECT R.*
FROM R
LEFT JOIN S ON R.key = S.key
WHERE S.key IS NULL;
세미조인안티조인SQL쿼리 최적화데이터베이스