개요
지난 편까지의 상태는 끝나지 않는 게임이었다. 좀비가 나오고, 때리면 죽고, 심장이 떨어지는데, 그게 전부다. 타이머도 없고 승패도 없고 재시작도 없다.
오늘 그걸 닫았다. 5분 타이머, 심장을 모으면 레벨업, 카드 3장 중 하나 선택, 스탯 적용, 승/패 화면, 재시작까지. 원래 일요일 몫이던 웨이브 테이블까지 당겨왔다.
그런데 오늘 가장 오래 붙잡은 건 새 기능이 아니었다. 8월 말에 “나중에 갚자”고 적어둔 한 줄이 오늘 청구서를 보내왔다. 그것도 네 번째로.
솔직히 귀찮았다.. 매번 같은 자리를 고치느니 근본부터 갈아엎고 싶었다. 그런데 그걸 지금 하면 안 되는 이유가 따로 있었다.
이번 글이 답할 질문. 알고도 미룬 대가는 언제, 어떤 모습으로 돌아오는가?
1. 오늘 만든 것 — 게임이 드디어 끝난다
결과물부터 보자. 심장을 모으면 화면이 멈추고 카드 세 장이 뜬다.

이 화면이 뜨는 동안 게임은 완전히 정지 상태다. Time.timeScale = 0을 넣으면 물리도 좀비도 타이머도 전부 멈춘다.
만든 조각은 이렇다.
| 조각 | 하는 일 |
|---|---|
GameTimer |
5분을 세고, 다 되면 OnExpired를 발화한다 |
GameEnder |
승/패를 판정하고 timeScale을 0으로 만든다 |
PlayerExperience |
심장을 먹은 만큼 쌓고, 요구치를 넘으면 OnLevelUp을 발화한다 |
CardSelector |
카드 3장을 뽑아 창을 열고, 고른 카드를 스탯에 반영한다 |
WaveDirector |
시간이 지날수록 좀비를 더 자주, 더 많이 뱉게 만든다 |
여기서 timeScale이 뭔지 한 줄로 풀면 — 게임 세계의 시계 배속이다. 1이면 정상 속도, 0.5면 절반, 0이면 시간이 멈춘다.
timeScale = 0이 멈추는 건 게임 세계지 프로그램 전체가 아니다.Update는 계속 호출되고, 키 입력도 UI 버튼도 그대로 살아 있다. 그래서 멈춘 화면에서 카드를 클릭할 수 있다.
2. 한 프레임에 세 번 레벨업 — 로그가 뭉쳐서 찍혔다
레벨업을 붙이고 테스트하다가 콘솔에서 이걸 봤다.

