개요

지난 편SpawnerBase 하나에 오전을 다 쓴 이야기였다. 그때 세워둔 기준이 오늘 다시 쓰였는데, 같은 기준을 두 번 적용했더니 답이 갈렸다. 그 이야기가 5번 섹션에 있다.

오늘 목표는 네 개였다. 경험치 드랍, 스탯업, 게임 오버, 웨이브. 실제로 끝난 건 첫 번째 하나뿐이다.

나머지 시간은 코드를 짜는 대신 “이걸 지금 만드는 게 맞나”를 확인하는 데 썼다. 그게 낭비였는지 아닌지는 아직 잘 모르겠다..

이번 글이 답할 질문. 런타임에 조용히 틀리는 버그를, 컴파일이 안 되는 문제로 바꿀 수 있는가?


1. 오늘 만든 것 — 좀비가 죽은 자리에 심장이 뜬다

좀비를 죽이면 그 자리에 경험치 심장이 떨어지는 것까지가 오늘의 결과물이다. 만든 건 세 조각뿐이다.

조각 위치 하는 일
HeartSpawner Growth/ 넘겨받은 좌표에 심장 프리팹을 만든다
EnemyDeath의 콜백 Enemy/ 죽는 순간 자기 위치를 붙여서 그 함수를 부른다
EnemySpawner의 배선 Enemy/ 좀비를 만들 때 그 콜백을 꽂아준다

좀비 두 마리를 서로 떨어진 곳에서 죽여 심장이 각자 자리에 떴다

심장 두 개가 다른 좌표에 떠 있는 게 오늘의 증거다. 하나만 떴으면 좌표가 제대로 넘어간 건지, 그냥 아무 데나 뜬 건지 구분이 안 된다.

결과물은 세 조각인데 하루가 다 갔다. 왜 이만큼밖에 못 갔는지가 이 글의 나머지다.


2. Action을 필드에 넣으려다 막혔다 — 화살표를 반대로 잡고 있었다

처음엔 스포너가 콜백을 들고 있어야 한다고 생각했다. 그래서 이렇게 썼다.

// 틀림: 스포너가 콜백을 들고 있으려 했다
private Action<Vector2> _position;

protected override void Setup(GameObject obj)
{
    _pendingPosition = _position;
}

Action<>을 못 찾는다길래 using System을 추가했다. 그랬더니 이번엔 Vector2로 변환이 안 된다고 했다.

당연했다. Action<Vector2>는 “값”이 아니라 “Vector2를 받는 메서드를 담는 변수” 다. 좌표를 담는 그릇이 아니라 함수를 담는 그릇인데, 거기서 좌표를 꺼내려고 했으니 될 리가 없다.

방향이 반대였다. 스포너는 콜백을 들고 있는 쪽이 아니라 콜백이 가리키는 쪽이었다.

쓰는 법
spawner.SpawnHeart(pos) 지금 호출한다. 결과가 나온다
spawner.SpawnHeart 그 메서드를 가리키는 참조를 넘긴다

괄호 하나 차이다. 괄호를 붙이면 실행이고, 안 붙이면 “이 함수를 기억해둬라”가 된다. 뒤쪽을 메서드 그룹이라고 부른다.

인자는 배선하는 쪽이 아니라 호출하는 쪽이 채운다

그러면 좌표는 누가 넣나. 배선하는 쪽이 아니라 실제로 부르는 쪽이다. 스포너는 좀비를 만드는 시점에 그 좀비가 어디서 죽을지 모른다. 죽는 순간을 아는 건 EnemyDeath뿐이다.

더 쉬운 비유로

전화번호를 적어주는 것과
지금 전화를 거는 것은
다른 일이다

친구한테 “이 번호로 연락해”라고 번호를 적어주는 게 spawner.SpawnHeart다. 이때 나는 친구가 무슨 말을 할지 모른다. 할 말은 친구가 전화를 걸 때 정한다.

