Blog으로 돌아가기
2026.08.10.10 min read

보스가 공격할 때마다 커졌다 작아지는 버그, 범인은 스프라이트가 아니라 보정 코드였다

게임잼 사전과제로 팀과 2D 액션 로그라이크를 만들고 있습니다. 이 게임에는 플레이어보다 다섯 배쯤 큰 보스가 하나 있는데, 어느 날부터 이 보스가 이상해졌습니다. 평소에는 멀쩡한데 공격 모션 중 특정 순간에만 몸이 갑자기 1.5배쯤 부풀었다가 다음 프레임에 아무 일 없었다는 듯 돌아오는 겁니다. 팀원이 스프라이트 시트를 며칠 동안 다듬어 온 터라 당연히 그림 문제라고 생각했습니다. 시트를 다시 정렬해서 교체하고도 증상이 남아서야 코드를 의심하기 시작했고, 결론부터 말하면 범인은 예전에 바로 그 그림 문제를 덮으려고 제가 넣었던 보정 코드였습니다.

Phaser디버깅게임개발

증상, 웅크리는 순간에만 커진다

제보는 이렇게 들어왔습니다. "공격할 때 가끔 보스 크기가 커지고 다시 작아지던데, 스프라이트는 문제 없거든? 문제가 뭘까." 팀원 입장에서는 무작위로 보였을 겁니다. 어떤 공격에서는 멀쩡하고, 어떤 공격에서는 순간적으로 거대해지니까요.

그런데 게임을 직접 돌려보면서 조건을 좁혀 보니 규칙이 있었습니다. 보스가 서 있거나 걸을 때는 아무 문제가 없습니다. 내려찍기의 착지 순간이나 돌진 준비 자세처럼 몸을 낮게 웅크리는 프레임에서만 화면 속 보스가 갑자기 커졌고, 그 프레임이 지나가면 바로 원래 크기로 돌아왔습니다. 무작위처럼 보였을 뿐 실제로는 특정 프레임에서 정확히 재현되는 결정적 버그였습니다.

이런 종류의 버그에서 첫 용의자는 당연히 에셋입니다. 보스 스프라이트는 상태별로 시트가 12장쯤 됩니다. 셀 규격만 463x409로 같을 뿐 안에 그려진 그림 크기는 제각각이었습니다. 실제로 며칠 전까지 프레임마다 원화 크기가 들쭉날쭉해서 포즈가 바뀔 때 덩치가 널뛰는 문제를 겪었고, 시트를 다시 뽑고 정렬하는 작업을 반복하던 중이었습니다. 그래서 이번에도 시트 몇 장을 다시 정렬해서 교체했습니다. 그런데도 증상이 남았습니다. 여기서부터 이야기가 재미있어집니다.

첫 번째 가설, 실측표가 낡았다

이 게임 코드에는 프레임별 그림 높이 실측표라는 게 있었습니다. 각 시트의 프레임마다 불투명 픽셀의 높이를 재서 코드에 박아둔 표입니다. 왜 이런 표가 있는지는 뒤에서 설명하기로 하고, 일단 시트를 교체했으니 이 표가 낡았을 거라는 게 두 번째 가설이었습니다. 표에는 옛 그림의 실측값이 들어 있는데 실제 그림은 바뀌었으니 어긋난 만큼 보정이 틀어져서 크기가 튄다는 시나리오죠. 꽤 그럴듯했습니다.

검증은 어렵지 않았습니다. Python과 Pillow로 현재 시트 10장을 열어서 프레임별 불투명 픽셀 높이를 전부 다시 쟀습니다. 30줄짜리 스크립트면 충분한 일입니다.

결과가 허무했습니다. 새로 잰 값은 옛 표와 프레임당 1에서 3픽셀밖에 차이가 나지 않았습니다. 342가 341이 되는 수준의 오차로는 눈에 보이는 크기 튐을 설명할 수 없습니다. 팀원이 시트를 재정렬하면서도 그림 크기 자체는 거의 그대로 유지한 셈입니다. 가설이 무너졌습니다. 남은 용의자는 그 표를 사용하는 코드 자체였습니다.

text재측정 결과. 옛 표와 거의 같다. 표가 낡아서가 아니었다.
bossIdle:          [341, 341, 341, 342, 341]   // 표: [342, 342, 342, 342, 342]
bossExecutionSlam: [269, 332, 382, 283, 288, 209, 125, 265]
                   // 표: [269, 333, 383, 285, 289, 211, 126, 266]
bossDashAttack:    [157, 153, 138, 191, 188, 177]
                   // 표: [158, 154, 139, 193, 189, 178]

이 재측정이 헛수고였냐면, 전혀 아니었습니다. "표가 낡았다"는 그럴듯한 오답을 데이터로 확실하게 기각한 덕분에, 더 이상 에셋 쪽을 의심하지 않고 코드로 직행할 수 있었으니까요. 디버깅에서 가설을 죽이는 것도 전진입니다.

진범, 매 프레임 키를 똑같이 맞추는 정규화

표를 쓰는 코드는 syncSpriteScale이라는 메서드였고 게임 루프의 update에서 매 프레임 호출되고 있었습니다. 하는 일을 요약하면 이렇습니다. 지금 재생 중인 애니메이션 프레임의 그림 높이를 표에서 찾아서, 기준 높이인 idle의 342픽셀로 나눈 배율을 스프라이트 전체에 곱합니다. 한 줄로 줄이면 "어떤 프레임이든 그림 높이가 idle과 똑같아 보이게 늘리거나 줄인다"입니다.

