테스트 638건이 전부 통과했고, 운영 데이터 116개가 지워져 있었습니다
라벨을 바꾸고 곧바로 테스트를 돌렸습니다. 638건 전부 초록불이었습니다. 그 사이 운영 그래프에서 발주처 노드 116개와 관계 109건이 지워져 있었습니다. 빨간불은 하나도 없었습니다.
테스트 설정 한 줄이 운영 DB 테이블을 지운 사고를 석 달 전에 정리한 적이 있습니다. 이번에는 다른 저장소에서 같은 종류의 일이 났습니다. 설정이 아니라 테스트의 정리 함수였고, 관계형 DB 가 아니라 그래프 DB 였습니다.
이름만 바꾸는 작업이었습니다
사내 제안서 지식검색 도구는 문서를 그래프로 들고 있습니다. 그래프의 노드 라벨 Project 와 관계형 대장의 테이블 project 가 같은 말이라, 「그래프의 프로젝트 195개」와 「대장의 프로젝트 121개」를 매번 말로 구분해야 했습니다.
그래서 그래프 라벨만 Project 에서 Folder 로 바꾸기로 했습니다. 코드 54곳, 파일 9개를 치환하고 제약 이름 하나를 고치는 작업이었습니다. 데이터는 한 건도 건드리지 않는 「이름만 바꾸는 무해한 작업」으로 분류했습니다.
무중단 이행이라 순서를 이렇게 잡았습니다. 코드를 새 라벨로 치환하고, 기존 노드에 새 라벨을 덧붙여 두 라벨이 함께 있는 상태를 거친 뒤, 마지막에 옛 라벨을 뗍니다. 이 순서에는 한 구간이 생깁니다. 코드는 새 라벨을 보는데 새 라벨을 가진 노드는 아직 0개인 구간입니다. 테스트는 그 구간에서 돌았습니다.
638건 통과, 발주처 116개 삭제
라벨을 치환한 뒤 돌린 테스트는 638건 전부 통과했습니다.
발견은 다른 작업 중에 했습니다. 화면의 발주처 수가 이상하게 적었습니다. 그래프를 세어 보니 발주처 노드 Organization 이 0개였습니다. 발주처와 사업 폴더를 잇는 COMMISSIONED 관계 109건도 같이 없었습니다. Document·Chunk·Person 같은 나머지 노드와 관계는 무사했습니다.
테스트는 자기가 지운 것을 검증하지 않습니다. 정리 코드에는 단언이 하나도 걸려 있지 않았습니다.
정리 함수가 조건으로 지우고 있었습니다
테스트 파일의 정리 함수를 열어 봤습니다. 테스트가 남긴 고아 노드를 치우는 함수였습니다. 삭제 기준은 「Folder 를 가리키는 COMMISSIONED 관계가 없는 Organization 을 지운다」였습니다. 노드를 관계와 함께 지우는 삭제였습니다.
개명 전에는 이 조건이 옛 라벨 Project 를 봤습니다. 개명 뒤에는 새 라벨 Folder 를 봅니다. 그런데 테스트가 돈 시점에 새 라벨을 가진 노드는 0개였습니다. 새 라벨을 가리키는 관계도 0건입니다. 모든 발주처가 「관계 없음」에 해당했고, 116개가 한 번에 지워졌습니다.
조건 자체는 틀린 적이 없습니다. 「남길 것」의 정의가 바뀌었을 뿐입니다. 이 정리 함수는 무엇을 남길지 정하고 그 부정을 지우는 형태였습니다. 남길 것의 정의가 바뀌는 순간 그 부정은 전체 집합이 됩니다.

백업 없이 복구한 방법
쓰고 있던 그래프 DB 구성에는 백업도 시점 복구도 없었습니다. 재구성할 재료는 둘이었습니다. 색인이 폴더 이름에서 뽑아 저장해 둔 발주처 파생값, 그리고 관계형 대장의 발주처 컬럼입니다.
파생값만 쓰면 112개 중 18개가 틀립니다. 폴더 이름 규칙에 발주처 약칭 자리가 있는데, 약칭이 빠진 이름에서는 그 옆 조각인 사업 종류가 발주처 자리로 올라옵니다.
사람이 화면에서 고쳐 놓은 값은 대장 쪽에만 있었습니다. 그래서 대장 값을 우선하고, 대장에 없는 자리만 파생값으로 채웠습니다. 대장과 그래프의 발주처를 대조해 불일치 0건을 확인했습니다.
다만 원상복구는 아닙니다. 발주처가 116개에서 124개로, 관계가 109건에서 195건으로 늘었습니다. 사고 전에는 발주처가 안 붙은 폴더가 있었는데 복구 뒤에는 195개 전부 붙었습니다. 파생으로 채운 것 중에는 폴더 이름이 발주처가 된 오답이 섞여 있어 화면에서 사람이 고칠 몫으로 남았습니다. 사고 전과 다른 상태로 봉합됐다는 것이 가장 정직한 기술입니다.

