안동민 개발노트

본문 시작

하위 트리 이동

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

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

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

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

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

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

parent_id만 프론트엔드 3으로 바꾼 뒤 클로저가 여전히 백엔드 2를 외부 조상으로 반환하는 불일치를 만듭니다.


일부 경로만 바꾼 이동

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

두 원본이 갈라졌어도 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은 없습니다.

원문과 기준 데이터로 예상한 오류 예시

이 문서의 결과 블록은 SQL과 MySQL 8.4 규칙에 따른 예상 형태이며 이번 작업에서 새로 실행한 관측 로그가 아닙니다.

adjacency parent of Spring(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. 전제 — 정상 트리·완전한 클로저에서 한 작성자만 구조를 바꿉니다. 아래의 두 노드 잠금만으로 모든 겹치는 이동을 보호하지는 못합니다.
  2. 순환 — 노드가 신규 부모의 조상인지 인덱스로 조회합니다.
  3. 분리 — 이전 외부 조상×하위 트리 자손 경로를 삭제합니다.
  4. 연결 — 신규 조상×하위 트리 자손 경로를 깊이 합으로 삽입합니다.

내부·외부 경로의 구분

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

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

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

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

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

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

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

Spring 하위 트리의 이동 전후

Spring 하위 트리의 이동 전후이동 전에는 백엔드 2 아래 Spring 5와 데이터베이스 7이 있습니다. 이동 후에는 프론트엔드 3 아래로 같은 하위 트리가 연결됩니다. 공통 조상 개발 1과 나머지 분기는 생략했습니다.이동 전이동 후2 · 백엔드3 · 프론트엔드5 · Spring7 · 데이터베이스5 · Spring7 · 데이터베이스
Spring 하위 트리의 이동 전후이동 전에는 백엔드 2 아래 Spring 5와 데이터베이스 7이 있습니다. 이동 후에는 프론트엔드 3 아래로 같은 하위 트리가 연결됩니다. 공통 조상 개발 1과 나머지 분기는 생략했습니다.이동 전이동 후부모 2백엔드부모 3프론트엔드분류 5Spring분류 7데이터베이스분류 5Spring분류 7데이터베이스

공통 조상 1 · 개발과 나머지 분기는 생략했습니다. 이동 대상 내부의 5 → 7 관계는 그대로입니다.


안전한 하위 트리 이동

먼저 초기 트리의 부모 관계를 복원하고 클로저를 재생성합니다. 프로시저 밖의 UPDATE·DELETE·INSERT는 한 트랜잭션이 아니므로 중간 읽기나 실패 시 원자성이 보장되지 않습니다. 이 복구 구간은 다른 읽기·쓰기를 중단한 유지보수 상태를 전제로 합니다. 백필의 깊이 20 상한 안에 전체 트리가 들어와야 합니다.

이동 프로시저는 오류 처리기로 롤백하고 순환·대상 존재를 검사한 뒤 경로와 부모를 함께 바꿉니다. 자체 START TRANSACTION·COMMIT을 소유하므로 호출자는 열린 트랜잭션 밖에서 호출해야 합니다. MySQL의 START TRANSACTION은 기존 트랜잭션을 암묵적으로 커밋하며 중첩 트랜잭션을 만들지 않습니다.

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

삭제 대상은 이전 조상 {1, 2} × 하위 트리 {5, 7}의 4개 경로이며, 삽입 대상은 새 조상 {1, 3} × {5, 7}의 4개입니다. 공통 조상 1의 두 경로도 삭제 후 다시 삽입합니다. 내부 경로 (5,5,0), (5,7,1), (7,7,0)은 유지됩니다.

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

기준 데이터의 예상 결과
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행입니다. 앞 오류 예제의 drift 2행도 깊이 1의 양방향 차집합이며, 전체 전이 경로의 차집합 크기와 다릅니다. 직접 대사 0은 자기 참조·모든 간접 경로와 깊이까지 올바르다는 증명은 아닙니다.

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

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

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

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

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

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

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

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

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

  • 동기 이동 — 커밋 즉시 두 모델이 일치하지만 잠금과 쓰기 증폭이 커집니다. 하위 트리가 작고 강한 최신성이 필요할 때 적합합니다.
  • 비동기 재생성 — 원본 쓰기는 짧지만 클로저가 잠시 이전 상태를 보여 줍니다. 경로 지연을 허용할 때 검토합니다.
  • 전체 재생성 — 절차는 단순하지만 모든 경로를 다시 씁니다. 이동이 매우 드물고 테이블이 작을 때 적합합니다.
  • 인접 목록 전용 — 이동은 한 행으로 끝나지만 읽기마다 재귀가 필요합니다. 클로저의 이득이 측정되지 않았다면 유지할 수 있습니다.

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

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

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

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

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


클로저 테이블 갱신 기준

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

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

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


연습 문제

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

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

해설과 예시 답안

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

아래 추가 프로시저도 열린 트랜잭션 밖에서, 다른 구조 작성자가 없는 상태로 호출합니다. 오류 처리기는 분류와 경로 INSERT를 함께 롤백하지만 부모 경로를 읽는 동안의 동시 이동까지 보호하지는 않습니다.

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행이어야 합니다.


핵심 정리

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

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