개요
12편에서 하루치 이야기를 여섯 편으로 나눠 마무리했다. 그날의 결론은 이름이 설계 결함을 알려줬다는 거였다.
이번 편은 그 다음 날 이야기다. 시작은 별거 아니었다. 화면에 남은 시간이랑 좀비가 몇 마리인지 띄우는 것. 딱 그거였는데, 끝에 가서는 그 숫자를 누가 가지고 있어야 하는가를 정하고 있었다.
그리고 이번에 방향이 틀렸다고 알려준 건 이름이 아니었다. 아무도 안 쓰는 인자 하나였다.

이번 글이 답할 질문. 화면에 숫자를 띄우는 일이 왜 소유권 문제가 되는가?
1. 남은 시간 — 소수점을 초로 바꾸는 일
타이머가 들고 있는 남은 시간은 297.4183... 같은 소수다. 화면에는 4:58처럼 나와야 하니 정수 초로 바꿔야 한다.
바꾸는 방법이 세 가지 있다. 버리기, 반올림, 올리기.
| 방법 | 297.4를 넣으면 | 0.3초 남았을 때 |
|---|---|---|
| 버림 | 297 | 0 이 뜨는데 게임은 안 끝났다 |
| 반올림 | 297 | 0 |
| 올림 | 298 | 1 — 0이 되는 순간이 진짜 종료다 |
올림을 골랐다. 이유는 하나다. 버림을 쓰면 화면에 0:00이 뜨고 나서도 게임이 0.9초쯤 더 돌아간다. 올림을 쓰면 0:01이 사라지는 순간이 실제로 게임이 끝나는 순간이 된다. 화면과 실제가 어긋나지 않는다.
표시는 실제보다 먼저 끝나면 안 된다. 끝났다고 써놓고 안 끝나 있으면 그건 거짓말이다.
매 프레임 글자를 다시 넣지 않는다
남은 시간은 1초에 60번쯤 갱신된다. 그런데 화면에 보이는 글자는 1초에 한 번만 바뀐다. 나머지 59번은 똑같은 글자를 다시 집어넣는 셈이다.
이게 왜 아까운가. 두 가지가 겹친다.
- 글자는 고쳐 쓸 수 없다. C#에서 문자열은 한 번 만들면 못 고치는 값이라, 넣을 때마다 매번 새 글자 덩어리가 새로 만들어진다.
- 유니티에서 쓰는 글자 표시 도구는 내용이 같아도 값을 넣는 순간 “다시 그려야 함” 표시를 세운다.
그래서 직전에 넣었던 정수 초를 기억해뒀다가, 값이 그대로면 아무것도 안 하고 빠져나가게 했다.
그런데 한 프레임도 안 걸렸다
void Update()
{
int current = Mathf.CeilToInt(_timer.Remaining);
if (current == _prevRemainingSecond)
{
return;
}
_prevRemainingSecond = current; // 이 줄을 빼먹었었다
_text.text = $"{current / 60}:{current % 60:00}";
}
기억해두는 줄을 안 썼다. 그러면 어떻게 되냐면, 기억값은 처음 넣은 300에서 영원히 안 변하고, 지금 값은 299, 298로 계속 내려간다. 다시는 300이 될 일이 없다.
빠져나가는 길이 단 한 프레임도 안 열렸다. 최적화를 하겠다고 붙인 코드가 비교 한 번만큼 원래보다 더 무거워진 상태로 돌고 있었던 것이다.
아끼려고 넣은 코드가 한 번도 발동을 안 하면, 그건 그냥 더 늘어난 코드다.
12편에서 “신호를 선언만 하고 안 보내면 아무 에러가 안 난다”는 걸 겪었는데, 이것도 같은 계열이다. 틀렸는데 아무 소리도 안 난다. 화면은 멀쩡히 잘 나오니 더 안 보인다..
완성된 글자를 한 번 더 처리했다
처음엔 이렇게 썼었다.
_text.text = string.Format($"{minutes}:{seconds:00}");
$가 앞에 붙은 글자는 그 자리에서 이미 값이 채워진다. 4:58이라는 완성품이 나온 다음, 그걸 다시 서식 도구에 통째로 넘긴 꼴이다. 다 만든 문장을 인쇄기에 한 번 더 넣은 것과 같다.
게다가 $가 붙는 순간 {0} 같은 것도 자리 번호가 아니라 그냥 글자로 취급된다. 둘은 같이 쓰라고 만든 게 아니었다.
$를 쓰면 서식 도구는 필요 없다. 둘 다 쓰면 하나는 확실히 헛일이다.
2. 좀비 수 세기 — 이벤트를 인자로 넘겼더니
남은 시간을 붙였으니 좀비가 몇 마리인지도 띄우기로 했다. 좀비가 하나 태어나면 더하고, 하나 사라지면 빼면 된다.
사라질 때가 문제였다. 좀비가 사라졌다는 걸 누구한테 어떻게 알리느냐다.
이 프로젝트엔 이미 방법이 하나 있었다. 좀비를 만들 때 “다 쓰면 이걸 불러라”를 미리 꽂아주는 방식이다. 그래서 여기에 신호를 하나 꽂았다.
death.SetDespawnCallback(OnDespawn); // 문제의 한 줄
OnDespawn은 여러 명이 동시에 들을 수 있는 신호로 만들어둔 것이었다. 그런데 이걸 이렇게 인자로 넘겨버리면 신호로 안 굴러간다.
이유가 좀 얄궂다. 이런 신호의 정체는 사실 “부를 사람 명단” 이다. 인자로 넘기는 순간 그 시점의 명단이 복사돼서 좀비 몸에 박힌다. 좀비가 태어난 뒤에 누가 새로 구독해도, 그 좀비는 옛날 명단을 들고 죽는다.
더 나쁜 경우가 있다. 태어날 때 명단이 비어 있으면 좀비는 빈 명단, 그러니까 아무것도 아닌 것을 들고 있게 된다. 그리고 죽을 때 그걸 부르려다 터진다.
같은 파일에서 회수와 심장 떨구기는 “없으면 그냥 넘어가라”를 붙여뒀는데, 이것만 안 붙어 있었다. 하나를 만들 때 옆의 규칙을 안 보고 지나간 셈이다.
방향을 정리했다
- 좀비 → 스포너 : 직접 꽂아주는 방식(콜백). 좀비는 자기를 만든 게 누군지 몰라도 된다
- 스포너 → 화면 : 신호(이벤트). 화면이 몇 개가 붙든 스포너는 알 바 아니다
- 스포너가 중계 지점
12편에서 심장 먹는 처리를 신호에서 직접 호출로 바꿨었다. 하나를 콕 집어야 하면 신호가 아니다가 그때 얻은 규칙이었는데, 여기서 반대쪽 경우가 나왔다. 화면 쪽은 누가 듣든 상관없는 방송이라 신호가 맞다.
하나를 지목해야 하면 꽂아주고, 누가 듣든 상관없으면 방송한다. 같은 사건이라도 구간마다 답이 다르다.
3. 부모를 열지 않고 늘리기 — 과거의 내가 뚫어둔 구멍
좀비를 치우는 일은 스포너의 부모 클래스가 맡고 있다. 그래서 “치울 때 숫자도 빼라”를 넣으려면 그 치우는 함수를 자식에서 덮어써야 한다고 생각했다.
안 됐다. 컴파일러가 막았다.