내가 지금 그 번호로 전화를 걸어버리면 spawner.SpawnHeart(pos)다. 이러면 할 말을 내가 정해야 하는데, 나는 아직 아무 일도 안 일어났으니 할 말이 없다.

그리고 이건 3일차에 이미 한 번 썼던 형태다. death.SetReleaseCallback(Despawn) — 완전히 같은 모양인데 못 알아봤다..

같은 패턴을 두 번째 만나도 못 알아보는 이유는, 처음에 그걸 이름으로 외웠지 구조로 이해하지 않았기 때문이다.


3. 405줄의 NullReferenceException — 두 개의 증상을 하나로 착각했다

씬에 손으로 놓아둔 좀비로 테스트를 하다가 두 가지가 동시에 터졌다.

  • (a) 콘솔에 NullReferenceException405줄 쌓였다
  • (b) 좀비가 나에게서 멀어진다

EnemyChase.FixedUpdate에서 같은 예외가 405줄 쌓였다

처음 세운 가설은 (b)를 보고 나온 것이었다. 방향 벡터 부호가 뒤집혔나. 3일차에 toTarget = P_enemy - P_player로 잘못 적어둔 적이 있고, 추격의 주체가 반대라서 충분히 의심할 만했다.

틀렸다. 둘은 별개 증상이 아니라 하나의 원인이 만든 두 얼굴이었다.

씬에 직접 놔둔 좀비라 Init(Transform) 주입을 안 받았고, 그래서 _targetnull이었다. EnemyChase가 속도를 대입하지 못한다. 그런데 좀비와 플레이어는 겹쳐 있으니 물리 엔진이 서로 밀어내는 속도를 계속 만들어낸다. 매 프레임 덮어써주던 값이 사라지니, 엔진이 만든 밀어내기 속도가 지워지지 않고 그대로 남았다.

즉 좀비는 도망친 게 아니라 내가 밀친 반작용으로 미끄러진 것이다. 2일차에 “대가”라고 적어뒀던 현상이 가드가 없는 상태에서 순수하게 드러난 셈이다.

속도를 대입하지 않으면, 물리 엔진이 만든 속도가 그대로 남는다. 안 움직이는 게 아니라 남의 속도로 움직인다.

405라는 숫자 자체가 정보였다

에러 줄 수를 그냥 “많다”로 넘길 뻔했는데, 이게 힌트였다.

쌓이는 속도 터지는 자리
한두 줄 이벤트·초기화처럼 한 번 도는 코드
수백 줄 Update / FixedUpdate처럼 매 프레임 도는 코드

FixedUpdate는 초당 50번 돈다. 405줄이면 대략 8초다. 실제로 스택 트레이스도 EnemyChase.FixedUpdate를 가리키고 있었다.

그리고 여기서 멈췄다

원인을 알았으니 _target에 널 가드를 넣으면 된다. 넣으려다 손을 멈췄다.

씬에 좀비를 직접 놓는 건 앞으로 안 쓸 경로다. 실제 게임에선 전부 스포너가 만들고, 스포너는 반드시 Init을 태운다. 안 쓸 경로를 위한 방어를 만드느라 순서를 거스르고 있었던 것이다.

방어 코드를 짜기 전에 물어야 할 게 하나 있다. 이 경로를 앞으로 실제로 쓰는가. 안 쓸 거면 그건 방어가 아니라 순서를 거스르는 일이다.


4. 필드를 우회로로 쓰면 생기는 것 — 그리고 그걸 컴파일러에게 넘긴 이야기

SpawnerBase는 위치를 자식에게 물어본다. 그런데 그 훅이 이렇게 생겼다.

protected abstract Vector2 GetSpawnPosition();

인자를 못 받는다. 좀비 스포너는 자기가 알아서 링 위의 좌표를 뽑으면 되니까 문제가 없었는데, 심장은 밖에서 좌표를 받아야 한다.

