제조 PLM 시스템을 Next.js로 마이그레이션하면서 겪은 문제 해결 과정을 기록하는 시리즈입니다. 두 번째 편은 BOM 트리의 부모 참조 규약, 그리고 그 규약을 어긴 엑셀 대량 등록 기능이 만든 “루트만 보이는 트리” 버그 이야기입니다.
배경
산업설비 하나의 BOM은 유닛 → ASSY → 부품으로 내려가는 다단계 트리입니다. 이 트리 하나 위에서 구조 조회, 원가 집계, 발주, 구조 편집이 전부 돌아갑니다. 트리를 읽고 쓰는 규약이 흔들리면 특정 화면 하나가 아니라 시스템 전체가 같이 흔들리는 구조라, 마이그레이션 초기에 규약부터 못 박고 시작했습니다.
규약: 부모는 “부품”이 아니라 “행”이다
트리 테이블을 처음 설계하면 직관적으로 parent_part_id, 즉 부모 부품을 가리키고 싶어집니다. 그런데 BOM에서는 동일 부품이 트리의 여러 위치에 등장합니다. 같은 모터가 A유닛에도 B유닛에도 들어가고, 같은 볼트는 수십 군데 들어갑니다. 부품 ID로 부모를 가리키는 순간 “어느 위치에 쓰인 그 부품인지”가 모호해집니다.
그래서 이 시스템의 규약은 이렇습니다. 트리의 노드는 부품이 아니라 BOM 행(row)이고, 부모 참조는 부모 행의 행 ID다. 부품은 마스터 데이터일 뿐이고, BOM 행은 “그 부품이 그 위치에 쓰였다는 사실”을 나타냅니다. 같은 부품이 열 군데 쓰이면 행이 열 개인 겁니다.
트리 전개: 재귀 CTE와 path 배열
조회는 PostgreSQL 재귀 CTE로 전개합니다. 이때 path 배열에 지나온 행 ID를 누적하면 순환 참조 방지와 정렬 안정화를 한 번에 얻습니다.
WITH RECURSIVE bom_tree AS (
SELECT r.id, r.parent_row_id, r.part_id, ARRAY[r.id] AS path
FROM bom_rows r
WHERE r.parent_row_id IS NULL
UNION ALL
SELECT c.id, c.parent_row_id, c.part_id, t.path || c.id
FROM bom_rows c
JOIN bom_tree t ON c.parent_row_id = t.id
WHERE NOT c.id = ANY(t.path) -- 순환 참조 방지
)
SELECT * FROM bom_tree ORDER BY path;
여기까지는 교과서적인 이야기입니다. 문제는 이 규약을 모든 쓰기 경로가 지켜야 성립한다는 데 있습니다.
사고: 루트만 보이는 트리
BOM을 화면에서 한 줄씩 입력하는 대신 엑셀로 수백 행을 한 번에 올리는 대량 등록 기능이 있습니다. 어느 날 이 기능으로 등록한 설비의 트리를 열었더니 루트만 보였습니다. 등록은 성공했다고 했고, 테이블을 직접 조회하면 행도 전부 들어가 있는데, 화면에는 최상위 노드 하나뿐이었습니다.
원인은 대량 등록 코드가 부모 참조에 행 ID가 아니라 부품 ID를 저장하고 있었던 것. 조회 쿼리는 규약대로 행 ID 기준으로 조인하니, 부품 ID가 들어간 자식 행들은 전부 부모 없는 고아가 돼서 트리에서 사라진 겁니다. 에러는 한 번도 나지 않았습니다. 규약 위반은 예외를 던지지 않고, 화면에서 조용히 사라지는 방식으로 나타났습니다.
수정: 2-패스 행 ID 선발급
고치려고 보니 닭과 달걀 문제가 있습니다. 엑셀 안에서 부모-자식 관계를 맺으려면 서로의 행 ID를 알아야 하는데, 행 ID는 INSERT를 해야 생깁니다. 그렇다고 한 행씩 INSERT하며 ID를 받아 연결하면 수백 행짜리 등록이 느려지고 실패 지점도 애매해집니다.
그래서 2-패스 선발급으로 갔습니다.
- 1패스: 시퀀스에서 전체 행 수만큼 행 ID를 미리 발급받고, 엑셀상의 부모-자식 관계를 “발급된 행 ID 간의 매핑”으로 먼저 변환합니다.
- 2패스: 매핑이 끝난 상태로
parent_row_id를 채워 일괄 INSERT합니다.
이미 잘못 들어가 있던 데이터는 부품 ID → 행 ID로 교정하는 스크립트를 돌렸습니다. 이때 같은 부모 아래 동일 부품이 두 번 나오는 케이스처럼 기계적으로 확정할 수 없는 행이 있는지 먼저 집계로 확인하고 진행했습니다.
덤: 트리를 다 펼치지 않기
규약과 별개로 렌더링 문제도 하나 있었습니다. 유닛을 선택하지 않고 설비 전체를 조회하면 하위 노드가 수백 건씩 전개되는데, 이걸 전부 그리면 화면이 버팁니다. 그래서 상위 2레벨까지만 펼치고 나머지는 접힌 상태로 내려주는 방식으로 조정했습니다. 트리는 “다 보여주는 것”보다 “찾아 들어가게 하는 것”이 맞다는 쪽으로.
교훈
- 트리의 노드는 “무엇”이 아니라 “어디에 쓰였는가”다. 동일 개체가 여러 위치에 나타날 수 있는 도메인이라면 부모 참조는 개체 ID가 아니라 행 ID여야 한다.
- 규약 위반은 에러가 아니라 침묵으로 나타난다. 쓰기 경로가 여러 개(화면 입력, 엑셀 등록, 추후 API)라면 규약을 강제하는 공통 계층을 두거나, 최소한 등록 직후 “고아 행” 검증을 넣어야 한다.
- 대량 입력에서 행끼리 서로를 참조해야 한다면 ID 선발급이 정석이다. INSERT 후 ID를 줍는 구조는 느리고, 실패 시 정합성도 애매해진다.
덧붙여
이 프로젝트는 AI 코딩 에이전트(Claude Code)와 페어로 진행하고 있습니다. 재귀 CTE 작성, 교정 스크립트, 고아 행 집계 쿼리는 에이전트가 빠르게 만들어줬지만, “부모는 부품이 아니라 행이다”라는 규약 자체의 결정과, 교정 시 기계적으로 확정할 수 없는 케이스의 판별은 사람의 몫이었습니다. 도메인 규약은 코드보다 위에 있고, 그건 여전히 사람이 정해야 한다는 걸 확인한 편이었습니다.
프로젝트 전체 개요와 다른 문제 해결기는 GitHub 저장소에 정리되어 있습니다.