빈칸을 58점으로 채우던 리포트 — AI가 지어낸 기본값 9종을 걷어낸 밤

미리 3줄로 보면
  • 증상: 학습 리포트가 늘 그럴듯하게 나왔습니다. 그런데 기록이 없는 주의 요약 점수가 항상 58점이었어요 — 계산된 게 아니라 코드에 박힌 상수였습니다
  • 원인: AI와 빠르게 만든 코드 곳곳에 “값이 없으면 이걸로” 기본값이 심겨 있었습니다. 58점, 90분, 출석률 100%, 품질 4점, 지속성 70, 이해도 3… 전부 근거 없이 채워진 숫자였어요
  • 해결: 결측은 결측으로(null) 남기고, 데이터가 없으면 리포트 생성 자체를 막는 게이트를 세웠습니다. 그리고 필드를 “확장”했다가 컴파일러가 침묵해 루프가 사람 방식으로 여섯 곳을 훑어야 했고, 다음부터는 “개명“으로 컴파일러가 소비처를 열거하게 하기로 했어요
딱따구리가 성적표를 들여다보는데, 빈칸이어야 할 자리마다 똑같은 숫자 도장이 찍혀 있고, 딱따구리는 지우개로 그 도장들을 하나씩 지우고 있는 모습
딱따구리가 성적표를 들여다보는데, 빈칸이어야 할 자리마다 똑같은 숫자 도장이 찍혀 있고, 딱따구리는 지우개로 그 도장들을 하나씩 지우고 있는 모습 (AI 생성 삽화)

혹시 이런 상황인가요?

AI와 만든 앱이 잘 돌아갑니다. 화면도 예쁘고 숫자도 채워져 있어요. 그런데 어느 날 같은 숫자가 너무 자주 보입니다. 이 학생도 58, 저 학생도 58. 수업 시간은 늘 90분. 출석률은 언제나 100%. 우연이 아니라, 코드 어딘가에서 “값이 없으면 이 숫자”라고 정해 둔 겁니다. 제 경험상 AI가 코드를 빠르게 짤 때 가장 자주 보이는 씨앗이 이거예요 — 빈칸을 참지 못하는 기본값.

저도 똑같이 당했습니다

저는 수학 수업용 학습 리포트 시스템을 AI와 만들고 있습니다. 과외 학생의 시험지와 수업 기록으로 주간·월간 리포트를 뽑는 도구예요. 먼저 밝혀두면, 이 글의 수리 작업은 야간 자율 루프(설계 AI가 짠 작업 명령서대로 도는 루프)가 돌린 것이고, 저는 루프를 설계해 두고 라운드마다 “계속 진행해”로 결과만 확인한 쪽입니다. 그 사이클은 수업 기록이 없는 주의 리포트 문제를 논의하다 시작됐는데, 설계 AI의 판단 회신에 이런 대목이 있었습니다.

이 블록을 남기면 #149의 DoD가 다른 경로로 우회된다 — calculateSessionStats는 null을 내는데 주차 요약 점수는 58을 지어내는 반쪽 수리가 된다.

수업 기록이 없으면 통계 함수는 정직하게 “없음(null)”을 냈는데, 그 옆의 요약 점수 코드는 58이라는 상수를 넣고 있었어요. 더 무서운 건 다음 줄이었습니다.

이 조작은 실데이터에서 항상 발동하는 경로였으니, 실데이터 모양의 테스트가 있어야 잡힌다.

테스트가 통과하던 이유가 있었습니다. 테스트 데이터는 값이 다 채워져 있었고, 실제 저장 데이터엔 그 필드 자체가 없었어요. 실데이터에선 언제나 58이 발동하고, 테스트에선 한 번도 발동하지 않는 코드. 그래서 숨어 있었습니다.

루프가 파헤치기 시작하니 하나가 아니었습니다. 그 사이클에서 AI 검수와 실행이 오가며 걷어내거나 걷어내기로 한 “지어낸 값”이 아홉 종류였어요.

지어낸 값어디에무엇을 속였나
요약 점수 58주차 요약기록 없는 주가 “보통” 성적으로 보임
수업 시간 90분월간·반기·연간 집계시각이 기록 안 된 수업을 전부 90분으로
출석률 100%연간결석 데이터 없음 = 개근으로
숙제 품질 4점숙제 축평가 안 한 숙제가 4/5점
지속성 점수 70주간AI가 안 준 값을 70으로
이해도·집중도 3수업 기록 입력 폼교사가 안 고르면 “보통(3)”으로 저장
성장 추세 ‘maintaining'(유지)리포트계산 없이 상수로 박힌 값 — 상수는 걷어냈고 계산 연결은 다음 PR로
“상반기 학습 완료” 등 더미 문장반기·연간하위 리포트 0건인데 완료 문장
월 번호 = 순서 번호반기6월 데이터가 “1월”로 실림
결측(missing value)

