함수 이름을 _draw 로 지었더니 웹에서만 두 번 그려졌다
항아리 화면 내용이 웹 빌드에서만 두 번 그려졌다. 헤드리스 시험은 조용했다. 내가 지은 함수 이름 _draw 가 엔진의 가상 함수였다.
「브루니 키우기: 바랜 섬」 웹판에서 항아리 화면을 열었더니 내용이 두 번 그려져 있었다. 웹에서만 그랬다. 시험은 이걸 잡지 못했다. 저장하고 이어하기까지 흐름 전체를 도는 통합 시험이 있는데도 그랬다.
범인은 함수 이름 하나였다. 화면 내용을 그리는 함수에 _draw 라는 이름을 붙여 둔 것이다.
내가 지은 이름이 엔진의 이름이었다
Godot 에서 밑줄로 시작하는 함수 중 상당수는 엔진이 부르는 가상 함수다. _ready, _process 처럼 일부러 덮어쓰라고 열어 둔 자리다. _draw 도 그중 하나다. CanvasItem 의 가상 함수라서, 노드에 _draw 가 있으면 엔진이 그 노드를 다시 그릴 때마다 그 함수를 부른다.
항아리 화면의 _draw 는 그런 의도로 만든 게 아니었다. 화면 내용을 그리는 내 함수였을 뿐이다. 그런데 엔진은 의도를 모른다. 이름만 본다. 다시 그릴 일이 생길 때마다 엔진은 그 함수를 불렀고, 화면 내용은 그만큼 겹쳐 그려졌다.
GDScript 는 이걸 막아 주지 않는다. 같은 이름의 함수를 정의하면 그게 곧 덮어쓰기다. override 같은 키워드가 없으니 「덮어쓸 생각이 없었다」를 표현할 방법도 없다. 이름이 같으면 엔진에게는 덮어쓴 함수다.
헤드리스 시험은 왜 못 잡았을까
이 프로젝트의 시험은 godot --headless 로 돈다. tests/ 아래 test_*.gd 를 전부 돌리고, 종료 코드가 곧 실패 수다. 시험 도중 엔진이나 스크립트 오류가 하나라도 나면 그 시험은 실패로 친다. 새 게임부터 튜토리얼, 첫 판, 저장, 이어하기, 화면 넘침, 30일 오래 하기까지 통합 시험이 본다.
이렇게 촘촘해도 이 버그는 지나갔다. 이유는 단순하다. 헤드리스는 그리지 않는다. 그리지 않으니 _draw 를 부를 일도 없다. 엔진이 함수를 부르지 않으면 겹침도 생기지 않는다. 시험 입장에서는 아무 일도 없었다.
개발 문서에도 이 구분이 적혀 있다. 겹침이나 빈 칸 같은 그리기 문제는 헤드리스 시험의 몫이 아니다. 그건 tools/tour/tour.gd 로 실제 창을 띄워 화면을 찍고, tools/tour/sheet.gd 로 모아서 눈으로 본다. 시험이 초록색이라고 화면까지 맞다는 뜻은 아니다.
같은 뿌리의 사고, 오토로드 전체가 Nil
같은 함정을 오토로드에서도 밟았다. 이쪽은 증상이 훨씬 컸다.
오토로드 하나에 func _get(kind, id) 라는 조회 함수를 두었다. 종류와 id 로 무언가를 찾아 주는, 이름만 보면 자연스러운 함수다. 그런데 _get 은 Object 의 가상 함수다. 엔진 쪽 _get 은 속성 이름 하나를 받는 모양인데, 내 함수는 인자가 둘이었다. 이름이 겹친 채 모양이 맞지 않으니 스크립트 파싱이 실패했다.
파싱이 실패한 오토로드는 아예 올라오지 않는다. 그 오토로드를 부르는 모든 곳에서 값이 Nil 이 됐다. 함수 하나가 아니라 오토로드 전체가 사라진 셈이다.
증상만 보면 두 사고는 전혀 달라 보인다. 하나는 웹에서만 그림이 두 번 나오고, 하나는 전역 객체가 통째로 없다. 하지만 뿌리는 같다. 내가 지은 내부 함수 이름이 엔진 가상 함수와 겹쳤다. 개발 문서의 「밟은 함정」 표에도 두 줄이 나란히 올라 있다.
| 함정 | 증상 | 대응 |
|---|---|---|
_get 이름 | 오토로드에 func _get(kind, id) 를 두면 Object._get 과 겹쳐 파싱 실패 → 오토로드 전체가 Nil | 내부 함수 이름은 엔진 가상 함수와 겹치지 않게 |
함수 이름 _draw | 항아리 화면 내용이 웹에서만 두 번 그려졌다 — _draw 는 CanvasItem 가상 함수라 엔진이 다시 그릴 때마다 부른다. 헤드리스 시험은 못 잡음 | 엔진 가상 함수 이름을 쓰지 않는다(test_no_engine_virtual_name_clash 가 지킨다) |
이름을 소스에서 직접 찾는 시험
「조심하자」는 규칙은 오래 못 간다. 「그리기」를 하는 함수에 _draw 만큼 자연스러운 이름은 없다. 다음에 누가 또 쓸지 모른다. 그래서 규칙을 시험으로 바꿨다. 그리는 걸 시험할 수 없다면 그 이름이 소스에 있는지를 시험하면 된다.
## 화면 함수 이름이 엔진 가상 함수(_draw 등)와 겹치면 웹에서 다시 그릴 때마다 엔진이 불러 내용이 겹친다 — 헤드리스는 안 그려서 못 잡는다
func test_no_engine_virtual_name_clash() -> void:
var bad: Array = []
var names := ["_draw", "_get", "_set", "_has_point", "_get_minimum_size", "_to_string", "_get_tooltip", "_can_drop_data", "_drop_data", "_get_drag_data", "_make_custom_tooltip"]
for dir in ["res://scenes", "res://ui", "res://core", "res://autoload"]:
for f in _gd_files(dir):
var src := FileAccess.get_file_as_string(f)
for n in names:
if src.contains("\nfunc %s(" % n) or src.contains("\nstatic func %s(" % n):
bad.append("%s %s" % [f, n])
eq(bad, [], "가상 함수 이름 충돌 없음")하는 일은 이렇다. scenes, ui, core, autoload 네 폴더 아래 .gd 파일을 재귀로 전부 모은다. 파일마다 소스를 문자열로 읽고, 금지 이름 목록의 함수 정의가 있는지 찾는다. 걸린 게 있으면 파일 경로와 이름을 모아 한 번에 실패시킨다.
목록에는 사고를 낸 _draw, _get 말고도 이름이 흔해서 겹치기 쉬운 것들이 들어 있다. _set, _to_string, 그리고 UI 쪽 가상 함수인 _has_point, _get_minimum_size, _get_tooltip, 드래그 앤 드롭 세 개(_can_drop_data, _drop_data, _get_drag_data), _make_custom_tooltip 이다.
이 시험이 고른 것과 버린 것
이 시험은 일부러 거칠다. 리플렉션으로 클래스 상속을 따지지 않는다. 그냥 소스에서 \nfunc _draw( 라는 글자를 찾는다. 그래서 이 프로젝트에서는 정말로 _draw 를 덮어쓰고 싶어도 그 네 폴더에서는 쓸 수 없다. 규칙이 「이름을 쓰지 않는다」이지 「잘못 쓰지 않는다」가 아니기 때문이다. 대신 판정이 단순하다. 걸리면 이름을 바꾸면 끝이다.
빈틈도 있다. 줄바꿈 뒤의 func 만 찾으니, 파일 첫 줄에 바로 정의가 있거나 들여쓰기된 정의는 놓친다. 목록에 없는 가상 함수도 못 잡는다. 그래도 지금 이 저장소에서 실제로 사고를 낸 두 이름과 겹치기 쉬운 이름들은 막혀 있다. 새 이름에서 또 사고가 나면 목록에 한 줄 더하면 된다.
무엇보다 이 시험은 헤드리스에서도 돈다. 그리기를 안 해도, 웹 빌드를 안 해도, 이름만 보면 판정이 된다. 렌더링에서만 드러나는 버그를 렌더링 없이 막는 방법은 결국 원인 쪽을 보는 것이었다.
밑줄 함수를 지을 때 먼저 확인할 것
Godot 에서 밑줄로 시작하는 이름은 「내 전용 함수」라는 뜻으로 쓰기 쉽다. 다른 언어에서 비공개 표시로 밑줄을 붙이던 습관 그대로다. 그런데 Godot 에서는 그 자리를 엔진이 먼저 쓰고 있다. 새 함수에 밑줄을 붙이기 전에 상속받는 클래스(Object, CanvasItem, Control)에 같은 이름의 가상 함수가 있는지부터 보자. 헷갈리면 밑줄 대신 동사로 시작하는 이름을 쓰면 된다.
웹에서만 화면이 두 번 그려졌던 건 결국 이름 하나 때문이었다.
핵심 한 줄엔진 가상 함수와 이름이 겹치면 엔진이 그 함수를 부른다, 헤드리스 시험은 그걸 못 본다.
자주 묻는 것
Godot 오토로드가 Nil 이 되는 이유
오토로드 스크립트가 파싱에 실패하면 오토로드가 올라오지 않아 Nil 이 된다. 이 프로젝트에서는 func _get(kind, id) 가 Object._get 가상 함수와 겹쳐 파싱이 실패했다. 내부 함수 이름을 엔진 가상 함수와 겹치지 않게 지으면 된다.