여기서부터 Part 4 심화 모델링입니다. Part 3이 저장 방식의 문제였다면, 이번 Part는 계층·시간·유연성·분석·테넌트처럼 모델링 자체가 까다로운 주제를 다룹니다.
1. 쇼핑몰 운영사의 계층 데이터
- 상품 카테고리: 전체 → 패션 → 여성의류 → 아우터 → 코트
- 조직: 본부 → 센터 → 팀 → 조
- 세트/번들 상품 구성: 상위 상품 → 하위 상품 (한 상품이 여러 세트에 사용될 수 있음)
상품 카테고리에서는 깊이가 데이터에 따라 다릅니다. 미리 고정이 불가능합니다. 어떤 분류는 전체 → 도서 두 단계에서 끝납니다. 어떤 분류는 전체 → 패션 → 여성의류 → 아우터 → 코트처럼 다섯 단계가 됩니다. 운영 중에 중간 단계가 새로 생깁니다. 고정 깊이 계층에서는 단계 하나에 테이블 하나씩 두면 되고, 아래 네 모델의 비교가 필요 없습니다. 가변 깊이가 아래 네 모델의 전제입니다.
트리(부모 1개)와 DAG(부모 여러 개)를 먼저 구분합니다. 아래 네 가지 모델은 기본적으로 트리용이며, DAG는 클로저 테이블이나 교차 테이블(6강 세트 구성)을 씁니다.
2. 인접 리스트 (Adjacency List)
CREATE TABLE category (
category_id BIGINT PRIMARY KEY,
parent_id BIGINT REFERENCES category(category_id),
name VARCHAR(50) NOT NULL
);- 가장 단순하고, 이동(부모 변경)이 한 행 수정으로 끝납니다.
- 하위 전체 조회에 재귀 쿼리가 필요합니다.
-- PostgreSQL, MySQL 8.0+ (SQL Server는 RECURSIVE 키워드 없이 동일 구조)
WITH RECURSIVE subtree AS (
SELECT category_id, parent_id, name, 1 AS depth
FROM category WHERE category_id = :root
UNION ALL
SELECT c.category_id, c.parent_id, c.name, s.depth + 1
FROM category c JOIN subtree s ON c.parent_id = s.category_id
)
SELECT * FROM subtree;
-- Oracle 전통 문법
SELECT category_id, name, LEVEL
FROM category
START WITH category_id = :root
CONNECT BY PRIOR category_id = parent_id;재귀 CTE가 모든 주요 DBMS에서 지원되면서, "인접 리스트는 조회가 어렵다"는 과거의 안티패턴 논리는 상당 부분 약해졌습니다. 깊이가 수십 단계 이내이고 트리 크기가 적당하면 인접 리스트가 기본값입니다. parent_id 인덱스는 필수입니다.
3. 경로 열거 (Path Enumeration / Materialized Path)
CREATE TABLE category (
category_id BIGINT PRIMARY KEY,
path VARCHAR(500) NOT NULL, -- '/1/4/17/'
name VARCHAR(50) NOT NULL
);
CREATE INDEX ix_category_path ON category (path); -- PG는 text_pattern_ops 필요할 수 있음
-- 하위 전체
SELECT * FROM category WHERE path LIKE '/1/4/%';
-- 조상 전체
SELECT * FROM category WHERE '/1/4/17/' LIKE path || '%';- 하위 조회가 접두사 검색 한 번으로 끝납니다.
- 부모 변경 시 하위 전체의 path를 갱신해야 합니다.
- 참조 무결성을 DB가 보장하지 못합니다 (path 안의 ID가 실제로 존재하는지 모름).
- PostgreSQL
ltree확장은 이 모델을 타입과 GiST 인덱스로 지원합니다.
4. 중첩 집합 (Nested Set)
1 [전체] 10
/ \
2 [패션] 7 8 [가전] 9
/ \
3 [여성의류] 4 5 [남성의류] 6-- 하위 전체: 부모의 lft ~ rgt 범위
SELECT c.* FROM category p JOIN category c ON c.lft BETWEEN p.lft AND p.rgt
WHERE p.category_id = :root;- 하위 조회와 하위 개수(
(rgt - lft - 1) / 2)가 매우 빠릅니다. - 삽입·이동 시 트리 오른쪽 전체 번호를 재계산 → 쓰기가 잦으면 부적합
- 직계 자식 조회가 오히려 어렵습니다.
5. 클로저 테이블 (Closure Table)
CREATE TABLE category_path (
ancestor_id BIGINT NOT NULL REFERENCES category(category_id),
descendant_id BIGINT NOT NULL REFERENCES category(category_id),
depth INT NOT NULL,
PRIMARY KEY (ancestor_id, descendant_id)
);
CREATE INDEX ix_cp_desc ON category_path (descendant_id, depth);모든 조상-자손 쌍(자기 자신 포함, depth 0)을 저장합니다.
-- 하위 전체
SELECT c.* FROM category c
JOIN category_path cp ON cp.descendant_id = c.category_id
WHERE cp.ancestor_id = :root;
-- 새 노드 :new를 :parent 아래에 추가
INSERT INTO category_path (ancestor_id, descendant_id, depth)
SELECT ancestor_id, :new, depth + 1 FROM category_path WHERE descendant_id = :parent
UNION ALL
SELECT :new, :new, 0;- 조상·자손 조회 모두 인덱스 한 번
- FK로 무결성 보장
- DAG도 표현 가능
- 공간: 최악 O(n²), 일반적인 균형 트리에서는 O(n × 깊이)
- 서브트리 이동은 "기존 외부 조상과의 연결 삭제 + 새 조상과의 연결 삽입"으로 여러 행 작업
6. 비교
| 항목 | 인접 리스트 | 경로 열거 | 중첩 집합 | 클로저 테이블 |
|---|---|---|---|---|
| 직계 자식 | 쉬움 | 보통 | 어려움 | 쉬움 (depth=1) |
| 하위 전체 | 재귀 CTE | 쉬움 | 쉬움 | 쉬움 |
| 조상 전체 | 재귀 CTE | 쉬움 | 쉬움 | 쉬움 |
| 삽입 | 쉬움 | 쉬움 | 어려움 | 보통 |
| 이동 | 쉬움 | 보통 | 어려움 | 보통 |
| 참조 무결성 | O | X | X | O |
| DAG | X | X | X | O |
| 추가 저장 | 없음 | 경로 문자열 | 두 컬럼 | 별도 테이블 |
7. 실무 조합
인접 리스트 + 파생 구조가 흔한 선택입니다.
- 원본(진실의 원천)은
parent_id - 조회 성능이 필요하면 클로저 테이블이나 path를 트리거·배치로 파생 (10강 반정규화)
- 조직도처럼 변경이 드물고 조회가 많은 경우, 매일 밤 클로저 테이블을 재생성하는 배치도 충분히 실용적입니다.
순환 방지는 인접 리스트에서 DB 제약만으로 막기 어렵습니다. 부모 변경 시 "새 부모가 자신의 자손이 아닌지"를 재귀 쿼리나 클로저 테이블로 검사합니다.
17강 정리
- 재귀 CTE 지원이 보편화되어 인접 리스트가 기본값이 되었다.
- 조회가 압도적으로 많으면 경로 열거·중첩 집합, 무결성과 DAG가 필요하면 클로저 테이블.
- 원본은 인접 리스트로 두고 조회용 구조를 파생시키는 조합이 실용적이다.