있어야 할 값이 기록되지 않은 상태. 문제는 결측 자체가 아니라, 결측을 그럴듯한 값으로 바꿔치기해 결측이었다는 사실을 지우는 것입니다.

지어낸 기본값 9종 지도 — 요약 점수 58, 수업 시간 90분, 출석률 100%, 숙제 품질 4점, 지속성 70, 폼 기본값 3, 하드코딩된 성장 추세, 더미 완료 문장, 순서 번호를 월로 쓴 것이 리포트 파이프라인의 어느 단계(입력 폼·집계·AI 응답 처리·리포트 조립)에 심겨 있었는지를 그린 도식
지어낸 기본값 9종 지도 — 요약 점수 58, 수업 시간 90분, 출석률 100%, 숙제 품질 4점, 지속성 70, 폼 기본값 3, 하드코딩된 성장 추세, 더미 완료 문장, 순서 번호를 월로 쓴 것이 리포트 파이프라인의 어느 단계(입력 폼·집계·AI 응답 처리·리포트 조립)에 심겨 있었는지를 그린 도식

원인은 이거였습니다 — 빈칸을 참지 못하는 코드

AI가 코드를 짤 때 || 3, ?? 90, score ?? 58 같은 기본값은 정말 자연스럽게 나옵니다. 타입이 숫자를 요구하니 숫자를 넣고, 화면이 깨지지 않게 무언가를 채우는 거죠. 문제는 그 값이 측정된 적 없는 값이라는 표시가 어디에도 남지 않는다는 겁니다. 학부모가 받는 리포트에 “이번 주 요약 58점”이 찍히면, 그건 측정처럼 읽힙니다.

수학 오답노트로 치면, 답을 모르는 문제에 빈칸 대신 “4번”을 찍어 두는 학생과 같아요. 채점하면 가끔 맞기까지 하니까 더 위험하죠. 저희가 세워 둔 원칙은 이겁니다 — “근거 한 줄이 없으면 값도 없다.” 코드가 그 원칙을 아홉 군데서 어기고 있었던 겁니다.

이렇게 고쳤습니다 — 세 겹

첫째, 결측은 결측으로 둡니다. 실행 AI가 58 대신 null을, 90분 대신 “시각 미기록은 집계에서 제외, 전부 미기록이면 null”을, 출석률 100 대신 계산 불가 표시를 넣었습니다. 그리고 그 null이 화면에서 “0점”이나 “NaN%”로 새지 않는지까지가 수리 범위였어요 — 설계 AI가 그 축을 DoD(완료 기준)에 못 박았습니다.

둘째, 없으면 만들지 않습니다. 하위 리포트가 0건이면 반기·연간 리포트 생성 자체를 400으로 거부하고 AI를 호출하지 않는 게이트를 실행 AI가 세웠습니다. 더미 문장으로 채운 리포트를 내보내느니, 안 만드는 게 정직하니까요. 그 게이트는 조회 실패(500) 뒤, AI 호출 앞에 놓여 “진짜 0건”과 “조회가 죽은 것”을 구분합니다.

셋째 — 이게 제일 배운 것인데 — 다음부터는 필드를 늘리지 않고 이름을 바꾸기로 했습니다. 이건 성공담이 아니라 실패에서 나온 결정이에요. 그 사이클에서 시수 쪽을 고칠 때 실행 AI는 타입을 확장했고, 컴파일러는 침묵했습니다. 같은 값을 읽는 화면 여섯 곳을 사람 방식으로 grep해 목록부터 만들어 둬야 했고, 그렇게 세는 방식이 놓친 복제본이 그 사이클에만 네 번이었어요. 검수가 낸 논증을 설계 AI가 이렇게 승인했습니다.

확장은 컴파일러가 침묵하고, 개명은 소비처를 “프로퍼티 없음” 컴파일 실패로 강제 열거한다. 이번 사이클 복제본 4회 누락이 전부 확장의 침묵 지대에서 났으니, 같은 함정에 옵셔널 필드 추가로 재진입하는 것은 배운 것을 버리는 일이다.

그래서 그 사이클 종료 시점에 정한 다음 PR의 방침은 totalHours를 classHours로 개명하는 것입니다 — 그 필드를 읽는 여섯 곳이 컴파일 에러로 강제 열거되게요. 사람의 “소비처 전수 grep” 규율을 컴파일러의 강제로 바꾸자는 결정이었고, 저는 그다음 루프 지시에 그 규칙을 그대로 실었습니다. 규율보다 구조가 낫습니다. (단, any나 인덱스 접근처럼 컴파일러가 못 보는 경로엔 여전히 grep이 필요합니다.)

