노드 없음 경고 11건, 노드는 멀쩡히 있었습니다
운영 기록을 그래프 데이터베이스로 옮기는 작업이 있었습니다. 글감 289건을 넣고, 글감마다 그 글의 근거가 된 작업 기록을 관계로 이어 붙이는 구조입니다. 이 관계가 나중에 이 글이 어디서 나왔는지를 되짚는 유일한 경로입니다.
이관 자체는 끝났습니다. 경고가 11건 남았고, 열한 건이 전부 같은 문구였습니다.
노드 없음 — 관계 생략 (id=…)
289건 중 11건입니다. 비율로 보면 넘길 만한 규모이고, 실제로 저는 넘겼습니다. 문구가 없음이라고 적혀 있었으니 원본 문서에 오래된 식별자가 남은 정도로 읽었습니다. 데이터를 나중에 손보면 되는 일로 분류했습니다. 이 분류가 며칠을 잡아먹었습니다.
없음이 아니라 아님이었습니다
며칠 뒤 계보가 통째로 비어 있는 글감을 발견하고 경고를 다시 열었습니다. 적혀 있던 식별자를 그래프에서 직접 조회했습니다. 노드는 멀쩡히 있었습니다.
관계를 잇는 쿼리가 작업 노드만 매칭하고 있었습니다. 근거 목록에는 작업 말고 질의응답 노드도 섞여 들어옵니다. 질의응답 식별자가 오면 작업 라벨로는 안 잡히고, 코드는 그것을 없는 것으로 처리했습니다. 없는 게 아니라 찾는 종류가 아니었습니다.
범위를 확인해보니 11건 중 일부는 질의응답이 유일한 근거인 글감이었습니다. 그런 글은 관계가 하나도 안 붙어 계보가 통째로 비어 있었습니다. 관계 몇 개가 빠진 상태가 아니라 출처를 되짚을 경로가 없는 상태에 가까웠습니다.
문구가 원인을 가린 지점이 여기입니다. 없음이라고 적으면 읽는 사람은 두 가지를 먼저 의심합니다. 노드가 삭제됐거나, 식별자에 오타가 있거나. 둘 다 데이터를 뒤지는 방향입니다. 라벨 조건 때문이라는 단서가 문구 어디에도 없었습니다.
경고가 정확했으면 이관 당일에 끝났을 일입니다. 틀린 실패 메시지는 시간을 쓰게 만드는 정도가 아니라 사람을 엉뚱한 방향으로 몰고 갑니다.

요구사항에 넷 중 하나만 적혀 있었습니다
코드를 열어보니 구현은 요구사항대로였습니다. 요구사항 문서에 근거 대상이 완료된 작업 하나로만 적혀 있었습니다. 원래 설계 명세는 완료 작업, 변화한 질의응답, 결정, 이슈 네 종류를 대상으로 잡고 있었는데, 요구사항으로 옮겨 적을 때 실제로 가장 많이 쓰이는 하나만 넘어갔습니다.
구현 잘못이 아니라 요구사항 누락입니다. 이 구분이 필요한 이유는 재발을 막는 지점이 다르기 때문입니다. 구현 잘못은 코드 리뷰에서 걸립니다. 요구사항 누락은 리뷰를 그대로 통과합니다. 명세와 요구사항 사이는 아무도 대조하지 않는 구간이라 조용히 지나갑니다.
허용 목록은 명세가 아니라 실측으로 정했습니다
고칠 방법은 세 가지였습니다.
방법 | 이점 | 걸리는 것 |
|---|---|---|
라벨 조건 없이 식별자로만 매칭 | 코드가 가장 단순합니다 | 오타 식별자가 무관한 노드에 붙습니다 |
명세대로 네 종류 다 허용 | 나중에 막힐 일이 없습니다 | 한 번도 안 쓰인 둘을 미리 엽니다 |
실제로 쓰이는 두 종류만 허용 | 검증된 경로만 엽니다 | 새 종류가 처음 나올 때 한 번 막힙니다 |
실측을 먼저 했습니다. 근거로 실제 들어온 노드는 작업 502건, 질의응답 13건이었습니다. 결정과 이슈는 한 건도 없었습니다. 명세에 적혀 있다는 이유로 한 번도 쓰인 적 없는 경로를 두 개 더 여는 것은 추측으로 문을 여는 일에 가깝습니다. 실제로 쓰이는 둘만 열었습니다.
라벨 조건을 아예 없애는 첫 번째 안을 버린 이유는 따로 있습니다. 근거 식별자는 사람이 쓴 정리 문서에서 파싱해 그대로 넣는 값입니다. 사람이 쓴 문서에서 읽어온 식별자에는 틀린 것이 섞입니다. 라벨 제한 없이 식별자로만 매칭하면 오타 하나가 전혀 무관한 노드에 관계를 붙여버립니다. 조용히 붙은 잘못된 관계는 안 붙은 관계보다 찾기 어렵습니다.