오른쪽 끝의 3이 같은 메시지가 세 번 찍혔다는 뜻이다. 그런데 타임스탬프가 13:10:17로 전부 똑같다.
처음엔 이렇게 봤다 (틀렸다)
경험치가 요구치보다 많이 들어온 줄 알았다. 요구치가 5인데 8이 한꺼번에 들어오면 while문이 여러 바퀴 돌 테니, 그게 원인이라고 생각했다.
그래서 심장 하나가 주는 값을 확인했다. 1이었다. 8이 한 번에 들어올 수가 없다.
진짜 원인은 심장을 여러 개 동시에 밟은 것이었다. OnTriggerEnter2D는 같은 프레임에 여러 콜라이더와 닿으면 각각 따로 호출된다. 심장 세 개 위를 지나가면 그 프레임에 Add(1)이 세 번 불린다.
| 내가 생각한 것 | 실제 |
|---|---|
| 한 번의 획득에 값이 크게 들어왔다 | 획득 자체가 세 번 일어났다 |
while이 세 바퀴 돈다 |
Add가 세 번 불리고, 각각 한 바퀴씩 돈다 |
가설과 결과가 겉으로는 똑같이 “레벨업 3회”라 구분이 안 됐다. 값을 직접 찍어보기 전까지는.
이게 왜 위험한가
예외적인 상황이 아니다. 심장이 몰려 있는 자리를 지나가면 평범하게 재현된다.
그리고 이대로 두면 카드 창이 세 번 겹쳐 뜨고, 두 번째 발화가 첫 번째를 덮어쓴다. 플레이어 입장에서는 레벨을 도둑맞는다. 에러도 안 나고 로그도 안 남는다.
조용히 틀리는 버그가 제일 나쁘다. 터지면 고치는데, 안 터지면 안 고친다.
3. 큐를 만든 방법 — 되돌아오는 이벤트
“레벨업 세 개가 순서대로 처리돼야 한다”까지는 바로 나왔다. 문제는 어떻게 기다리느냐였다.
처음 떠올린 건 “비동기 대기처럼 하면 되지 않나” 였다. 후보를 셋 놓고 봤다.
| 후보 | 왜 안 했나 |
|---|---|
async / await
|
timeScale = 0 상태에서 실행 흐름 관리가 지저분해진다. WebGL 빌드는 스레드가 없어 주의점이 더 는다 |
| 코루틴 |
timeScale = 0에서도 도는 물건이라 “고를 때까지 기다린다”를 매 프레임 확인하는 코드로 짜게 된다 |
| 이벤트 | 이미 쓰고 있는 도구. 되돌아오는 신호 하나만 더하면 끝난다 |
결국 카운터 하나로 끝났다.
void HandleLevelUp()
{
++_pendingLevelUps;
// 이미 진행중인 작업이 있다면 무시
if (_pendingLevelUps > 1)
{
return;
}
OpenSelection();
}
public void Select(UpgradeCard card)
{
// 비정상적 접근 방지
if (_pendingLevelUps <= 0)
{
return;
}
_applier.Apply(card);
--_pendingLevelUps;
if (_pendingLevelUps > 0)
{
OpenSelection(); // 남았으면 다시 연다
}
else
{
OnSelectionClosed?.Invoke();
Time.timeScale = 1f;
}
}
레벨업이 세 번 발화되면 카운터가 3이 되고, 창은 한 번만 열린다. 카드를 고르면 카운터가 줄고, 남아 있으면 그 자리에서 다시 연다.
기다리는 코드는 한 줄도 없다. 신호가 돌아오는 것이 곧 기다림이다. 비동기 대기의 본질이 사실 그거였다.
Ducktopia 때와 비교하면
이전에 만든 Ducktopia 게임서버에서도 이벤트와 콜백을 같이 썼었다. 그때는 그냥 되는 대로 골라 썼던 것 같은데, 이번에 기준이 하나 잡혔다.
| 구조 | 언제 쓰나 | 이번 프로젝트에서 |
|---|---|---|
| 이벤트 (구독) | 누가 관심 있는지 발신자가 모를 때 | 레벨업, 피격, 사망 |
| 직접 호출 / 콜백 주입 | 정확히 하나를 지목해야 할 때 | 심장 획득, 카드 선택 |
심장을 먹는 처리를 처음엔 이벤트로 짰다가 엎었다. 인자 없는 이벤트에 바닥의 심장 300개가 전부 구독하고 있으면, 하나를 밟는 순간 300개가 다 자기가 먹혔다고 판단하고 사라진다.
상태 변수는 하나로
중간에 _working이라는 플래그를 하나 더 뒀다가 지웠다.
상태 변수가 둘이면 어긋날 수 있다.
_pendingLevelUps == 1이 “창이 열려 있다”와 “방금 열렸다”를 겸하게 되니까 플래그가 필요 없어졌다. 변수를 늘리면 둘을 항상 같이 갱신해야 하고, 한 군데만 빠뜨리면 그때부터 조용히 어긋난다.
실제로 Select의 가드를 처음에 if (_working) return;으로 방향을 반대로 써서 카드를 아예 못 고르는 상태를 만들기도 했다.. 변수 하나가 줄면 실수할 자리도 하나 준다.
4. 부채가 청구됐다 — 0008에 적어둔 대가
여기가 오늘의 본편이다.
8월 말에 공격 주기를 코루틴으로 처리하면서 WaitForSeconds를 필드에 담아뒀다. 매번 새로 만들면 쓰레기가 쌓이니 한 번 만들어 재사용한 것이다.
_wait = new WaitForSeconds(1f / _attacksPerSecond); // Awake에서 한 번
그때 결정 기록에 대가를 같이 적었다. “간격을 런타임에 바꿔도 반영되지 않는다.” 그리고 “웨이브 만들 때 갚자”고 미뤘다.
그런데 공격속도 카드가 웨이브보다 먼저 도착했다. 갚기로 한 날짜보다 청구서가 빨리 온 것이다.
원인을 정확히 말하면
처음엔 “WaitForSeconds가 런타임 업데이트를 지원하지 않는다”고 생각했는데, 문장이 틀렸다.
진짜 원인은 “
_wait은Awake에서 한 번 만들어진 뒤 아무도 다시 만들지 않는다” 이다.
WaitForSeconds는 만들 때 초 수가 정해지고, 그걸 바꾸는 통로가 아예 없는 물건이다. 그러니까 값을 바꾸려면 객체를 새로 만들어 갈아끼우는 수밖에 없다.
원인을 “지원하지 않는다”로 적으면 할 수 있는 게 없어 보인다. “아무도 다시 안 만든다”로 적으면 할 일이 바로 보인다. 원인 문장을 어디까지 파고드느냐가 해결책의 크기를 정한다.
A/B/C 중에 A
| 안 | 반영 시점 | 대가 |
|---|---|---|
A. _wait을 새로 만든다 |
다음 바퀴부터 | 최대 1주기 늦게 반영 |
| B. 코루틴을 끊고 다시 시작 | 즉시 | 주기가 리셋된다. 레벨업이 잦을수록 공격이 느려지는 역설 |
C. WaitForSeconds를 버리고 시간 비교로 |
즉시 |
AttackLoop 전면 재작성 + 매 프레임 도는 코루틴 |
B는 언뜻 좋아 보이는데 함정이 있다. 카드를 고를 때마다 공격 주기가 처음부터 다시 시작되니까, 공격속도를 올릴수록 공격이 안 나가는 상황이 생긴다.
C가 근본 해결이다. 하고 싶었다. 그런데 안 했다.
매 프레임 도는 코루틴이 좀비 300마리 상황에서 얼마나 비싼지는 Profiler를 켜봐야 안다. 측정 전에 미리 바꾸면 before/after 비교 대상이 흐려진다.
다음 작업이 오브젝트 풀링 성능 측정이라 기준점이 필요했다. 지금 구조를 바꾸면 나중에 “빨라진 게 풀링 덕인지 코루틴 덕인지” 구분이 안 된다. 귀찮은 걸 참는 것도 이유가 있으면 참을 만하다.
5. 값을 뒤집었다 — 초당 몇 대 때리는가
부채를 갚는 김에 필드 이름도 바꿨다. 원래는 _interval, 공격 사이의 간격(초) 이었다.
문제는 이 값이 작아져야 강해진다는 것이다. 공격속도 카드에 증가량을 넣어야 하는데, 다른 카드는 전부 +5 같은 덧셈인데 이것만 0.9를 곱해야 한다.
에셋 파일만 열어보면 이렇게 읽힌다. “공격속도 0.9 증가”. 강해지는 건지 약해지는 건지 알 수가 없다..
그래서 _attacksPerSecond, 초당 공격 횟수로 뒤집었다.

