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

안동민 개발노트

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

하위 트리 이동

parent_id만 바꿔 클로저 테이블이 최신 상태를 잃는 오류를 재현하고 이전 경로 삭제·신규 경로 삽입·순환 검사를 프로시저 하나로 묶습니다.

클로저 읽기가 빠른 대가는 이동 한 번이 여러 경로를 바꾼다는 점입니다.

Spring 하위 트리를 프론트엔드 아래로 옮기면 Spring뿐 아니라 그 자손 데이터베이스의 모든 외부 조상 경로도 다시 계산해야 합니다.

인접 목록과 클로저를 따로 커밋하면 어느 한쪽만 새 구조가 되어 쿼리마다 다른 트리를 보여 줍니다.

이동은 순환 검사부터 두 모델 갱신까지 하나의 트랜잭션이어야 합니다.

현재 Spring 5와 데이터베이스 7은 백엔드 2 아래에 있습니다.

parent_id만 프론트엔드 3으로 바꾼 뒤 클로저가 여전히 데이터베이스를 조상으로 반환하는 불일치를 만듭니다.


일부 경로만 바꾼 이동

직속 자식 쿼리는 새 부모를 보여 주지만 클로저 조상 쿼리는 이전 경로를 반환합니다.

두 원본이 갈라졌어도 SQL 오류와 FK 위반은 없습니다.

closure를 갱신하지 않은 subtree 이동
UPDATE categories
SET parent_id = 3
WHERE category_id = 5;

SELECT parent_id
FROM categories
WHERE category_id = 5;

SELECT ancestor.category_id,
       ancestor.category_name,
       path.depth
FROM category_closure AS path
JOIN categories AS ancestor
  ON ancestor.category_id = path.ancestor_id
WHERE path.descendant_id = 7
ORDER BY path.depth;

인접 목록 부모는 3이지만 데이터베이스 클로저에는 백엔드 2가 조상으로 남고 프론트엔드 3은 없습니다.

오류 실행 결과
adjacency parent of index(5): 3

closure ancestors of 데이터베이스(7):
7 데이터베이스       depth 0
5 Spring       depth 1
2 데이터베이스 depth 2
1 개발  depth 3

adjacency/closure drift rows: 2

하위 트리 내부 경로는 이동 뒤에도 같습니다.

바뀌는 것은 이전 외부 조상에서 하위 트리 자손으로 가는 경로와 신규 부모의 모든 조상에서 하위 트리 자손으로 가는 경로입니다.

새 부모가 하위 트리 안에 있으면 순환이므로 클로저의 (node, new_parent) 존재 여부 한 번으로 거절할 수 있습니다.

클로저가 이미 불일치 상태라면 이동보다 재생성을 먼저 해야 합니다.

부모 1행과 클로저 4행이 갈라진 순간

  1. 잠금 — 이동 노드와 신규 부모를 고정하고 현재 클로저를 같은 스냅샷에서 읽습니다.
  2. 순환 — 노드가 신규 부모의 조상인지 인덱스로 조회합니다.
  3. 분리 — 이전 외부 조상×하위 트리 자손 경로를 삭제합니다.
  4. 연결 — 신규 조상×하위 트리 자손 경로를 깊이 합으로 삽입합니다.

내부·외부 경로의 구분

이전 조상 집합은 클로저에서 자손=노드인 행 중 노드 자신을 제외한 행 수입니다.

하위 트리 집합은 조상=노드인 모든 행 수입니다.

두 집합의 곱이 삭제 대상입니다.

신규 부모의 조상 경로 깊이 + 부모→노드 한 간선 + 노드의 하위 트리 깊이가 새 경로 깊이입니다.

인접 목록 UPDATE와 같은 트랜잭션에서 수행합니다.

이전 조상·하위 트리·신규 조상 집합 나누기

  1. 집합 — 이전 조상·신규 조상·하위 트리 자손을 클로저로 구합니다.
  2. 삭제 — 이전 조상과 하위 트리의 Cartesian 경로만 제거합니다.
  3. 삽입 — 신규 조상 깊이 + 1 + 하위 트리 깊이를 적재합니다.
  4. 감사 — depth1 차집합과 하위 트리 행 건수를 커밋 뒤 확인합니다.

