경영진이 매일 보는 대시보드를 만들었다. 카드마다 숫자가 큼직하게 박혀 있고, 그 숫자를 보고 사람이 움직인다. "반 배정 대기 227명"이면 누군가는 오늘 227명을 배정해야 한다.
그런데 그 227명을 실제로 배정하려던 담당자가 물었다. "이 사람들 명단은 어디서 봐요?"
명단을 뽑아봤다. 조건에 맞는 사람은 0명이었다.
이 글은 그 뒤로 카드를 하나씩 운영 DB에서 직접 세어보며 찾은 것들에 대한 기록이다. 결론부터 말하면, 틀린 이유가 카드마다 전부 달랐다.
「반 배정 대기」의 정의는 이랬다.
정식 계정이고, 수강권 결제가 끝났고, 환불되지 않았고, 신청한 학기가 아직 진행 중이고, 반 배정만 안 된 사람
이 다섯 개를 모두 만족하는 사람은 운영 DB에 0명이었다. 그럼 227명은 어디서 왔나.
분류 로직은 이렇게 생겼다. 자녀 한 명을 여러 유형 중 하나로 보내고, 어디에도 안 걸리면 맨 끝 catch-all 로 떨어뜨린다. 그 catch-all 이 하필 「반 배정 대기」였다.
그리고 그 앞에 있어야 할 제외 규칙이 운영에서 한 번도 발동하지 않고 있었다.
// 의도: "이 자녀 말고 다른 정상 자녀가 있는 경우는 제외 대상에서 빼자"
// 부모 단위로 모아 집계한다
MAX(CASE WHEN child.isNormal THEN 1 ELSE 0 END) AS hasOtherNormalChild
// ...
if (signals.hasOtherNormalChild !== 1) {
exclude(child); // ← 운영에서 단 한 번도 실행되지 않았다
}
문제는 MAX 가 판정 대상 자녀 본인까지 포함해서 집계한다는 것이다. "다른(other)"이라는 단어가 변수 이름에만 있고 쿼리에는 없었다.