인스펙터에 Attacks Per Second 2.5라고 뜨면 “초당 2.5대”로 바로 읽힌다. 모든 카드가 “올라가면 강해진다”로 통일됐다.
public void AddAttackSpeed(float amount)
{
if (amount <= 0f)
{
Debug.LogWarning("공격속도 증가량이 음수로 들어옴!!");
return;
}
_attacksPerSecond += amount;
_wait = new WaitForSeconds(1f / _attacksPerSecond); // 여기서 갚는다
}
뒤집고 나서 발견한 것
간격을 쓸 때는 없던 검사가 하나 필요해졌다. 역수를 취하기 때문이다.
| 값 | 1f / _attacksPerSecond |
결과 |
|---|---|---|
| 0 | Infinity |
영원히 대기. 공격이 아예 안 나간다 |
| 음수 | 음수 | 대기가 0으로 처리되어 매 프레임 공격 |
0은 그래도 “안 나가네?” 하고 바로 알아챈다. 음수가 더 나쁘다. 에러 없이 공격이 미친 듯이 나가는데, 그게 버그인지 밸런스인지 구분이 안 된다.
그래서 Awake에서 막았다.
if (_attacksPerSecond <= 0f)
{
Debug.LogError("초당 공격속도가 비정상적 입니다.", this);
enabled = false;
return;
}
조용히 이상해지는 쪽이 시끄럽게 터지는 쪽보다 항상 더 나쁘다. 막을 거면 나눗셈 앞에서 막아야 한다.
정리
-
OnTriggerEnter2D는 같은 프레임에 여러 번 불린다. 겹친 콜라이더 수만큼 각각 호출된다. - 같은 결과를 내는 원인이 둘 이상일 수 있다. “레벨업 3회”만 보면 값이 큰 건지 호출이 세 번인지 구분이 안 된다. 값을 직접 찍어야 갈린다.
- 되돌아오는 이벤트 하나가 큐가 된다. 기다리는 코드를 안 짜고도 순서를 지킬 수 있다.
- 상태 변수가 둘이면 어긋난다. 하나로 겸할 수 있으면 겸하게 둔다.
- 이벤트는 발신자가 수신자를 모를 때, 직접 호출은 하나를 지목할 때. 반대로 쓰면 300개가 한꺼번에 반응한다.
- 미룬 부채는 예정보다 일찍 청구된다. 갚을 날짜를 정해둬도 그 전에 다른 기능이 먼저 도착한다.
- 원인 문장을 어디까지 파느냐가 해결책의 크기를 정한다. “지원 안 함”과 “아무도 다시 안 만듦”은 다른 문제다.
- 측정 전에 구조를 바꾸면 비교 대상이 사라진다. 근본 해결도 순서가 있다.
- 값의 방향을 뒤집으면 이름이 정직해진다. 간격 대신 초당 횟수로 두니 모든 카드가 “올라가면 강해진다”가 됐다.
-
나눗셈 앞에서 막아야 한다. 뒤에서 막으면
Infinity가 이미 들어가 있다.
참고 자료
- Unity 6000.0 Manual - WaitForSeconds
- Unity 6000.0 Manual - Time.timeScale
- Unity 6000.0 Manual - Collider2D.OnTriggerEnter2D
- Unity 6000.0 Manual - Coroutines
- Microsoft Learn - event 키워드
- 지난 편 - 유니티 학습 프로젝트 < 6 >
한줄 평
- 미룬 걸 네 번째로 고치면서 귀찮았는데, 그때 대가를 같이 적어둔 덕에 어디를 고칠지는 매번 바로 알았다!