잘 돌던 이유가 버그였다 — 죽은 설정값이 GPU 타임아웃을 막고 있었다
설정 파일의 죽은 버전 값이 옛 바이너리를 붙잡아 GPU 타임아웃 버그를 우연히 막고 있던 사례와, 서로 상쇄된 두 버그를 다루는 판단 기준을 정리합니다.
몇 달째 조용히 잘 돌던 부분을 건드렸다가, 그게 사실은 두 개의 버그가 우연히 서로를 막아주고 있던 상태였다는 걸 알게 됐습니다. 이번 글은 그 이야기입니다.
제가 만든 영상 렌더 파이프라인에는 음성을 텍스트로 옮기는 단계가 있습니다. Whisper 계열의 로컬 바이너리를 불러다 쓰는 구조인데, 어느 날 문서를 정리하다가 설정 파일에 WHISPER_VERSION 같은 버전 지정 값이 박혀 있는 걸 발견했습니다. “아, 특정 버전을 고정해서 쓰고 있었구나” 하고 넘어갈 뻔했는데, 실제로 돌고 있는 바이너리를 확인해 보니 설정에 적힌 버전이 아니었습니다. 몇 달째, 설정과 무관한 옛 바이너리가 돌고 있었던 겁니다.
설정은 살아 있는데 값은 죽어 있었다
원인은 허무할 만큼 단순했습니다. 실행할 바이너리 경로를 정하는 코드가 대략 이런 모양이었습니다.
const path = pkg.getWhisperExecutablePath?.(version) ?? fallbackPath;
옵셔널 체이닝(?.())이 붙어 있었습니다. 문제는 제가 실제로 설치해 쓰던 패키지 버전에는 getWhisperExecutablePath라는 함수 자체가 없었다는 점입니다. 함수가 없으니 ?.()는 조용히 undefined를 반환하고, 코드는 아무 에러 없이 fallbackPath로 넘어갑니다. 그 폴백 경로가 바로 예전에 받아둔 옛 바이너리였습니다.
즉 WHISPER_VERSION을 뭐라고 적든, 그 값을 읽어 경로를 만들어야 할 함수가 존재하지 않으니 설정은 언제나 무시됐습니다. 설정 파일에는 멀쩡히 값이 적혀 있고, 코드도 그 값을 참조하는 것처럼 보이지만, 실제로는 한 번도 반영된 적이 없는 죽은 값이었습니다. 옵셔널 체이닝이 “없으면 그냥 넘어가라”는 편의 문법이라, 없다는 사실 자체가 에러로 드러나지 않고 몇 달을 숨어 있었던 겁니다.
여기까지는 흔한 실수입니다. 고치면 되니까요. 문제는 이걸 고치는 순간 벌어졌습니다.
고쳤더니 오히려 깨졌다
설정을 제대로 살려서 최신 바이너리를 쓰게 만들었더니, 짧은 음성 처리 속도가 눈에 띄게 빨라졌습니다. 최신 바이너리는 Metal, 그러니까 GPU 가속을 지원했거든요. 60초짜리 음성을 실측해 보니 CPU로는 62초가 걸리던 게 GPU에서는 8초에 끝났습니다. 대략 7배입니다. 여기까지만 보면 “진작 고칠걸” 싶은 성과였습니다.
그런데 긴 음성을 넣자 상황이 달라졌습니다. 어느 길이를 넘어가는 작업에서 GPU Timeout Error가 나면서 통째로 죽어버렸습니다. 짧은 건 빨라졌는데, 긴 건 아예 완료를 못 하게 된 겁니다. 실제 파이프라인에서 다루는 음성은 짧은 것보다 긴 것이 많았으니, 체감상 이건 개선이 아니라 명백한 회귀였습니다.
그제서야 그림이 맞춰졌습니다. 옛 바이너리는 GPU 가속이 아예 빠져 있었습니다. 그래서 모든 작업을 CPU로 처리했고, 느린 대신 길이에 상관없이 끝까지 돌았습니다. 다시 말해, 설정이 죽어 있어서 옛 바이너리가 돌았고, 옛 바이너리에 GPU가 없어서 GPU 타임아웃 버그가 애초에 발생할 수 없었던 것입니다.
- 버그 A: 죽은 설정값 → 항상 옛 바이너리로 폴백
- 버그 B: 최신 바이너리의 GPU 백엔드 → 긴 음성에서 타임아웃
버그 A가 버그 B를 정확히 가려주고 있었습니다. 둘 다 버그인데, 하필 서로를 상쇄하는 방향으로 겹쳐 있어서 몇 달간 아무 문제 없이 돌았던 겁니다. 그래서 A 하나만 고치자 B가 드러났습니다.
결국 GPU를 끄는 쪽을 택했다
해결 자체는 어렵지 않았습니다. 최신 바이너리를 쓰되 -ng 옵션으로 GPU를 끄는 것이었습니다. 그러면 CPU로 처리하니 짧은 음성의 7배 속도는 포기하게 되지만, 긴 음성이 타임아웃으로 죽는 일은 사라집니다. 사실상 옛 바이너리가 우연히 만들어주던 “느리지만 안 죽는” 상태를, 이번엔 의도적으로 되돌린 셈입니다.
속도를 포기하는 게 아깝긴 했지만, 제 파이프라인에서 더 중요한 건 짧은 작업 몇 초 단축이 아니라 긴 작업이 끝까지 완주하는 것이었습니다. GPU를 켜고 길이에 따라 분기하는 방법도 있었지만, 1인 운영에서 “가끔 죽는 최적화”보다 “항상 되는 안정”이 압도적으로 쌉니다. 그래서 -ng로 못을 박았습니다.
이번 일에서 제가 챙긴 교훈은 하나입니다. 잘 돌고 있으면, 왜 잘 도는지부터 확인하라. 저는 “잘 돌아간다 = 의도대로 돌아간다”라고 무심코 믿고 있었는데, 실제로는 설명하지 못하는 이유로 돌고 있었습니다. 왜 지금 괜찮은지 한 문장으로 설명할 수 없는 부분은, 사실 두 개의 버그가 우연히 균형을 맞추고 있는 상태일 수 있습니다. 그런 균형은 한쪽만 건드리는 순간 무너집니다.
그러니 멀쩡해 보이는 코드를 “개선”하러 들어갈 때는, 고치기 전에 “지금 이게 왜 문제없이 도는가”를 먼저 적어보려 합니다. 그 답을 못 쓰겠으면, 아직 고칠 준비가 안 된 겁니다.
자주 묻는 질문
설정값을 바꿔도 동작이 안 변하면 어디부터 봐야 하나요?
설정을 읽는 코드가 실제로 그 값을 반영하는지부터 확인해야 합니다. 옵셔널 체이닝이나 예외 무시(try/catch) 뒤에서 조용히 폴백이 일어나면 설정은 살아 있어 보여도 죽은 값입니다. 로그를 찍어 실제로 어떤 경로가 선택됐는지 눈으로 확인하는 게 가장 빠릅니다.
Whisper에서 GPU(Metal)를 켜면 왜 긴 음성에서 타임아웃이 나나요?
짧은 음성은 GPU 가속으로 훨씬 빨리 끝나지만, 긴 음성은 처리 시간이 길어지면서 GPU 백엔드가 정해진 시간 안에 응답하지 못해 타임아웃으로 죽는 경우가 있습니다. 이럴 때는 -ng 옵션으로 GPU를 끄고 CPU로 처리하면 느리지만 안정적으로 완료됩니다.
지금 잘 돌아가는 시스템을 굳이 파봐야 하나요?
잘 돌아간다고 해서 의도대로 돌아가는 것은 아닙니다. 왜 잘 도는지 설명할 수 없다면, 그건 우연히 두 문제가 상쇄된 상태일 수 있습니다. 최소한 "왜 지금 괜찮은가"를 한 문장으로 설명할 수 있을 때까지는 확인해 두는 편이 안전합니다.
서로 상쇄된 두 버그는 어떻게 발견하나요?
보통 하나를 "고쳤는데" 갑자기 멀쩡하던 기능이 깨질 때 드러납니다. 한쪽을 수정한 순간 다른 쪽 버그가 노출되기 때문입니다. 수정 전후로 동작이 이상하게 뒤집히면, 방금 건드린 값이 다른 버그를 가려주고 있었는지 의심해 보는 게 좋습니다.