화면이 바뀌는 그 순간에 숨어 있던 버그 두 개 — 0으로 나눈 속도와 남아 있던 탭
벽돌깨기 공이 기어가듯 느렸던 진짜 이유는 숨겨진 화면의 높이를 재서 1px로 계산했기 때문이었습니다. 숫자 퍼즐은 마지막 조각을 놓은 그 손가락 동작이 방금 나타난 배너까지 눌러 완성 화면을 건너뛰었습니다. 둘 다 화면이 전환되는 한순간에 생긴 버그였고, 방어 코드가 원인을 감췄습니다
같은 주에 성격이 전혀 다른 버그 두 개를 잡았는데, 원인을 파고들어 보니 자리가 같았습니다. 화면이 전환되는 그 한순간입니다.
하나는 값이 이상하게 계산됐고 하나는 이벤트가 한 박자 늦게 도착했습니다. 그리고 둘 다 제가 안전을 위해 넣어 둔 코드가 원인을 감추고 있었습니다.
1. 공이 기어간다
벽돌깨기를 만들고 폰에서 실행했더니 공이 눈에 보이게 느렸습니다. 게임이 안 되는 수준입니다. 속도 계수를 올려 보고, 프레임 계산을 의심해 보고, 몇 번 고쳐서 다시 설치했는데 그대로였습니다. “제대로 고친 거 맞냐”는 말을 들을 만했습니다.
속도는 이렇게 정해집니다.
1
2
3
4
/* 공 속도(px/s): 화면 높이 기준. 레벨 1은 약 1.5초에 화면을 가로지른다 */
function speedForLevel(lvl: number): number {
return Math.min(H * 1.25, H * 0.62 * (1 + (lvl - 1) * 0.04));
}
H가 캔버스 높이입니다. 화면 크기에 상관없이 “1.5초에 화면을 가로지르는” 체감을 유지하려고 높이에 비례하게 잡았습니다. 800px 높이면 초당 약 496px입니다.
추측을 멈추고 속도를 재 봤다
같은 곳을 세 번 고치고도 안 되면 추측이 틀린 겁니다. 그래서 헤드리스 크롬을 붙여 공이 실제로 몇 px씩 움직이는지 찍어 봤습니다. 캔버스에 원을 그리는 함수를 가로채 좌표를 기록하는 방식입니다.
결과가 명확했습니다. H가 1이었습니다.
그러면 속도는 1 × 0.62 = 0.62px/s입니다. 1초에 0.6픽셀. 기어가는 게 아니라 사실상 멈춰 있던 겁니다.
왜 1이었나
캔버스 크기를 잡는 코드는 이렇습니다.
1
2
3
4
5
6
function resizeCanvas(): void {
const r = wrap.getBoundingClientRect();
W = Math.max(1, r.width);
H = Math.max(1, r.height);
...
}
그리고 게임을 시작하는 쪽이 이 순서였습니다.
1
2
resizeCanvas(); // ← 게임 화면이 아직 display:none 인 상태
showScreen('game');
display: none인 요소의 getBoundingClientRect()는 전부 0을 돌려줍니다. 그러니 r.height가 0이고, Math.max(1, 0)이 1을 만듭니다.
여기가 이 버그의 핵심입니다. 저 Math.max(1, ...)는 0으로 나누는 것을 막으려고 넣은 방어 코드입니다. 실제로 0이 들어오면 나눗셈이 Infinity나 NaN이 되고, 캔버스는 아무것도 안 그리거나 새까맣게 됩니다. 그러면 저는 5분 안에 원인을 찾았을 겁니다. 방어 코드가 그 요란한 실패를 “조금 느린 게임”이라는 조용한 오답으로 바꿔 놓은 겁니다.
처방
순서를 뒤집었습니다.
1
2
showScreen('game'); // 먼저 보이게 만들고
resizeCanvas(); // 그다음에 재기
그리고 속도를 공이 발사되는 시점에 다시 계산하도록 했습니다. 화면이 보이는 것이 확실한 시점입니다.
1
2
3
4
function launchBall(): void {
const speed = currentSpeed(); // 여기서 다시 계산
...
}
2. 마지막 조각을 놓은 그 탭이 다음 화면까지 눌렀다
숫자 퍼즐(15퍼즐)에 요청이 들어왔습니다. 다 맞췄을 때 결과 화면으로 바로 넘어가지 말고, 완성된 그림을 보여 준 다음 한 번 더 탭하면 넘어가게 해 달라는 것이었습니다.
만들었습니다. 완성되면 빈칸에 마지막 조각을 채워 그림을 완성하고, 그 위에 “🎉 완성! / 탭해서 계속” 배너를 띄우고, 배너를 누르면 결과로 갑니다.
폰에서 확인한 사용자의 답은 이랬습니다. “완성 되자마자 결과 화면 나오잖아.”
손가락 한 번이 이벤트 세 개다
브라우저에서 탭 한 번은 이벤트 하나가 아닙니다.
1
pointerdown → pointerup → click
순서대로 세 개가 발생합니다. 그리고 마지막 조각을 옮기는 처리는 pointerup 단계에서 끝납니다. 그 순간 퍼즐이 완성되고 배너가 판 위에 나타납니다.
그런데 click이 아직 남아 있습니다. 이제 그 좌표에는 방금 생긴 배너가 있고, 배너에는 “누르면 결과로 간다”는 리스너가 붙어 있습니다. 그래서 손가락을 떼는 그 한 동작이 조각을 옮기고, 퍼즐을 완성하고, 배너까지 눌렀습니다. 사용자 눈에는 완성 연출이 아예 없는 것처럼 보입니다.
처방 두 겹
1
2
3
4
5
6
7
8
9
10
11
12
13
let solvedAt = 0;
const SOLVED_TAP_GRACE_MS = 450;
function completeFromTap(): void {
if (!awaitingTap) return;
if (performance.now() - solvedAt < SOLVED_TAP_GRACE_MS) return; // 완성한 그 손가락 동작은 무시
awaitingTap = false;
solvedBanner.hidden = true;
void finishRun();
}
// click은 완성 탭의 잔여 이벤트로도 발생하므로 새 pointerdown만 받는다
solvedBanner.addEventListener('pointerdown', completeFromTap);
첫째, 배너는 click이 아니라 pointerdown만 듣습니다. 완성시킨 동작의 pointerdown은 배너가 생기기 전에 이미 지나갔으니, 배너에 도달하는 pointerdown은 반드시 새 동작입니다.
둘째, 그래도 완성 직후 450밀리초는 어떤 탭도 받지 않습니다. pointerdown만 듣는 것으로 이 사례는 막히지만, 기기나 브라우저에 따라 이벤트 순서와 합성 방식이 다를 수 있어서 시간 유예를 한 겹 더 뒀습니다. 사람이 화면 변화를 보고 다시 누르기까지는 그보다 오래 걸리니 조작감에는 영향이 없습니다.
뒤로가기로 나가는 경우는 유예 없이 바로 결과로 보냅니다. 완성해 놓고 나가는 사람의 기록을 버리지 않기 위해서입니다.
내 검증이 틀렸던 지점
이 버그는 제가 한 번 “고쳤다”고 보고한 뒤에 다시 나왔습니다. 그게 더 문제였습니다.
처음 검증할 때 저는 완성 직전 상태로 저장된 판을 불러와서 결과 화면이 뜨는지 확인했습니다. 그 경로에서는 배너가 정상 동작합니다. 실제 사고는 조각을 직접 놓는 입력 경로에서만 일어나는데, 저는 그 경로를 지나가지 않는 방법으로 확인한 겁니다.
두 번째 검증은 이렇게 했습니다. 한 수 남은 판을 만들고, 마지막 조각을 실제 탭 시퀀스(pointerdown → pointerup → click)로 누르고, 그 click이 배너에 도달하는 것까지 재현한 다음, 배너가 유지되는지 확인했습니다. 그다음 두 번째 탭에서 결과로 넘어가는 것도 봤습니다.
증상이 나는 경로를 그대로 지나가지 않는 검증은 검증이 아닙니다.
두 버그의 공통점
성격은 달랐지만 구조가 같았습니다.
| 벽돌깨기 | 숫자 퍼즐 | |
|---|---|---|
| 언제 | 화면을 보이기 직전 | 화면이 나타난 직후 |
| 무엇이 | 크기를 재는 시점 | 이벤트가 도착하는 시점 |
| 감춘 것 | Math.max(1, ...) 방어 코드 | click이라는 익숙한 선택 |
| 증상 | 조용한 오답(느린 공) | 연출 건너뜀 |
여기서 남길 교훈은 세 가지입니다.
요소를 재기 전에 보이게 만든다. getBoundingClientRect()는 숨은 요소에 0을 준다는 것을 알고 있어도, 코드 순서로 그걸 어기기는 쉽습니다. 화면 전환과 크기 측정이 한 함수 안에 있으면 순서를 매번 확인하는 게 좋습니다.
방어 코드가 실패를 조용하게 만들면 진단이 어려워진다. Math.max(1, x) 같은 코드는 크래시를 막지만 잘못된 값을 통과시킵니다. 개발 중에는 오히려 시끄럽게 실패하는 편이 낫습니다. 지금은 이런 자리에 “값이 비정상이면 콘솔에 남기기”를 같이 두는 쪽으로 바꾸고 있습니다.
추측을 세 번 이상 반복하지 않는다. 같은 곳을 세 번 고쳐서 안 되면 전제가 틀렸다는 신호입니다. 벽돌깨기는 속도를 직접 측정한 순간 5분 만에 끝났습니다. 그 전까지 며칠을 썼습니다.
두 게임은 브라우저에서 바로 할 수 있는 목록에는 아직 없지만, Google Play와 앱인토스에서 받을 수 있습니다. 소식은 이 블로그와 인스타그램(@fadongkwon.soft) (새 창에서 열림)에서 전해드립니다.
