레코드에 '알 수 없음'을 표현할 단어가 없을 때, 빈 값은 '모든 것이 괜찮다'고 말한다
요약
AI 시스템에서 데이터 레코드의 '없음(Nothing)' 상태를 처리하는 과정에서 발생한 설계적 문제를 다룹니다. 기존에는 네 가지 종류의 '없음'을 하나의 스위치로 통합하여, 실제 원인과 설계상 선택 여부를 구별할 수 없게 되는 문제가 있었습니다. 이 글은 이러한 모호성을 해결하고 데이터 무결성을 확보하기 위한 개선 방안에 대해 논합니다.
핵심 포인트
- 'Nothing'은 단일 값이 아닌 네 가지 다른 원인으로 분리되어야 합니다.
- 단순히 비어있는 값(null)만으로는 '문제 없음'을 의미한다고 해석될 위험이 있습니다.
- 데이터 무결성을 위해 Append-only 방식을 사용하여 기록의 연속성과 원인을 보존해야 합니다.
최근에 발생한 일 중 하나로 시작하고 싶습니다.
저희는 결함을 수정했습니다. 네 가지 종류의 '없음(nothing)'이 하나의 스위치로 통합되면서, 읽어들이는 쪽에서 이것을 '설계상(by design)'인지 아니면 '고장난 것(broken)'인지 구별할 수 없게 되었습니다. 그리고 다음 문제로 넘어갔습니다. 그 새로운 필드가 배포된 다음 날, 한 독자가 댓글로 그것을 지적하며 같은 결함을 다시 언급하고 어떻게 처리해야 할지 적어 놓았습니다.
이것은 똑같은 버그가 돌아온 것이 아닙니다. 우리가 배웠다고 생각했던 곳에 같은 종류의 버그가 자라나고 있는 것입니다.
이 글은 한 가지에 관한 것입니다. 레코드에 '알 수 없음(unknown)'을 위한 슬롯이 없을 때, '알 수 없음'은 '알려진 것(known)'으로 읽히며 — 특히 그 필드가 할 수 있는 가장 강력한 진술로 읽힌다는 것입니다.
- 네 가지 종류의 '없음', 하나의 스위치 배경부터.
AI가 레코드를 작성할 때, 저희는 그것이 이전과 이후에 어떤 모습이었는지 스냅샷을 유지합니다. 취소(Revocation) 기능은 어디로 돌아가야 하는지 알아야 합니다.
문제는 '저희가 애프터 스냅샷(after-snapshot)'을 포착할 수 없었다는 것입니다. 이것이 발생하는 데에는 최소 네 가지 이유가 있습니다:
- 아예 캡처기가 연결되지 않음 — 설계상 선택적인 부분
- 엔티티가 해결되지 않았고, 폴백 데이터도 비어 있음
- 엔티티는 해결되었지만 행(row)이 사라짐 — 이상 현상(anomaly)
- 캡처 과정에서 오류 발생 — 에러(error)
앞의 두 가지는 설계가 선택한 것입니다. 뒤의 두 가지는 무언가 잘못된 경우입니다.
저희는 이 모든 것을 위해 하나의 부울(boolean) 필드를 가지고 있었습니다. 네 가지 이유에 하나의 스위치였습니다.
그래서 누군가 '왜 이 스냅샷들이 비어 있나요?'라고 물었을 때, 답변할 수 없었습니다. 설계상인지 고장난 것인지 구별할 수 없었기 때문입니다. 더 나쁜 부분은 방향성입니다. 그 스위치는 기본값이 false로 설정되어 있어, '스냅샷이 괜찮다'고 읽힙니다.
'없음(Nothing)'은 하나의 값이 아니라 네 가지 다른 값들입니다. 이것들을 하나로 통합하는 것은 가장 강력한 해석이 이 모든 것에 대한 답변을 내리게 합니다.
우리는 이를 네 가지 원인(causes)으로 나누었고, 각 원인은 독립적으로 저장되고 읽을 수 있습니다. 이 과정에서 우리가 처음에 제안했던 네 개의 이름 중 두 개는 해당 클래스에 속하지 않는다는 것을 발견했습니다. 하나는 다른 경로(쓰기 전에 참조된 테이블)에 존재하며, 다른 하나는 원인이라기보다는 메커니즘입니다. 따라서 최종적으로 확정된 네 가지는 처음 시작했던 네 가지와 다릅니다.
- 다음 날, 우리가 방금 작성한 필드에서 작업을 진행했습니다. 이것을 확정한 후, 우리는 다음 사항으로 넘어갔습니다. 즉, revoke가 실행되기 전에 이 시도가 어떤 멤버들을 비교할 의도인지 기록하는 것입니다.
여기서 '약속(promise)'의 개념이 중요합니다. revoke 이전에는 그것은 약속이며, 이후에는 실제 값입니다. 오직 그 둘 사이의 차이점만이 "비교하기로 약속했던 멤버가 실제로 비교 가능하지 않았음"이라고 말할 수 있게 해줍니다. 구현 과정에서 그룹의 루트 행(root row)에 이 차이를 담을 열(column)을 두었습니다. 만약 이 시도가 아무런 차이점을 발견하지 못했다면, 해당 열은 비어 있는 값으로 기록됩니다.
그것만 봐서는 문제가 없어 보였습니다. 그러다 누군가 댓글로 이렇게 작성했습니다:
"멤버 행에 개별 원인(per-reason) 열이 존재하며, 가장 마지막으로 실행된 revoke 시도에 의해 덮어쓰여지기 때문에, 두 번째 시도가 첫 번째 시도의 깨진 약속을 지워버립니다. Append-only 방식을 사용해야 분리될 수 있습니다."
그가 맞습니다. 그리고 그가 제시한 것보다 약간 더 심각합니다.
시도가 차이점을 발견하지 못하면, 해당 열은 비어 있는 값으로 기록됩니다. 그리고 이 '비어 있음'이라는 값은 동시에 두 가지를 의미합니다: "마지막 비교에서 차이점이 발견되지 않았음"과 "이 행은 전혀 revoke되지 않았음". 따라서:
첫 번째 revoke가 "약속했지만, 비교할 수 없었음"을 발견하여 기록함 —
두 번째 revoke는 정상적으로 진행되었고 — 첫 번째 시도의 기록을 비어 있는 값으로 덮어씀
나중에 그 행을 읽으면: 비어 있음. 당신은 "한때 깨진 약속이 있었다"는 사실도 알 수 없고, "그것이 지워졌다"는 사실도 알 수 없습니다.
성공적으로 재시도하는 것은 이전 시도의 실패 기록을 지워버립니다.
우리 스스로에게 공정하게 말하자면, 그 삭제(clearing)는 의도적입니다. 그 근거는 데이터가 들어왔을 때의 설계 기록에 있습니다. 오래된 차이점(stale difference)은 그것을 생성한 비교 과정보다 더 오래 지속되어서는 안 됩니다. 이 추론 자체는 틀리지 않았습니다. 잘못된 것은 빈 값(empty value)이 두 번째 의미를 가질 수 있는 공간이 없었다는 점입니다.
그 후 우리는 그가 설명한 방식으로 변경했습니다.
공유되는 단일 셀 대신, 비교에 도달한 시도마다 한 행을 기록하게 된 것입니다. 두 가지 판독값은 작성된 위치에서 분리됩니다. 즉, 이 행은 "차이점이 발견됨"이거나 아니면 "확인했으며, 차이점 없음" 중 하나입니다. 따라서:
- 나중의 시도가 이전의 시도를 지우지 않습니다. 대신 자신만의 행을 기록합니다.
- 빈 히스토리(아직 비교에 도달한 시도가 없는 경우)와 "확인했으며, 깨끗함"은 더 이상 같은 판독값이 아닙니다.
- 이전 규칙—오래된 차이점은 그것을 생성한 비교 과정보다 오래 지속되어서는 안 된다—은 철회되었습니다. 이는 기록 보존에 관한 것이 아니라, 두 가지를 하나의 셀에 사용하려 했다는 점에서 잘못된 것이었습니다.
- 이전 열(column)은 삭제되지 않았습니다. 여전히 읽히며, "알 수 없는 시점에 작성됨"으로 표시된 레거시 항목입니다. 이것을 삭제하면 이 모든 논의가 다루고 있던 증거 자체를 지우게 됩니다.
- 같은 시스템 내에서 올바르게 처리한 사례
위 두 가지 모두 "미지(unknown)를 위한 슬롯이 없음"이라는 상황에 해당합니다. 하지만 같은 시스템 내에서도 슬롯을 가진 곳이 있습니다.
취소 요청(revoke)이 외부 시스템으로 나갈 때, 우리가 할 수 있는 것은 요청을 보내는 것뿐입니다. 최종 상태(terminal state)는 그들의 측에 달려있습니다. 그들이 2xx 응답만 보낸다고 해서 요청이 도착했다는 것만 증명할 뿐, 무효화했다는 것을 의미하지 않습니다. 따라서 해당 행은 "취소됨"이라고 기록하지 않습니다. 대신 "보상 요청됨, 결과 미확인(compensation requested, outcome unknown)"이라는 전용 판독값을 기록합니다.
그 효과는 즉각적입니다. 아무도 이를 완료된 것으로 오해하지 않습니다. 인터페이스는 그 결과가 대상 시스템에 있다는 것을 명확히 보여줍니다. 추가적인 설명이 필요 없습니다.
그러므로 이것은 불가능한 일이 아닙니다. "미지(unknown)" 상태가 슬롯을 필요로 한다는 점을 인지했는지의 문제입니다. 슬롯을 제공하면 그대로 유지됩니다. 그렇지 않으면 가장 강력하게 사용 가능한 판독값으로 떨어져 버립니다.
- 어려운 이유
기본값이 침묵하기 때문에 어렵습니다.
모든 필드가 스스로 결정하며, 항상 가장 저렴한(가장 쉽게 선택할 수 있는) 판독값을 고릅니다:
null 값은 '해당 항목 없음(no such item)'으로 읽히고,
빈 컬렉션은 '차이점 없음(no difference)'으로 읽히며,
false는 '표시되지 않음(not marked)'으로, 나아가 '괜찮음(fine)'으로 읽힙니다.
이러한 해석들은 거의 항상 정확합니다. 문제는 나머지 부분입니다. 즉, '아무것도 아님(nothing)'과 '알 수 없음(unknown)'이 같은 값을 가질 때, 그 필드는 거짓말을 하는 필드가 되며 — 아무도 이를 감지하지 못하다가 누군가가 이 값에 기반하여 행동할 때, 당신을 대신해 '모든 것이 괜찮다'고 말하게 됩니다.
더 심각한 것은, 하나의 인스턴스를 수정한다고 해서 그 클래스가 제거되는 것이 아니라는 점입니다 — 심지어 수정하는 과정 중에도 그렇습니다.
이러한 수정 과정 속에서 발생했던 일들을 살펴보겠습니다. '차이점 없음(no difference)'이 자체적인 해석을 갖게 되었음에도 불구하고, 여전히 빈 반환 값이 두 가지 의미를 지닌 함수가 있었습니다: 실제로 모든 것이 비교되었거나, 애초에 약속할 것이 아무것도 없었거나 (캡처기가 연결되지 않은 상태에서 '비교'는 정의되지 않습니다). 첫 번째 경우에는 '확인됨, 깨끗함(checked, clean)'이라고 쓰는 것이 맞습니다. 하지만 두 번째 경우에 그렇게 쓴다면, 실제로 실행되지 않은 확인 과정을 보증하는 것이 됩니다.
우리는 거의 그렇게 했습니다. 우리가 수정한 방식은 이렇습니다: 아무것도 약속하지 않았으므로, 어떤 행도 기록되지 않도록 한 것입니다.
이것이 바로 이 클래스의 진입점들이 존재하는 모든 '아무것도 아님(nothing)'의 지점인 이유입니다. 하나를 수정하면, 그 수정을 작성하는 코드 라인에서 당신을 기다리고 있습니다.
- 스스로 실행할 수 있는 확인 작업: '내 레코드가 완전한가?'부터 시작하지 마세요. 더 기본적인 것부터 시작하세요:
시스템 내에서 '없음(none) / 알 수 없음(unknown) / 빈 값(empty)'을 의미하는 모든 값을 나열하고, 각각에게 물어보세요: 이것은 무엇으로 읽히나요?
한 번에 두 가지 이상의 의미를 지니는 것은 잠재적인 오보고(misreport)입니다. 세 가지 일반적인 형태가 있습니다:
형태 1: null 값
의미: '해당 항목 없음(no such item)' / '검색했으나 발견된 것 없음(looked, found none)' / '전혀 검색하지 않음(never looked)'
형태 2: 빈 컬렉션
의미: '진정으로 없음(genuinely none)' / '읽어낼 수 없음(couldn't read it out)'
형태 3: false / 기본값
의미: '표시되지 않음(not marked)' / '없음으로 표시됨(marked as no)'
테스트는 간단합니다: 이 값이 비어 있을 때, 독자(reader)가 여전히 왜 비어 있는지 설명할 수 있습니까? 그렇지 못하다면, 그 필드는 대신 추측하고 있는 것입니다.
마무리하며
이러한 종류의 문제는 찾기 어렵습니다. 오류를 발생시키지 않기 때문입니다. 시스템은 실행되고, 인터페이스는 렌더링되며, 하나의 셀이 조용히 거짓말을 합니다.
그리고 가장 많이 나타나는 곳은 바로 방금 고친 곳입니다. 왜냐하면 그때는 이미 어떻게 해야 할지 알고 바쁘기 때문이죠.
현재 상황을 말씀드리자면, 우리는 이제 패턴을 알게 되었고 두 가지 사례를 수정했습니다. 두 번째 사례의 형태는 리더(reader)로부터 왔습니다. 저희가 체계적인 검토를 한 것은 아닙니다. 이 세 가지 형태 중 몇 개가 레코드 레이어에 있는지, 그리고 각각이 무엇으로 읽히는지에 대한 목록은 없습니다.
그래서 이것만으로는 결론을 내릴 수 없습니다. 자체 시스템에서 확인해 보시면 아마 몇 가지 사례를 발견하실 겁니다. 저희 쪽에서는 아직 살펴볼 것이 남아 있다는 것만 알고 있습니다.
AI 비서의 도움을 받아 작성되었습니다. 시스템, 위치 및 오류는 제가 직접 담당했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기