---
title: "같은 값을 인스턴스마다 복제하지 않는다"
description: "적 2000마리가 각자 들고 있는 이름과 아이콘과 기본 스탯은 전부 같은 값입니다. 하나만 두고 모두가 가리키면 됩니다."
author: "유니얼"
pubDate: 2026-09-09
category: "game-dev/design-patterns"
tags: ["Unity", "최적화"]
canonical: https://blog.unialgames.com/posts/unity-flyweight/
---
적 스크립트를 이렇게 씁니다.

```csharp
public class Enemy : MonoBehaviour
{
    [SerializeField] string enemyName;
    [SerializeField] string description;
    [SerializeField] Sprite icon;
    [SerializeField] int maxHealth;
    [SerializeField] int baseAttack;
    [SerializeField] int baseDefense;
    [SerializeField] float baseMoveSpeed;

    int currentHealth;
}
```

슬라임을 2000마리 소환합니다. **위 필드가 2000벌 만들어집니다.**

그런데 `enemyName` 은 2000마리 전부 `"슬라임"` 입니다.
`maxHealth` 도 `baseAttack` 도 전부 같습니다. 다른 것은 `currentHealth` 하나뿐입니다.

도서관에서 100명이 같은 책을 읽는다고 책을 100권 사지는 않습니다.
한 권을 두고 100명이 봅니다. **각자 따로 들고 있어야 하는 것은
"몇 페이지까지 읽었는가" 뿐**입니다.

## 나누는 기준은 질문 하나

> **이 값은 인스턴스마다 달라야 하는가.**

아니라면 하나만 두고 모두가 가리킵니다. 그렇다면 각자 들고 있습니다.

| | 공유해도 되는 것 | 각자 가져야 하는 것 |
| --- | --- | --- |
| 값의 성격 | 모든 인스턴스가 같다 | 인스턴스마다 다르다 |
| 메모리 | **전체에 하나** | 인스턴스 수만큼 |
| 예 | 이름, 설명, 아이콘, 최대 체력, 기본 공격력 | 현재 체력, 위치, 쿨다운, 타깃 |
| 언제 정해지나 | 에디터에서 미리 | 실행 중에 계속 |

## 유니티에서는 이미 도구가 있다

`ScriptableObject` 가 그것입니다. 게임 오브젝트에 붙지 않고 **프로젝트의 에셋으로**
존재하기 때문에, 신에 적이 2000마리 있어도 **에셋은 메모리에 하나만 올라옵니다.**

```csharp
[CreateAssetMenu(menuName = "Game/Enemy Data")]
public class EnemyData : ScriptableObject
{
    [SerializeField] string enemyName;
    [SerializeField] string description;
    [SerializeField] Sprite icon;
    [SerializeField] int maxHealth;
    [SerializeField] int baseAttack;
    [SerializeField] int baseDefense;

    public string EnemyName => enemyName;      // 읽기 전용으로만 연다
    public int MaxHealth => maxHealth;
    public int BaseAttack => baseAttack;
    public int BaseDefense => baseDefense;
}
```

```csharp
public class Enemy : MonoBehaviour
{
    [SerializeField] EnemyData data;   // 참조 하나만 복제된다

    int currentHealth;

    void Start() => currentHealth = data.MaxHealth;

    public void TakeDamage(int raw)
    {
        int reduced = Mathf.Max(1, raw - data.BaseDefense);
        currentHealth = Mathf.Max(0, currentHealth - reduced);
    }
}
```

인스턴스가 들고 있는 것은 **에셋을 가리키는 참조 하나와 현재 체력**뿐입니다.

<x-calc>
<script type="application/json">
{
  "label": "공유했을 때와 안 했을 때의 메모리",
  "inputs": [
    { "id": "n", "label": "인스턴스 수", "options": [10, 100, 1000, 5000], "default": 1000, "suffix": "개" },
    { "id": "f", "label": "공유 가능한 필드", "options": [4, 10, 20, 40], "default": 20, "suffix": "개" },
    { "id": "b", "label": "필드 하나 크기", "options": [["4바이트 (int)", 4], ["8바이트 (참조)", 8], ["16바이트", 16]], "default": 8 }
  ],
  "bars": [
    { "label": "각자 들고 있기", "expr": "f * b * n / 1024", "unit": "KB" },
    { "label": "하나만 두고 가리키기", "expr": "(f * b + 8 * n) / 1024", "unit": "KB", "accent": true }
  ],
  "verdict": { "expr": "bars[1] < bars[0] * 0.8", "yes": "공유가 확실히 이득", "no": "차이가 크지 않습니다" },
  "note": "공유 쪽은 에셋 한 벌에 인스턴스마다 참조 8바이트만 더합니다. 인스턴스가 적으면 차이가 거의 없고, 인스턴스가 늘수록 격차가 선형으로 벌어집니다."
}
</script>
</x-calc>

