인접 리스트와 순환 방지
부모 ID 하나로 카테고리 트리를 저장하고, 자기 참조 외래 키가 막지 못하는 순환을 이동하기 전에 하위 트리 탐색으로 거부합니다. 오류 뒤의 상태와 정상 이동을 확인하고, 동시 이동이 깨뜨리는 전제도 구분합니다.
이 장은 깊이가 정해지지 않은 분류나 조직, 메뉴처럼 계층의 조회와 이동이 실제 요구일 때 읽는 선택 심화입니다. 기본 키와 자기 참조 외래 키, 트랜잭션, 저장 프로시저의 실행 방법을 알고 있다고 전제합니다.
한 행을 노드(node), 부모와 자식의 직접 연결을 간선(edge), 부모가 없는 시작 노드를 루트(root), 다시 출발점으로 돌아오는 경로를 순환(cycle)이라고 부릅니다. 부모가 하나이고 순환이 없는 계층은 트리(tree)이며, 부모가 여러 개일 수 있지만 순환이 없는 구조는 DAG(Directed Acyclic Graph, 방향성 비순환 그래프)입니다. 이 장의 트리는 루트 하나에서 모든 노드로 이어지는 구조를 말하고, 같은 부모 열로 서로 이어지지 않은 루트를 여러 개 저장하면 여러 트리의 모음인 숲(forest)이 됩니다.
게시판의 카테고리는 루트에서 시작해 백엔드, Spring, 데이터베이스처럼 깊어집니다. 관계형 테이블은 평면이지만, 각 행이 바로 위의 부모를 가리키는 인접 리스트(adjacency list)로 깊이가 정해지지 않은 트리를 작게 저장할 수 있습니다.
인접 리스트의 어려움은 저장보다 변경에 있습니다. 자기 참조 외래 키는 없는 부모를 막지만, 노드를 자신의 자손 아래로 옮겨 순환을 만드는 UPDATE까지는 막지 못합니다. 첫 예제는 개발 1 → 백엔드 2 → Spring 3의 세 노드로 시작해 개발을 Spring 아래로 옮기고, 뒤의 정상 예제는 별도의 일곱 노드 트리를 씁니다.
부모가 모두 존재해도 생기는 순환
반례의 세 노드를 읽는다. 재귀 조회는 순환 경로를 만난다. 각 단계의 값과 전환 이유를 비교합니다.
ADJACENCY LIST · PARENT EXISTS / NO CYCLE
부모 존재를 검사하는 FK는 1→2→3→1 순환을 막지 못한다
categories_bad의 개발 1을 Spring 3 아래로 이동하면 부모가 모두 존재해 UPDATE는 성공합니다. 하위 탐색은 다시 1로 돌아가며 기본 재귀 상한에서 중단됩니다.
아래 categories_bad의 UPDATE 이후입니다. 화살표는 부모 → 자식 방향입니다.
이동 전 검사 · 자손을 새 부모로 선택하지 않기
정상 7노드 예제: 이동 노드 1의 하위 트리에 새 부모 7이 존재
move_category_adjacency(1,7) → 1644 · 부모 NULL 유지
이 프로시저는 정상 유한 트리의 단일 작성자 예제입니다. 자체 트랜잭션·잠금·방문 경로 방어·직접 UPDATE 권한 제한을 구현하지 않았습니다. 동시 이동을 지원하려면 별 직렬화 정책이 필요합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 직선 계층부터 순환 검사까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | categories_bad의 세 노드입니다. 부모에서 자식으로 1→2→3→1 순환이 생기며, 오른쪽 바깥 경로가 3에서 1로 돌아갑니다. 개발 1의 parent_id=3은 존재하는 부모를 가리키므로 FK는 통과합니다. 정상 7노드 트리와 다른 입력입니다. |
| 설명·조건 | 아래 categories_bad의 UPDATE 이후입니다. 화살표는 부모 → 자식 방향입니다. |
| 설명·조건 | 이 프로시저는 정상 유한 트리의 단일 작성자 예제입니다. 자체 트랜잭션·잠금·방문 경로 방어·직접 UPDATE 권한 제한을 구현하지 않았습니다. 동시 이동을 지원하려면 별도의 직렬화 정책이 필요합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 직선 계층 반례의 세 노드를 읽는다. |
|
|
| 부모 변경 1의 부모를 존재하는 3으로 바꾼다. |
|
|
| 순환 검사 재귀 조회는 순환 경로를 만난다. |
|
|
부모가 모두 존재해도 생기는 순환
반례의 세 노드를 읽는다. 재귀 조회는 순환 경로를 만난다. 각 단계의 값과 전환 이유를 비교합니다.
ADJACENCY LIST · PARENT EXISTS / NO CYCLE
부모 존재를 검사하는 FK는 1→2→3→1 순환을 막지 못한다
categories_bad의 개발 1을 Spring 3 아래로 이동하면 부모가 모두 존재해 UPDATE는 성공합니다. 하위 탐색은 다시 1로 돌아가며 기본 재귀 상한에서 중단됩니다.
아래 categories_bad의 UPDATE 이후입니다. 화살표는 부모 → 자식 방향입니다.
이동 전 검사 · 자손을 새 부모로 선택하지 않기
정상 7노드 예제: 이동 노드 1의 하위 트리에 새 부모 7이 존재
move_category_adjacency(1,7) → 1644 · 부모 NULL 유지
이 프로시저는 정상 유한 트리의 단일 작성자 예제입니다. 자체 트랜잭션·잠금·방문 경로 방어·직접 UPDATE 권한 제한을 구현하지 않았습니다. 동시 이동을 지원하려면 별 직렬화 정책이 필요합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 직선 계층부터 순환 검사까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | categories_bad의 세 노드입니다. 부모에서 자식으로 1→2→3→1 순환이 생기며, 오른쪽 바깥 경로가 3에서 1로 돌아갑니다. 개발 1의 parent_id=3은 존재하는 부모를 가리키므로 FK는 통과합니다. 정상 7노드 트리와 다른 입력입니다. |
| 설명·조건 | 아래 categories_bad의 UPDATE 이후입니다. 화살표는 부모 → 자식 방향입니다. |
| 설명·조건 | 이 프로시저는 정상 유한 트리의 단일 작성자 예제입니다. 자체 트랜잭션·잠금·방문 경로 방어·직접 UPDATE 권한 제한을 구현하지 않았습니다. 동시 이동을 지원하려면 별도의 직렬화 정책이 필요합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 직선 계층 반례의 세 노드를 읽는다. |
|
|
| 부모 변경 1의 부모를 존재하는 3으로 바꾼다. |
|
|
| 순환 검사 재귀 조회는 순환 경로를 만난다. |
|
|
순환 이동의 위험
board_lab의 categories는 게시글이 외래 키로 참조하고 있어 지우거나 다시 만들 수 없으므로, 이 장의 실습은 별도의 데이터베이스 cs_db_lab에서 합니다. 다음 장은 다시 board_lab에서 진행합니다.
세 노드가 모두 있으므로, 외래 키는 개발의 부모를 Spring으로 바꾸는 UPDATE를 허용합니다. 그 뒤의 하위 탐색은 출발점으로 다시 돌아와, 재귀의 제한에 걸릴 때까지 같은 행을 반복합니다.
CREATE DATABASE IF NOT EXISTS cs_db_lab;
USE cs_db_lab;
DROP TABLE IF EXISTS categories_bad;
CREATE TABLE categories_bad (
category_id BIGINT UNSIGNED NOT NULL,
category_name VARCHAR(100) NOT NULL,
parent_id BIGINT UNSIGNED NULL,
PRIMARY KEY (category_id),
CONSTRAINT fk_category_bad_parent FOREIGN KEY (parent_id)
REFERENCES categories_bad (category_id)
) ENGINE=InnoDB;
INSERT INTO categories_bad
(category_id, category_name, parent_id)
VALUES
(1, '개발', NULL),
(2, '백엔드', 1),
(3, 'Spring', 2);
UPDATE categories_bad
SET parent_id = 3
WHERE category_id = 1;
WITH RECURSIVE descendants AS (
SELECT category_id, category_name, parent_id, 0 AS depth
FROM categories_bad
WHERE category_id = 1
UNION ALL
SELECT c.category_id, c.category_name, c.parent_id, d.depth + 1
FROM categories_bad AS c
JOIN descendants AS d ON c.parent_id = d.category_id
)
SELECT category_id, category_name, depth
FROM descendants;ERROR 3636 (HY000): Recursive query aborted after 1001 iterations. Try increasing @@cte_max_recursion_depth to a larger value.UPDATE는 외래 키 오류 없이 한 행을 바꾸었지만, 1 → 2 → 3 → 1의 경로가 반복되어 재귀 CTE가 중단됩니다. 횟수 1001은 cte_max_recursion_depth의 기본값 1000을 넘은 시점입니다.
자기 참조 외래 키는 parent_id가 가리키는 행이 있다는 한 행 단위의 규칙만 보장합니다. 순환이 없다는 것은 여러 행을 따라가야 알 수 있는 그래프의 규칙이므로, 옮기기 전에 새 부모가 현재 하위 트리 안에 있는지 탐색해야 합니다. 깊이 제한을 늘리는 것은 순환의 해결이 아니므로, 쓰기 경로에서 거부하고 읽기 CTE에도 방문 여부의 검사와 최대 깊이를 두어 이미 오염된 데이터가 무한 반복으로 번지지 않게 합니다.
막는 순서는 이렇습니다. category_id와 parent_id가 같은 직접 순환은 CHECK로 먼저 막고, 새 부모가 현재 노드의 자손 중 하나인지 조회합니다. 검사와 UPDATE 사이에 다른 이동이 끼어들지 않도록 구조의 쓰기를 직렬화하고, 읽기는 방문 경로와 최대 깊이로 끝나게 합니다.
인접 리스트의 저장 원리
루트는 parent_id가 NULL이고, 나머지 노드는 정확히 부모 하나를 갖습니다. parent_id의 인덱스는 직속 자식의 조회와 재귀 단계마다의 조회를 지원합니다. 분류 노드의 수명과 이름은 분류가 소유하고, parent_id는 바로 위 부모로 가는 간선 하나만 저장합니다.
애플리케이션 계정에 일반 UPDATE 권한을 열어 두면, 안전한 이동 프로시저를 우회할 수 있습니다. 애플리케이션 계정은 이동 프로시저만 실행하고 테이블의 직접 UPDATE는 제한해야, 순환 검사가 실제 규칙이 됩니다.
순환을 막는 이동 프로시저
게시판 카테고리 일곱 개와 부모 인덱스를 만들고, 이동 프로시저가 대상의 존재와 자기 자신으로의 이동, 자손 아래로의 이동을 검사한 뒤 한 번의 UPDATE만 하게 합니다. 이 프로시저는 정상적인 유한 트리를 한 작성자만 바꾸는 예제입니다. 자체 트랜잭션과 잠금, 방문 경로의 방어를 구현하지 않았으므로, 여러 이동이 동시에 일어나거나 이미 순환이 생긴 데이터에 그대로 쓰지 않습니다.
DROP PROCEDURE IF EXISTS move_category_adjacency;
DROP TABLE IF EXISTS categories;
CREATE TABLE categories (
category_id BIGINT UNSIGNED NOT NULL,
category_name VARCHAR(100) NOT NULL,
parent_id BIGINT UNSIGNED NULL,
sort_order SMALLINT UNSIGNED NOT NULL,
PRIMARY KEY (category_id),
CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id)
REFERENCES categories (category_id) ON DELETE RESTRICT,
CONSTRAINT chk_categories_self
CHECK (parent_id IS NULL OR parent_id <> category_id),
INDEX ix_categories_parent_order (parent_id, sort_order, category_id)
) ENGINE=InnoDB;
INSERT INTO categories
(category_id, category_name, parent_id, sort_order)
VALUES
(1, '개발', NULL, 10),
(2, '백엔드', 1, 10),
(3, '프론트엔드', 1, 20),
(4, 'Java', 2, 10),
(5, 'Spring', 2, 20),
(6, 'React', 3, 10),
(7, '데이터베이스', 5, 10);
DELIMITER //
CREATE PROCEDURE move_category_adjacency(
IN p_category_id BIGINT UNSIGNED,
IN p_new_parent_id BIGINT UNSIGNED
)
procedure_body: BEGIN
DECLARE v_category_count INT DEFAULT 0;
DECLARE v_parent_count INT DEFAULT 0;
DECLARE v_cycle_count INT DEFAULT 0;
SELECT COUNT(*) INTO v_category_count
FROM categories
WHERE category_id = p_category_id;
IF v_category_count = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'category not found';
END IF;
IF p_new_parent_id IS NOT NULL THEN
SELECT COUNT(*) INTO v_parent_count
FROM categories
WHERE category_id = p_new_parent_id;
IF v_parent_count = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'new parent not found';
END IF;
WITH RECURSIVE subtree AS (
SELECT category_id
FROM categories
WHERE category_id = p_category_id
UNION ALL
SELECT c.category_id
FROM categories AS c
JOIN subtree AS s ON c.parent_id = s.category_id
)
SELECT COUNT(*) INTO v_cycle_count
FROM subtree
WHERE category_id = p_new_parent_id;
IF v_cycle_count > 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'cycle-producing move';
END IF;
END IF;
UPDATE categories
SET parent_id = p_new_parent_id
WHERE category_id = p_category_id;
END//
DELIMITER ;
SELECT COUNT(*) AS nodes,
SUM(parent_id IS NULL) AS roots,
SUM(parent_id IS NOT NULL) AS direct_edges
FROM categories;nodes | roots | direct_edges
7 | 1 | 6정상적인 이동은 parent_id 한 행만 바꾸고, 노드를 자기 하위 트리 아래로 옮기려는 요청은 사용자 정의 오류로 중단됩니다. 하위 트리 탐색은 출발 노드 자신도 포함하므로, 자기 자신을 새 부모로 지정해도 같은 오류가 납니다.
여러 작성자를 지원하려면 검사부터 갱신까지 겹치는 구조 변경을 배제하는 잠금이나 직렬화 정책이 필요하며, 직접 UPDATE의 권한을 제한하는 명령도 위 SQL에는 들어 있지 않습니다. 클로저 테이블을 쓰면 순환 검사가 인덱스 조회 한 번이 되지만, 쓰는 행이 늘어납니다(14장 3편). 루트가 여러 개인 숲도 같은 스키마로 나타낼 수 있으므로, 루트가 정확히 하나라는 것이 업무 규칙이라면 배포 뒤의 일치 확인과 이동 API에서 보장하고, 마이그레이션 중에 루트 여러 개를 잠시 허용할지도 정해 둡니다.
순환 이동과 정상 이동의 검증
오류가 난 뒤 루트의 관계가 그대로인지 확인하고, 정상 이동 뒤에는 이전 부모와 새 부모의 직속 자식 목록이 정확히 바뀌었는지 봅니다. 첫 CALL은 루트 개발을 자손인 데이터베이스 아래로 옮기려 하고, 둘째 CALL은 Spring을 프론트엔드 아래로 옮깁니다.
CALL move_category_adjacency(1, 7);
SELECT category_id, category_name, parent_id
FROM categories
WHERE category_id = 1;
CALL move_category_adjacency(5, 3);
SELECT parent.category_name AS parent_name,
child.category_id,
child.category_name,
child.sort_order
FROM categories AS parent
JOIN categories AS child
ON child.parent_id = parent.category_id
WHERE parent.category_id IN (2, 3)
ORDER BY parent.category_id, child.sort_order, child.category_id;
SELECT COUNT(*) AS root_count
FROM categories
WHERE parent_id IS NULL;ERROR 1644 (45000): cycle-producing move
category_id | category_name | parent_id
1 | 개발 | NULL
parent_name | category_id | category_name | sort_order
백엔드 | 4 | Java | 10
프론트엔드 | 6 | React | 10
프론트엔드 | 5 | Spring | 20
root_count
1순환을 만드는 이동은 SIGNAL에서 멈추었으므로 개발의 부모는 그대로 NULL입니다. 정상 이동 뒤 Spring은 프론트엔드의 자식이 되어 백엔드의 자식 목록에서 빠졌고, Spring의 자식인 데이터베이스도 간선을 바꾸지 않은 채 함께 옮겨졌습니다. 루트는 여전히 하나입니다.
다음 글은 처음의 일곱 노드 트리를 기준으로 하므로, 확인을 마쳤다면 Spring을 원래 부모로 되돌립니다.
CALL move_category_adjacency(5, 2);프로시저의 두 인자는 차례로 옮길 분류의 id와 새 부모의 id이므로, Spring(5)을 처음의 부모인 백엔드(2) 아래로 다시 옮깁니다. 데이터베이스(7)는 여전히 Spring을 부모로 가리키므로 함께 돌아오고, 트리는 처음의 일곱 노드 모양이 됩니다.
저장 방식과 삭제 규칙
| 방식 | 장점 | 비용 | 알맞은 경우 |
|---|---|---|---|
| 인접 리스트 | 저장이 작고 이동이 간단하다 | 깊은 조회에는 재귀가 필요하다 | 보통 규모의 일반 트리 |
| 구체화 경로 | 접두사로 하위 트리를 조회한다 | 이동과 이름 변경 때 경로를 다시 쓴다 | 읽기와 경로 표시가 많을 때 |
| 중첩 집합 | 범위 조회 한 번으로 하위 트리를 읽는다 | 삽입과 이동 때 대량 갱신이 필요하다 | 거의 변하지 않는 트리 |
| 클로저 테이블 | 조상과 자손을 인덱스로 읽는다 | 이동에 깊이와 하위 트리 크기에 비례하는 쓰기가 든다 | 읽기 비율과 깊이가 높을 때 |
삭제의 정책은 부모 외래 키만으로 정해지지 않습니다. 자식이 있는 분류의 삭제를 RESTRICT로 막을지, 자식을 위로 올릴지, 하위 트리 전체를 보관할지 명령마다 정합니다. 같은 부모 안에서 sort_order가 겹칠 수 있다면 category_id를 동률을 가르는 정렬 키로 쓰고, 사용자가 직접 순서를 바꾼다면 간격을 둔 순서 값이나 별도의 순위 전략을 검토합니다.
부모가 여러 개인 DAG라면 parent_id 한 열이 아니라 간선 테이블이 필요하고, 루트의 삭제는 하위 트리 전체의 수명 정책과 함께 승인합니다. 이름의 중복을 허용하더라도, 같은 부모 아래 형제의 이름은 유일해야 하는지 따로 정합니다(연습 문제 2).
운영에서는 루트의 수와 자기 참조 간선, 고아, 순환 탐지 쿼리의 결과, 최대 깊이를 스키마 건전성의 지표로 둡니다. 분류를 옮길 때는 이전 부모와 새 부모, 하위 트리의 크기, 요청자, request_id를 감사 기록에 남깁니다.
| 따져 볼 것 | 질문 |
|---|---|
| 부모 수 | 각 노드의 부모가 최대 하나인가 |
| 변경 | 삽입과 이동이 읽기보다 충분히 중요한가 |
| 깊이 | 깊이가 정해지지 않은 조회를 재귀 CTE로 허용할 수 있는가 |
| 순환 | 모든 이동 경로가 검증하는 명령을 거치는가 |
| 삭제 | 자식과 하위 트리의 수명 규칙이 분명한가 |
인접 리스트는 좋은 기본값이지만, 자기 참조 외래 키만 보고 안전하다고 말할 수는 없습니다. 순환 없는 이동과 보호 조건이 있는 읽기까지가 모델의 일부입니다.
정리
- 인접 리스트는 바로 위 부모로 가는 간선만 저장합니다.
- 자기 참조 외래 키는 부모의 존재를 보장하지만 순환을 막지 못합니다.
- 옮기기 전에 새 부모가 하위 트리 안에 있는지 검사하고, 직접 UPDATE의 권한을 제한합니다.
- 읽기에도 방문 여부와 최대 깊이의 안전장치를 둡니다.
- 한 요청의 검사가 맞아도, 동시에 끝난 두 이동은 순환을 만들 수 있습니다.
연습 문제
1. 동시에 통과한 두 검사
처음의 일곱 노드 트리에서 요청 A는 백엔드 2를 프론트엔드 3 아래로, 요청 B는 프론트엔드 3을 백엔드 2 아래로 옮깁니다. 두 요청 모두 상대의 UPDATE 전의 트리를 읽어 자손 검사를 마친 뒤, A와 B의 UPDATE가 각각 적용되었다고 합시다. 처음의 검사에서 각 새 부모는 자기 하위 트리 안에 있나요? 둘 다 적용된 구조는 외래 키와 순환 없음의 조건을 각각 만족하나요?
해설 보기
처음 트리에서 2의 하위 트리는 {2, 4, 5, 7}, 3의 하위 트리는 {3, 6}이므로 두 검사 모두 상대를 찾지 못하고 통과합니다. 그러나 두 UPDATE가 적용되면 2의 부모는 3, 3의 부모는 2가 되어 2 → 3 → 2의 순환이 생깁니다. 두 부모 행은 모두 있으므로 자기 참조 외래 키의 조건은 맞지만, 순환 없음은 깨집니다.
이것이 본문의 프로시저가 한 작성자만 구조를 바꾼다는 전제를 둔 이유입니다. 검사와 갱신을 트랜잭션으로 묶는 것만으로는 부족하고, 두 요청이 같은 오래된 구조를 보고 검사를 끝내지 않도록 구조 쓰기의 직렬화 범위를 정해야 합니다. 예를 들어 이동하는 트랜잭션마다 루트 행이나 별도의 트리 잠금 행을 SELECT ... FOR UPDATE로 먼저 잠그면, 두 이동이 차례로 실행되어 뒤의 요청이 바뀐 구조에서 검사합니다. 이 해설은 동시 실행을 관찰한 결과가 아니라 정해 둔 순서를 따라 읽은 모델입니다.
2. 형제 이름의 유일성
같은 부모 아래에서 category_name이 겹치지 않게 하되, 다른 분기의 같은 이름은 허용해 보세요. 부모가 NULL인 루트의 이름도 겹치지 않아야 합니다. 본문의 이동을 되돌린 상태에서 시작합니다.
해설 보기
MySQL의 UNIQUE는 NULL을 여러 개 허용하므로, 루트를 위한 고정 부모 키를 생성 열로 만들고 (effective_parent_id, category_name)을 유일하게 둡니다. 이 답안은 실제 category_id로 0을 쓰지 않고 루트의 표식으로 예약한다는 정책을 전제로 합니다. UNSIGNED라도 0은 허용되므로 모든 쓰기 경로가 양수 ID만 써야 하며, 이를 지키지 않으면 실제 부모 0의 자식과 루트가 같은 유일 키 공간에 섞입니다.
ALTER TABLE categories
ADD COLUMN effective_parent_id BIGINT UNSIGNED
GENERATED ALWAYS AS (COALESCE(parent_id, 0)) STORED,
ADD CONSTRAINT uq_categories_sibling_name
UNIQUE (effective_parent_id, category_name);
START TRANSACTION;
INSERT INTO categories (category_id, category_name, parent_id, sort_order)
VALUES (8, 'Spring', 3, 30);
INSERT INTO categories (category_id, category_name, parent_id, sort_order)
VALUES (9, 'Spring', 2, 30);
INSERT INTO categories (category_id, category_name, parent_id, sort_order)
VALUES (10, '개발', NULL, 20);
ROLLBACK;ERROR 1062 (23000): Duplicate entry '2-Spring' for key 'categories.uq_categories_sibling_name'
ERROR 1062 (23000): Duplicate entry '0-개발' for key 'categories.uq_categories_sibling_name'프론트엔드 분기의 Spring은 백엔드 분기의 Spring과 부모가 달라 허용되고, 같은 백엔드 아래의 두 번째 Spring과 같은 이름의 두 번째 루트는 1062로 거부됩니다. 오류는 각 문장만 되돌리고 마지막 ROLLBACK이 허용된 첫 INSERT까지 취소합니다. 생성 열의 기반 열 parent_id에는 CASCADE나 SET NULL 같은 참조 동작을 둘 수 없지만, 이 외래 키는 RESTRICT이므로 문제가 없습니다. 다음 글들은 이 열 없이 진행하므로, 확인한 뒤에는 ALTER TABLE categories DROP INDEX uq_categories_sibling_name, DROP COLUMN effective_parent_id;로 되돌립니다.
다음 글 재귀 CTE 탐색에서는 고정된 깊이의 자기 참조 JOIN 대신, 재귀 CTE에 깊이와 전체 경로, 방문 여부의 방어 조건을 붙여 하위 트리 전체를 한 번에 읽습니다.