SQL에서 재귀 쿼리(CTE) 사용법 및 NOT IN vs NOT EXISTS 비교

    최근 프로그래머스 SQL 고득점 Kit에서 4~5레벨 위주로 몇 문제를 풀고있습니다.

    https://school.programmers.co.kr/learn/courses/30/lessons/301651

    오늘 푼 문제 중 하나가 재귀 CTE를 이용하는 쿼리여서 이 참에 정리해보려고 합니다.

    데이터베이스에서 계층 구조와 같은 복잡한 데이터를 다룰 때 재귀 쿼리를 사용하면 도움이 될 수 있습니다. 이번 글에서는 SQL의 재귀 쿼리 활용법과 동작 원리, 그리고 NOT IN과 NOT EXISTS를 성능 측면에서 비교해 보겠습니다.

     

    재귀 쿼리란?

    재귀 쿼리는 자기 자신을 참조하는 쿼리로, SQL에서는 CTE(Common Table Expression)를 사용하여 작성할 수 있습니다. 주로 다음과 같은 상황에서 유용하게 사용됩니다:

    • 조직도와 같은 계층 구조 데이터 탐색
    • 그래프 탐색 (경로 찾기, 친구의 친구 찾기 등)

    둘 다 대충 느낌이 비슷하죠?

    경험상으로는 주로 한 테이블 내에서 같은 테이블 내 컬럼을 참조해야할 때 유용하게 쓰였던 것 같습니다.

     

    재귀 CTE의 기본 구조

    재귀 CTE는 다음과 같은 기본 구조를 가집니다:

    WITH RECURSIVE [CTE 참조할 이름] AS (
        -- 앵커 멤버(기본 케이스)
        SELECT col1, col2, col3 ...
        FROM table
        WHERE [condition]
    
        UNION ALL
    
        -- 재귀 멤버(재귀 케이스)
        SELECT col1, col2, col3 ...
        FROM table t
        JOIN [CTE 참조할 이름] r ON [JOIN_CONDITION]
        WHERE [condition]
    )
    SELECT * FROM [CTE 참조할 이름];
    • MySQL은 위 처럼 RECURSIVE 키워드를 써줘야 합니다.
    • MS-SQL은 모든 CTE가 기본적으로 재귀 가능이라서 안 써줘도 된다네요.

     

    재귀 쿼리의 동작 원리

    재귀 쿼리는 다음과 같은 단계로 실행됩니다:

    1. 앵커 멤버 실행: 초기 결과 집합 생성
    2. 재귀 멤버 실행: 이전 단계의 결과를 입력으로 사용
    3. 반복 실행: 재귀 멤버에서 새로운 행이 더 이상 생성되지 않을 때까지 반복
    4. 결과 병합: 모든 단계의 결과가 UNION ALL로 합쳐진 결과 반환

     

    실제 문제: 멸종위기의 대장균 찾기

    위 문제로 풀어보겠습니다. 문제 설명은 읽고 와주세요.

    세대별로 리프 노드를 구하는 문제입니다. 그럼 뭐부터 해야할까요?

    네, 세대 정보부터 계산해야 합니다.

    현재 세대 정보는 ‘PARENT_ID를 ID로 가진 행의 세대 + 1’ 로 구할 수 있습니다.

    어라? 자기 참조하면서 그래프 탐색하듯 구해야겠네요?

    네, 재귀로 풀 수 있습니다.

    WITH RECURSIVE RECURSIVE_ECOLI AS (
        -- 앵커 멤버: 부모가 없는 최초 세포(1세대)
        SELECT ID, PARENT_ID, 1 as GENERATION
        FROM ECOLI_DATA
        WHERE PARENT_ID IS NULL
    
        UNION ALL
    
        -- 재귀 멤버: 자식 세포 탐색
        SELECT e.ID, e.PARENT_ID, (r.GENERATION + 1) as GENERATION
        FROM ECOLI_DATA e
        JOIN RECURSIVE_ECOLI r ON e.PARENT_ID = r.ID
    )
    SELECT COUNT(*) COUNT, GENERATION
    FROM RECURSIVE_ECOLI
    GROUP BY GENERATION
    ORDER BY GENERATION ASC;

    초기 결과 집합은 부모가 없는 1세대로 시작합니다.

    재귀 멤버에서는 이전 단계 결과를 부모로 하는 현재 세대를 구합니다.

    자, 이 쿼리는 모든 세포를 세대별로 집계합니다. 그런데 리프 노드(자식이 없는 세포)만 집계하고 싶다면 어떻게 해야 할까요?

    따로 필터링이 필요합니다.

     

    리프 노드 필터링: NOT IN vs NOT EXISTS

    리프 노드를 식별하는 두 가지 주요 방법을 비교해 보겠습니다.

    두 방법 모두 현재 ID가 PARENT_ID 컬럼에 등장하지 않는 경우를 찾습니다.

    1. NOT IN 사용

    WITH RECURSIVE RECURSIVE_ECOLI AS (
        ...
    )
    SELECT COUNT(*) COUNT, GENERATION
    FROM RECURSIVE_ECOLI
    WHERE ID NOT IN (
        SELECT DISTINCT PARENT_ID
        FROM ECOLI_DATA
        WHERE PARENT_ID IS NOT NULL
    )
    GROUP BY GENERATION
    ORDER BY GENERATION ASC;

    2. NOT EXISTS 사용

    WITH RECURSIVE RECURSIVE_ECOLI AS (
        ...
    )
    SELECT COUNT(*) COUNT, GENERATION
    FROM RECURSIVE_ECOLI r
    WHERE NOT EXISTS (
        SELECT 1
        FROM ECOLI_DATA e
        WHERE e.PARENT_ID = r.ID
    )
    GROUP BY GENERATION
    ORDER BY GENERATION ASC;

     

    NOT IN vs NOT EXISTS: 성능 비교

    둘 모두 동일한 결과를 내놓습니다. 이 둘의 차이는 뭘까요?

    NOT IN의 특징

    • 작동 방식: 서브쿼리 결과를 한 번 계산하여 메모리에 저장한 후 비교
    • 장점: 서브쿼리가 한 번만 실행됩니다
    • 단점NULL 값 처리에 주의 필요 (서브쿼리에 NULL이 있으면 항상 빈 결과 반환)
    • 서브쿼리 결과가 큰 경우 메모리 사용량 높음

    NOT EXISTS의 특징

    • 작동 방식: 상관 서브쿼리로 외부 쿼리의 각 행마다 평가
    • 장점:NULL 값 상관 X
    • 조건 충족 시 즉시 평가 중단 (단락 평가)
    • 단점: 외부 쿼리의 각 행마다 서브쿼리가 실행됩니다 (상관 서브쿼리의 특징)

    이 둘을 비교하면

    NOT IN이 유리한 상황은

    • 서브쿼리 결과가 작고 외부 테이블이 큰 경우
    • 서브쿼리에 NULL 값이 없을 때

    NOT EXISTS가 유리한 상황은

    • 관련 컬럼에 인덱스가 있는 경우
    • 외부 테이블의 많은 행이 조건을 만족하는 경우

    일반적으로 그렇고 저도 그렇게 생각해왔는데, 찾아보니 재밌는 글을 발견했습니다.

    https://stackoverflow.com/questions/173041/not-in-vs-not-exists

    답변 작성자는 NOT EXISTS가 최적화됨에 따라 실제로는 안티-세미 조인 방식을 사용할 수 있기 때문에 사실상 비슷하고, NOT IN의 경우 NULL 값을 추가로 처리해야되기 때문에 NOT EXISTS가 훨씬 더 빠를 수 있다는 말입니다.

    저도 완전히 이해하지는 못 했지만 이 내용을 이해하기 위해 AI(Claude)와 대화한 내용을 공유합니다.

     

    풀이

    -- 코드를 작성해주세요
    
    WITH RECURSIVE RECURSIVE_ECOLI AS (
        SELECT ID, PARENT_ID, 1 as GENERATION
        FROM ECOLI_DATA
        WHERE PARENT_ID IS NULL
    
        UNION ALL
    
        SELECT r.ID, r.PARENT_ID, (o.GENERATION + 1) as GENERATION
        FROM ECOLI_DATA r
        JOIN RECURSIVE_ECOLI o ON o.ID = r.PARENT_ID
    )
    
    -- NOT IN
    # SELECT COUNT(*) COUNT, GENERATION
    # FROM RECURSIVE_ECOLI
    # WHERE ID NOT IN (SELECT DISTINCT PARENT_ID FROM ECOLI_DATA WHERE PARENT_ID IS NOT NULL)
    # GROUP BY GENERATION
    # ORDER BY GENERATION ASC;
    
    -- NOT EXISTS
    SELECT COUNT(*) COUNT, GENERATION
    FROM RECURSIVE_ECOLI r
    WHERE NOT EXISTS (
        SELECT 1
        FROM ECOLI_DATA e
        WHERE e.PARENT_ID = r.ID
    )
    GROUP BY GENERATION
    ORDER BY GENERATION ASC;

    참고

    https://www.geeksforgeeks.org/recursive-cte-in-sql-server/

    https://stackoverflow.com/questions/173041/not-in-vs-not-exists

    댓글