KKflow

BUG · 버그 도감 #10

한 번도 보인 적 없는 스핑크스를 정복했다: 화면 세 곳이 서로 다른 목표를 보던 날

스택 타워 도전 모드에서 첨성대를 목표로 쌓았더니 7층에서 '스핑크스 정복!' 배너가 떴습니다. 스핑크스는 화면에 한 번도 나온 적이 없었고, 왼쪽 위에는 '첨성대 18/3' 같은 숫자가 남았습니다. 그릴 수 없는 목표와, 같은 질문에 따로 답하던 세 화면 요소를 정리했습니다.

· KKflow · 읽는 시간 약 7분

버그 도감은 KKflow 프로젝트에서 실제로 만난 버그를 한 편에 하나씩 기록하는 시리즈입니다. 모든 내용은 해당 저장소의 커밋 기록과 코드를 근거로 합니다.

프로젝트 스택 타워 · 시기 2026년 8월 26일 · 관련 기술 게임 상태 설계, three.js, 회귀 테스트

배경: 랜드마크를 옆에 두고 쌓는 게임

스택 타워는 블록을 한 층씩 쌓아 올리는 3D 웹게임입니다. 한 층을 3미터로 계산해서, 탑이 높아질수록 첨성대(9m), 개선문(50m), 에펠탑(330m) 같은 실제 랜드마크의 높이를 넘어섭니다. 8월 말 이틀 사이에 이 랜드마크를 보여 주는 장치가 연달아 붙었습니다.

  • 8월 25일 15시 50분 — 배경 실루엣. 다음에 넘을 랜드마크의 모양이 탑 옆 배경에 서 있고, 탑이 그 옆을 타고 오릅니다.
  • 8월 25일 16시 28분 — 오른쪽 위 목표 배지. 다음 랜드마크의 전체 모양과 진행도를 작게 보여 줍니다.
  • 8월 26일 16시 59분 — 도전 모드. 랜드마크 하나를 골라 그 높이까지 쌓으면 "정복"입니다. 앞의 것을 깨야 다음이 열리고, 목표를 넘겨 계속 쌓으면 위쪽 도전도 함께 깨집니다. 왼쪽 위 HUD에는 "첨성대 2/3"처럼 목표 이름과 층수가 나옵니다.

버그는 도전 모드가 들어가고 80분 뒤인 18시 19분 커밋에서 고쳐졌습니다. 이 커밋은 Claude Code가 작성했고, 커밋 설명에 증상과 원인이 자세히 적혀 있습니다.

증상: 보이지 않던 것의 정복 알림

커밋이 적은 장면은 이렇습니다. 첨성대(3층)를 목표로 고르고 시작합니다. 3층을 넘으면 첨성대 정복입니다. 여기까지는 정상입니다. 그런데 그 뒤로 화면이 엇갈리기 시작합니다.

"7층에서 난데없이 '스핑크스 정복!' 배너가 떴다. 화면에 한 번도 나온 적 없는 대상이 정복 알림만 울린 것이다. 같은 화면에서 HUD는 '첨성대 18/3'처럼 분모를 넘긴 숫자를 계속 달고 있었다."

3층을 넘은 순간 배경과 배지는 이미 다음 목표인 개선문(17층)으로 넘어가 있었습니다. 그런데 7층에서 배너는 스핑크스를 정복했다고 하고, 왼쪽 위 HUD는 여전히 첨성대에 머물러 "7/3", "18/3"을 보여 줍니다. 화면의 세 곳이 각각 다른 랜드마크를 말하고 있었습니다.

첨성대(3층)를 목표로 골라 7층까지 쌓았을 때 화면 고치기 전 고친 뒤 왼쪽 위 HUD 첨성대 7/3 개선문 7/17 오른쪽 위 배지·배경 개선문 개선문 정복 배너 스핑크스 정복! (없음) 셋이 각자 다른 것을 가리킴 셋 다 '다음 목표' 하나를 봄 스핑크스(7층)는 실루엣이 없어 배경·배지에는 한 번도 나오지 않았는데, 도전 목록에는 있어서 배너만 떴습니다.
커밋 설명의 장면을 표로 옮겼습니다. 고치기 전에는 HUD는 고른 목표에 멈춰 있고, 배경은 실루엣이 있는 다음 랜드마크로 넘어갔으며, 배너는 실루엣 없는 스핑크스를 정복했다고 알렸습니다.

