홈 › 읽을거리 › 개발노트

알까기에서 승리 소리가 끝없이 울린 이유

개발노트 · 2026-10-08 · GamePool 편집팀

알까기를 새로 올린 날(2026년 10월 8일) 같은 날 안에 고친 버그가 하나 있습니다. 상대 돌을 모두 밀어내 판이 끝났는데 "승리!" 효과음이 한 번이 아니라 계속 울리고, 컴퓨터 대전 전적의 승리 횟수가 한 판에 수십씩 올라가는 문제였습니다. 수정은 커밋 ("승패 처리가 매 프레임 반복되어 효과음·승리 횟수가 계속 오르던 오류 수정") 에 담겼고, 바뀐 소스는 두 줄입니다. 이 글은 원인이 어디에 있었는지, 어떤 조건에서 재현되는지, 그리고 같은 유형의 버그가 다른 게임에 없는지 확인한 과정을 적은 기록입니다. 이 버그를 처음 어떻게 발견했는지는 이력에 남아 있지 않아 쓰지 않았고, 코드와 커밋에서 확인되는 것만 적었습니다.

증상: 한 판이 끝났는데 소리가 멈추지 않는다

알까기는 흑돌과 백돌을 손가락이나 마우스로 당겼다 놓아 튕기고, 상대 돌을 판 밖으로 모두 밀어내면 이기는 게임입니다. 컴퓨터와 둘 때는 승패가 전적(승·패 횟수)으로 저장되고, 승리 횟수는 닉네임 랭킹 점수로도 올라갑니다. 문제는 마지막 돌이 판 밖으로 떨어지고 나서 일어났습니다. 오버레이에 "🎉 승리!" 가 뜨는 그 순간부터 승리 효과음이 짧은 간격으로 계속 반복되었고, 화면에 보이는 전적의 승리 숫자도 가만히 있지 않고 올라갔습니다. 소리를 켜 두지 않았다면 놓치기 쉬운 버그였지만, 숫자는 localStorage 의 gpalkkagi:record 에 매번 저장되고 있었으니 소리를 끈 사람의 기록도 오염되고 있었던 셈입니다.

원인을 따라간 순서: 프레임 루프, settle(), phase

알까기의 흐름은 단순합니다. 돌을 튕기는 shoot() 이 phase 를 'sim' 으로 바꾸면, 매 프레임 호출되는 frame() 이 phase 가 'sim' 일 때 물리 계산(1/120초 단위 step)을 돌립니다. 그리고 frame() 의 마지막 줄 근처에서 if (!moving(stones)) settle() 로 "모든 돌이 멈췄는지" 확인합니다. settle() 은 떨어진 돌을 정리(reap)하고, 흑돌·백돌 개수(count)를 세어서 한쪽이 0이면 승패를 처리하고, 그렇지 않으면 차례를 바꾸고 phase 를 'aim' 으로 돌립니다. 평소 흐름에서는 settle() 이 끝나면 phase 가 'aim' 이 되어 frame() 이 다음 프레임부터 물리 계산도, settle() 호출도 하지 않으니 문제가 없습니다.

버그는 판이 끝나는 분기에 있었습니다. 수정 전 settle() 은 !b || !w 인 경우 over = true 로 바꾸고, 제목과 메시지를 정하고, 효과음과 전적 저장을 하고, 600밀리초 뒤 오버레이를 띄운 다음 return 으로 빠져나갔습니다. 이 분기에는 phase 를 바꾸는 코드가 없었습니다. 즉 게임이 끝난 뒤에도 phase 는 'sim' 그대로였고, 돌은 이미 모두 멈춰 있으니 moving() 은 계속 false 였고, 다음 프레임에도 그 다음 프레임에도 settle() 이 다시 불렸습니다. 호출될 때마다 count 는 같은 결과(한쪽 0)를 내놓으니 같은 분기가 처음부터 다시 실행되었습니다. rec.w++ 와 GameAudio.sfx('win') 이 프레임마다 한 번씩 실행된 것이 증상의 전부입니다. 60Hz 화면이라면 오버레이가 뜨는 600밀리초 사이에만도 30번 넘게 반복되고, 그 뒤에도 멈추지 않습니다.