base에 Spawn(Vector2) 오버로드를 여는 안을 먼저 생각했다가 접었다. 자식 하나 편하자고 부모에 통로를 뚫으면, 그다음 자식이 또 다른 통로를 요구한다. 그렇게 base가 범용 잡동사니가 된다.

그래서 필드에 넣고 곧바로 부르는 쪽으로 갔다.

public void SpawnHeart(Vector2 position)
{
    _pendingPosition = position;
    Spawn();
}

protected override Vector2 GetSpawnPosition() => _pendingPosition;

돌아간다. 그런데 순서 의존이 생겼다. 넣는 줄과 쓰는 줄이 떨어져 있어서, 밖에서 Spawn()을 직접 부르면 _pendingPosition직전 좌표가 그대로 남아 있다. 방금 죽인 좀비는 눈앞인데 심장은 저 멀리 뜬다. 그리고 에러는 안 난다.

후보를 셋 놓고 봤다. 갈림길은 성능이 아니라 어느 층에서 막느냐였다.

후보 막는 층
Spawn()protected로 내린다 컴파일 타임 — 애초에 부를 수 없다
유효성 플래그 필드를 추가한다 런타임 — 필드가 둘로 늘고 서로 어긋날 여지가 생긴다
Vector2?로 두고 읽은 뒤 비운다 런타임 — 잘못 불린 뒤에 알아챈다

막는 층이 다르면 버그의 운명이 다르다

골라야 할 게 명확해졌다. 버그의 존재 자체를 없애는 것과, 잘못된 뒤에 알아채는 것은 다른 일이다. 접근 제한으로 갔다.

부수 효과도 하나 있었다. 5편에서 대가로 남겨뒀던 “Spawn()public이라 밖에서 부르면 그대로 실행된다”가 여기서 같이 닫혔다.

대신 강제되는 것도 생겼다. 웨이브 매니저는 스폰을 직접 호출할 수 없다. on/off와 파라미터만 넘기게 된다. 이건 제약이 아니라 원래 그래야 하는 모양이라 받아들였다.

이름을 겹치게 지었다가 되돌린 것

처음엔 HeartSpawner.Spawn(Vector2)로 이름을 맞췄다. base의 Spawn()과 나란히 두면 오버로드가 되니까 자연스러워 보였다.

되돌렸다. 5편에서 만난 CS0108은 그래도 경고라도 띄워준다. 오버로드는 그보다 더 조용하다 — 컴파일러가 아예 문제로 보지 않는다. 나중에 Spawn()을 부른 사람이 어느 쪽이 불리는지 헷갈릴 여지만 남는다.

base가 소유한 이름을 자식이 오버로드하지 않는다. 이름을 다르게 지으면 그 헷갈림이 처음부터 없다.


5. 같은 기준을 두 번 적용했더니 답이 갈렸다

5편에서 abstractvirtual을 가르려고 문장을 하나 정해뒀다.

안 채우면 반드시 버그인가. 그렇다면 abstract, 아니면 virtual.

오늘 HeartSpawner.Setup빈 블록으로 남았다. 심장은 아무것도 주입받을 게 없기 때문이다. 5편에서 OnInitvirtual로 내렸던 것과 모양이 똑같다.

protected override void Setup(GameObject obj)
{
}

그래서 처음엔 Setupvirtual로 내리려고 했다. 그러다 되돌렸다.

기준을 훅마다 따로 적용해야 했다. 빈 블록이 생겼다는 사실은 같아도, 그 훅을 다른 자식이 누락했을 때 벌어지는 일이 다르다.

다른 자식이 비우면 판정
OnInit 초기화할 게 없는 자식은 그대로 정상 동작한다 virtual
Setup EnemySpawner가 누락하면 _target 주입이 사라진다 abstract 유지

