셀프 조인과 계층형 데이터

동일 테이블을 두 번 조인하는 셀프 조인의 원리, 조직도·카테고리 트리 같은 계층형 데이터 쿼리 패턴, 그리고 임의 깊이 탐색이 필요할 때 쓰는 재귀 CTE와의 비교를 다룹니다.

· 5 min read · PALDYN Team

지난 글에서 CROSS JOIN과 카테시안 곱을 살펴봤다. 이번에는 같은 테이블을 두 번 조인하는 **셀프 조인(Self Join)**을 다룬다. 조직도, 카테고리 트리, 댓글 스레드처럼 하나의 테이블 안에서 부모-자식 관계가 있는 데이터를 다룰 때 핵심 패턴이다.


셀프 조인이란

셀프 조인은 특별한 조인 키워드가 따로 있는 게 아니다. 같은 테이블에 서로 다른 **별칭(Alias)**을 붙여 두 번 참조하는 일반 JOIN이다.

-- employees 테이블: id, name, manager_id (자기 참조 FK)
SELECT
    e.name  AS employee,
    m.name  AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;

employees가 두 역할로 등장한다. e는 직원으로서, m은 관리자로서 사용된다. manager_id가 NULL인 최상위 직원도 LEFT JOIN 덕분에 결과에 포함된다.

셀프 조인 — 계층형 데이터 구조


자기 참조 외래키 설계

계층형 데이터를 저장하는 가장 단순한 방법은 인접 목록(Adjacency List) 패턴이다. 같은 테이블의 id를 참조하는 parent_id 또는 manager_id 컬럼을 둔다.

CREATE TABLE categories (
    id        BIGINT PRIMARY KEY,
    name      VARCHAR(100) NOT NULL,
    parent_id BIGINT REFERENCES categories(id)  -- 자기 참조
);

INSERT INTO categories VALUES
    (1, '전자제품', NULL),
    (2, '스마트폰', 1),
    (3, '태블릿', 1),
    (4, '아이폰', 2),
    (5, '갤럭시', 2);

최상위 노드는 parent_id = NULL이다.


셀프 조인 활용 패턴

1단계 부모 조회

-- 카테고리와 상위 카테고리 이름
SELECT
    c.id,
    c.name             AS category,
    p.name             AS parent
FROM categories c
LEFT JOIN categories p ON p.id = c.parent_id;

-- 결과:
-- 1  전자제품   NULL
-- 2  스마트폰   전자제품
-- 4  아이폰     스마트폰

같은 그룹 내 비교

셀프 조인은 계층 탐색 외에도 같은 테이블의 행끼리 비교할 때 사용한다.

-- 같은 부서에서 월급 차이가 100만 원 이상인 직원 쌍
SELECT
    a.name AS emp_a,
    b.name AS emp_b,
    ABS(a.salary - b.salary) AS diff
FROM employees a
JOIN employees b
    ON a.dept_id = b.dept_id
   AND a.id < b.id         -- 중복 쌍 제거
WHERE ABS(a.salary - b.salary) >= 1000000;

a.id < b.id 조건으로 (Alice, Bob)과 (Bob, Alice)가 중복 반환되지 않도록 한다.


재귀 CTE로 임의 깊이 탐색

셀프 조인은 한 번 조인할 때마다 한 단계 내려간다. 계층이 3단계면 JOIN을 3번 써야 한다. 깊이를 알 수 없는 계층을 탐색할 때는 **재귀 CTE(WITH RECURSIVE)**를 사용한다.

재귀 CTE vs 셀프 조인

-- 조직 전체 계층 순회
WITH RECURSIVE org AS (
    -- 앵커: 최상위 직원
    SELECT id, name, manager_id, 0 AS depth
    FROM employees
    WHERE manager_id IS NULL

    UNION ALL

    -- 재귀: 자식 직원 탐색
    SELECT e.id, e.name, e.manager_id, o.depth + 1
    FROM employees e
    JOIN org o ON o.id = e.manager_id
)
SELECT
    REPEAT('  ', depth) || name AS indented_name,
    depth
FROM org
ORDER BY depth, name;

-- 결과:
-- Alice           (depth 0)
--   Bob           (depth 1)
--     Dave        (depth 2)
--     Eve         (depth 2)
--   Carol         (depth 1)
--     Frank       (depth 2)

재귀 CTE는 무한 루프를 방지하기 위해 MAXRECURSION 설정(SQL Server) 또는 depth < 100 같은 조건을 추가하는 것이 안전하다.


하위 트리 전체 조회

특정 노드 아래의 모든 자식을 재귀적으로 가져올 때도 같은 패턴을 사용한다.

-- 'Bob(id=2)' 아래의 모든 하위 직원
WITH RECURSIVE subtree AS (
    SELECT id, name, manager_id
    FROM employees
    WHERE id = 2  -- 시작 노드

    UNION ALL

    SELECT e.id, e.name, e.manager_id
    FROM employees e
    JOIN subtree s ON s.id = e.manager_id
)
SELECT * FROM subtree;
-- Bob, Dave, Eve

데이터베이스별 계층 쿼리

DB방법
PostgreSQL, SQL Server, MySQL 8+, SQLite 3.35+WITH RECURSIVE
OracleCONNECT BY PRIOR + START WITH
공통셀프 조인 (1단계)

Oracle의 CONNECT BY 문법은 표준 SQL이 아니지만 계층 쿼리에 특화된 편리한 기능을 제공한다. LEVEL 가상 컬럼으로 깊이를, SYS_CONNECT_BY_PATH로 경로 문자열을 얻을 수 있다.

-- Oracle CONNECT BY 예시
SELECT
    LEVEL AS depth,
    LPAD(' ', (LEVEL-1)*2) || name AS indented_name
FROM employees
START WITH manager_id IS NULL
CONNECT BY PRIOR id = manager_id;

지난 글: CROSS JOIN과 카테시안 곱

다음 글: NATURAL JOIN과 USING 절


읽어주셔서 감사합니다. 😊