wan의 개발일기
RoomCraft #17. 매 프레임 도는 코드 최적화하기 — 그리고 최적화하다 만든 버그 본문
소스코드 → github.com/Jywan/RoomCraft
들어가며
커스텀 방 편집이 end-to-end로 돌아가는 걸 확인하고 나니, 코드 여기저기 미뤄뒀던 게 눈에 밟혔다. 특히 가구를 드래그하는 동안 매 프레임 도는 코드들 — 처음 짤 때는 동작만 확인하면 됐지만, 쌓이고 보니 손볼 곳이 많았다. 이번 편은 화면엔 아무 변화도 없는 성능 정리 편이다. 그리고 최적화하다가 버그를 하나 새로 만든 이야기도 같이 있다.
안 쓰는 코드부터
Phase 5-3에서 그리드 셀 토글 방식을 버리고 점 클릭 방식으로 갈아탔었는데, 그 흔적인 CustomShapeEditorUI.cs, GridOutlineExtractor.cs, GridCell.prefab이 어디서도 참조되지 않은 채로 남아있었다. 씬/스크립트 전체를 검색해서 진짜 아무 데서도 안 쓰는 걸 확인하고 지웠다.
Unity의 == null은 공짜가 아니다
UnityEngine.Object를 상속한 타입(대부분의 컴포넌트)은 ==/!= 연산자가 오버로딩되어 있다. 단순 참조 비교가 아니라 네이티브 오브젝트가 실제로 살아있는지까지 확인하는 연산이라, 어쩌다 한 번이면 상관없는데 매 프레임 반복되면 쌓인다. 가구를 드래그하는 동안 이 비교가 한 프레임에 여러 번씩 실행되고 있었다.
FurnitureInteraction에서 selectedFurniture != null을 매번 계산하는 대신, 선택이 바뀌는 시점(선택/해제/삭제)에만 갱신되는 bool 하나를 두기로 했다.
private FurnitureObject selectedFurniture = null;
private bool hasSelection = false;
public bool HasSelection => hasSelection;
private void HandleDrag()
{
if (!hasSelection || !isDragging) return;
// ...
}
선택 여부를 밖에서(FurnitureToolbar, DimensionDisplay) 확인해야 할 때도 GetSelectedFurniture() != null 대신 이 HasSelection 프로퍼티를 읽도록 바꿨다. 값이 필요할 때만 GetSelectedFurniture()로 실제 레퍼런스를 가져오는 식이다.
같은 이유로 FurnitureBounds.IsOverlapping()에서 씬의 다른 가구와 자기 자신을 구분하려고 쓰던 other == furniture도 ReferenceEquals(other, furniture)로 바꿨다. 둘 다 null이 아닌 진짜 오브젝트끼리 비교하는 상황이라도, Unity의 ==는 여전히 오버로딩된 채로 호출된다 — ReferenceEquals는 그 오버로딩을 건너뛰고 순수 참조 비교만 한다.
매 프레임 씬을 통째로 뒤지고 있었다
== null 비용을 살펴보다가 더 큰 게 눈에 띄었다. FurnitureBounds.IsOverlapping()이 드래그 중 매 프레임 FindObjectsByType<FurnitureObject>()로 씬에 있는 가구를 전부 다시 찾고 있었다. 겹침 검사 하나 하려고 매 프레임 씬 전체를 스캔하는 셈이다.
FurnitureObject에 정적 리스트를 두고, 컴포넌트가 활성화/비활성화될 때 스스로 등록·해제하도록 바꿨다.
public static readonly List<FurnitureObject> All = new List<FurnitureObject>();
private void OnEnable() { All.Add(this); }
private void OnDisable() { All.Remove(this); }
IsOverlapping()은 이제 FindObjectsByType 대신 이 리스트를 순회한다. 생성/삭제 시점에만 리스트가 갱신되니 매 프레임 검색할 필요가 없다.
GetComponent 계열도 같은 문제였다. FurnitureBounds가 가구의 Collider, Renderer를 매번 GetComponent/GetComponentInChildren로 새로 찾고 있었는데, FurnitureObject.Initialize() 시점에 한 번만 찾아서 캐싱해두고 GetBounds()/GetRenderers()로 꺼내 쓰도록 바꿨다. 드래그 중 매 프레임 필요하던 컴포넌트 탐색이 전부 사라졌다.
가비지 컬렉션도 한번 보자
여기까지 하고 나니 할당 쪽도 궁금해졌다. 두 군데가 걸렸다.
첫째, IsInsideRoom()이 매 프레임 4칸짜리 Vector2[] 배열을 새로 만들고 있었다. 크기가 항상 고정이니 재사용 버퍼로 바꿨다.
private readonly Vector2[] cornerBuffer = new Vector2[4];
// 매번 새 배열을 만드는 대신, 기존 버퍼의 값만 갱신
cornerBuffer[0] = new Vector2(bounds.min.x, bounds.min.z);
cornerBuffer[1] = new Vector2(bounds.max.x, bounds.min.z);
cornerBuffer[2] = new Vector2(bounds.max.x, bounds.max.z);
cornerBuffer[3] = new Vector2(bounds.min.x, bounds.max.z);
둘째, DimensionDisplay.ShowInfo()가 가구 선택 중 매 프레임 문자열 보간($"...")으로 이름/치수/위치 텍스트를 다시 만들고 있었다. 위치가 그대로인 프레임에도 매번 새 문자열을 할당하고 있었던 것. 선택된 가구가 바뀔 때만 이름/치수를 갱신하고, 위치가 실제로 바뀔 때만 위치 텍스트를 갱신하도록 바꿨다. 가만히 선택만 하고 있으면 문자열 할당이 아예 없다.
기능 하나 끼워넣기: 높이 경계 검사
겸사겸사 미뤄뒀던 것도 하나 처리했다. FurnitureBounds.IsInsideRoom()이 지금까지 가로/세로(X/Z) 발자국만 검사하고, 가구 높이가 천장을 넘는지는 아예 체크하지 않고 있었다. SetRoomShape()가 방 높이도 같이 받도록 파라미터를 추가하고, 가구 콜라이더의 최고점이 천장을 넘으면 무효 처리하도록 했다.
public void SetRoomShape(List<Vector2> vertices, float height)
{
roomOutLine = GetInsetPolygon(vertices, wallTickness);
roomHeight = height;
}
public bool IsInsideRoom(FurnitureObject furniture)
{
if (roomOutLine == null) return true;
Bounds bounds = furniture.GetBounds();
if (bounds.max.y > roomHeight) return false;
// ...
}
삽질 기록
최적화하다가 만든 버그
위의 cornerBuffer 재사용 버퍼로 바꾸는 과정에서, 네 줄을 복사해서 인덱스만 바꾸다가 실수를 냈다. 인덱스 2, 3이 bounds.max.z를 써야 하는데 각각 인덱스 1, 0과 똑같은 bounds.min.z를 그대로 쓰고 있었다.
// 버그가 있던 버전
cornerBuffer[0] = new Vector2(bounds.min.x, bounds.min.z);
cornerBuffer[1] = new Vector2(bounds.max.x, bounds.min.z);
cornerBuffer[2] = new Vector2(bounds.max.x, bounds.min.z); // bounds.max.z 여야 함
cornerBuffer[3] = new Vector2(bounds.min.x, bounds.min.z); // bounds.max.z 여야 함
결과적으로 모서리 4개 중 2개가 나머지 2개랑 완전히 같은 값이 되어버려서, 가구 바운딩 박스의 실제 모서리는 2개만 검사되는 상태였다. 방 안쪽으로 튀어나온 모서리가 있어도 못 잡아내는 조용한 버그 — 겉으로는 잘 도는 것처럼 보이니 더 위험했다.
코드를 직접 적용하고 나서 다시 한 번 훑어보다가 발견했다. 반복되는 값을 손으로 네 줄 옮겨 적을 땐 항상 이런 실수가 나온다 — 다음부턴 이런 고정 크기 좌표 목록은 배열 리터럴을 유지하고 인덱스만 바꾸는 대신, 애초에 실수하기 어려운 형태로 짜는 게 나을 것 같다.
정리
| 작업 | 핵심 변경 |
|---|---|
| 미사용 코드 정리 | 박스 클리커 방식 잔재 3개 파일 삭제 |
| Unity Object 비교 캐싱 | hasSelection/hasBounds bool, ReferenceEquals |
| 씬 검색/컴포넌트 조회 캐싱 | FurnitureObject.All 레지스트리, GetBounds()/GetRenderers() |
| GC 할당 감소 | 버퍼 재사용, 값이 바뀔 때만 문자열 갱신 |
| 높이 경계 검사 | FurnitureBounds.SetRoomShape(vertices, height) |
| 다음 단계 | Phase 6 WebGL/모바일 빌드 배포 |
'1인 게임개발 활동 > 가구배치 시뮬레이터' 카테고리의 다른 글
| RoomCraft #19. 있는 척했던 기능 만들기 — 1인칭 시점 (0) | 2026.07.09 |
|---|---|
| RoomCraft #18. 첫 배포 — WebGL + GitHub Pages, 그리고 아직 못 고친 한글 버그 (0) | 2026.07.08 |
| RoomCraft #16. 방 모양 편집을 일반/커스텀 탭으로 - 그리고 버그 네 마리 (0) | 2026.07.07 |
| RoomCraft #15. 방 모양 프리셋 — 사각형 넘어 L자형까지 (0) | 2026.07.05 |
| RoomCraft #14. 방을 다각형으로 - 사각형을 넘어서기 위한 첫걸음 (0) | 2026.07.05 |