확장 대 개명 개념도 — 그 사이클에서 실제로 한 옵션 필드 확장은 컴파일러가 침묵해 소비처 여섯 곳을 사람이 grep으로 세어야 했고 복제본 누락이 네 번 났으며, 다음 방침으로 채택한 필드 개명은 소비처가 컴파일 실패로 강제 열거되어 누락이 어려워지는 구조
확장 대 개명 개념도 — 그 사이클에서 실제로 한 옵션 필드 확장은 컴파일러가 침묵해 소비처 여섯 곳을 사람이 grep으로 세어야 했고 복제본 누락이 네 번 났으며, 다음 방침으로 채택한 필드 개명은 소비처가 컴파일 실패로 강제 열거되어 누락이 어려워지는 구조

검수 AI는 처치마다 역방향 변이를 돌렸습니다 — 고친 걸 일부러 되돌려 테스트가 실제로 죽는지 확인하는 거예요. “90분 부활 → 테스트 1건 실패”, “출석률 100 부활 → 2건 실패”. 이 숫자가 안 나오면 테스트가 장식이라는 뜻이라, 검수 통과 기준이 “테스트가 초록”이 아니라 “되돌리면 빨갛게 되는가“였습니다.

결측 처리 전후 흐름 — 전에는 결측이 상수로 바꿔치기돼 리포트에 측정값처럼 실렸고, 후에는 결측이 null로 보존되어 화면에 미기록으로 표시되거나 데이터 0건이면 생성 게이트가 리포트 자체를 거부하는 구조
결측 처리 전후 흐름 — 전에는 결측이 상수로 바꿔치기돼 리포트에 측정값처럼 실렸고, 후에는 결측이 null로 보존되어 화면에 미기록으로 표시되거나 데이터 0건이면 생성 게이트가 리포트 자체를 거부하는 구조

같은 실수를 막는 체크리스트

  • 코드에서 || 0, || 3, ?? 90 같은 숫자 기본값을 grep해 보셨나요? 하나하나 “이 값이 측정된 적 있는가”를 물으세요
  • 테스트 데이터가 실제 저장 데이터와 같은 모양인가요? 필드가 다 채워진 테스트는 결측 경로를 영원히 안 밟습니다
  • 결측을 null로 두면 화면에서 0·NaN·”보통”으로 새지 않나요? null 보존은 화면까지 따라가야 완결입니다
  • 재료가 0건인데 결과물을 만들고 있진 않나요? 생성 게이트(0건이면 거부, AI 미호출)가 더미 문장보다 정직합니다
  • 필드를 고칠 때 옵션 필드를 추가하고 있진 않나요? 개명하면 컴파일러가 소비처 대부분을 찾아줍니다(단 any·인덱스 접근 경로는 여전히 grep)
  • 고친 뒤 되돌려 봤나요? 테스트가 안 죽으면 그 테스트는 아무것도 지키지 않습니다

자주 묻는 질문

Q. 기본값이 다 나쁜 건가요?
아니요. 문제는 측정값 자리에 들어가는 기본값입니다. “표시용 폴백”과 “저장되는 값”은 다릅니다. 저장되는 순간 그 값은 측정으로 둔갑해요. 폼의 이해도·집중도 기본값 3을 없앤 것도 그 이유였습니다 — 교사가 안 고른 것과 “보통”이라고 판단한 것은 다른 정보니까요.

Q. null로 두면 화면이 깨지지 않나요?
그게 진짜 작업량이에요. null을 받는 화면마다 “미기록”을 그리게 해야 합니다. 저희는 이걸 수리 범위에 넣고, 새는 곳이 없는지를 검수 AI가 확인했습니다. 화면을 안 고치고 null만 넣으면 0점으로 새서 더 나쁜 거짓이 됩니다.

Q. 왜 이렇게 오래 숨어 있었나요?
테스트가 초록이었기 때문입니다. 테스트 데이터엔 값이 다 있어서 기본값 경로가 한 번도 안 돌았어요. “테스트 통과”와 “실데이터에서 정직함”은 다른 명제더라고요.

Q. AI가 짠 코드라서 생긴 문제인가요?
사람도 똑같이 심습니다. 다만 제 경우엔 AI가 빠르게, 많이, 자연스럽게 심어서 나중에 한꺼번에 아홉 개가 나왔습니다. 그래서 “값이 없으면 어떻게 되나”를 코드 리뷰 질문으로 고정해 두는 게 낫습니다.

한 줄 교훈

빈칸을 채운 숫자는 데이터가 아니라 거짓말의 씨앗입니다 — 결측은 결측으로 남기고, 없으면 만들지 말고, 고칠 땐 컴파일러가 찾아주게 이름을 바꾸세요.

댓글 남기기