개요
14편에서 얻은 결론은 비용을 결정하는 건 좀비 마리 수가 아니라 얼마나 몰려 있느냐였다. 682마리에서 753마리 사이에 프레임 시간이 다섯 배로 뛰었다.
그러면 답은 둘 중 하나다. 몰리지 않게 하거나, 애초에 그만큼 안 만들거나.
앞의 것부터 검토했고, 보고 나서 접었다. 뒤의 것을 골라 상한을 넣었다. 그리고 마지막에 실제로 플레이해봤더니 그 상한이 걸릴 일이 없었다.
이번 글이 답할 질문. 최악의 경우를 막는 장치는 그 경우가 오지 않으면 무의미한가?
1. 먼저 나온 제안을 기각했다
멘토 역할로 쓰는 AI가 먼저 낸 안은 거리 기반 회수였다.
화면 밖으로 멀어진 좀비를 걷어들이면 마리 수와 밀집도가 같이 내려간다. 걷어들인 걸 다시 쓰면 오브젝트를 돌려쓰는 근거도 새로 생긴다.
말이 됐다. 그런데 실제 분포를 눈으로 보고 나서 접었다.

작업 화면에서 좀비들이 어디에 있는지를 봤더니, 멀리 흩어진 게 아니었다. 플레이어 쪽을 향해 반원 모양으로 정체해 있었다. 당연했다. 좀비가 태어나는 자리는 플레이어를 중심으로 한 원 둘레고, 태어나자마자 전부 플레이어를 향해 온다. 출발점이 원이고 목적지가 한 점이면 결국 한 점으로 모인다.
걷어들일 만큼 멀어진 개체가 없다. 회수 조건이 성립하지 않으니 제안은 기각이다.
분포를 안 보고 코드부터 짰으면, 한 번도 안 걸리는 회수 로직을 만들고 뿌듯해했을 것이다.
여기서 스폰 위치를 흩어놓는 방법도 생각해봤는데, 어떻게 흩어놓든 목적지가 플레이어 한 점이라 결국 같은 곳에서 뭉친다. 그래서 위치가 아니라 동시에 존재하는 수를 건드리기로 했다.
보면서 같이 알게 된 것
- 앞줄이 벽처럼 막고 있어서 뒷줄 수백 마리는 플레이어에게 닿지도 못한다. 그런데 물리 계산은 매번 다 낸다. 비용은 전부 내면서 게임에는 아무 기여도 안 하는 상태다
- 좀비끼리 밀다가 벽을 뚫고 나가는 현상이 있었다. 빠른 물체가 벽을 지나쳐버리는 그 문제가 아니라, 뒤에서 밀어대는 압력에 밀려 스며 나가는 쪽이다. 한 프레임에 풀 수 있는 겹침의 양을 넘어서면 이렇게 된다
두 번째는 마리 수가 줄면 같이 사라질 것으로 보고 일단 보류했다. 원인이 겹침의 양이면, 겹침이 줄면 없어진다.
2. 상한 600 — break를 어디에 두느냐
동시에 존재할 수 있는 좀비 수에 뚜껑을 씌우기로 했다. 코드는 짧다.
for (int i = 0; i < _countPerSpawn; ++i)
{
if (AliveCount >= _limitAliveCount) break;
GameObject obj = Spawn();
if (obj == null) continue;
++AliveCount;
OnSpawn?.Invoke();
}
yield return _wait;
여기서 중요한 건 break가 어디까지 빠져나가느냐다.
스폰은 두 겹으로 돈다. 바깥은 스포너가 사는 동안 계속 도는 반복문이고, 안쪽은 이번에 몇 마리 낼지만큼 도는 반복문이다.
break는 자기를 감싼 가장 가까운 반복문 하나만 빠져나온다. 그래서 안쪽만 끊기고 바깥은 계속 돈다.
| 만약 바깥까지 끊겼다면 | 실제 동작 |
|---|---|
| 600에 닿는 순간 스폰이 영영 멈춘다 | 600에 닿으면 이번 회차만 건너뛴다 |
| 좀비가 죽어 자리가 나도 안 채워진다 | 죽어서 599가 되면 다음 회차에 다시 채운다 |
막는 것과 끝내는 것은 다르다. 상한은 잠그는 게 아니라 자리가 없을 때만 기다리는 것이어야 한다.
한 마리씩 검사하는 이유
검사를 반복문 안에 뒀다. 밖에 두면 “이번에 6마리”인 상황에서 599마리일 때 6마리가 통째로 들어와 605가 된다.
안에서 한 마리마다 검사하면 599에서 한 마리만 채우고 끊긴다. 정확히 600이다.
왜 하필 600인가
14편에서 잰 숫자에서 뽑았다.
| 좀비 수 | 프레임 시간 |
|---|---|
| 682 | 2.32ms |
| 753 | 11.41ms |
682와 753 사이 어딘가에서 갈린다. 어디가 정확한 경계인지는 모른다. 그래서 경계보다 넉넉히 아래인 600으로 잡았다.
경계를 모를 땐 경계에 붙이지 않는다. 정확한 지점을 찾겠다고 여러 번 더 재는 것보다, 여유를 두고 한 번에 넘어가는 게 싸다.
3. 상한에 닿은 상태를 쟀다
상한이 실제로 걸리는 상황, 그러니까 이 게임이 낼 수 있는 최악의 상태를 만들어놓고 쟀다.

