---
title: "바뀌었다는 사실만 적어 두고 계산은 미룬다"
description: "한 프레임에 열 번 바뀌어도 화면에 나가는 것은 마지막 하나뿐입니다. 앞의 아홉 번은 계산해서 곧바로 버려집니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/design-patterns"
tags: ["Unity", "최적화"]
canonical: https://blog.unialgames.com/posts/unity-dirty-flag/
---
인벤토리의 총 무게를 보여 줍니다.

```csharp
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번 계산 | 꺼내 쓸 때 |

가운데 방식도 계산은 한 번인데, **안 바뀐 것까지 전부 다시 계산합니다.**
더티 플래그는 **바뀐 것만** 한 번 계산합니다.

```csharp
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) × 계산 비용` 입니다.

## 계층 구조에서 진가가 나온다

자동차 오브젝트를 생각해 봅시다. 차체 아래에 보닛과 사이드미러가 있고,
구동부 아래에 바퀴가, 바퀴 아래에 스프링과 휠커버가 있습니다.

**부모가 움직이면 그 아래 전부의 월드 좌표가 무효가 됩니다.**
손목시계를 찬 채 몸을 움직이면 시계도 같이 움직이는 것과 같습니다.

문제는 한 프레임 안에서 이 트리를 건드리는 코드가 한 군데가 아니라는 점입니다.

<x-flow>
<script type="application/json">
{
  "label": "한 프레임 안에서 트리를 세 번 건드린다",
  "lines": [
    "// 한 프레임 안에서",
    "root.position = ...;      // 게임플레이",
    "wheel.rotation = ...;     // 물리",
    "root.position = ...;      // 게임플레이가 다시",
    "",
    "// 프레임 끝, 렌더링이 좌표를 요청한다",
    "var m = spring.worldMatrix;"
  ],
  "cases": [
    {
      "label": "즉시 계산하면",
      "state": "재계산 0회",
      "path": [
        { "line": 1, "why": "하위 전체의 월드 좌표를 다시 계산합니다.", "state": "재계산 1회", "out": "하위 전체 재계산" },
        { "line": 2, "why": "바퀴 아래를 또 계산합니다.", "state": "재계산 2회", "out": "하위 전체 재계산" },
        { "line": 3, "why": "또 계산합니다.", "state": "재계산 3회", "out": "하위 전체 재계산" },
        { "line": 6, "why": "이미 계산돼 있어 그대로 씁니다.", "state": "재계산 3회, 화면에 쓰인 것은 1회분" }
      ],
      "skip": []
    },
    {
      "label": "더티 플래그면",
      "state": "dirty = false",
      "path": [
        { "line": 1, "why": "바뀌었다고 표시만 합니다.", "state": "dirty = true", "out": "표시만" },
        { "line": 2, "why": "이미 표시돼 있으니 아무것도 안 합니다.", "state": "dirty = true" },
        { "line": 3, "why": "마찬가지입니다.", "state": "dirty = true" },
        { "line": 6, "why": "이때 딱 한 번 계산하고 표시를 지웁니다.", "state": "dirty = false", "out": "하위 전체 재계산" }
      ],
      "skip": []
    }
  ],
  "note": "즉시 계산에서 앞의 두 번은 화면에 한 번도 나오지 않고 버려집니다. 트리가 깊을수록 버려지는 양이 커집니다."
}
</script>
</x-flow>

전파할 때 한 줄이 중요합니다.

```csharp
void MarkDirty()
{
    if (worldDirty) return;                    // 이미 표시돼 있으면 여기서 멈춘다
    worldDirty = true;
    foreach (var c in children) c.MarkDirty();
}
```

**첫 줄의 조기 반환이 없으면 부모를 열 번 건드릴 때 하위 순회가 열 번 일어납니다.**
플래그를 확인하는 비용은 거의 0이라, 자식이 많을수록 이 한 줄이 크게 남습니다.

## 유니티가 이미 이렇게 동작한다

```csharp
transform.position = newPos;
```

**이 줄에서 월드 행렬이 계산되지 않습니다.** 유니티는 바뀌었다는 사실만 기록해 두고,
실제로 좌표가 필요해지는 시점에 몰아서 계산합니다.
"대입하면 그 자리에서 다 끝난다" 는 직관이 틀리는 자리입니다.

그래서 `Physics.SyncTransforms()` 같은 API 가 존재합니다.
**미뤄 둔 계산을 지금 당장 반영하라**는 뜻입니다.
`transform.position` 을 바꾸고 곧바로 `Raycast` 를 쏘면 옛 위치로 맞을 수 있는데,
그 이유가 여기 있습니다.

uGUI 는 한 걸음 더 나갑니다. **플래그를 셋으로 나눠 씁니다.**

| 플래그 | 무엇이 무효가 되나 | 언제 켜지나 |
| --- | --- | --- |
| Layout | 크기와 위치 재배치 | 자식 추가, 크기 변경 |
| Vertex | 메시 정점 재생성 | 색, 스프라이트, 글자 변경 |
| Material | 머티리얼 재바인딩 | 머티리얼 교체, 마스크 |

**글자 색만 바꿨는데 레이아웃 전체를 다시 계산하면 낭비**라서 나눈 것입니다.
무효화 범위를 잘게 쪼갤수록 다시 계산할 것이 줄어듭니다.
다만 너무 잘게 쪼개면 플래그를 관리하는 코드 자체가 복잡해집니다. 그 균형이 설계입니다.

> 여기서 캔버스 분리 최적화가 나옵니다. **한 캔버스 안의 요소 하나만 더티가 되어도
> 그 캔버스 전체의 배칭 메시가 다시 만들어집니다.**
> 그래서 자주 바뀌는 것(체력바, 타이머, 데미지 숫자)과 가만히 있는 것(배경, 프레임)을
> 다른 캔버스로 나눕니다.

## 플래그가 "다시 계산해야 함" 이 아닐 때

오픈월드의 지형 로딩에서는 더티의 뜻이 조금 다릅니다.

```csharp
bool shouldBeLoaded = Vector3.Distance(transform.position, playerPos) < radius;
IsDirty = shouldBeLoaded != IsLoaded;      // 원하는 상태와 지금 상태가 어긋남
```

여기서 더티는 **"의도한 상태와 실제 상태가 다르다"** 는 뜻입니다.
매 프레임 거리만 재고, **어긋난 구역만** 실제 로드나 해제를 합니다.
거리 재기는 싸고 로드는 비싸니까요.

> **경계에서 진동하는 것을 조심해야 합니다.** 로드 반경과 해제 반경이 같으면
> 플레이어가 경계선에서 조금만 움직여도 로드와 해제가 매 프레임 반복됩니다.
> 해제 반경을 로드 반경보다 크게 두면 사라집니다.

## 실패하는 두 가지 방식

**1. 바꿔 놓고 더티로 표시하지 않는 것.** 값은 바뀌었는데 화면이 그대로입니다.
에러도 안 나고 로그도 안 남습니다. **재현이 어려운 시각 버그**의 전형입니다.
새 필드를 추가하면서 표시하는 줄을 빠뜨리면 바로 이렇게 됩니다.

**2. 더티로 표시해 놓고 아무도 안 지우는 것.** 매번 다시 계산하게 되어
플래그를 안 쓴 것보다 느려집니다.

두 실수 모두 **표시하는 곳과 지우는 곳이 멀리 떨어져 있어서** 생깁니다.
가능하면 값을 바꾸는 통로를 프로퍼티 하나로 좁혀 두는 편이 안전합니다.

> **옵저버와 무엇이 다른가.** 옵저버는 바뀌는 즉시 알립니다.
> 더티 플래그는 바뀌었다는 사실만 적어 두고 나중에 처리합니다.
> 알림을 받는 쪽이 곧바로 반응해야 하면 옵저버, 결과만 제때 맞으면 되면 더티 플래그입니다.

## 언제 쓰지 않는가

**프레임당 한 번만 바뀌고 계산이 싸면 손해입니다.** 플래그를 확인하고 관리하는
비용만 늘어납니다. 이득은 중복이 있을 때만 생깁니다.

**메모리를 더 씁니다.** 계산 결과를 들고 있어야 하니까요.
CPU 시간과 메모리를 맞바꾸는 기법입니다.

이득이 나는 조건은 셋이 겹칠 때입니다.

> 계산이 **비싸고**, 원본이 한 프레임에 **여러 번 바뀌고**,
> 꺼내 쓰는 횟수가 바뀌는 횟수보다 **적거나 같을 때**.

## 정리

- 값이 바뀔 때마다 계산하면, 화면에 나가지 않는 계산을 반복하게 된다.
- 더티 플래그는 바뀌었다는 사실만 적어 두고 꺼내 쓸 때 한 번 계산한다.
- 계산을 없애는 게 아니라 **중복을 없애는** 것이다. 중복이 없으면 이득도 없다.
- 계층 구조에서 이득이 가장 크다. 전파할 때 이미 더티면 즉시 멈추는 한 줄이 핵심이다.
- `transform.position` 대입은 그 줄에서 계산하지 않는다. `SyncTransforms` 가 있는 이유다.
- uGUI 는 플래그를 셋으로 나눈다. 캔버스 분리 최적화가 여기서 나온다.
- 스트리밍에서는 더티가 "원하는 상태와 지금 상태가 어긋남" 을 뜻한다.
- 실패는 둘이다. 바꾸고 표시를 안 하거나, 표시하고 안 지우거나.
- 계산 결과를 들고 있어야 하므로 메모리를 더 쓴다.

여기까지는 **이미 만들어 둔 것을 다시 계산하지 않는** 이야기였습니다.