데이터베이스는 몇 달 전에 수정되었습니다. 하지만 웹사이트는 동의하지 않았습니다.
요약
데이터의 최신성을 유지하기 위해 Claude Code를 활용하여 전 세계 대마초 법적 상태 데이터를 감사한 사례를 다룹니다. AI 에이전트를 통해 단순 사실 오류뿐만 아니라, 잘못된 기본값 설정으로 인한 심각한 데이터 오류를 발견하는 과정을 설명합니다.
핵심 포인트
- AI 에이전트를 활용한 데이터 감사로 수동 검토가 놓친 오류 발견
- 데이터의 최신성(Recency)이 반드시 정확성을 보장하지 않음
- 잘못된 기본값(Default) 설정은 단순 버그를 넘어 법적 책임 문제로 직결됨
- 데이터 스키마 설계 시 허용적인 방향의 기본값 설정 주의 필요
저는 213개국과 미국 50개 전 주에서 대마초가 레크리에이션용인지, 의료용으로만 허용되는지, 비범죄화되었는지, 혹은 불법인지에 대한 법적 상태 데이터(legal-status data)를 핵심 데이터셋으로 사용하는 여행 사이트를 운영하고 있습니다. 이러한 항목 중 하나라도 틀리는 것은 단순한 오타가 아닙니다. 누군가는 그 정보를 바탕으로 여행 계획을 세우기 때문입니다.
문제는 이런 종류의 데이터는 조용히 부패한다는 점입니다. 2025년과 2026년 내내 법률은 끊임없이 변했습니다. 제가 이 감사를 실시하기 불과 3주 전에 미국의 한 주가 첫 판매점(dispensaries)을 열었고, 카리브해의 한 국가는 올해 비범죄화를 단행했으며, 합법화로 유명했던 한 국가는 거의 완전히 입장을 되돌렸습니다. 작성 당시에는 정확했던 데이터셋이라도 지금은 틀린 데이터셋입니다. 단지 어디가 틀렸는지 모를 뿐입니다.
그래서 저는 AI 에이전트(저의 경우 Claude Code)에게 직설적인 지시를 내렸습니다: 모든 주와 모든 국가를 살펴보고, 저장된 각 상태를 현재 법률과 비교하여 무엇이 잘못되었는지 나에게 알려달라는 것이었습니다. 제가 승인하기 전까지는 아무것도 수정하지 마세요.
저는 오래된 항목들의 목록을 예상했습니다. 실제로 그런 목록을 받았지만, 예상치 못했던 다른 두 가지 유형의 오류가 추가로 발견되었으며, 그 둘은 훨씬 더 심각했습니다.
오류 유형 #1: 사실이 변함
지루하고 예상 가능한 유형입니다. 데이터가 작성된 이후 법이 단순히 변경된 약 12개의 항목이 있었습니다.
이 더미에서 기억해 둘 만한 두 가지 세부 사항은 다음과 같습니다:
- 최신성(Recency)은 양날의 검입니다. 가장 오래된 항목은 오래된 것이 아니었습니다. 그것은 불과 3주 전에 종료된 법적 체제를 설명하고 있었습니다. 가장 최신처럼 보이는 데이터가 가장 틀릴 수 있습니다.
- 일부 "오래된" 데이터는 처음부터 맞지 않았습니다. 미국의 한 주는 비범죄화(decriminalized-only) 상태로 표시되어 있었습니다. 하지만 그 주는 2013년부터 의료 프로그램을 운영해 왔습니다. 그 오류는 이전의 모든 검토 과정에서도 페이지에 그대로 남아 있었습니다. 왜냐하면 아무도 그럴듯해 보이는 항목은 감사(audit)하지 않기 때문입니다.
좋습니다. 이것이 바로 감사를 수행하는 이유입니다. 이야기가 여기서 끝났다면 쓸 가치도 없었을 것입니다.
오류 유형 #2: 기본값에 의해 만들어짐
태평양의 미세 국가들, 카리브해 섬들, 몇몇 프랑스 및 네덜란드 영토 등 약 30개의 작은 국가와 영토가 모두 동일한 상태를 나타내고 있었습니다: 의료용(Medical).
그들 중 의료용 프로그램을 가진 국가는 거의 없습니다. 몇몇 국가는 지구상에서 가장 엄격한 금지 관할 구역 중 하나입니다.
단서는 균일성이었습니다. 이 항목들은 어느 시점에 대량으로 생성되었으며, 이를 생성한 사람 — 혹은 무엇이든 간에 — 자신들이 전혀 알지 못하는 장소에 대해 어떤 값을 필요로 했습니다. 그것은 "의료용 (Medical)"을 선택했습니다. 그럴듯하게 들리고, 중간 정도의 성격을 띠며, 가장 중요한 한 가지 방향에서 틀렸습니다: 바로 **허용적 (permissive)**이라는 점입니다.
이 비대칭성을 생각해 보십시오. 만약 합법적인 장소를 "불법 (Illegal)"이라고 표시한다면, 그 비용은 여행 기회를 놓치거나 짜증 나는 이메일을 받는 정도일 것입니다. 하지만 엄격히 금지된 섬을 "의료용 (Medical)"이라고 표시한다면, 그 비용은 누군가가 절대 가져와서는 안 될 물건을 챙기는 것입니다. 허용하는 방향으로 잘못된 기본값 (default)은 데이터 버그가 아니라, 책임 소지 생성기 (liability generator)입니다.
여기서 제가 얻은 교훈은 다음과 같습니다: 기본값은 정책이다 (defaults are policy). 만약 생성기가 알 수 없는 값을 채워 넣어야 한다면, 스키마 (schema)가 허용하는 가장 엄격한 값을 채워 넣어야 하며, 이상적으로는 이를 미검증 (unverified) 상태로 표시해야 합니다. "알 수 없음 (Unknown)"이 결코 "어느 정도 허용됨"으로 반올림되어서는 안 됩니다.
틀리는 세 번째 방법: 수정되었지만, 페이지는 이를 듣지 못함
이것이 바로 감사를 수행할 가치가 있게 만든 부분입니다.
데이터베이스에는 다섯 개의 카리브해 국가에 대해 정확한 행 (rows)이 있었습니다 — 상태는 몇 달 전 이전 데이터 패스 (data pass)에서 수정되었습니다. 하지만 라이브 페이지는 그중 세 곳에 대해 여전히 오래된 잘못된 값을 보여주고 있었습니다. 오래된 데이터 (stale data)가 아니었습니다. 렌더링 (rendered)되지 않은 정확한 데이터였습니다.
페이지는 런타임 (runtime)에 정적 폴백 배열 (static fallback array)과 데이터베이스를 병합하여 국가 목록을 구축합니다. 매칭은 URL 슬러그 (URL slug) 또는 정확한 이름으로 이루어졌습니다:
const dbCountry = dbCountries.find(
(db) => db.slug === staticCountry.slug ||
db.name.toLowerCase() === staticCountry.name.toLowerCase()
...
이제 실제 행들을 살펴보겠습니다:
| 데이터베이스 내용 | 정적 엔트리 내용 | 일치 여부? |
|---|---|---|
| Saint Lucia | St Lucia | ❌ |
| ... | ||
| "Saint" 대 "St". "and" 대 "&". 동일한 국가이지만 철자가 다르고, 일치하는 항목은 하나도 없습니다. 조회가 실패했을 때, 코드는 합리적으로 보이는 동작을 수행했습니다. 즉, 정적 엔트리(static entry)로 폴백(fallback)한 것입니다. 오류도 없었고, 로그 라인도 남지 않았습니다. 저장된(stored) 데이터와 표시된(displayed) 데이터를 국가별로 하나씩 대조해 보지 않는 한 알아챌 방법이 없었습니다. |
데이터베이스 수정 사항은 이미 배포되었고, 검토되었으며, 축하까지 받았습니다. 하지만 웹사이트는 몇 달 동안 조용히 동의하지 않은 채 남아 있었습니다.
해결책은 두 부분으로 나뉩니다. 첫째, 비교하기 전에 양쪽 모두를 정규화(normalize)하는 것입니다. 소문자로 변환하고, 악센트를 제거하며, "&"를 "and"로, "St"를 "Saint"로 취급하는 방식입니다. 여기에 정규화만으로는 메울 수 없는 두 이름 사이의 별칭 맵(alias map)을 추가했습니다. 둘째, 그리고 더 중요한 것은 테스트입니다. 모든 데이터베이스 행을 모든 정적 엔트리와 대조하여 실행하고 그 쌍을 출력하는 것입니다. 정확히 문제가 된 5개의 쌍만 일치했고, 그 외에는 아무것도 일치하지 않았습니다. 테스트하지 않은 조인(join)은 조인이 아니라 희망 사항일 뿐입니다.
진짜 질병: 하나의 사실, 네 개의 복사본
이 모든 것을 파헤치면서 근저에 깔린 구조적인 문제가 드러났습니다. 사이트는 각 국가의 법적 상태를 네 곳에 보관하고 있었습니다:
- 페이지 컴포넌트 내부의 정적 폴백 배열 (static fallback array)
- 데이터베이스 (이론상으로는 신뢰할 수 있는 단일 원천, source of truth)
- 상세 렌더링에 사용되는 "rich profiles" 데이터 파일
- 모든 상태 칩(status chip)의 자체 복사본을 가진 사이트 검색 인덱스 (site-search index)
누구도 "복사본을 네 개로 유지하자"라고 결정한 적은 없습니다. 각 복사본은 국지적으로는 타당한 이유로 추가되었습니다. 회복탄력성을 위한 폴백, 속도를 위한 인덱스, 더 풍부한 필드를 위한 프로필 파일 같은 것들 말입니다. 항상 이런 식으로 일이 벌어집니다. 그리고 네 개의 복사본이 있고 조정(reconciliation) 과정이 없다면, 데이터 드리프트(drift)는 위험 요소가 아니라 예정된 수순입니다.
만약 이 과정을 재현하고 싶다면, 실제로 효과가 있었던 감사(audit) 방법은 다음과 같습니다:
- 모든 소스를 덤프(Dump)하세요. 4개의 복사본 모두를 플랫 파일(flat files)로 만듭니다.
- 병합(Merge)을 시뮬레이션하세요. 각 항목이 무엇을 '표시(displays)'하는지를 계산하세요. 저장소(store)가 무엇을 포함하고 있는지가 아니라, 표시되는 값이 유일한 값입니다.
- 검증된 현재 법률과 비교(Diff)하세요. 논쟁이 되는 각 주장을 웹을 통해 1차 자료(primary sources)와 대조하여 검증하세요 — 이에 대해서는 아래에서 더 자세히 다룹니다.
- 한 번의 과정(one pass)으로 모든 복사본을 수정하세요. 여기에 조인(join)과 테스트를 추가하고, 헤드라인 수치(예: "N개국에서 합법")는 반드시 빌드 타임(build time)에 데이터로부터 도출되어야 하며, 절대 수동으로 작성해서는 안 됩니다. 하드코딩된 수치는 그저 다섯 번째 복사본일 뿐입니다.
감사자(Auditor)가 스스로 정답지를 채점하게 두지 마세요
사람들이 흔히 건너뛰는 부분이라 한 가지만 더 덧붙이자면, 검증 과정 중에 에이전트(agent)가 기억하는 법률 내용이 적어도 한 번은 완전히 틀렸습니다. 에이전트는 특정 국가가 의료 법안을 제정했다고 "기억"했습니다. 하지만 웹 검색 결과 그 법안은 발의만 되었을 뿐 통과되지 않았음이 드러났습니다 — 데이터베이스의 지루한 "불법(illegal)" 판정이 맞았고, 자신만만한 모델의 기억이 오류였습니다.
이는 감사 루프(audit loop)가 다음과 같아야 함을 의미합니다: 모델이 제안하고, 1차 자료(primary sources)가 결정(dispose)합니다. LLM이 자신의 학습 메모리(training memory)를 바탕으로 데이터를 감사하는 것은 감사가 아닙니다. 그것은 방식 #2의 템플릿 기본값인 '자신만만한 채우기(confident filler)'가 검토자의 배지를 달고 나타난 것과 같은 실패 모드일 뿐입니다.
어떤 데이터셋을 다루든 제가 지키는 원칙
- 저장된 것이 아니라 렌더링(render)되는 것을 감사하세요. 병합을 시뮬레이션하세요. 사용자는 당신의 데이터베이스를 절대 보지 못합니다.
- 기본값(Defaults)은 곧 정책입니다. 알 수 없는 값은 가장 그럴듯한 값이 아니라, 가장 엄격한 값으로 반올림(round)되어야 합니다.
- 사실의 모든 복사본은 부채(liability)입니다. 복사본의 개수를 세어보세요. 당신이 생각하는 것보다 더 많을 것이며, 검색 인덱스(search index) 또한 항상 그중 하나입니다.
- 렌더링 테스트가 없는 수정은 수정이 아닙니다. 카리브해 관련 수정 사항은 몇 달 동안 "완료"된 상태였습니다. 완료되었다는 것은 표시(displayed)된다는 것을 의미합니다.
- 모델의 기억이 아니라 소스(sources)를 바탕으로 검증하세요 — 특히 모델이 직접 검증을 수행하고 있을 때는 더욱 그렇습니다.
전체 과정(감사, 검증, 코드 수정, 데이터베이스 수정, 테스트, 배포)에 드는 총비용은 에이전트(agent)가 덤프(dumps), 디프(diffs), 검색(searches)을 수행하며 진행한 하나의 긴 세션이었습니다. 유일하게 진정으로 인간적인 부분은 판단(judgment calls)이었습니다. 즉, 여행자에게 '의료(medical)'라는 단어가 무엇을 의미해야 하는가에 대한 결정이었습니다. 그 외의 모든 것은 에이전트가 수행해야 할 바로 그 종류의 작업이었습니다. 즉, 인내심 있고, 철저하며, 이러한 오류들이 몇 달 동안 발견되지 않은 채 방치되게 만들었던 '그럴듯함(plausibility)'에 휘둘리지 않는 작업이었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기