Database ‐ 계층 구조 설계 - thought-corner/Backend-PlayGround GitHub Wiki
Database - Hierarchical Structure Design
인접 리스트 모델
- 계층 구조를 데이터베이스에 저장하는 가장 직관적이고 널리 사용되는 방법이 바로 인접 리스트 모델(Adjacency List Model)이다.
- 인접 리스트 모델은 각 행이 자신의 부모를 참조하는 방식이다.
- 각 노드는 자신의 부모 노드를 가리키는 외래 키를 가진다.
- 최상위 노드는 부모가 없으므로
NULL을 가진다.
CREATE TABLE category (
category_id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
parent_id BIGINT NULL,
PRIMARY KEY (category_id),
FOREIGN KEY (parent_id) REFERENCES category(category_id)
);
- 여기서
parent_id가 부모 카테고리를 참조하는 외래키이고 자기 자신의 테이블을 참조하는 자기 참조 관계가 된다. - 특정 카테고리의 부모를 조회하려면 셀프 조인을 사용한다.
SELECT p.*
FROM category c
JOIN category p ON c.parent_id = p.category_id
WHERE c.category_id = 7;
- 인접 리스트 모델은 다음과 같은 장점이 있다.
- 직관적인 구조 : 부모-자식 관계를 외래 키 하나로 표현하므로 이해하기 쉽다.
- 간단한 데이터 추가/수정/삭제 : 노드를 추가하거나 이동하는 작업이 단순하다.
- 저장 공간 효율성 : 각 노드당 하나의 행만 저장하므로 저장 공간이 효율적이다.
- 외래 키 제약 조건 활용 : 데이터베이스 외래 키 제약 조건을 활용하여 참조 무결성을 보장할 수 있다.
- 그러나 인접 리스트 모델은 깊은 계층의 데이터를 한 번에 조회하기 어렵다는 단점이 있다.
실제로는 Materialized Path가 권장된다. 읽기가 핵심이고 depth + 페이징이 필요한 경우 Materialized Path가 권장되며 인접 리스트는 구조가 단순하지만 무한 depth 조회를 재귀(CTE)에 의존하게 되는 부분이 약점으로 지목된다.
계층 구조 조회 어려움
방법 1 : 가장 단순한 방법은 다음과 같이 애플리케이션 코드에서 데이터베이스를 여러 번 호출하는 것이다.
SELECT * FROM category WHERE category_id = 1;
SELECT * FROM category WHERE parent_id = 1;
SELECT * FROM category WHERE parent_id IN (4, 5, 6);
SELECT * FROM category WHERE parent_id IN (7, 8, 9, 10, 11, 12, 13);
- 이 방식은 구현이 간단하지만, 계층의 깊이만큼 데이터베이스를 호출해야 한다.
- 계층이 10단계라면 10번의 쿼리가 필요한데 이는 네트워크 비용과 성능 문제가 발생할 수 있다.
방법 2 : JOIN을 사용한 고정 깊이 조회
- 계층의 최대 깊이가 정해져 있다면, JOIN을 사용하여 한 번의 쿼리로 조회할 수 있다.
SELECT
c1.category_id AS level1_id,
c1.name AS level1_name,
c2.category_id AS level2_id,
c2.name AS level2_name,
c3.category_id AS level3_id,
c3.name AS level3_name
FROM category c1
LEFT JOIN category c2 ON c2.parent_id = c1.category_id
LEFT JOIN category c3 ON c3.parent_id = c2.category_id
WHERE c1.category_id = 1;
- 이 방식은 한 번의 쿼리로 결과를 얻을 수 있지만 다음과 같은 문제가 있다.
- 계층의 깊이가 고정되어야만 한다.
- 깊이가 늘어나면 그에 따라 JOIN도 늘어나야 한다.
- 결과가 중복으로 표시되어 처리가 필요하다.
방법 3 : UNION ALL을 사용한 조회
- 각 레벨별로 쿼리를 작성하고 UNION ALL로 합칠 수 있다.
SELECT category_id, name, parent_id, 1 AS level
FROM category
WHERE category_id = 1
UNION ALL
SELECT category_id, name, parent_id, 2 AS level
FROM category
WHERE parent_id = 1
UNION ALL
SELECT c.category_id, c.name, c.parent_id, 3 AS level
FROM category c
WHERE c.parent_id IN (
SELECT category_id FROM category WHERE parent_id = 1);
- 그러나 이 방식도 계층의 깊이만큼 UNION을 추가해야 하므로 유연하지 않다.
- 결과적으로 계층 구조의 깊이가 가변적일 때 대응하기 어렵다는 것이다. 그렇다고 애플리케이션에서 반복 호출하게 되면 무수히 많은 네트워크 호출이 발생하고 결과적으로 성능이 저하된다.
- 이런 문제를 해결하기 위해 SQL 표준에 재귀 쿼리(Recursive Query)가 도입되었고, 이를 CTE(Common Table Expression)로 작성할 수 있다.
CTE와 재귀 쿼리
- MySQL 8.0부터 재귀 CTE를 지원하면서 앞서 경험한 문제를 SQL 한 번으로 해결할 수 있게 되었다.
- CTE는 특정 데이터베이스에만 존재하는 독자적인 기능이 아니다.
CTE란?
- CTE(Common Table Expression)는 쿼리 내에서 임시로 사용할 수 있는 이름 있는 결과 집합을 말한다.
WITH절을 사용하여 정의하고 해당 SQL 내에만 잠시 존재하는 임시 뷰라고 볼 수 있다.
WITH top_categories AS (
-- CTE를 정의하는 쿼리
SELECT * FROM category WHERE parent_id IS NULL
)
-- CTE를 사용하는 메인 쿼리
SELECT * FROM top_categories;
- CTE는 다음과 같은 장점이 있다.
- 쿼리의 가독성이 좋아진다.
- 같은 CTE를 여러 번 참조할 수 있다.
- 재귀 쿼리를 작성할 수 있다.
재귀 CTE 문법
- CTE는 스스로 재귀 쿼리를 작성할 수 있는 재귀 문법을 제공한다.
- 재귀 CTE는 자기 자신을 참조하는 CTE다.
WITH RECURSIVE키워드를 사용한다.
WITH RECURSIVE cte_name AS (
-- 기본 케이스 (Anchor Member): 재귀의 시작점
SELECT ...
UNION ALL
-- 재귀 케이스 (Recursive Member): 자기 자신을 참조
SELECT ... FROM cte_name WHERE ...
)
SELECT * FROM cte_name;
- 기본 케이스와 재귀 케이스를 구분하는 곳에 UNION 또는 UNION ALL을 반드시 적어주어야 한다.
- 재귀 CTE는 크게 두 부분으로 구성된다.
- 기본 케이스 : 재귀 시작점. 처음 실행되는 쿼리이다.
- 재귀 케이스 : CTE 자기 자신을 참조하여 반복 실행되는 쿼리이다.
- 실행 흐름은 다음과 같다.
- 기본 케이스를 실행하여 초기 결과를 얻는다.
- 재귀 케이스를 실행하여 새로운 행을 얻는다.
- 새로운 행이 없을 때까지 2번을 반복한다.
- 모든 결과를 합쳐서 반환한다.
모든 자손 조회
WITH RECURSIVE descendants AS (
-- 기본 케이스: 시작 노드 (전자제품)
SELECT category_id, name, parent_id, 1 AS depth
FROM category
WHERE category_id = 1
UNION ALL
-- 재귀 케이스: 이전 결과의 자식들을 찾음
SELECT c.category_id, c.name, c.parent_id, d.depth + 1
FROM category c
JOIN descendants d ON c.parent_id = d.category_id
)
SELECT * FROM descendants;
JOIN descendants는 자신의 결과를 활용하는 역할을 한다.- 기본 케이스를 실행하고 그 결과를 새로운
descendants에 담는다. - 재귀 케이스를 실행한다. 이 때, 앞서 실행한 결과였던
descendants를 사용한다.- 만약 결과가 있다면 결과를 새로운
descendants에 담고 재귀 케이스를 다시 실행한다. - 만약 결과가 없다면 종료한다.
- 만약 결과가 있다면 결과를 새로운
- 지금까지 기본 케이스, 재귀 케이스에서 반환한 모든 데이터를 새로운
descendants에 담아서 반환한다. - 최종적으로
SELECT * FROM descendants를 통해descendants를 출력한다.
특정 깊이까지만 조회
- 재귀 CTE에서
WHERE절을 사용하면 특정 깊이까지만 조회할 수 있다.
WITH RECURSIVE descendants AS (
SELECT category_id, name, parent_id, 1 AS depth
FROM category
WHERE category_id = 1
UNION ALL
SELECT c.category_id, c.name, c.parent_id, d.depth + 1
FROM category c
JOIN descendants d ON c.parent_id = d.category_id
WHERE d.depth < 2 -- 2단계까지만 재귀(재귀 쿼리에서 깊이를 제한할 때는 "내가 원하는 깊이보다 작다"는 조건을 걸어야 한다)
)
SELECT * FROM descendants;
폐쇄 테이블 모델
- 인접 리스트 모델과 CTE 조합으로 대부분의 계층 구조 요구사항을 처리할 수 있다.
- 하지만 다음과 같은 상황에서는 성능 문제가 발생할 수 있다.
- 카테고리 데이터가 수 만 개 이상
- 계층의 깊이가 10단계 이상
- 매우 빈번한 계층 조회
- 이런 상황에서 조회 성능을 극대화하기 위해 폐쇄 테이블 모델을 사용할 수 잇다.
- 폐쇄 테이블 모델은 모든 조상-자손 관계를 미리 계산해서 별도의 테이블에 저장하는 방식이다.
인접 리스트 : 노트북 -> 컴퓨터(직속 부모만)
폐쇄 테이블 : 노트북 -> 노트북, 노트북 -> 컴퓨터, 노트북 -> 전자제품
- 핵심 아이디어는 조회 시점의 재귀를 저장 시점으로 옮기는 것이다.
- 데이터를 저장할 때 미리 모든 관계를 계산해두면 조회할 때는 단순히 JOIN만으로 매우 빠르게 결과를 얻을 수 있다.
폐쇄 테이블 설계
- 폐쇄 테이블 모델은 2개의 테이블이 필요하다.
- 노드 테이블 : 실제 데이터를 저장
- 경로 테이블 : 모든 조상-자손 관계를 저장
-- 카테고리 테이블 (노드 테이블)
CREATE TABLE category_closure (
category_id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
PRIMARY KEY (category_id)
);
-- 경로 테이블 (관계 테이블)
CREATE TABLE category_path (
ancestor_id BIGINT NOT NULL, -- 조상 노드
descendant_id BIGINT NOT NULL, -- 자손 노드
depth INT NOT NULL, -- 거리 (깊이 차이)
PRIMARY KEY (ancestor_id, descendant_id),
FOREIGN KEY (ancestor_id) REFERENCES category_closure(category_id),
FOREIGN KEY (descendant_id) REFERENCES category_closure(category_id)
);
- 그러나 폐쇄 테이블에서 만약 자식이 있는 노드를 삭제하려면 자손들의 경로도 함께 삭제해야 한다.
- 이 부분은 복잡하기에 보통 애플리케이션 레벨에서 처리하거나 자식이 있는 노드는 삭제하지 못하도록 제약을 둔다.
- 폐쇄 테이블은 노드를 관리하는 부분에서 복잡함이 있다. 이렇게 관리가 복잡한 것이 폐쇄 테이블 모델의 주요 단점이다.
| 항목 | 인접 리스트 | 폐쇄 테이블 |
|---|---|---|
| 저장 공간 | 효율적(n개 행) | 비효율적(O(n^2)) |
| 직속 자식 조회 | 빠름 | 빠름 |
| 모든 자손 조회 | CTE 필요(재귀) | 매우 빠름(단순 JOIN) |
| 모든 조상 조회 | CTE 필요(재귀) | 매우 빠름(단순 JOIN) |
| 노드 추가 | 간단 | 경로 추가 필요 |
| 노드 삭제 | 간단 | 경로 삭제 필요 |
| 서브트리 이동 | 매우 간단 | 복잡 |
| 구현 복잡도 | 낮음 | 높음 |
언제 폐쇄 테이블을 사용하는 것이 좋은지?
- 조회가 매우 빈번한 경우 : 쓰기보다 읽기가 압도적으로 많은 경우
- 데이터가 매우 많은 경우 : 카테고리가 수만 개 이상이고 계층 깊이가 깊은 경우
- 재귀 쿼리를 사용할 수 없는 경우 : 레거시 시스템이나 MySQL 5.7 이하인 경우