조건이 아니라 표식으로 지웁니다
정리 함수의 삭제 기준을 바꿨습니다. 테스트 전용 접두사가 붙은 이름과 테스트 표식 속성이 있는 것만 지웁니다. 접두사와 표식이 없는 노드는 어떤 상태여도 삭제 대상이 아닙니다. 「무엇을 남길까」의 부정이 아니라 「무엇을 지울까」의 명시입니다.
표식 방식은 누수를 감수합니다. 표식 없이 만든 테스트 데이터는 영원히 안 지워집니다. 조건 방식이 쓸어담던 고아가 실제로 쌓이는 환경이면 청소 절차가 따로 필요합니다.
같이 드러난 더 큰 문제가 있었습니다. 테스트가 운영 저장소를 보고 있었습니다. 정리 함수는 증상이고 원인은 이쪽에 가깝습니다. 테스트가 실제 발주처 이름과 실제 폴더 이름을 그대로 쓰고 있었습니다. MERGE 같은 upsert 계열 연산은 이름이 같으면 새로 만들지 않고 기존 노드에 얹습니다.
그래서 테스트가 쓰는 이름을 전부 전용 접두사로 격리했습니다. 「테스트 DB 를 따로 쓴다」에서 멈추지 않고 「테스트가 쓰는 이름을 따로 쓴다」까지 가야 upsert 가 운영 노드에 얹히는 경로가 닫힙니다. 재실행 결과 638건 통과, 실데이터는 그대로였습니다.
테스트가 운영 저장소를 가리키고 있으면 경로는 달라도 결말은 같습니다. 다른 저장소의 사고에서는 스텁이 빈 응답을 줬고 저장 함수가 빈 응답에도 DELETE 부터 실행했습니다. 이번에는 정리 함수의 조건이 뒤집혔습니다.

무해한 작업이 무해하지 않은 이유
이 사고에서 뽑은 패턴은 셋입니다.
삭제 조건을 「남길 것」의 부정으로 쓰면, 남길 것의 정의가 바뀌는 날 전량이 대상이 됩니다. 「참조가 없는 것」·「최신이 아닌 것」·「매핑에 없는 것」이 전부 같은 형태입니다.
스키마 개명은 무해한 작업으로 분류되기 쉽습니다. 그런데 조건문이 그 이름에 기대고 있으면 이름을 바꾸는 순간 조건의 결과 집합이 바뀝니다. 이행 중간 구간에서는 그 집합이 0이거나 전체입니다.
초록불은 안전의 증거가 아닙니다. 픽스처 준비·정리·마이그레이션 훅처럼 단언이 걸리지 않은 코드 경로가 사각지대입니다.
반대로 조건 기반 정리 자체가 나쁜 것은 아닙니다. 테스트가 전용 저장소를 쓰는 환경이면 오히려 편하고 안전합니다. 운영과 같은 저장소를 보는 순간 폭탄이 됩니다.
백업이나 시점 복구가 있었으면 이건 사고가 아니라 5분짜리 복구였을 겁니다. 손실로 이어진 진짜 이유는 조건문이 아니라 되돌릴 수단이 없었다는 것입니다. 조건만 고치고 이 축을 안 보면 다음 사고 때 같은 자리에 섭니다.
복구 가능성은 백업만의 문제도 아닙니다. 여기서 복구가 된 것은 사람이 손으로 고친 값이 다른 저장소에 남아 있었기 때문입니다. 기계 파생값만 있었으면 16%가 틀린 채로 굳었을 겁니다. 이중 보관이 늘 낭비인 것은 아닙니다.
스키마 이름을 바꾸기 전에 볼 것
바꾸려는 이름을 참조하는 삭제·정리 조건이 있는가. 있으면 이행 중간 구간에서 그 조건의 결과 집합이 0인지 전체인지 세어 봅니다.
테스트의 정리 코드가 「남길 것의 부정」으로 지우고 있는가. 표식이나 접두사로 「지울 것」을 명시하는 형태인가.
테스트가 운영 저장소를 보고 있는가. 보고 있다면 테스트가 쓰는 이름이 실제 데이터의 이름과 겹치는가.
지워지면 되돌릴 수단이 있는가. 백업·시점 복구가 없다면 사람이 고친 값이 다른 곳에도 남아 있는가.
테스트 결과에 색이 붙지 않는 코드 경로(픽스처 준비·정리·마이그레이션 훅)가 무엇을 만지는가.
초록불이 본 것과 못 본 것
638건의 초록불은 테스트가 검증하기로 한 것만 말합니다. 정리 함수가 운영 발주처 116개를 지우는 일은 검증 목록에 없었습니다. 목록 밖에서 일어난 일에는 색이 붙지 않습니다. 그래서 초록불을 「안전하다」로 읽는 순간 거짓말이 됩니다.
이번 편은 삭제 조건이 뒤집힌 사례였습니다. 이 연작의 다음 편은 같은 초록불이 다른 자리에서 거짓말한 사례를 이어서 다룹니다.
연작 「초록불이 거짓말한다」
이 글은 다섯 편 중 1편입니다. 테스트가 전부 통과했는데 실제로는 아무것도 지켜주지 않았거나 운영을 부순 사례를 이어서 다룹니다.