안전한 하위 트리 이동

먼저 클로저를 인접 목록에서 재생성해 잘못된 예제 데이터를 복구합니다.

프로시저는 오류 처리기로 롤백하고 순환·대상 존재를 검사한 뒤 경로와 부모를 함께 바꿉니다.

closure-aware subtree 이동 procedure
UPDATE categories
SET parent_id = 2
WHERE category_id = 5;

DELETE FROM category_closure;

INSERT INTO category_closure
  (ancestor_id, descendant_id, depth)
WITH RECURSIVE paths (ancestor_id, descendant_id, depth) AS (
  SELECT category_id, category_id, 0
  FROM categories
  UNION ALL
  SELECT p.ancestor_id, c.category_id, p.depth + 1
  FROM paths AS p
  JOIN categories AS c
    ON c.parent_id = p.descendant_id
  WHERE p.depth < 20
)
SELECT ancestor_id, descendant_id, depth FROM paths;

DROP PROCEDURE IF EXISTS move_category_subtree;
DELIMITER //
CREATE PROCEDURE move_category_subtree(
  IN p_node_id BIGINT UNSIGNED,
  IN p_new_parent_id BIGINT UNSIGNED
)
procedure_body: BEGIN
  DECLARE v_node_count INT DEFAULT 0;
  DECLARE v_parent_count INT DEFAULT 0;
  DECLARE v_cycle_count INT DEFAULT 0;
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN
    ROLLBACK;
    RESIGNAL;
  END;

  START TRANSACTION;

  SELECT COUNT(*) INTO v_node_count
  FROM categories
  WHERE category_id = p_node_id
  FOR UPDATE;

  IF v_node_count = 0 THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'node 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
    FOR UPDATE;

    IF v_parent_count = 0 THEN
      SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'new parent not found';
    END IF;

    SELECT COUNT(*) INTO v_cycle_count
    FROM category_closure
    WHERE ancestor_id = p_node_id
      AND descendant_id = p_new_parent_id;

    IF v_cycle_count > 0 THEN
      SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'cycle-producing move';
    END IF;
  END IF;

  DELETE path
  FROM category_closure AS path
  JOIN category_closure AS old_ancestor
    ON old_ancestor.ancestor_id = path.ancestor_id
   AND old_ancestor.descendant_id = p_node_id
   AND old_ancestor.ancestor_id <> p_node_id
  JOIN category_closure AS subtree
    ON subtree.ancestor_id = p_node_id
   AND subtree.descendant_id = path.descendant_id;

  IF p_new_parent_id IS NOT NULL THEN
    INSERT INTO category_closure
      (ancestor_id, descendant_id, depth)
    SELECT new_ancestor.ancestor_id,
           subtree.descendant_id,
           new_ancestor.depth + 1 + subtree.depth
    FROM category_closure AS new_ancestor
    JOIN category_closure AS subtree
      ON subtree.ancestor_id = p_node_id
    WHERE new_ancestor.descendant_id = p_new_parent_id;
  END IF;

  UPDATE categories
  SET parent_id = p_new_parent_id
  WHERE category_id = p_node_id;

  COMMIT;
END//
DELIMITER ;

CALL move_category_subtree(5, 3);

Spring에서 데이터베이스로 이어지는 하위 트리 내부 경로는 유지되고, 백엔드로 이어지던 외부 조상 경로는 프론트엔드 쪽으로 교체됩니다.

이제 인접 리스트와 클로저 테이블의 직접 간선이 다시 일치합니다.

개선 실행 결과
moved subtree root: 5
subtree nodes: 2
old external paths removed: 4
new external paths inserted: 4

ancestors of 데이터베이스:
7 데이터베이스      depth 0
5 Spring      depth 1
3 프론트엔드    depth 2
1 개발 depth 3

drift rows: 0

하위 트리가 크고 깊이가 깊으면 삭제·삽입 행 수가 커져 잠금과 리두 로그가 증가합니다.