원인 1: 그릴 수 없는 목표

스핑크스는 왜 배경에 한 번도 나오지 않았을까요? 배경 실루엣을 그리는 방식 때문입니다. 스택 타워의 실루엣은 높이에 따른 폭을 적은 목록으로 만듭니다. 예를 들어 첨성대는 이렇게 적혀 있습니다.

// 첨성대 — 밑지름 5.2m에서 목 부분 2.5m로 좁아지는 병 모양
'첨성대': {round:true, p:[[0,5.2],[0.5,4.5],[0.82,3.1],[0.9,2.5],[1,2.5]]},

바닥(0)에서 꼭대기(1)까지 몇 지점의 폭만 정하면, 그 사이를 이어 좌우가 똑같은 모양을 만듭니다. 탑, 빌딩, 오벨리스크처럼 위로 솟은 건물에는 잘 맞습니다. 하지만 커밋의 표현으로 "머리가 앞에 붙은 스핑크스도, 겹친 조가비 형태의 오페라하우스도 그릴 수 없다." 스핑크스는 옆으로 누운 몸 앞쪽에 머리가 솟아 있고, 시드니 오페라하우스는 조가비 모양 지붕 여러 개가 겹쳐 있습니다. 둘 다 "높이마다 폭 하나"로는 표현이 안 됩니다. 그래서 이 둘에는 실루엣이 없었습니다.

배경과 배지는 이 사정을 알고 있었습니다. 다음 랜드마크를 고를 때 실루엣이 있는 것 중에서 고르도록 이미 짜여 있었습니다(nextShapedLandmark). 그래서 첨성대 다음으로 스핑크스를 건너뛰고 개선문을 세웠습니다.

문제는 하루 뒤에 들어간 도전 모드였습니다. 도전 목록은 이렇게 만들어졌습니다.

// 고치기 전
const QUESTS = LANDMARKS.filter(l => l.name !== '기린');

"실제 구조물만 목표로 삼는다"는 주석대로 높이 감각용 참고치인 기린만 뺐고, 실루엣이 있는지는 보지 않았습니다. 스핑크스와 오페라하우스가 도전 목록에 들어갔습니다. 도전 정복 판정은 목록을 아래부터 훑으면서 지금 층수가 넘은 것을 차례로 깹니다. 첨성대를 깬 직후 스핑크스가 열리고, 7층에 닿자 스핑크스가 깨지며 배너가 떴습니다. 배경과 배지에는 나올 수 없는 목표가, 배너에만 나타난 것입니다.

원인 2: 같은 질문에 따로 답하던 세 곳

두 번째 원인은 "지금 목표가 뭐지?"라는 같은 질문에 화면의 여러 곳이 각자 답하고 있었다는 점입니다.

  • 배경은 고른 목표에 닿기 전까지는 그것을 세워 두고, 넘어선 뒤에는 실루엣 있는 다음 랜드마크로 넘어갔습니다.
  • HUD는 처음 고른 목표(game.quest)만 보고 있었습니다. 그래서 목표를 넘긴 뒤에도 계속 "첨성대 18/3"이었습니다.
  • 배너는 도전 목록 기준으로 깨진 것을 알렸습니다.

각자의 규칙은 따로 보면 다 그럴듯합니다. 하지만 셋이 같은 장면에 함께 놓이면 서로 다른 이름이 동시에 보입니다. 이 버그의 진짜 모양은 "어느 한 곳이 틀렸다"가 아니라 "한 가지 사실을 세 곳이 따로 계산했다"입니다.

해결: 목표는 실루엣이 있는 것만, 기준은 하나로

첫째, 도전 목록의 기준을 바꿨습니다.

// 고친 뒤
const QUESTS = LANDMARKS.filter(l => LM_SHAPES[l.name]);

실루엣(LM_SHAPES)이 있는 랜드마크만 도전 목표가 됩니다. 도전 목표는 17곳에서 15곳으로 줄었습니다. 스핑크스와 오페라하우스가 게임에서 사라진 것은 아닙니다. 커밋에 따르면 둘은 일반 랜드마크로 남아, 높이를 넘을 때 뜨는 돌파 배너와 높이 비교에는 그대로 나옵니다. 도전 목록이 실루엣 목록을 참조하게 되면서, 코드 안에서 두 정의의 순서도 바꿨습니다.