대상 자녀가 정식 계정이면 본인 때문에 이 값이 항상 1 이 된다. 가드는 !== 1 일 때만 제외하므로, 제외는 영원히 일어나지 않는다. 그렇게 걸러졌어야 할 사람들이 전부 catch-all 로 흘러들어 227명이 됐다.
재밌는 건, 일부 계정은 우연히 살아남았다는 것이다. 계정 번호 접두사가 다른 유형들은 isNormal 이 0 이라 MAX 도 0 이 됐고, 그래서 가드를 통과해 제외됐다. 같은 버그가 데이터 모양에 따라 어떤 줄은 통과시키고 어떤 줄은 안 통과시킨 것이다. "일부는 맞게 나온다"가 로직이 맞다는 증거가 되지 않는다.
제외 신호 세 개를 전부 MIN 집계로 바꿨다.
// "자녀 전원이 그 유형일 때만 제외한다" — 전칭을 전칭으로 표현
MIN(CASE WHEN child.isMigratedInactive THEN 1 ELSE 0 END) AS allMigratedInactive
그리고 의미가 없어진 hasOtherNormalChild 가드를 지웠다. catch-all 도 옮겼다. 나머지를 받아내는 칸은 하나여야 하고, 「반 배정 대기」가 그 역할을 겸하면 항목의 뜻 자체가 흐려진다. 그래서 catch-all 은 「미등록」으로 보냈다.
운영 DB 실측으로 227명을 분해해보니 이렇게 나뉘었다.
| 실제로는 | 인원 | 원래 가야 할 곳 |
|---|---|---|
| 학기 종료·미배정 | 88명 | 전역 집계에서 제외 |
| 이관·무활동 | 69명 | 전역 집계에서 제외 |
| 신청만 하고 결제 미완료 | 69명 | 임시 계정 분류 |
| 나머지 | 1명 | 미등록 |
| 반 배정 대기 | 0명 | — |
교훈 — 전칭(∀, 모두)과 존재(∃, 하나라도)를 SQL 집계로 옮길 때 MIN/MAX 를 거꾸로 쓰면 조건이 소리 없이 죽는다. 에러도 안 나고 로그도 안 남는다. 그냥 평생 참이거나 평생 거짓일 뿐이다.
「자동결제 실패」 카드는 운영에서 30건을 보여주고 있었다. 담당자가 30명에게 연락해야 한다는 뜻이다.
처음 고칠 때는 이렇게 생각했다. "이미 닫힌 주문은 빼면 되겠지." 그래서 주문 상태가 취소·실패인 건을 뺐다.
그래도 숫자가 이상해서 구독 테이블을 직접 조인해 세어봤다.
| 구독 상태 | 건수 | 조치 대상인가 |
|---|---|---|
| 진행 중 | 6 | ✅ |
| 일시 중지 | 3 | ✅ 운영자가 연락해야 풀림 |
| 해지 | 19 | ❌ |
| 총 회차 완료 | 1 | ❌ |
| 구독 없음 | 1 | ❌ |
구독이 해지돼도 주문 상태는 결제 완료로 남아 있었다. 30건 중 21건이 이미 끝난 구독이었던 것이다. 주문은 "그때 결제가 어떻게 됐나"의 기록이고, 구독은 "지금 살아 있나"의 상태다. 둘이 같은 질문에 답한다고 가정한 게 틀렸다.
여기서 하나 더 배운 게 있다. 일시 중지(3건)를 포함할지 말지가 애매했는데, 코드를 따라가 보니 구독이 일시 중지되는 경로는 하나뿐이었다. 학부모가 자동결제 카드를 삭제했는데 대체 카드가 없을 때. 스케줄러는 진행 중인 구독만 결제하므로 그 상태로는 저절로 풀리지 않는다. 운영자가 연락해야 하는 건이라 카드에 포함했다.
그리고 이 판단을 도메인 문서에 「살아 있는 구독 = 진행 중 + 일시 중지」라고 정의로 적어두고, 같은 판정을 쓰는 다른 코드들과 공용 상수로 묶었다. 다음 사람이 같은 고민을 반복하지 않게.
세 번째 카드의 부제는 「본원 결재 대기 3건」이었다.
<CardSubtitle>본원 결재 대기 3건</CardSubtitle>
하드코딩된 문자열이었다. 실제 건수와 무관하게 영원히 3건이었다.
변명의 여지가 있긴 하다. 퍼블리싱 단계에서 서버에 해당 값이 없었고, 그때 PR 에 "서버 값이 없어 시안 그대로 둔다"고 적어두긴 했다. 그 메모는 PR 에만 남았고, 화면에는 아무 표시도 없었다. 그리고 몇 주가 지났다.
지금은 집계를 붙여 실제 값을 넣었다. 하지만 진짜 교훈은 다른 데 있다.
"나중에 연결할 값"을 진짜처럼 생기게 두면 안 된다. 세 가지 중 하나를 했어야 했다.
— 로 두고 "연동 전"을 화면에 표시한다// TODO: 가 아니라 실패하는 테스트를 남긴다PR 본문의 메모는 그 PR 을 읽는 사람에게만 보인다. 몇 주 뒤 그 화면을 보는 사람에게는 안 보인다.
카드가 하나 더 있었다. 재택 강사 미답변 건수. 이것도 당연히 틀렸을 거라 생각하고 운영 DB 에서 세어봤는데 — 화면 값과 정확히 일치했다.
그래서 안 건드렸다. 이것도 기록해둘 만하다고 생각한다. 세 개가 틀렸다고 네 번째도 틀렸을 거라 단정하고 "개선"했으면, 맞던 걸 망가뜨렸을 것이다.
① 지표는 출처가 전부다. 카드에 적힌 숫자가 아니라 그 숫자가 어느 테이블의 어느 조건에서 나왔는지가 지표의 정의다. 「미답변」, 「대기」, 「실패」 같은 단어는 사람마다 다르게 읽히고, 코드는 그중 하나만 구현한다. 어느 하나인지를 문서에 적지 않으면 아무도 모른다.
② 화면을 믿지 말고 DB 에서 세어봐라. 이번에 고친 것 중 코드만 읽어서 찾은 건 하나도 없다. 전부 운영 DB 에서 직접 세어보다가 "어? 이 숫자 왜 이러지" 하고 발견했다. 집계 코드는 조용히 틀린다.
③ 숫자가 사실인지는 그 숫자로 일하는 사람이 가장 먼저 안다. 이 모든 건 "명단 어디서 봐요?"라는 질문에서 시작됐다. 대시보드를 만들었으면, 그 숫자로 실제로 일을 해보거나 일하는 사람 옆에 앉아 있어야 한다.
다음에 집계 화면을 만들 때 쓰려고 정리해뒀다.
다음 글에서는 이 작업을 하다가 만난 더 불편한 문제를 쓰려고 한다. 집계가 틀린 게 아니라, 데이터 자체가 틀어져 있던 계정 334개를 되살리면서 내가 쓴 분석을 스스로 뒤집은 이야기다.