재현 조건과 번지는 범위

재현에 특별한 조건은 없었습니다. 어떤 방식으로든 한 판이 끝나기만 하면 됩니다. 사람과 둘이 두는 모드에서는 "흑 승리!" 또는 "백 승리!" 와 승리 효과음이 반복되었고, 컴퓨터 모드에서는 승리하면 rec.w, 패배하면 rec.l 이 반복해서 늘고 효과음도 win 또는 lose 가 반복되었습니다. 무승부(양쪽 돌이 동시에 모두 떨어진 경우)도 같은 분기라 마찬가지로 settle() 이 계속 불렸습니다. 한 가지 더 걸리는 것은 랭킹 보고입니다. 승리 때 GameRank.report(rec.w) 를 호출하는데, 공용 모듈(_shared/rank.js)은 "이번 접속에서 이미 보낸 점수보다 좋을 때만" 서버로 보내게 되어 있습니다. 수정 전에는 rec.w 가 프레임마다 커지니 매번 "더 좋은 기록" 으로 판정됩니다. 닉네임을 정해 둔 상태였다면 요청이 프레임마다 나갔을 가능성이 코드상으로 있습니다. 실제 서버에 몇 번 들어갔는지는 확인하지 못했으므로 가능성으로만 적습니다.

수정 전후: 두 줄

고친 내용은 settle() 맨 앞에 두 가지를 넣은 것입니다. 하나는 if (over) return; 입니다. 이미 끝난 판이면 아무것도 하지 않고 돌아옵니다. 다른 하나는 reap(stones) 앞에 phase = 'idle'; 을 넣은 것입니다. 일단 settle() 에 들어오면 곧바로 phase 를 'sim' 도 'aim' 도 아닌 값으로 바꿔서, 판이 끝나는 분기로 빠지든 차례를 넘기는 분기로 가든 frame() 이 같은 정지 상태를 다시 처리하지 않게 했습니다. 두 가지를 함께 넣은 이유는 서로 다른 구멍을 막기 위해서입니다. phase 만 바꾸면 settle() 이 외부에서 호출될 때 막지 못하고, over 검사만 넣으면 프레임마다 reap 과 count 를 헛돌립니다. 새 판을 시작하는 newGame() 은 이미 turn = 1; phase = 'aim'; over = false 로 모두 초기화하므로 '다시 하기' 이후에는 정상으로 돌아옵니다. 화면 위쪽 턴 표시에서 phase === 'sim' 이면 "굴러가는 중…" 을 보여 주는 코드도 있는데, over 가 먼저 평가되어 "게임 종료" 가 나오는 순서였기에 이 부분은 손대지 않았습니다.

같은 유형이 다른 게임에도 있는지 점검한 것

이 버그의 본질은 "프레임마다 도는 루프가 끝난 상태를 다시 처리한다"입니다. 그래서 같은 날 public/games 아래에서 끝 처리 함수(finish, gameOver, endGame, end)를 모두 찾아 첫 줄과 호출 위치를 읽어 보았습니다. 눈으로 확인한 결과는 이렇습니다. 군단 점령전은 호출부가 if (st.baseHp <= 0 && !st.over) finish(true) 로 막고 있고, 왕국 수비대는 함수 첫 줄이 if (state !== 'play') return 입니다. 챔피언 방어전은 if (G.ended) return, 학교 눈빛 대작전은 if (state === 'over') return 이 첫 줄에 있습니다. 동물 연결하기와 과일 합치기는 gameOver() 첫 줄에서 running = false 로 바꾸어 루프의 진입 조건을 막고, 두더지 잡기는 end() 에서 running 을 내리고 타이머도 clearInterval 로 지웁니다. 장기는 승패를 정하는 finish() 가 한 수를 둘 때마다 한 번 실행되는 afterMove() 안에서만 불리기 때문에 프레임 루프와 무관합니다.

