같은 값을 인스턴스마다 복제하지 않는다
적 스크립트를 이렇게 씁니다.
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마리 있어도 에셋은 메모리에 하나만 올라옵니다.
[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;
}
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);
}
}
인스턴스가 들고 있는 것은 에셋을 가리키는 참조 하나와 현재 체력뿐입니다.
절약량은 공유 필드 크기 × (인스턴스 수 - 1) 입니다.
인스턴스가 한둘이면 아무 의미가 없고, 수백 수천일 때 커집니다.
많을 때만 쓸모 있는 방법이라는 조건을 기억하면 됩니다.
반드시 한 번은 겪는 함정
public void ApplyBuff(int amount)
{
data.BaseAttack += amount; // 이 적의 버프가 같은 종류 전부에게 걸린다
}
공유하는 것을 고쳤으니 모두가 영향을 받습니다. 슬라임 하나에 버프를 걸었는데 맵의 슬라임 전부가 세집니다.
에디터에서는 더 고약합니다. ScriptableObject 는 에셋이라서
플레이를 멈춰도 바뀐 값이 그대로 남습니다. 빌드에서는 실행 중에만 유지되고
껐다 켜면 돌아옵니다. 에디터와 빌드의 동작이 달라지는 버그가 됩니다.
int bonusAttack; // 각자 가져야 하는 값
public int CurrentAttack => data.BaseAttack + bonusAttack;
public void ApplyBuff(int amount) => bonusAttack += amount;
공유 데이터는 읽기만 합니다. 위 예제에서 필드를 private 으로 두고
읽기 전용 프로퍼티만 연 이유가 이것입니다. 규칙에 기대는 것보다 문법으로 막는 편이 낫습니다.
이미 쓰고 있습니다
string a = "슬라임";
string b = "슬라임";
Console.WriteLine(ReferenceEquals(a, b)); // True
C# 은 소스에 적힌 같은 문자열을 하나만 만들어 두고 나눠 씁니다.
Sprite 나 Mesh 를 여러 오브젝트가 참조해도 실제 데이터는 하나뿐인 것도 같습니다.
ScriptableObject 가 특별해서가 아니라, 엔진과 언어가 이미 이 방식으로 만들어져 있어서
우리가 따로 만들 필요가 없는 것입니다. 순수 C# 이라면
Dictionary<키, 공유데이터> 로 직접 만들어야 하는 일입니다.
대가
참조를 한 번 더 타고 들어갑니다. data.BaseAttack 은 필드를 직접 읽는 것보다
한 단계 멉니다. 대부분은 무시할 수준이지만, 수천 번 도는 루프 안이라면
로컬 변수에 한 번 꺼내 두고 쓰는 편이 낫습니다.
int def = data.BaseDefense; // 루프 밖에서 한 번
foreach (var hit in hits) { ... def ... }
에셋이 참조되는 순간 메모리에 올라옵니다. 그리고 그 에셋이 다른 에셋을 참조하고 있으면 그 체인이 통째로 따라 올라옵니다. 프리팹 하나가 모든 데이터를 참조하고 있으면 게임을 켜자마자 전부 로드됩니다. 규모가 커지면 어드레서블로 나눠야 합니다.
인스턴스가 적으면 하지 않습니다. 파일이 늘고 인스펙터에서 끌어다 놓을 일이 늘어납니다.
정리
- 인스턴스가 많아지면 같은 값이 그만큼 복제된다. 필드 하나는 작아도 곱하면 커진다.
- 나누는 기준은 질문 하나다. 이 값은 인스턴스마다 달라야 하는가.
- 아니면 하나만 두고 가리킨다. 유니티에서는
ScriptableObject가 그 자리다. - 절약량은
공유 필드 크기 × (인스턴스 수 - 1)이다. 많을 때만 의미가 있다. - 공유 데이터를 실행 중에 고치면 전부가 영향을 받는다. 에디터에서는 값이 남기까지 한다.
- 그래서 공유 데이터는 읽기 전용으로 연다. 규칙이 아니라 문법으로 막는다.
- 문자열 상수와 스프라이트 참조가 이미 같은 방식이다. 새로운 개념이 아니다.
- 참조를 한 단계 더 타므로, 뜨거운 루프에서는 로컬로 꺼내 쓴다.