정답률 50%의 범인은 뒤집힌 PNG 한 장이었다
cham3(참참참)는 웹캠으로 사용자의 고개와 손 방향을 인식해 컴퓨터와 겨루는 모션인식 웹게임이다. 로그인도 서버도 없이 브라우저 위에서만 돌아가고, Vercel에 배포돼 있다.
매 라운드 방향은 좌(L)·가운데(C)·우(R) 셋 중 하나로 분류된다. 수비 모드는 얼굴 방향으로, 공격 모드는 손 방향으로 판정한다. 문제는 이 분류였다. 랜드마크 점은 화면에 잘 찍히는데 L/C/R 판정이 자꾸 틀려 정답률이 50% 아래로 떨어졌다. 동전 던지기 수준이었다. 지시는 명확했다. “짐작 패치 말고 근본 원인을 완전히 분석하고 확실히 수정하라.” 판정 로직이 잘못됐다는 뜻이니, 가장 먼저 할 일은 그 로직을 짜둔 코드를 다시 읽는 것이라 생각했다.
코드가 잡은 버그 두 개, 그런데 범인이 아니었다
코드 정독은 실제로 버그 두 개를 찾아냈다. 둘 다 진짜 버그였고 고쳐야 했지만, 나중에 밝혀지듯 정답률 50%의 주범은 아니었다.
- 임계값 스케일 혼동: 공격 모드가 손 방향을 판정할 때 얼굴용 임계값(기본 0.08)을 그대로 손에 가져다 썼다. 손의 정상 임계값은 0.3, 약 네 배 큰 값이다. 즉, 손에는 정상 임계값의 약 1/4이 들어간 셈이다. 저울 눈금을 1/4로 낮춰 달아놓은 셈이라 손을 곧게 펴도 손 떨림 수준의 노이즈가 그 임계값을 가볍게 넘겼고, 판정이 거의 항상 L 아니면 R로 튀어 정면(C)이 나올 구간이 없다시피 했다. 화면에 표시되는 값과 실제 판정에 쓰이는 값이 서로 달랐다는 게 단서였다.
- 라이브·캡처 경로 불일치: 화면에 실시간으로 보여주는 값과, 캡처(확정 판정) 시점에 쓰는 값의 계산 경로가 서로 달랐다. 콤보가 쌓일수록 두 값의 차이가 벌어져 “화면엔 R로 보이는데 판정은 C”가 나왔고, 민감도 설정을 조정해도 실제 판정에는 반영되지 않았다.
버그 둘을 고치면 정답률이 오를 것으로 기대했다. 그런데 고치고 나서도 정답률은 여전히 50%대에 머물렀다. 코드는 이제 맞는데 증상은 그대로였다.
빗나간 가설: 수비 쪽을 의심하다
판정 축은 손과 얼굴 둘뿐이다. 손 쪽 임계값 문제는 이미 잡았는데도 정답률이 그대로라면, 남은 용의자는 수비 쪽이라는 소거법적 결론에 이르렀다. 게다가 수비 모드는 임계값 스케일이 일관돼 있어 명백한 코드 버그도 눈에 띄지 않았다. 그래서 코드만 들여다본 채로 “얼굴 방향 추정이 노이즈에 약한가 보다”라고 짐작했다. 소거법 자체는 합리적으로 보였지만 전제가 틀렸다 — 손 쪽에도 아직 잡지 못한 원인이 남아 있었다.
이 가설을 좇아 계측을 새로 깔았다.
- 라운드마다 얼굴 방향 값을 수집해 로그로 남겼다.
- 캘리브레이션 단계에 표본 수를 세는 로그를 붙였다.
- 얼굴 방향 추정 방식 자체를 다른 방식으로 바꾸자는 제안까지 나왔다.
돌아보면 이 구간이 가장 아까운 시간이었다. 계측을 깔고 로그를 읽고 다시 계측을 손보는 사이클을, 멀쩡한 부분을 대상으로 반복하고 있었다.
진범은 반전된 PNG였다
방향 값은 화면(거울) 기준으로 통일돼 있었다. 입력값도, 화면의 방향 마커도, 입력 표시 화면도 전부 이 기준을 지켰다. 그런데 딱 하나, 손 캐릭터 이미지만 이 기준을 어기고 있었다.
hand-left.png는 이름과 달리 실제로는 화면 오른쪽을 가리키는 그림이었다. 코드는 방향값과 이미지를 매핑할 때 파일명만 믿고 그대로 연결했다. 그 결과 컴퓨터의 손은 항상 실제 방향과 반대로 표시됐다. 오른쪽으로 가라고 적힌 이정표가 왼쪽을 가리키고 있는 셈이니, 이정표를 아무리 정밀하게 분석해도 “거꾸로 박혔다”는 사실은 눈으로 봐야만 확인된다.
이걸 찾아낸 경위가 이 버그의 핵심이다. 코드를 아무리 읽어도 hand-left.png 파일 자체를 열어보지 않는 이상 문제는 드러나지 않는다. 실제 발견은 코드 리뷰가 아니라 직접 플레이하다가 나왔다. “손 방향과 CPU 표시가 반대”라는 관찰 한 줄이 몇 주간의 짐작을 끝냈다. 수정 후 다시 확인한 소감도 짧았다. “이제 방향을 제대로 읽네.”
계측을 걷어내고 정리하다
진범이 확정되자 수비 쪽 가설은 자연히 반증됐다. 얼굴 방향 추정 자체는 문제가 없었다 — “화살표는 정확”했다는 확인이 그 증거다. 가치 있는 수정만 남기고 불필요한 흔적은 걷어냈다.
- 앞서 깔았던 계측 코드와 로그는 전량 제거했다.
- 검토 중이던 얼굴 방향 추정 방식 전환은 보류했다.
hand-left.png·hand-right.png를 실제 방향에 맞게 교정하고, 방향-이미지 매핑을 직관대로(왼쪽 방향→왼쪽을 가리키는 이미지) 원복했다.- 임계값 계산 경로를 한 곳으로 통합해, 공격 모드가 손 전용 임계값을 쓰도록 고쳤다.
- 검증: 린트 PASS·타입 검사 0 errors(364개 파일)·빌드 PASS·단위테스트 172개 통과. 카메라에 실제로 의존하는 판정은 자동화 테스트로 끝까지 확인할 수 없어, 직접 플레이로 최종 확인했다.
실측 먼저, 짐작 나중
“짐작 패치 말고 근본 원인을 완전히 분석하라”는 지시를 기준으로 스스로 채점하면 반은 맞았고 반은 틀렸다. 임계값 버그 둘은 코드 분석으로 실제 근본 원인을 찾아 고쳤으니 지시를 지켰다. 하지만 정답률 50%의 진짜 주범은 코드 분석이 아니라 직접 플레이해본 관찰에서 나왔다. 즉, “분석”이라는 말을 코드 정독으로만 좁게 해석한 게 이번 삽질의 원인이었다.
코드 정독과 직접 플레이는 대체재가 아니라 상호 보완재였다. 코드 정독은 값의 흐름이 어긋난 문제를 잡는 데 강했고, 직접 플레이는 화면에 그대로 드러나 있지만 로직상으로는 이상이 없는 표현 층위의 문제를 잡는 데 강했다. 이번 삽질은 후자가 필요한 지점에서 전자만 반복했기 때문에 생겼다.
결정론적으로 맞는 코드를 고치는 것과 증상의 주범을 찾는 것은 다른 문제다. 화면에 그대로 드러나 있는 시각적 버그를 코드로 짐작해 우회하면, 멀쩡한 부분을 의심하며 계측까지 깔고 헛발질하게 된다. 실측이 먼저라는 원칙은 로그나 계측만 가리키는 말이 아니다. 가장 값싸고 가장 먼저 해야 할 실측은, 화면을 한 번 보는 것이다.