개요

지난 편까지의 상태는 끝나지 않는 게임이었다. 좀비가 나오고, 때리면 죽고, 심장이 떨어지는데, 그게 전부다. 타이머도 없고 승패도 없고 재시작도 없다.

오늘 그걸 닫았다. 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로 같다

오른쪽 끝의 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가 런타임 업데이트를 지원하지 않는다”고 생각했는데, 문장이 틀렸다.

진짜 원인은 _waitAwake에서 한 번 만들어진 뒤 아무도 다시 만들지 않는다” 이다.

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로 보인다

인스펙터에 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가 이미 들어가 있다.

참고 자료

한줄 평

  • 미룬 걸 네 번째로 고치면서 귀찮았는데, 그때 대가를 같이 적어둔 덕에 어디를 고칠지는 매번 바로 알았다!