
지난 글에서 대시보드 숫자가 왜 틀렸는지를 썼다. 집계를 고치다 보니 더 불편한 걸 발견했다. 집계가 틀린 게 아니라 데이터 자체가 틀어진 계정이 쌓여 있었다.
334명. 2년 반 동안.
서비스에는 학생 계정 번호가 두 종류 있다. 결제 전의 임시 번호와 결제 후의 정식 번호. 수강권 결제가 완료되면 임시 → 정식으로 바뀌고, 그 결제가 환불되면 다시 임시로 돌아가야 한다.
되돌리는 코드는 이렇게 생겼다.
async restoreOriginalIdIfExists(userId: number) {
const original = await this.findOriginalId(userId); // 캐시 또는 이력 테이블
if (!original) {
this.logger.warn(`원본 번호를 찾을 수 없습니다: ${userId}`);
return; // ← 여기
}
await this.updateLoginId(userId, original);
}
return 한 줄. 원본을 못 찾으면 경고만 남기고 넘어간다.

캐시는 만료되고, 이력 테이블에는 모든 전환이 남아 있지 않았다. 그래서 환불은 됐는데 정식 번호를 그대로 달고 있는 계정이 조용히 쌓였다. 에러가 아니라 경고였고, 경고를 보는 사람은 없었다.
이게 앞 글의 227명 중 일부로도 흘러들어가 있었다. 정식 번호를 달고 있으니 분류 로직이 "결제한 재원생"으로 봤던 것이다. 집계 버그와 데이터 버그가 같은 화면에서 겹쳐 있었다.
334명에게 새 임시 번호를 발급하는 마이그레이션을 쓰기로 했다. 그런데 번호를 바꾸는 건 되돌리기 어려운 작업이다. 이 계정들이 정말로 쓰이지 않고 있는지를 먼저 확인해야 했다.
처음엔 이렇게 확인했다.
여섯 개 테이블을 봤고, 전부 0이었다. 그래서 PR 본문에 이렇게 썼다.
이 학생들은 반 배정 이력 자체가 없다. 중도 이탈자와 겹치지 않는다.
그리고 리뷰를 기다리는 동안, 찜찜해서 한 번 더 봤다.
학생 식별자를 가진 테이블을 스키마에서 전부 긁었다. 96개였다. 내가 확인한 건 그중 6개였다.
나머지 90개를 전부 세어봤더니, 334명 중 40명에게 흔적이 있었다.
| 흔적 | 인원 | 의미 |
|---|---|---|
| 알림장 | 12 | 담임이 반 학생에게 쓰는 것 — 과거 반 소속의 증거 |
| 과제 배정 | 5 | |
| 강사 피드백 | 1 | |
| 게시글 조회 | 14 | 그 계정으로 로그인해서 본 것 |
| 무료 프로그램 참여 | 14 | 미션 제출·만족도 평가 |
| 합집합 | 40 | 나머지 294명은 어떤 흔적도 없음 |
알림장이 특히 뼈아팠다. 알림장은 담임이 자기 반 학생에게 쓰는 것이다. 알림장이 12건 있다는 건 그 학생이 과거에 반에 속해 있었다는 뜻이고, 나는 바로 그걸 "이력 자체가 없다"고 단언했던 것이다.
새 커밋을 올리면서 PR 본문에 이 섹션을 추가했다.
⚠️ 정정 — "반 이력 자체가 없다"는 사실이 아닙니다
처음에는 "이 학생들은 반 배정 이력 자체가 없다" 고 적었으나, 학생 식별자를 가진 96개 테이블을 전수 조사한 결과 334명 중 40명에게 흔적이 있었습니다.
지울 수도 있었다. 아직 머지 전이었고, 리뷰어가 그 문장을 읽었는지도 알 수 없었다. 조용히 수정하면 아무도 모를 일이었다.
안 지운 이유는 이거다. 그 문장을 근거로 이 마이그레이션이 안전하다고 판단했기 때문이다. 근거가 바뀌었으면 판단도 다시 설명돼야 한다. 결론이 같더라도(실제로 같았다 — 40명도 현재 활동은 없어서 대상에 남겼다) 어떤 근거로 같은 결론에 도달했는지는 달라진다.
그리고 솔직히, 다음에 비슷한 걸 쓸 때 내가 다시 꼼꼼해지려면 이 기록이 남아 있는 게 낫다.
새 임시 번호를 발급할 때, SQL 안에서 번호 생성 규칙을 다시 구현하고 싶은 유혹이 있었다. 그게 더 짧으니까.
안 했다. 앱의 계정 생성 코드가 쓰는 것과 같은 규칙(접두사 + 연도 2자리 + 연내 일련번호)을 그대로 따랐다. 마이그레이션이 만든 번호와 앱이 만드는 번호가 다른 모양이면, 그 차이가 다음 사람에게는 또 하나의 수수께끼가 된다.
그리고 넣지 않은 것도 적어뒀다 — 왜 자동 복구를 안 붙였는가. 이력이 없을 때 자동으로 새 번호를 발급하게 고치면, 원본이 늦게 복구되는 경우(캐시 지연 등)에 멀쩡한 번호를 덮어쓸 수 있다. 334명은 사람이 확인한 일회성 데이터이고, 앞으로의 유입은 이력 보존 쪽을 고쳐서 막는 게 맞다. 그건 별도 작업으로 남겼다.
① 경고 로그는 처리가 아니다. logger.warn() 뒤에 return 을 쓰는 순간, 그 실패는 "처리됐다"가 아니라 "데이터로 쌓이기 시작했다"가 된다. 되돌리기·보상 트랜잭션처럼 실패하면 데이터가 어긋나는 경로에서는 조용히 넘어가면 안 된다. 최소한 세는 지표라도 있어야 한다.
② "없다"는 "있다"보다 증명 비용이 훨씬 비싸다. 뭔가 있다는 건 한 줄만 찾으면 되지만, 없다는 건 찾을 수 있는 모든 곳을 봐야 한다. 그걸 6개 테이블 보고 선언했다. 스키마에서 식별자를 가진 테이블 목록을 기계적으로 뽑는 것이, 내 기억으로 테이블을 떠올리는 것보다 언제나 낫다.
③ 공개한 분석은 공개적으로 정정한다. 내가 쓴 근거를 남들이 읽고 판단한다. 근거가 틀렸으면 그 판단도 다시 받아야 한다. 창피한 건 잠깐이고, 틀린 근거가 남아 있는 건 영원하다.
다음 글은 조금 다른 종류다. 운영 DB 에 쌓인 상담 답변 10,953건을 전수 분석해서 상용구를 뽑았는데, 빈도 1위 문구가 올해는 0건이었던 이야기.