바뀌었다는 사실만 적어 두고 계산은 미룬다
인벤토리의 총 무게를 보여 줍니다.
public void Add(Item item)
{
items.Add(item);
totalWeight = 0;
foreach (var i in items) totalWeight += i.Weight; // 넣을 때마다 전부 다시 센다
}
아이템 100개를 한 번에 넣으면 목록을 100번 순회합니다. 그런데 총 무게를 실제로 화면에 보여 주는 것은 그 뒤 한 번뿐입니다. 앞의 99번은 계산해서 곧바로 버려집니다.
옷 하나 벗을 때마다 세탁기를 돌리는 것과 같습니다. “빨래감 있음” 표시만 해 두고 하루가 끝나면 한 번에 돌리면 됩니다.
세 가지 방식
| 방식 | 값이 바뀔 때 | 한 프레임에 N번 바뀌면 | 값은 최신인가 |
|---|---|---|---|
| 즉시 계산 | 그 자리에서 다시 계산 | N번 계산 | 항상 |
| 매 프레임 전부 | 아무것도 안 함 | 1번 계산 | 항상 |
| 더티 플래그 | 표시만 | 1번 계산 | 꺼내 쓸 때 |
가운데 방식도 계산은 한 번인데, 안 바뀐 것까지 전부 다시 계산합니다. 더티 플래그는 바뀐 것만 한 번 계산합니다.
public class Inventory
{
readonly List<Item> items = new();
int cachedWeight;
bool isDirty = true;
public void Add(Item item)
{
items.Add(item);
isDirty = true; // 여기서 계산하지 않는다
}
public int TotalWeight
{
get
{
if (isDirty) // 실제로 필요할 때 딱 한 번
{
cachedWeight = 0;
foreach (var i in items) cachedWeight += i.Weight;
isDirty = false;
}
return cachedWeight;
}
}
}
더티 플래그는 계산을 안 하는 기법이 아닙니다. 같은 계산을 여러 번 하지 않게 만드는 기법입니다. 이득의 크기는
(프레임당 변경 횟수 - 1) × 계산 비용입니다.
계층 구조에서 진가가 나온다
자동차 오브젝트를 생각해 봅시다. 차체 아래에 보닛과 사이드미러가 있고, 구동부 아래에 바퀴가, 바퀴 아래에 스프링과 휠커버가 있습니다.
부모가 움직이면 그 아래 전부의 월드 좌표가 무효가 됩니다. 손목시계를 찬 채 몸을 움직이면 시계도 같이 움직이는 것과 같습니다.
문제는 한 프레임 안에서 이 트리를 건드리는 코드가 한 군데가 아니라는 점입니다.
전파할 때 한 줄이 중요합니다.
void MarkDirty()
{
if (worldDirty) return; // 이미 표시돼 있으면 여기서 멈춘다
worldDirty = true;
foreach (var c in children) c.MarkDirty();
}
첫 줄의 조기 반환이 없으면 부모를 열 번 건드릴 때 하위 순회가 열 번 일어납니다. 플래그를 확인하는 비용은 거의 0이라, 자식이 많을수록 이 한 줄이 크게 남습니다.
유니티가 이미 이렇게 동작한다
transform.position = newPos;
이 줄에서 월드 행렬이 계산되지 않습니다. 유니티는 바뀌었다는 사실만 기록해 두고, 실제로 좌표가 필요해지는 시점에 몰아서 계산합니다. “대입하면 그 자리에서 다 끝난다” 는 직관이 틀리는 자리입니다.
그래서 Physics.SyncTransforms() 같은 API 가 존재합니다.
미뤄 둔 계산을 지금 당장 반영하라는 뜻입니다.
transform.position 을 바꾸고 곧바로 Raycast 를 쏘면 옛 위치로 맞을 수 있는데,
그 이유가 여기 있습니다.
uGUI 는 한 걸음 더 나갑니다. 플래그를 셋으로 나눠 씁니다.
| 플래그 | 무엇이 무효가 되나 | 언제 켜지나 |
|---|---|---|
| Layout | 크기와 위치 재배치 | 자식 추가, 크기 변경 |
| Vertex | 메시 정점 재생성 | 색, 스프라이트, 글자 변경 |
| Material | 머티리얼 재바인딩 | 머티리얼 교체, 마스크 |
글자 색만 바꿨는데 레이아웃 전체를 다시 계산하면 낭비라서 나눈 것입니다. 무효화 범위를 잘게 쪼갤수록 다시 계산할 것이 줄어듭니다. 다만 너무 잘게 쪼개면 플래그를 관리하는 코드 자체가 복잡해집니다. 그 균형이 설계입니다.
여기서 캔버스 분리 최적화가 나옵니다. 한 캔버스 안의 요소 하나만 더티가 되어도 그 캔버스 전체의 배칭 메시가 다시 만들어집니다. 그래서 자주 바뀌는 것(체력바, 타이머, 데미지 숫자)과 가만히 있는 것(배경, 프레임)을 다른 캔버스로 나눕니다.
플래그가 “다시 계산해야 함” 이 아닐 때
오픈월드의 지형 로딩에서는 더티의 뜻이 조금 다릅니다.
bool shouldBeLoaded = Vector3.Distance(transform.position, playerPos) < radius;
IsDirty = shouldBeLoaded != IsLoaded; // 원하는 상태와 지금 상태가 어긋남
여기서 더티는 “의도한 상태와 실제 상태가 다르다” 는 뜻입니다. 매 프레임 거리만 재고, 어긋난 구역만 실제 로드나 해제를 합니다. 거리 재기는 싸고 로드는 비싸니까요.
경계에서 진동하는 것을 조심해야 합니다. 로드 반경과 해제 반경이 같으면 플레이어가 경계선에서 조금만 움직여도 로드와 해제가 매 프레임 반복됩니다. 해제 반경을 로드 반경보다 크게 두면 사라집니다.
실패하는 두 가지 방식
1. 바꿔 놓고 더티로 표시하지 않는 것. 값은 바뀌었는데 화면이 그대로입니다. 에러도 안 나고 로그도 안 남습니다. 재현이 어려운 시각 버그의 전형입니다. 새 필드를 추가하면서 표시하는 줄을 빠뜨리면 바로 이렇게 됩니다.
2. 더티로 표시해 놓고 아무도 안 지우는 것. 매번 다시 계산하게 되어 플래그를 안 쓴 것보다 느려집니다.
두 실수 모두 표시하는 곳과 지우는 곳이 멀리 떨어져 있어서 생깁니다. 가능하면 값을 바꾸는 통로를 프로퍼티 하나로 좁혀 두는 편이 안전합니다.
옵저버와 무엇이 다른가. 옵저버는 바뀌는 즉시 알립니다. 더티 플래그는 바뀌었다는 사실만 적어 두고 나중에 처리합니다. 알림을 받는 쪽이 곧바로 반응해야 하면 옵저버, 결과만 제때 맞으면 되면 더티 플래그입니다.
언제 쓰지 않는가
프레임당 한 번만 바뀌고 계산이 싸면 손해입니다. 플래그를 확인하고 관리하는 비용만 늘어납니다. 이득은 중복이 있을 때만 생깁니다.
메모리를 더 씁니다. 계산 결과를 들고 있어야 하니까요. CPU 시간과 메모리를 맞바꾸는 기법입니다.
이득이 나는 조건은 셋이 겹칠 때입니다.
계산이 비싸고, 원본이 한 프레임에 여러 번 바뀌고, 꺼내 쓰는 횟수가 바뀌는 횟수보다 적거나 같을 때.
정리
- 값이 바뀔 때마다 계산하면, 화면에 나가지 않는 계산을 반복하게 된다.
- 더티 플래그는 바뀌었다는 사실만 적어 두고 꺼내 쓸 때 한 번 계산한다.
- 계산을 없애는 게 아니라 중복을 없애는 것이다. 중복이 없으면 이득도 없다.
- 계층 구조에서 이득이 가장 크다. 전파할 때 이미 더티면 즉시 멈추는 한 줄이 핵심이다.
transform.position대입은 그 줄에서 계산하지 않는다.SyncTransforms가 있는 이유다.- uGUI 는 플래그를 셋으로 나눈다. 캔버스 분리 최적화가 여기서 나온다.
- 스트리밍에서는 더티가 “원하는 상태와 지금 상태가 어긋남” 을 뜻한다.
- 실패는 둘이다. 바꾸고 표시를 안 하거나, 표시하고 안 지우거나.
- 계산 결과를 들고 있어야 하므로 메모리를 더 쓴다.
여기까지는 이미 만들어 둔 것을 다시 계산하지 않는 이야기였습니다.