70개의 회귀 방어(regression guards) 중 44개가 의도적으로 코드를 망가뜨렸을 때 초록색을 유지했습니다
요약
AI 가상 스테이징 도구의 안정성을 보장하기 위해 '가드(guards)'라는 독립적인 Node 스크립트를 구축했습니다. 이 가드는 검사, 결제 규칙, 동의 로직 등 핵심 동작이 의도치 않게 변경되는 것을 방지합니다. 테스트 결과, 70개의 가드 중 44개에서 실제 오류를 감지하지 못하는 '구멍(hole)'이 발견되어 시스템 안정성 강화의 중요성을 강조했습니다.
핵심 포인트
- 가드는 핵심 로직의 의도치 않은 변경을 막는 독립적인 스크립트입니다.
- 70개의 가드 중 44개에서 실제 오류를 감지하지 못하는 '구멍'이 발견되었습니다.
- 단순한 테스트 통과 여부만으로는 충분하지 않으며, 실제 실패 시나리오 검증이 필수적입니다.
저희는 AI 가상 스테이징 도구를 구축하고 있습니다. 사용자가 방 사진을 업로드하면, 이미지 모델이 이를 리스타일링하고, 아무도 보기 전에 일련의 검사(checks)가 결과물을 원본과 비교합니다. 저희는 이 검사에 대해 이전 게시물에서 작성한 적이 있습니다. 이번 글은 그 아래층, 즉 검사(checks), 결제 규칙(billing rules), 그리고 동의 로직(consent logic)이 우리가 고친 대로 유지되도록 보장하는 스크립트들에 관한 것입니다.
저희는 이것들을 가드(guards)라고 부릅니다. 각각은 독립적인 Node 스크립트인 .mjs 또는 .mts 파일이며, 하나의 동작을 단언하고 그 동작이 사라지면 0이 아닌 값으로 종료됩니다. 수정 사항이 배포될 때마다 가드가 함께 배포됩니다. 8월 말까지 저희는 약 70개의 가드를 보유했으며, npm run qa로 한 번에 실행했습니다.
그러고 나서 저희는 각 가드에게 간단한 질문을 던졌습니다. '당신이 보호하는 것을 망가뜨리면 빨간색으로 변하나요?' 그 결과, 70개 중 44개의 경우 답변은 아니었습니다.
여기까지 오게 된 과정
첫 번째 문제는 품질 자체가 아니라 아예 실행할 수 없었다는 것이었습니다. 8월 22일까지는 러너(runner)가 없었습니다. 목록은 디스크의 78개 스크립트 중 33개를 명시한 마크다운 파일에 존재했습니다. 이 목록을 실제 존재하는 파일과 비교하는 러너를 사용하자, 아무도 알아차리지 못한 채 빨간색인 가드 하나(18개 검사 중 15개)가 발견되었고, 몇 달 동안 실행되지 않은 활성 가드 27개가 발견되었으며, 디스크에는 있지만 git에 포함되지 않은 가드가 9개나 있었습니다.
이제 모든 것이 실행되었고, 모든 것이 초록색이었습니다. 그것은 증거처럼 느껴졌습니다. 하지만 그렇지 않았습니다.
이틀 후 독립적인 검토자가 작은 실험을 했습니다. 에러 트래커(error-tracker) 설정에서 DSN을 담는 환경 변수의 이름을 변경했습니다. 이름 변경 과정에서 흔히 하는 오타 같은 실수였습니다. 이 설정을 감시하는 가드의 55개 검사 모두 초록색을 유지했습니다. 하지만 프로덕션 환경에서 그 오타는 에러 트래커가 조용히 작동하지 않는(no-op) 것을 의미하며, 이는 바로 가드가 막기 위해 존재하는 정확한 실패 사례였습니다.
그것은 모든 가드에 대해 똑같이 해볼 충분한 이유가 되었습니다.
감사(The audit)
메서드별 수동 점검 (by hand, one guard at a time): 프로덕션 파일을 수정하여 가드가 보호하는 동작이 실제로 깨지게 만든 후, 해당 가드를 실행하고, 파일을 복원한 다음, 그 숫자를 기록합니다. 오직 실제 오류 발생 시에도 가드가 녹색(green)을 유지했을 때만 구멍(hole)으로 계산됩니다.
결과: 70개의 가드 중 44개에서 구멍 발견.
| 그룹 | 가드 수 | 구멍 수 |
|---|---|---|
| 오류 추적, 관측 가능성, 인프라 | 16 | 7 |
| ... | ||
| Some individual findings: |
- 한 가드는 전혀 검사를 수행하지 않았습니다. 코드가 어떤 모습이든 녹색 체크 표시를 출력하고 0으로 종료되었습니다.
- 하나의 검사는 라우트 파일에서 두 문자열의 위치를 비교하여 스토리지 블롭(storage blobs)이 데이터베이스 행(database rows)보다 먼저 삭제되는지 확인했습니다 (행을 먼저 처리하면 고객의 파일이 영구적으로 고아됩니다). 첫 번째 문자열은 3주 이상 전에 리팩터링으로 제거된 상태였습니다.
indexOf는 -1을 반환했고, -1은 어떤 값보다 작으므로 이 검사는 이후 모든 실행에서 통과했습니다. - 하나의 가드는 프로덕션 웹훅 대신 수동으로 작성한 SQL을 테스트했습니다.
44개 구멍 대부분에 깔린 패턴: 가드들이 동작(behaviour)이 아닌 텍스트를 기반으로 작성되었습니다. 우리는 라우트 파일을 문자열로 읽어 이름들을 찾았습니다. 이러한 가드는 양방향으로 실패합니다. 정직한 리팩터링은 아무 이유 없이 빨간색(red)으로 만들고, 실제 오류는 이름들이 여전히 존재하기 때문에 통과해 버립니다.
하나의 가드, 전과 후
동의(consent) 라우트는 순서대로 세 가지 작업을 수행합니다: 사용자 선택을 인증 제공자(auth provider)에 기록하고, 감사 로그(audit log)에 추가하며, 공개 쇼케이스에서 렌더를 비공개 처리합니다. 이 가드는 소스 코드의 indexOf를 사용하여 그 순서를 확인했습니다. 총 아홉 개의 검사 모두 녹색이었습니다. 이를 무력화한 변경 사항과 재작성된 코드는 다음과 같습니다:
// production: await; 실패 시 사용자에게 500을 반환합니다.
await client.users.updateUserMetadata(userId, { ... });
// mutation: 동일 호출, 파일 내 동일 위치 - 아홉 개의 검사 모두 여전히 녹색입니다.
...
변이(mutation)가 발생했음에도 세 호출의 순서는 바뀌지 않았기 때문에 기존 검사들은 통과했습니다. 의미는 사라졌습니다. 즉, 쓰기는 '발화 후 잊어버리는' 방식이며, 실패는 무시되고, 감사 로그에는 "취소됨(revoked)"으로 기록되며, 사용자는 옵트아웃했다고 믿고, 제공업체가 여전히 "허용됨(allowed)"이라고 말하기 때문에 쇼케이스는 계속 게시됩니다.
리라이트는 여전히 텍스트이지만, 구문 자체의 텍스트와 기존 버전을 무력화시킨 변이는 이제 영구적인 검사가 되었습니다. 코드를 가져올 수 있는 곳에서는 더 나아가서: 가드(guard)가 로더를 통해 실제 함수를 불러와 입력 테이블에 대해 실행합니다.
러너 (The runner)
이러한 모든 실험은 각각 50줄짜리 일회용 스크립트였으며, 각 스크립트는 동일한 함정에 부딪혔습니다. 즉, Windows 작업 복사본의 CRLF와 git의 LF, 두 번 일치하는 코드 조각, 그리고 빈 패턴으로는 되돌릴 수 없어(하나가 코드에 남아 있어 두 번째 패스에서 포착됨) '변이'를 일으키는 경우였습니다.
8월 28일에는 하나의 도구가 되었습니다. 작업은 JSON 형식입니다: 가드 명령어와 이름 지정된 치환(named substitutions)으로 구성됩니다.
{
"guard": "node tmp/qa/geometry-escalation-proof.mjs",
"mutations": [
...
루프는 짧지만, 그 주변의 규칙들이 우리가 비용을 지불한 부분입니다:
if (!runGuard()) fail("guard is already red; it proves nothing"); // 1. green before
for (const m of mutations) {
...
변이(Mutations)는 의도적으로 이름이 지정되고 의미가 부여됩니다. 전형적인 변이 테스트(mutation testing)는 연산자를 자동으로 뒤집지만, 우리는 각 변이를 고객이 어떻게 피해를 입을지에 대한 문장으로 작성합니다. 왜냐하면 그 문장이 가드가 존재하는 이유를 문서화하기 때문입니다. 트레이드오프는 커버리지입니다: 우리가 생각한 장애 지점만 테스트합니다.
프로덕션 코드에서 발견된 변이들
러너가 탄생한 날, 새로운 가드에 대한 실행은 예상치 못한 결과를 가져왔습니다. 결제 반환 URL이 오래되었는지 판단하는 함수에는 '결제 시간이 미래인 경우'라는 명시적인 분기(branch)가 있었습니다. 6개의 변이가 이 가드를 빨간색으로 만들었습니다. 일곱 번째, 즉 그 분기를 제거한 것은 아무것도 바꾸지 않았습니다. 미래의 타임스탬프는 음수 나이를 제공하며, 음수 나이는 임계값보다 커지지 않습니다. 이 분기는 가드가 작동하는 코드와 구분할 수 없는 지팡이 같은 것이었습니다. 우리는 그것을 제거하고 함수 위에 그 이유를 작성했습니다.
같은 세션에서 7개의 수정 사항에 걸쳐 35개의 변이를 통해 3개의 가드가 잘못된 것을 확인했습니다: 첫 번째 닫는 중괄호(closing brace)에서 멈추고 중첩 객체(nested object)에서 깨지는 파서; 로그가 한 분기에서만 남았음에도 인접한 다른 분기에 여전히 로그가 남아있어 녹색으로 유지되는 'catch 블록 로깅' 확인 기능; 그리고 가드가 파일의 어느 곳에 나타나든 전체 파일을 지우는 프로토타입-키(prototype-key) 가너 스캐너.
현황
- 93개의 변이 작업, 867개의 변이가 가드 옆 레포지토리에서 진행되었습니다.
- 러너 목록에는 171개의 가드가 있으며, 12개의 이름 지정 건너뛰기(named skips)가 있습니다 (비용이 발생하거나 달력에 따라 실행되거나 새 빌드가 필요한 경우). 이 목록은 커밋마다 디스크와 비교됩니다.
- 작성된 규칙: 변이 테스트를 거치지 않은 새로운 가드는 작성되지 않은 것으로 간주합니다.
- 프리-커밋 훅(pre-commit hook)은 레지스트리만 확인하며, 0.08초가 걸립니다. 전체 실행에는 몇 분이 걸리고 네트워크와 데이터베이스가 필요하며, 다른 사람의 장애로 인해 빨간색으로 바뀔 수 있습니다. 그러한 훅은 첫 번째 긴급 수정 시
--no-verify를 받기 쉬우며, 우회된 훅은 없는 것보다 더 나쁩니다. 전체 실행은 병합(merge) 전에 수동적이고 필수적으로 유지됩니다.
여전히 실패하는 부분
수작업으로 작성된 변이는 가드를 작성한 사람과 같은 사람이 작성하며, 같은 사각지대(blind spots)를 가지고 있습니다. 10월 5일에는 '링크 블록에 기사가 단 7개만 남음'이라는 이름의 변이가 17개의 링크 중 5개를 제거했지만 가드는 녹색을 유지했습니다. 이 변이는 너무 약해서 가드가 문제가 아니라 변이가 문제였습니다. 이제 이것은 모든 링크를 제거합니다. 약한 변이를 발견하는 것은 누군가가 알아차릴 때만 가능합니다.
많은 가드(guards)들이 여전히 소스 텍스트를 읽습니다. 실행된 가드가 더 좋지만, 저희 로직의 상당 부분은 인증(auth) 및 데이터베이스와 연결된 라우트 핸들러에 존재하며, 이를 테스트하기 위해 추출하는 것은 아직 우리가 할 만큼 충분히 이룬 리팩토링 작업이 아닙니다. 텍스트 가드는 그 변형 집합(mutation set)만큼만 정직합니다.
CI는 없습니다. 가드들은 병합(merge) 전에 노트북에서 실행됩니다.
그리고 모든 것의 첫 번째 발견입니다: 70개 중 44개가 부주의함에 관한 숫자가 아닙니다. 모든 가드는 실제 버그가 발생한 후에 좋은 의도로 작성되었습니다. 초록색 체크 표시는 주장일 뿐이며, 무언가가 그것을 빨갛게 만들려고 시도하기 전까지는 검증되지 않은 것입니다.
저희는 AI Flip Room에서 이것을 구축하고 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기