경고를 나눈 기준은 고칠 사람입니다
쿼리를 고치는 김에 경고도 갈랐습니다. 기준은 심각도가 아니라 이 줄을 보고 누가 무엇을 고쳐야 하는가였습니다.
심각도로 나누는 방식도 검토했습니다. 경고와 오류로 등급만 갈라두는 방식인데, 등급이 갈려도 읽는 사람이 할 일은 똑같이 둘 다 확인하는 것입니다. 등급은 무시해도 되는지를 정해주고, 원인의 방향은 정해주지 않습니다.
허용 라벨 아님 (id=…, label=질의응답) → 코드를 고칠 일
노드 없음 (id=…) → 글감 문서를 고칠 일두 실패는 대응이 정반대입니다. 앞은 허용 목록에 종류를 추가하는 일이고, 뒤는 원본 문서의 식별자를 고치는 일입니다. 정반대인 둘을 한 문구로 적어두면 읽는 사람은 매번 양쪽을 다 확인해야 합니다. 11건을 확인하려고 그래프 조회와 문서 확인을 22번 하게 되는 구조입니다.
메시지에 관측한 사실을 같이 싣는 것도 이때 했습니다. 라벨 값 한 조각이 왜 안 붙었는지를 즉시 답합니다. 실패했다는 말만 남는 로그는 로그를 안 남긴 것과 크게 다르지 않습니다.

소급 적용은 재등록 한 번으로 끝났습니다
지나간 11건이 그대로 남아 있었습니다. 노드는 처음부터 다 있었으므로 새로 만들 것은 없고 관계만 보강하면 되는 상태였습니다. 등록 API 가 멱등이라 해당 글감을 다시 등록하는 것만으로 관계가 붙었습니다. 관계 생성이 MERGE 라 같은 관계가 두 번 생기지도 않습니다.
다만 멱등이라고 적혀 있는 것과 두 번 돌려도 관계가 하나인 것은 다른 이야기입니다. 한 건만 먼저 재등록해 관계 수를 세어보고 나머지를 돌렸습니다.
일회성 마이그레이션 스크립트는 짜지 않았습니다. 멱등한 등록 경로가 이미 있으면 그 경로가 곧 백필 도구입니다. 스크립트를 따로 짜면 그 스크립트만 아는 규칙이 하나 더 생기고, 그 규칙은 다음에 등록 로직이 바뀔 때 같이 바뀌지 않습니다.
다음에 같은 것을 만들 때 보는 것
실패 메시지가 원인의 방향을 가리키는가, 증상만 적고 있는가
고칠 사람이 다른 실패를 한 문구로 묶어두지 않았는가
메시지에 관측한 값(라벨·상태·개수)이 하나라도 실려 있는가
허용 목록의 범위를 명세가 아니라 실측 건수로 정했는가
외부 문서에서 파싱한 식별자를 오타가 섞인 값으로 가정하고 있는가
이 방식에도 비용이 있습니다. 경고를 나누면 종류가 늘어납니다. 유형이 더 늘어나면 결국 코드로 관리하고 표를 따로 둬야 합니다. 실측으로 좁게 여는 방식도 새 종류가 처음 등장할 때 반드시 한 번 막힙니다. 대신 그때는 경고가 정확하니 원인까지 5분이면 갑니다.
조용히 생략하고 경고만 남기는 설계 자체도 안전하지 않습니다. 이번에는 글감 문서가 이미 쓰인 뒤라 전체를 실패시키면 파이프라인이 통째로 멈춘다는 것이 근거였습니다. 그래도 경고를 아무도 안 보면 조용한 실패와 같아집니다. 이번에 며칠이 늦어진 것도 그 성질 때문입니다.
지금은 경고 문구만 보고 코드 문제인지 데이터 문제인지 갈립니다. 문구를 두 줄로 나누고 라벨 값을 실어두는 데 든 시간은 30분이 안 됐습니다. 그 앞에서 원인을 엉뚱한 데서 찾은 시간이 훨씬 길었습니다.