| 항목 | 값 |
|---|---|
| 좀비 수 | 600 (상한 도달) |
| 한 프레임 시간 | 8.10ms |
| 그중 2D 물리 | 4.01ms (49.6%) |
| 쓰레기 메모리 | 0 B |

60fps 예산은 한 프레임 16.6ms다. 최악의 상태에서 8.10ms니까 절반쯤 쓴다. 상한 없이 같은 구간까지 쌓았을 때가 16.01ms였으니 대략 절반으로 줄었다.
물리 비중도 66.2%에서 49.6%로 내려왔다. 밀집이 줄었으니 밀어내기 연쇄도 줄었다.
여기까지는 계획대로였다. 최악의 경우를 정의하고, 그 상태를 만들고, 예산 안에 들어오는 걸 확인했다.
4. 반전 — 정상 플레이에서는 안 걸린다
측정용으로 바꿔둔 것들을 전부 원복했다. 체력을 원래대로 되돌리고, 직접 플레이했다.
| 조건 | 결과 | 그때 좀비 수 |
|---|---|---|
| 밸런스 조정 전 | 40초 만에 죽음 | 65 |
| 조정 후 | 끝까지 버팀 | 최대 30 |

65마리. 임계라고 잡았던 682의 10분의 1도 안 된다.
600짜리 뚜껑은 한 번도 안 열린다. 실제 플레이에서 그 근처까지 갈 일이 없기 때문이다.
성능을 재다가 게임이 어렵다는 걸 알았다
40초에 죽은 건 성능 문제가 아니다. 그냥 게임이 너무 어려웠다. 레벨업이 느려서 강해지기 전에 좀비에 깔린다.
경험치 요구량이 레벨마다 불어나는 배수를 2.0에서 1.2로 낮췄다. 그랬더니 이번엔 끝까지 안 죽고 좀비가 30마리에서 평형이 됐다. 너무 쉬워진 것이다.
한쪽 끝에서 반대쪽 끝으로 갔다. 가운데를 찾는 건 다음 일로 미뤘다.
성능 문제를 재려다 게임이 너무 어렵다는 걸 알게 됐다. 무적으로 재는 동안엔 절대 안 보이는 것이었다.
그럼 상한은 헛수고였나
아니라고 정리했다. 이유는 두 가지다.
- 밸런스는 앞으로 계속 바뀐다. 스폰을 늘리는 조정을 하다가 실수하면 그때 좀비가 폭주한다. 그때 뚜껑이 있다
- 최악의 경우가 예산 안이라는 걸 숫자로 확인했다. 이건 상한을 만들었기 때문에 잴 수 있었던 값이다
안전장치가 한 번도 안 걸린다는 건, 막으려던 상황이 애초에 오지 않는다는 뜻이다. 그게 나쁜 소식은 아니다.
정리
- 코드를 짜기 전에 분포를 눈으로 본다. 반원형으로 정체해 있어서, 거리로 걷어들이는 안은 한 번도 안 걸릴 코드였다.
- 출발점이 원이고 목적지가 한 점이면 결국 한 점에 모인다. 스폰 위치를 흩어도 소용없다.
- 뒷줄 수백 마리는 비용만 내고 게임에는 기여하지 않는다.
-
break는 자기를 감싼 가장 가까운 반복문 하나만 빠져나온다. 바깥까지 끊기면 상한이 아니라 영구 정지가 된다. - 막는 것과 끝내는 것은 다르다. 자리가 나면 다시 채워야 상한이다.
- 검사는 반복문 안에서 한 마리마다. 밖에서 하면 한 번에 여러 마리가 넘어 들어온다.
- 경계를 모를 땐 경계에 붙이지 않는다. 682와 753 사이가 경계라 600으로 잡았다.
- 최악의 상태에서 8.10ms. 60fps 예산의 절반이고, 상한 없을 때의 절반이다.
- 실제 부하는 임계의 10분의 1도 안 됐다. 상한은 최악을 막는 장치로만 남는다.
- 성능을 재려다 게임이 너무 어렵다는 걸 알았다. 그리고 고치고 나니 이번엔 너무 쉬워졌다.
참고 자료
- Microsoft Learn - break 문
- Unity 6000.0 Manual - 코루틴
- Unity 6000.0 Manual - Rigidbody 2D 충돌 처리
- 측정 조건과 1~5차 실측 기록 - 0030
- 지난 편 - 유니티 학습 프로젝트 < 14 >
한줄 평
- 최악의 경우를 막는 뚜껑을 잘 만들어놓고 정작 실제로 해보니 40초 만에 죽었는데, 이게 오늘 제일 웃픈 순간이었다..