이동 전 영향 경로 수를 계산하고 트랜잭션 예산을 넘으면 유지보수 시간 창이나 비동기 재생성을 사용합니다.

프로시저 권한만 열어도 DBA 직접 UPDATE는 여전히 가능합니다.

정기 불일치 검사와 재생성 실행 절차는 우회·과거 버그·복구 오류까지 다룹니다.

내부 경로 보존과 직접 불일치 검사하기

  1. 기준 상태 — 인접 목록과 클로저를 재생성해 불일치 0에서 시작합니다.
  2. 순환 — 노드 1을 자손 7 아래로 요청해 롤백을 확인합니다.
  3. 유효 이동 — 하위 트리 5→3을 실행하고 영향 경로 수를 셉니다.
  4. 직접 감사 — 인접 목록과 클로저 depth1 양방향 차집합이 0인지 봅니다.

이동 전후 경로 검증

데이터베이스 게시판의 조상, Spring의 자손, depth1 불일치를 함께 확인합니다.

내부 자기 참조·Spring→데이터베이스 경로는 이동 뒤에도 같아야 합니다.

subtree 이동 인수 query
SELECT a.category_name AS ancestor_name,
       p.depth
FROM category_closure AS p
JOIN categories AS a
  ON a.category_id = p.ancestor_id
WHERE p.descendant_id = 7
ORDER BY p.depth;

SELECT d.category_name AS descendant_name,
       p.depth
FROM category_closure AS p
JOIN categories AS d
  ON d.category_id = p.descendant_id
WHERE p.ancestor_id = 5
ORDER BY p.depth;

SELECT c.category_id
FROM categories AS c
LEFT JOIN category_closure AS p
  ON p.ancestor_id = c.parent_id
 AND p.descendant_id = c.category_id
 AND p.depth = 1
WHERE c.parent_id IS NOT NULL
  AND p.ancestor_id IS NULL;

SELECT p.ancestor_id, p.descendant_id
FROM category_closure AS p
LEFT JOIN categories AS c
  ON c.parent_id = p.ancestor_id
 AND c.category_id = p.descendant_id
WHERE p.depth = 1
  AND c.category_id IS NULL;

데이터베이스 게시판의 조상은 7·5·3·1이고 Spring 하위 트리는 5·7 두 행입니다.

두 직접 불일치 쿼리는 모두 0행입니다.

쓰기 증폭과 잠금 범위를 계산하는 법

노드를 새 루트로 옮기면 이전 외부 경로만 삭제하고 신규 경로 삽입을 건너뜁니다.

노드 자기 참조와 하위 트리 내부 경로가 남아 독립 트리로 동작합니다.

하위 트리 물리 삭제는 가장 깊은 항목부터 인접 목록 삭제보다 FK CASCADE와 클로저 경로 삭제 순서를 검토해야 합니다.

보통 보관 또는 RESTRICT 후 명시적 하위 트리 명령을 사용합니다.

이동 경로 수와 잠금 시간을 관찰하기

이동마다 하위 트리 노드 수, 삭제된/삽입된 경로 수, 잠금 시간, 롤백 수를 기록합니다.

직접 불일치 0, 자기 참조 경로=노드 수, 클로저 최대 깊이를 배치 건전성 검사로 유지합니다.

동기 이동·비동기·재생성 선택 비교

선택얻는 효과감수할 비용적합한 조건
동기 이동커밋 즉시 일치큰 잠금·쓰기 증폭하위 트리가 작고 강한 최신성이 필요할 때
비동기 재생성원본 쓰기 짧음클로저가 잠시 최신 상태 아님경로 지연을 허용할 때
전체 재생성단순하고 확실모든 경로 재작성이동이 매우 드물고 테이블이 작을 때
인접 목록 전용이동 1행읽기마다 재귀클로저 이득이 측정되지 않을 때

순환·신규 루트·복구를 반복하는 실험

  1. 순환 — 분류 3을 자손 6 아래로 이동해 1644를 확인합니다.
  2. 신규 루트 — 분류 5를 NULL 부모로 이동해 외부 경로가 사라지는지 봅니다.
  3. 복구 — 분류 5를 2 아래로 되돌리고 경로 수 18을 확인합니다.
  4. 영향 — 이동 전 이전 조상×하위 트리와 신규 조상×하위 트리 행 수를 계산합니다.