둘째, "지금 목표"를 정하는 함수를 하나 만들어 HUD·배지·배경이 모두 그것만 보게 했습니다.

const questFocus = () => {
  const q = game.quest;
  if(q && game.level < q.f) return q;              // 고른 목표에 닿기 전에는 그것
  return QUESTS.find(l => !questCleared(l.name)) || q; // 넘어선 뒤에는 다음 미정복 목표
};

고른 목표에 닿기 전에는 그것을, 넘어선 뒤에는 아직 깨지 않은 다음 목표를 가리킵니다. 이미 깬 곳에 다시 도전할 때도 그 높이에 닿기 전까지는 고른 목표를 씁니다. 이제 첨성대를 넘으면 HUD도 "개선문 4/17"로 바뀌고, 배지와 배경도 개선문을 가리킵니다.

셋째, 작은 뒷정리가 하나 더 있습니다. 진행도("랜드마크 3/15 정복")는 저장된 정복 목록의 길이로 셌는데, 이전 버전에서 스핑크스나 오페라하우스를 깬 사람은 그 이름이 저장 목록에 남아 있습니다. 그대로 두면 "17/15 정복" 같은 숫자가 나올 수 있어서, 진행도는 지금의 도전 목록 기준으로만 세게 했습니다(questDoneCount). 목록에서 무언가를 빼면 이미 저장된 데이터에는 빠진 것이 남는다는 점까지 챙긴 수정입니다.

마지막으로 서비스 워커의 캐시 이름을 stack-tower-v13에서 v14로 올렸습니다. 스택 타워는 홈 화면에 설치해 오프라인으로도 하는 웹앱이라, 캐시 이름을 바꿔야 설치한 사람들도 고친 코드를 받습니다.

테스트: 한 층씩 올리며 셋을 함께 본다

커밋은 검증 방법도 적었습니다. "한 층씩 올리며 HUD·배지·배경·배너를 추적하는 회귀 검사를 추가했다." 확인하는 것은 세 가지입니다.

  1. HUD·배지·배경이 같은 대상을 가리키는가
  2. "18/3"처럼 분모를 넘는 표시가 없는가
  3. 실루엣 없는 대상의 정복 배너가 뜨지 않는가

새 도전 테스트와 기존 테스트 9묶음이 모두 통과했다고 적혀 있습니다. 이 테스트들은 9월 중순 저장소의 tests/ 폴더로 옮겨졌고, 지금의 도전 모드 테스트(tests/questtest.js)에도 "분모 초과 표시 없음", "보이지 않는 목표의 배너 없음" 항목이 남아 있습니다. 도전 목록에 기린·스핑크스·오페라하우스가 없는지도 확인합니다.

이 테스트가 화면 요소 하나씩이 아니라 셋을 동시에 본다는 점이 중요합니다. HUD만 따로 테스트했다면 "고른 목표를 정확히 보여 준다"로 통과했을 것이고, 배경만 따로 봤다면 "다음 랜드마크로 잘 넘어간다"로 통과했을 것입니다. 버그는 둘 사이에 있었습니다.

교훈

  • 보여 줄 수 없는 것은 목표로 삼지 않는다. 실루엣이 없는 랜드마크를 도전 목표에 넣으면, 플레이어는 본 적 없는 것을 정복하게 됩니다. 목록을 만들 때 "이걸 화면이 표현할 수 있나"를 조건에 넣었습니다.
  • 같은 질문에는 한 곳에서 답한다. "지금 목표가 뭔가"를 HUD·배지·배경이 각자 계산하자 셋이 어긋났습니다. 답을 내는 함수를 하나로 모으면 어긋날 수가 없습니다.
  • 새 기능은 기존 기능의 전제를 확인한다. 배경과 배지는 "실루엣 있는 것만"이라는 전제 위에 있었는데, 하루 뒤 들어온 도전 모드는 그 전제를 몰랐습니다.
  • 목록에서 빼면 저장된 데이터를 확인한다. 빠진 이름은 이미 저장된 기록에 남아 "17/15" 같은 숫자를 만듭니다.

스택 타워의 다른 버그는 버그 도감 #1: 멈춘 카운트다운과 버그 도감 #8: 안내선대로 놓았더니 100% 빗나갔다에 있습니다. 전체 과정은 스택 타워 제작기에, 게임에 나오는 랜드마크 높이는 세계 랜드마크 높이표에 정리했습니다.

함께 읽으면 좋은 글