C#은 부모가 “여기는 고쳐 써도 된다”고 표시해둔 함수만 자식이 덮어쓸 수 있다. 그 표시가 없으면 아예 컴파일이 안 된다.
그런데 왼쪽 화면을 보다가 웃었다. 부모에 이미 이런 게 있었다.
// 소멸 이벤트 확장성
protected virtual void Cleanup(GameObject obj) {}
몸통이 텅 빈 함수가 “고쳐 써도 된다” 표시를 달고 놓여 있었다. 주석까지 “소멸 이벤트 확장성”이라고 적혀 있다. 과거의 내가 미리 뚫어둔 구멍이었다.
그리고 이 함수가 불리는 자리도 딱 맞았다. 파괴하기 바로 직전이다. “사라지기 직전에 알린다”는 원래 하려던 것과 같았다.
그럼 파괴 함수를 열면 안 되나
열 수도 있었다. 안 열었다. 이유를 표로 정리해두면 이렇다.
| 여는 자리 | 자식이 바꿀 수 있는 것 | 위험 |
|---|---|---|
Despawn (파괴 전체) |
파괴 자체를 안 할 수도 있다 | “만든 건 반드시 파괴한다”는 약속이 자식마다 달라진다 |
Cleanup (파괴 직전 알림) |
알림 내용만 | 파괴는 부모가 그대로 한다 |
부모가 지키고 있던 약속이 하나 있다. 자기가 만든 건 자기가 없앤다. 파괴 함수를 열어버리면 자식이 그 줄을 통째로 갈아치울 수 있게 되고, 약속이 자식 수만큼 갈라진다.
확장은 문을 넓게 여는 게 아니라 좁게 여는 것이다. 넓게 열면 지키던 약속까지 같이 나간다.
4. 인자를 안 쓰는 메서드 — 이건 청소가 아니라 회계였다
덮어쓴 함수는 이렇게 됐다.
protected override void Cleanup(GameObject obj)
{
--AliveCount;
OnDespawn?.Invoke();
}
다 쓰고 나서 한참을 봤다. obj를 안 쓴다.
받기는 받는데 몸통 어디에서도 안 쓴다. 처음엔 그냥 그러려니 했는데, 이게 뭘 뜻하는지 생각해보니 답이 나왔다.
이건 “이 좀비를 치우는 일”이 아니다. 어느 좀비인지 알 필요가 없으니까. 그냥 “하나 사라졌다”는 사실만 장부에 적는 일이다. 청소가 아니라 회계였다.
인자를 안 쓴다는 건 그 함수가 대상에 관심이 없다는 뜻이다. 대상에 관심 없는 함수는 대상을 다루는 함수가 아니다.
그래서 −1이 나온다
이걸 회계로 보고 나니 바로 걸리는 자리가 생겼다.
좀비를 만들 때 초기화가 실패하는 경우가 있다. 그때 만들다 만 오브젝트를 치워야 하는데, 그걸 파괴 함수에 태우면 어떻게 되는가.
- 실패한 오브젝트는 더하기를 밟은 적이 없다. 만드는 반복문에서 실패하면 그 자리에서 다음으로 넘어가버리기 때문이다
- 그런데 파괴 함수는 안에서 빼기를 한다
- 결과 : 0에서 시작해 −1
태어난 적 없는 것을 죽은 걸로 처리한 것이다. 장부에 없는 항목을 지운 셈이라 숫자가 음수로 내려간다.
고친 건 간단했다. 그 자리에서만 회계를 거치지 않고 바로 없앤다.
// 오버라이드한 메서드 발동 + 성공 여부 확인
bool isReady = Setup(obj);
if (!isReady)
{
/// 실패 시 제거 후 null 반환
Destroy(obj);
return null;
}
이 줄이 파괴 함수를 안 거치는 유일한 자리다. 나중에 이것만 보면 “왜 여기만 딴 길로 가지” 싶어 통일하고 싶어질 텐데, 통일하는 순간 −1이 돌아온다.
성공했는지를 돌려주게 했다
초기화 함수가 원래는 아무것도 안 돌려주고 있었다. 성공했는지 실패했는지를 부르는 쪽이 알 방법이 없다.
참·거짓을 돌려주도록 바꿨다. 이런 건 보통 손이 많이 가는데, 이 부모를 물려받은 자식이 딱 둘뿐이라 고칠 데도 둘이었다.
바꿔야 할 곳이 두 군데면 지금 바꾸는 게 싸다. 스물이 되고 나서 바꾸면 그때는 못 바꾼다.
5. 숫자의 주인을 옮겼다 — 표시용에서 제어용으로
여기까지 하고 나서 마지막으로 걸린 게 있다. 좀비 수를 세는 값을 화면 담당이 들고 있었다.
동작은 했다. 문제는 화면을 껐다 켤 때다.
화면 오브젝트를 끄면 그 안의 코드는 안 돈다. 그동안 좀비는 계속 죽는데 아무도 안 세고 있다. 다시 켜면 그동안 죽은 만큼이 통째로 빠진 채로 이어진다. 한 번 어긋나면 영원히 안 돌아온다.
그래서 값을 스포너로 옮겼다. 밖에서는 읽을 수만 있고, 고치는 건 스포너 자기만 할 수 있게 했다.
이 프로젝트에 이미 같은 모양이 두 개나 있었다.
| 값의 주인 | 화면 담당 | 하는 일 |
|---|---|---|
Health.CurrentHp |
체력바 | 읽어서 길이만 조절 |
GameTimer.Remaining |
남은 시간 글자 | 읽어서 글자만 바꿈 |
EnemySpawner.AliveCount |
좀비 수 글자 | 읽어서 글자만 바꿈 |
세 번째 줄을 앞의 두 줄에 맞춘 것뿐이다. 어제 12편에서 “이름이 안 지어지면 구조가 안 정해진 것”이라 썼는데, 이건 옆에 이미 있는 구조를 안 보고 새로 만든 경우다.
만든 쪽이 값을 가진다. 보여주는 쪽은 읽기만 한다. 보여주는 쪽은 꺼질 수 있기 때문이다.
이 숫자가 다음 편에서 자를 대신 쓰인다
여기까지는 그냥 화면에 숫자 하나 띄운 이야기다. 그런데 이 값이 몇 시간 뒤에 성질이 두 번 바뀐다.
- 재는 자 — 프레임이 느려질 때 좀비가 몇 마리였는지를 이 숫자로 잰다
- 막는 문 — 일정 수를 넘으면 더 안 만들도록 이 숫자로 막는다
표시용이던 값이 제어용이 되는 순간, 정확도의 요구 수준이 달라진다. 화면 숫자가 하나 틀리면 눈에 거슬리는 정도지만, 그걸로 무언가를 막으면 틀린 만큼 게임이 바뀐다.
−1이 나오던 걸 여기서 잡아둔 게 다행이었다. 안 잡았으면 이따 오후에 잰 숫자를 전부 못 믿을 뻔했다..
정리
- 표시를 정수로 줄일 땐 올림. 실제보다 먼저 끝나는 표시는 거짓말이 된다.
- 직전 값을 기억해두는 방식은 기억하는 줄을 빼먹는 순간 통째로 무의미해진다. 한 프레임도 안 걸리고 비교만 늘어난다.
-
$가 붙은 글자는 이미 완성품이다. 서식 도구에 한 번 더 넣을 이유가 없다. - 여러 명이 듣는 신호를 인자로 넘기면 그 순간의 명단이 복사돼 박힌다. 신호가 아니라 사본이 된다.
- 하나를 지목해야 하면 꽂아주고, 누가 듣든 상관없으면 방송한다.
- 부모가 열어둔 자리가 이미 있는지부터 본다. 몸통 빈 함수와 그 옆 주석이 과거의 내가 남긴 안내다.
- 확장 지점은 좁게 연다. 넓게 열면 부모가 지키던 약속이 자식마다 갈라진다.
- 인자를 안 쓰는 함수는 대상을 다루는 함수가 아니다. 이건 청소가 아니라 회계였다.
- 태어난 적 없는 것을 죽은 걸로 처리하면 숫자가 음수로 내려간다. 예외 경로는 회계를 안 거치게 하고 이유를 주석으로 남긴다.
- 만든 쪽이 값을 가지고, 보여주는 쪽은 읽기만 한다. 보여주는 쪽은 꺼질 수 있다.
- 표시용이던 값이 제어용이 되면 정확도의 요구 수준이 달라진다.
참고 자료
- Microsoft Learn - 이벤트
- Microsoft Learn - 대리자
- Microsoft Learn - 문자열 보간
- Microsoft Learn - override 한정자
- Unity 6000.0 Manual - Object.Destroy
- Unity 6000.0 Manual - Mathf.CeilToInt
- 지난 편 - 유니티 학습 프로젝트 < 12 >
한줄 평
- 화면에 숫자 하나 띄우려다 그 숫자의 주인을 정하고 있었는데, 알려준 게 아무도 안 쓰는 인자 하나였다는 게 제일 재밌었다!