본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
선택 심화 · 14장 : 계층형 데이터

인접 리스트와 순환 방지

부모 ID 하나로 회원 게시판 카테고리 트리를 저장하고 FK만으로 막히지 않는 순환을 안전한 이동 프로시저로 차단합니다.

이 장은 가변 깊이 분류·조직·메뉴처럼 계층 조회와 이동이 실제 요구일 때 읽는 선택 심화입니다.

PK·자기 참조 FK, 트랜잭션, 저장 프로시저 실행 방법이 선행 개념입니다.

한 행을 노드, 부모와 자식의 직접 연결을 간선, 부모가 없는 시작 노드를 루트, 다시 출발점으로 돌아오는 경로를 순환이라 부릅니다.

부모가 하나이고 순환이 없는 계층은 트리이며, 부모가 여러 개일 수 있지만 순환이 없는 구조는 DAG(Directed Acyclic Graph, 방향성 비순환 그래프)입니다.

게시판 카테고리는 루트에서 시작해 백엔드, Spring, 데이터베이스처럼 깊이가 늘어납니다.

관계형 테이블은 평면이지만 각 행이 바로 위 부모를 가리키면 가변 깊이 트리를 작게 저장할 수 있습니다.

인접 리스트의 어려움은 저장보다 변경입니다.

자기 참조 FK는 없는 부모를 막지만 자신의 자손 아래로 이동해 순환을 만드는 UPDATE까지 막지 못합니다.

회원 게시판 카테고리에 개발 → 백엔드 → Spring → 데이터베이스 경로를 만들고, 루트를 데이터베이스 아래로 옮기는 잘못된 작업을 실행합니다.


순환 이동의 위험

세 노드가 모두 존재하므로 FK는 UPDATE를 허용합니다.

이후 하위 탐색은 다시 출발점으로 돌아와 재귀 제한까지 같은 행을 반복합니다.

FK를 통과한 3노드 cycle
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;

FK 오류는 없지만 1→2→3→1 경로가 반복되어 MySQL 재귀 횟수 제한에서 중단됩니다.

오류 실행 결과
UPDATE: Query OK, 1 row affected

ERROR 3636 (HY000): Recursive query aborted after 1001 iterations.
Try increasing @@cte_max_recursion_depth.

foreign key violations: 0

자기 참조 FK는 parent_id가 존재한다는 국소 규칙만 보장합니다.

순환 없음은 여러 행을 따라가야 알 수 있는 그래프 규칙이므로 이동 전에 대상 부모가 현재 하위 트리 안에 있는지 탐색해야 합니다.

깊이 제한을 늘리는 것은 순환 해결이 아닙니다.

쓰기 경로에서 거절하고 읽기 CTE에도 방문 여부 방어 조건과 최대 깊이를 둬 기존 오염이 무한 반복으로 번지지 않게 합니다.

FK가 성공시킨 1→2→3→1 순환 추적

  1. 자기 참조category_idparent_id가 같은 직접 순환을 먼저 막습니다.
  2. 자손 — 새 부모가 현재 노드의 모든 자손 중 하나인지 조회합니다.
  3. 원자적 이동 — 검사와 UPDATE 사이에 다른 이동이 끼지 않도록 트랜잭션 단위를 둡니다.
  4. 읽기 방어 조건 — 오염된 기존 그래프에서도 방문 여부 경로와 최대 깊이로 종료합니다.

인접 리스트의 저장 원리

루트는 parent_id NULL, 나머지는 정확히 한 부모를 갖습니다.

parent_id 인덱스는 직속 자식과 재귀 단계의 조회를 지원합니다.

일반 UPDATE 권한을 열어두면 안전 프로시저를 우회할 수 있습니다.

애플리케이션 계정은 이동 프로시저만 실행하고 테이블 직접 UPDATE는 제한해야 순환 검사가 실제 규칙이 됩니다.

직속 간선과 그래프 불변식을 분리하기

  1. 소유권 — 분류 노드의 수명과 이름을 분류가 소유합니다.
  2. 간선parent_id는 바로 위 한 간선만 저장합니다.
  3. 이동 — 새 부모 후보가 하위 트리 밖인지 CTE로 판단합니다.
  4. 권한 — 검증된 명령 외 직접 부모 UPDATE를 막습니다.

순환을 막는 이동 프로시저

7개 게시판 카테고리와 부모 인덱스를 만들고, 이동 프로시저가 대상 존재·자기 참조 이동·자손 이동을 검사한 뒤 한 번의 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 ;

정상 이동은 parent_id 한 행만 바꾸고, 루트나 중간 노드를 자기 하위 트리 아래로 옮기는 요청은 사용자 정의 오류로 중단됩니다.

개선 실행 결과
categories inserted: 7
roots: 1
direct edges: 6

CALL move_category_adjacency(1, 7):
ERROR 1644 (45000): cycle-producing move

parent of category 1 after failure: NULL

검사 CTE와 UPDATE가 여러 구문이므로 강한 동시 이동이 있으면 트랜잭션과 잠금 정책이 더 필요합니다.

클로저 테이블을 도입하면 순환 검사가 단일 인덱스 조회가 되지만 쓰기 행 수가 늘어납니다.

루트가 여러 개인 여러 루트 트리도 같은 스키마로 표현할 수 있습니다.

정확히 하나의 루트가 업무 규칙이라면 배포 일치 검사와 이동 API에서 보장하고, 임시 마이그레이션 중 여러 루트를 허용할지 명시합니다.