그리고 그 누락의 증상이 정확히 3번 섹션의 405줄이다. Setupvirtual로 내렸다면 배선을 빠뜨려도 컴파일이 통과하고, 좀비는 미끄러지고, 콘솔은 초당 50줄씩 쌓인다.

기준을 이렇게 다시 적었다.

override가 생겼다는 사실만으로 abstract를 내리지 않는다.
판단은 “다른 자식이 그것을 누락했을 때 무슨 일이 생기는가” 로 한다.

빈 블록이 노이즈로 보였던 게 성급했다. 저 빈 중괄호는 “이 자식은 채울 것이 없다”는 명시적 선언이다. 심장 스포너를 나중에 읽는 사람은 저 줄을 보고 “주입이 빠진 게 아니라 원래 없는 거구나”를 안다.

인스펙터에 Prefab, Target, HeartSpawner가 전부 꽂혀 있다

배선이 실행 전에 눈으로 확인되는 것도 같은 이야기다. 컴파일러가 잡아주는 것, 인스펙터에서 보이는 것, 로그로 알려주는 것 — 셋은 알아채는 시점이 다르고 그 순서가 곧 비용이다.


6. 오늘 못 간 곳

목표 넷 중 하나만 끝났다. 경험치 수집, 스탯업, 게임 오버, 웨이브가 그대로 남았다. 마감은 9/7이다..

남긴 부채도 있다.

  • 프리팹 구성이 잘못됐을 때 StopAllCoroutines()으로 루프를 끊어야 한다. 지금은 로그만 남기고 계속 돈다
  • 프리팹 검증의 원래 자리는 base의 Awake다. 지금은 자식마다 흩어져 있다

그리고 5편에서 정해둔 “정지는 enabled = false로 통일한다”는 규칙이 코루틴 앞에서 부정확했다는 걸 오늘 알았다. enabled가 끄는 건 엔진이 주기적으로 불러주는 콜백뿐이다. 이미 돌기 시작한 코루틴은 엔진이 따로 관리해서 그대로 계속 돈다.

규칙을 세울 땐 그 규칙이 닿지 않는 곳까지 같이 적어둬야 한다. 통일 규칙은 예외를 안 적으면 거짓말이 된다.


정리

  • Action<T>는 값이 아니라 메서드를 담는 변수다. 괄호를 붙이면 호출, 안 붙이면 참조 전달이다.
  • 인자는 배선하는 쪽이 아니라 호출하는 쪽이 채운다. 배선 시점엔 아직 그 값이 존재하지 않는다.
  • 예외가 몇 줄 쌓였는지가 정보다. 한두 줄이면 일회성 코드, 수백 줄이면 매 프레임 도는 코드다.
  • 속도를 대입하지 않으면 물리 엔진이 만든 속도가 그대로 남는다. 안 움직이는 게 아니라 남의 속도로 움직인다.
  • 안 쓸 경로를 위한 방어를 만들고 있으면 순서를 거스르고 있는 것이다.
  • 필드를 인자 우회로로 쓰면 넣는 줄과 쓰는 줄 사이에 순서 의존이 생긴다. 그리고 어겨도 에러가 안 난다.
  • 접근 제한은 버그를 없애고, 런타임 검사는 버그를 알려준다. 층이 다르다.
  • base가 소유한 이름을 자식이 오버로드하지 않는다. CS0108보다 조용하다.
  • override 하나로 abstract를 내리지 않는다. 다른 자식이 누락했을 때의 대가로 판단한다.
  • enabled = false가 끄는 것은 엔진이 부르는 콜백뿐이다. 코루틴은 따로 끊어야 한다.
  • 컴파일러에게 맡길 수 있는 것을 런타임으로 내려보내지 않는다. 오늘 세 섹션이 전부 이 한 줄이었다.

참고 자료

한줄 평

  • 오늘 목표는 네 개였는데 끝난 건 하나고, 나머지 시간은 “이걸 지금 만드는 게 맞나”를 확인하는 데 썼다..