절약량은 `공유 필드 크기 × (인스턴스 수 - 1)` 입니다.
**인스턴스가 한둘이면 아무 의미가 없고, 수백 수천일 때 커집니다.**
많을 때만 쓸모 있는 방법이라는 조건을 기억하면 됩니다.

## 반드시 한 번은 겪는 함정

```csharp
public void ApplyBuff(int amount)
{
    data.BaseAttack += amount;   // 이 적의 버프가 같은 종류 전부에게 걸린다
}
```

공유하는 것을 고쳤으니 **모두가 영향을 받습니다.** 슬라임 하나에 버프를 걸었는데
맵의 슬라임 전부가 세집니다.

에디터에서는 더 고약합니다. `ScriptableObject` 는 에셋이라서
**플레이를 멈춰도 바뀐 값이 그대로 남습니다.** 빌드에서는 실행 중에만 유지되고
껐다 켜면 돌아옵니다. **에디터와 빌드의 동작이 달라지는** 버그가 됩니다.

```csharp
int bonusAttack;                                       // 각자 가져야 하는 값

public int CurrentAttack => data.BaseAttack + bonusAttack;

public void ApplyBuff(int amount) => bonusAttack += amount;
```

공유 데이터는 **읽기만 합니다.** 위 예제에서 필드를 `private` 으로 두고
읽기 전용 프로퍼티만 연 이유가 이것입니다. 규칙에 기대는 것보다 문법으로 막는 편이 낫습니다.

## 이미 쓰고 있습니다

```csharp
string a = "슬라임";
string b = "슬라임";
Console.WriteLine(ReferenceEquals(a, b));   // True
```

C# 은 소스에 적힌 같은 문자열을 **하나만 만들어 두고 나눠 씁니다.**
`Sprite` 나 `Mesh` 를 여러 오브젝트가 참조해도 실제 데이터는 하나뿐인 것도 같습니다.

`ScriptableObject` 가 특별해서가 아니라, **엔진과 언어가 이미 이 방식으로 만들어져 있어서**
우리가 따로 만들 필요가 없는 것입니다. 순수 C# 이라면
`Dictionary<키, 공유데이터>` 로 직접 만들어야 하는 일입니다.

## 대가

**참조를 한 번 더 타고 들어갑니다.** `data.BaseAttack` 은 필드를 직접 읽는 것보다
한 단계 멉니다. 대부분은 무시할 수준이지만, 수천 번 도는 루프 안이라면
**로컬 변수에 한 번 꺼내 두고 쓰는 편**이 낫습니다.

```csharp
int def = data.BaseDefense;              // 루프 밖에서 한 번
foreach (var hit in hits) { ... def ... }
```

**에셋이 참조되는 순간 메모리에 올라옵니다.** 그리고 그 에셋이 다른 에셋을 참조하고
있으면 그 체인이 통째로 따라 올라옵니다. 프리팹 하나가 모든 데이터를 참조하고 있으면
게임을 켜자마자 전부 로드됩니다. 규모가 커지면 어드레서블로 나눠야 합니다.

**인스턴스가 적으면 하지 않습니다.** 파일이 늘고 인스펙터에서 끌어다 놓을 일이 늘어납니다.

## 정리

- 인스턴스가 많아지면 같은 값이 그만큼 복제된다. 필드 하나는 작아도 곱하면 커진다.
- 나누는 기준은 질문 하나다. **이 값은 인스턴스마다 달라야 하는가.**
- 아니면 하나만 두고 가리킨다. 유니티에서는 `ScriptableObject` 가 그 자리다.
- 절약량은 `공유 필드 크기 × (인스턴스 수 - 1)` 이다. 많을 때만 의미가 있다.
- 공유 데이터를 실행 중에 고치면 전부가 영향을 받는다. 에디터에서는 값이 남기까지 한다.
- 그래서 공유 데이터는 읽기 전용으로 연다. 규칙이 아니라 문법으로 막는다.
- 문자열 상수와 스프라이트 참조가 이미 같은 방식이다. 새로운 개념이 아니다.
- 참조를 한 단계 더 타므로, 뜨거운 루프에서는 로컬로 꺼내 쓴다.