그러나 이 점검은 호출 구조를 읽어 확인한 것이지, 모든 게임을 끝까지 플레이해 본 결과가 아닙니다. 스네이크와 달리기처럼 한 번 부딪힌 순간 return end() 로 빠져나오는 구조는 안전하다고 읽혔지만 확신은 코드 읽기까지만입니다. 앞으로 새 게임에 끝 처리를 넣을 때는 세 가지를 확인합니다. 첫째, 끝 처리를 하는 함수 안에서 상태 값(over, state, running)을 가장 먼저 바꾸는지. 둘째, 그 상태 값을 루프의 진입 조건이 실제로 읽는지. 셋째, 전적이나 랭킹처럼 한 번만 쌓여야 하는 값이 같은 함수 안에서 한 번만 실행되는지입니다. 알까기의 경우 over 는 있었지만 루프가 읽는 값은 phase 였고, 둘이 연결되지 않은 것이 구멍이었습니다.

이번 일에서 남은 판단

이 버그는 시각적으로는 아무 이상이 없는 채로 소리와 숫자만 어긋났습니다. 그래서 화면 캡처나 눈으로 훑는 점검으로는 잡히지 않고, 끝 상태에서 몇 초 더 기다리며 소리를 듣거나 localStorage 값을 확인해야 드러납니다. 알까기 페이지에는 window.alkkagiTest 라는 확인용 입구(돌 목록, phase, turn, over, shoot, step, simulate)가 이미 달려 있어서 한 판을 끝까지 자동으로 두고 over 이후에 phase 가 'sim' 으로 남는지 볼 수 있습니다. 이 입구로 끝난 뒤의 상태까지 확인하는 점검이 새 게임 작업 순서에 들어가야 한다고 판단합니다. 수정은 두 줄이었지만, 찾는 데 필요했던 건 코드 양이 아니라 "끝났는데도 프레임 루프가 settle() 을 계속 부른다"는 가설을 세우는 것이었습니다.

관련 게임 플레이하기

다른 읽을거리

개발노트스도쿠 힌트 뒤 되돌리기로 칸이 잠기던 버그스도쿠에서 힌트를 쓴 직후 되돌리기를 누르면 빈 칸이 고정되어 판을 끝낼 수 없던 버그의 재현 순서와 원인, 한 줄 수정, 같은 유형의 상태 버그를 점검한 방법입니다.입문장기 입문 가이드: 말 움직임부터 외통 몰기까지장기의 말 움직임, 포와 차의 쓰임, 궁성 대각선, 장군과 외통의 원리, 초보가 자주 하는 실수를 GamePool 장기 기준으로 정리했습니다.입문알까기 입문: 힘 조절, 각도, 충돌 물리 활용법알까기를 이기려면 얼마나 세게, 어느 방향으로 튕겨야 할까요? 당김 길이와 이동 거리, 충돌 때 돌이 움직이는 방식과 전술을 정리했습니다.가이드터치 조작을 만들며 정한 임계값과 실패한 순서대포 드래그 1:1 환산, 스와이프 20~24픽셀, 블록 탭 회전, 알까기 선택 반경, 런너에서 숙이기보다 점프가 먼저 나가는 한계를 소스 수치로 정리했습니다.칼럼효과음 181개를 코드로 만든 기록: 볼륨과 템포 조정기GamePool 오디오 엔진의 실제 구조와 효과음 값, 군단 점령전 발사·충돌음 볼륨을 키운 변경, 레오니스 위기 때 템포가 40% 빨라지는 장치를 소스와 개발 이력으로 풀었습니다.칼럼장기·오목·알까기를 화면으로 옮기며 내린 결정들장기 AI의 읽는 수 1·3·5, 오목에 금수를 넣지 않은 선택과 6목 처리, 알까기 마찰·반발 값과 AI 후보 수까지. 소스에서 확인되는 구현 사실로 풀어 썼습니다.