오류 이동 뒤 이전 부모가 남았는지 확인하기

  1. 초기 시드 데이터 — 노드 7개·간선 6개·루트 1개를 확인합니다.
  2. 자기 참조 이동 — 분류 2를 부모 2로 옮겨 검사 또는 프로시저 오류를 확인합니다.
  3. 깊은 순환 — 분류 1을 자손 7 아래로 옮겨 1644를 확인합니다.
  4. 유효 이동 — 분류 5를 분류 3 아래로 옮긴 뒤 직속 자식을 조회합니다.

순환·형제 이동 검증

오류 발생 뒤 루트 관계가 그대로인지 확인하고 정상 이동 뒤에는 이전 부모와 신규 부모의 직속 자식 목록이 정확히 바뀌어야 합니다.

인접 리스트 이동 인수 query
CALL move_category_adjacency(1, 7);
-- ERROR 1644: cycle-producing move

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;

순환 이동 뒤 분류 1의 부모는 NULL입니다.

정상 이동 뒤 Spring은 프론트엔드의 자식이고 백엔드의 자식 목록에서는 빠집니다.

root_count는 1입니다.

인접·경로·중첩 집합·클로저 선택 비교

선택얻는 효과감수할 비용적합한 조건
인접 리스트작은 저장·간단한 이동깊은 조회는 재귀일반 트리와 보통 규모일 때
구체화 경로하위 트리 접두사 조회이름 변경·이동 경로 재작성읽기와 경로 표시가 많을 때
중첩 집합범위 한 번으로 하위 트리 조회삽입·이동 대량 갱신거의 변하지 않는 트리일 때
클로저 테이블조상·자손 인덱스 조회O(깊이×하위 트리) 쓰기읽기 비율과 깊이가 높을 때

동시 이동과 루트 수가 만드는 추가 규칙

삭제 정책은 부모 FK만으로 결정되지 않습니다.

자식이 있는 분류 삭제를 RESTRICT할지 자식을 부모로 승격할지 하위 트리 전체를 보관할지 명령별로 정의합니다.

동일 부모 안 sort_order 중복을 허용하면 category_id를 동률 정렬 키로 사용합니다.

사용자가 직접 재정렬한다면 희소 순서나 별도 순위 전략을 검토합니다.

DAG·형제 이름·하위 트리 삭제의 경계

  • 동시 이동 두 건이 서로의 검사 뒤 순환을 만들 수 있어 쓰기 직렬화가 필요할 수 있습니다.
  • DAG처럼 부모가 여러 명이면 parent_id 한 컬럼이 아니라 간선 테이블이 필요합니다.
  • 루트 삭제는 전체 하위 트리 수명 정책과 함께 승인합니다.
  • 이름 중복을 허용하되 같은 부모 아래 형제 이름 유일성이 필요한지 별도 결정합니다.

트리 건전성과 하위 트리 이동을 관찰하기

루트 수, 자기 참조 간선, 고아, 순환 탐지 쿼리와 최대 깊이를 스키마 건전성 지표로 둡니다.

분류 이동은 이전/신규 부모·하위 트리 크기·요청자·request_id를 감사 기록에 남깁니다.

자기 참조·깊은 순환·루트 이동을 흔드는 실험

  1. 직접 순환 — 분류 2의 부모를 2로 요청합니다.
  2. 깊은 순환 — 루트 1을 데이터베이스 7 아래로 요청합니다.
  3. 루트 이동 — 프론트엔드 3을 NULL 부모로 바꾸어 여러 루트 트리 정책을 평가합니다.
  4. 삭제 — 자식이 있는 데이터베이스 2를 삭제할 때 FK RESTRICT 오류가 발생하는지 봅니다.

다음 문서는 고정 깊이 자기 참조 JOIN을 버리고 재귀 CTE에 깊이·전체 경로·방문 여부 방어 조건을 붙여 전체 하위 트리를 한 번에 읽습니다.


인접 리스트 적용 기준

판단 축확인할 질문
부모 수각 노드가 최대 한 부모만 갖는가?
변경삽입·이동이 읽기보다 충분히 중요한가?
깊이가변 깊이 조회를 재귀 CTE로 허용할 수 있는가?
순환모든 이동 경로가 검증 명령을 통과하는가?
삭제자식·하위 트리 수명 규칙이 명확한가?

인접 리스트는 기본값으로 좋지만 자기 참조 FK만 보고 안전하다고 말할 수 없습니다.

순환 없는 이동과 보호 조건이 있는 읽기가 모델의 일부입니다.


연습 문제

같은 부모 아래 category_name 중복을 막되 다른 분기의 같은 이름은 허용하세요.

NULL 부모인 루트 이름도 중복되지 않아야 합니다.

해설과 예시 답안

MySQL UNIQUE는 NULL을 여러 개 허용하므로 루트용 고정 부모 키를 생성 열로 만들고 (effective_parent_id, category_name)을 유일하게 둡니다.

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);

백엔드 분기의 Spring와 프론트엔드 분기의 Spring는 허용됩니다.

같은 부모 안 중복과 같은 이름 루트 두 개는 1062여야 합니다.


핵심 정리

  • 인접 리스트는 직속 부모 간선만 저장합니다.
  • 자기 참조 FK는 존재를 보장하지만 순환을 막지 못합니다.
  • 이동 전 하위 트리 검사와 직접 UPDATE 권한 제한이 필요합니다.
  • 읽기에도 방문 여부와 최대 깊이 안전장치를 둡니다.

다음 문서에서는 재귀 CTE로 하위·상위·전체 경로를 깊이와 함께 읽습니다.