테넌트·동시 하위 트리·DAG 이동의 예외

  • 신규 부모가 현재 부모와 같으면 변경 없음으로 끝낼지 버전을 남길지 정합니다.
  • 서로 다른 테넌트 트리 사이 이동은 프로시저 첫 단계에서 차단합니다.
  • 동시에 겹치는 하위 트리를 이동하면 잠금 순서를 category_id로 통일해 교착 상태를 줄입니다.
  • DAG 클로저에서는 외부 경로 삭제가 단일 부모 트리보다 복잡하며 경로 중복 수를 보존해야 합니다.

다음 장은 구조 이동뿐 아니라 게시글 값이 누가·왜·언제 바뀌었는지 현재 행과 이력 행의 시간축으로 보존합니다.


클로저 테이블 갱신 기준

판단 축확인할 질문
원자성인접 목록과 클로저가 같은 트랜잭션에서 바뀌는가?
순환신규 부모가 하위 트리인지 인덱스 조회로 거절하는가?
영향이전/신규 경로 수를 실행 전에 예측하는가?
권한직접 부모 UPDATE를 제한하는가?
복구불일치 감사와 전체 재생성 절차가 있는가?

클로저의 빠른 읽기는 완전한 쓰기 명령이 있을 때만 안전합니다.

경로 일부만 고치는 SQL을 여러 호출자가 복사하게 두지 않습니다.


연습 문제

새 분류를 부모 아래 추가하면서 자기 참조 경로와 모든 조상 경로를 함께 만드는 프로시저를 작성하세요.

부모 NULL인 새 루트도 지원해야 합니다.

해설과 예시 답안

노드 INSERT 뒤 자기 참조 경로를 넣고, 부모가 있으면 부모의 모든 조상에서 새 노드로 가는 깊이+1 경로를 삽입합니다.

오류 처리기가 둘을 롤백합니다.

DROP PROCEDURE IF EXISTS add_category_closure;
DELIMITER //
CREATE PROCEDURE add_category_closure(
  IN p_category_id BIGINT UNSIGNED,
  IN p_category_name VARCHAR(100),
  IN p_parent_id BIGINT UNSIGNED,
  IN p_sort_order SMALLINT UNSIGNED
)
procedure_body: BEGIN
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN
    ROLLBACK;
    RESIGNAL;
  END;

  START TRANSACTION;

  IF p_parent_id IS NOT NULL AND NOT EXISTS (
    SELECT 1
    FROM categories
    WHERE category_id = p_parent_id
  ) THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'parent not found';
  END IF;

  INSERT INTO categories
    (category_id, category_name, parent_id, sort_order)
  VALUES
    (p_category_id, p_category_name, p_parent_id, p_sort_order);

  INSERT INTO category_closure
    (ancestor_id, descendant_id, depth)
  VALUES (p_category_id, p_category_id, 0);

  IF p_parent_id IS NOT NULL THEN
    INSERT INTO category_closure
      (ancestor_id, descendant_id, depth)
    SELECT ancestor_id, p_category_id, depth + 1
    FROM category_closure
    WHERE descendant_id = p_parent_id;
  END IF;

  COMMIT;
END//
DELIMITER ;

프론트엔드 3 아래 라우팅 8을 추가하면 자기 참조와 조상 3·1을 포함한 경로 3개가 생깁니다.

없는 부모 요청은 분류와 클로저 모두 0행이어야 합니다.


핵심 정리

  • 하위 트리 이동은 외부 조상 경로만 교체합니다.
  • 순환은 클로저의 조상 조회로 즉시 판단할 수 있습니다.
  • 인접 목록·경로 삭제·경로 삽입은 한 트랜잭션입니다.
  • 경로 영향 크기와 불일치 감사가 운영 기준입니다.

다음 장에서는 현재 행에 누가·언제·왜 바꿨는지 감사 메타데이터를 추가합니다.