아래는 실제 내려찍기 시트에 프레임별 그림 높이를 표시한 그림입니다. 여섯 번째, 일곱 번째 프레임을 봐 주세요. 보스가 검을 땅에 내리꽂으며 몸을 바닥까지 낮춘 자세라 그림 높이가 209픽셀, 125픽셀까지 내려갑니다.

보스 내려찍기 스프라이트 시트. 프레임마다 그림 높이가 표시되어 있고, 웅크린 프레임은 125픽셀로 낮다
내려찍기 시트의 프레임별 그림 높이. 착지 프레임(빨간 표시)은 웅크린 자세라 125픽셀밖에 안 된다.

이제 문제가 보입니다. 정규화 코드 입장에서 125픽셀짜리 프레임은 "그림이 작게 그려진 프레임"입니다. 그래서 342 나누기 125, 약 2.7배로 확대해서 키를 맞춰 버립니다. 하지만 실제로는 보스가 작아진 게 아니라 몸을 웅크려서 키가 낮아진 것뿐이거든요. 웅크린 보스를 세로로 2.7배 뻥튀기하니 화면에서는 갑자기 거대해진 것처럼 보이고 다음 프레임에서 그림 높이가 돌아오면 배율도 따라 돌아옵니다. "공격할 때 가끔 커졌다 작아진다"의 정체가 정확히 이거였습니다.

typescript문제의 정규화. 웅크린 자세 125픽셀을 idle 키 342픽셀로 늘려버린다.
// update()에서 매 프레임 호출되던 코드
private syncSpriteScale(sprite: Phaser.Physics.Arcade.Sprite): void {
  const heights = BOSS_FRAME_ART_HEIGHTS[sprite.texture.key];
  const artHeight = heights?.[Number(sprite.frame.name)];
  // 슬램 착지 프레임: 342 / 125 = 약 2.7배 확대. "갑자기 커졌다"의 정체.
  const nextScale = BOSS_SPRITE_SCALE * (artHeight ? BOSS_ART_HEIGHT / artHeight : 1);
  sprite.setScale(nextScale);
  // 이하 충돌 박스 역보정, 접지 스냅...
}

이 코드는 왜 존재했나

황당한 코드처럼 보이지만 사연이 있습니다. 예전 스프라이트 시트는 정말로 프레임마다 같은 자세인데 그림 크기가 다른 문제가 있었습니다. AI로 생성한 시트라 프레임 간 일관성이 부족했고 포즈가 바뀔 때마다 보스 덩치가 널뛰었습니다. 마감이 코앞이라 그림을 전부 다시 그릴 여유는 없었고, 코드에서 그림 높이 기준으로 배율을 맞춰 증상을 덮었습니다. 그 시점에는 이 보정이 실제로 문제를 해결해 줬습니다. 저는 꽤 뿌듯해했던 기억도 납니다.

그런데 이번에 팀원이 시트를 프레임 간 일관되게 다시 정렬했습니다. 원화 자체가 고쳐진 순간, 들쭉날쭉한 원화를 코드로 펴주던 보정은 할 일이 없어진 게 아니라 적극적으로 그림을 왜곡하는 코드가 됐습니다. 일관된 원화에서 프레임 높이가 다른 건 이제 오류가 아니라 연출이거든요. 웅크림, 도약, 착지 같은 동작 그 자체요. 보정은 그 연출을 오류로 판정하고 열심히 지워버리고 있었던 겁니다.

수정은 삭제였습니다. 정규화 메서드와 실측표를 통째로 지우고 고정 배율 하나만 남겼습니다. 44줄이 사라졌고 정규화 안에 얹혀살던 유휴 숨쉬기 오프셋과 접지 스냅만 update로 옮겼습니다. 빌드해서 배포하고 내려찍기와 돌진을 다시 확인하니 크기가 일정했습니다. 버그 수정 커밋의 diff가 온통 빨간색이면 기분이 좋습니다.

배운 것, 증상을 덮는 코드에는 유통기한이 있다

이번 버그가 남긴 교훈은 두 가지입니다. 첫째, 데이터의 결함을 코드로 덮었다면 그 코드는 데이터가 고쳐지는 순간 함께 치워야 합니다. 보정 코드는 결함의 존재를 전제로 깔고 있어서, 전제가 사라지면 보정이 곧 결함이 됩니다. 이번 경우 시트를 재정렬한 커밋과 보정을 제거하는 커밋이 한 몸이었어야 했는데, 시트만 갈아 끼우고 보정의 존재를 잊는 바람에 디버깅으로 반나절을 더 썼습니다. 임시방편을 넣을 때 "이건 원인이 고쳐지면 지워야 한다"는 주석이라도 남겨뒀다면 달랐을 겁니다.

둘째, "가끔"이라는 제보를 그대로 믿지 말 것. 팀원에게는 무작위로 보였지만 재현 조건을 좁혀 보니 그림 높이가 낮은 프레임이라는 정확한 규칙이 있었습니다. 무작위처럼 보이는 버그일수록 코드를 먼저 뒤지기보다 어떤 순간에 터지는지 조건을 좁히는 데 시간을 쓰는 편이 결과적으로 빨랐습니다.

이 보스전은 아래 링크에서 직접 확인할 수 있습니다. 이제는 내려찍기를 해도 보스가 부풀지 않습니다.

게임 플레이하기GitHub 저장소