29. Prefab Variant로 공통 설정 유지하며 파생 프리팹 운영하기
Unity Prefab Variant를 엔진 직렬화·오버라이드 관점에서 설명하고, 캐릭터/아이템 파생 프리팹을 공통 설정 유지하며 운영하는 실습과 성능 함정을 다룬다. 2022 LTS 기준 실무 패턴 포함.
Prefab Variant로 공통 설정 유지하며 파생 프리팹 운영하기
게임 만들다 겪는 사고는 대개 ‘공통 수정 한 번’에서 시작한다. 캐릭터 30종에 공통 콜라이더 크기를 바꿨는데 27종은 반영되고 3종은 안 바뀌어 QA에서만 터진다. 그 3종은 누군가 프리팹을 복사해 로컬 수정해둔 상태였고, 나중에 원본을 수정해도 연결이 끊겨 있었다. Prefab Variant는 이 사고를 구조적으로 막는 기능이고, 왜 막히는지는 Unity의 직렬화와 오버라이드 저장 방식까지 들어가야 납득이 된다.
핵심 개념
Prefab Variant는 ‘부모 프리팹(베이스)’을 참조하면서 일부 속성만 덮어쓰는 자식 프리팹이다. 복사본이 아니라 참조 관계이기 때문에, 베이스의 변경이 Variant로 전파된다. 전파되지 않는 부분은 Variant가 명시적으로 오버라이드한 항목뿐이다. 이 구조가 필요한 이유는 팀 규모가 커질수록 ‘공통 설정’과 ‘개별 차이’를 분리하지 않으면 수정 비용이 기하급수로 늘어나기 때문이다.
용어를 엔진 관점으로 정의한다. Base Prefab은 YAML 상에서 독립된 에셋이며, Variant는 Base Prefab을 가리키는 링크와 ‘변경 목록(override list)’을 가진다. Override는 “현재 값”이 아니라 “부모 대비 변경된 속성 경로(property path)”로 저장된다. Apply는 자식의 변경을 부모로 올리는 편집 작업이고, Revert는 자식의 변경 목록에서 항목을 제거하는 작업이다.
Nested Prefab(프리팹 안의 프리팹)과 Variant는 자주 섞인다. Nested는 ‘구성 요소 재사용’을 위한 합성(composition)이고, Variant는 ‘상속(inheritance) 흉내’를 위한 파생이다. 캐릭터처럼 공통 파이프라인이 강한 대상은 Variant가, 무기 이펙트처럼 조립식 구성이 많은 대상은 Nested가 더 맞는 경우가 많다.
실무에서 가장 많이 터지는 지점은 “어떤 변경이 오버라이드로 남았는지 모르는 상태”다. Inspector에서 파란색/굵은 표시로 보이는 값이 오버라이드인데, 이 표시가 곧 YAML의 override entry로 남는다고 보면 된다. 오버라이드가 많아질수록 병합 충돌(특히 Git)과 리그레션이 늘어난다.
1using UnityEngine;
2
3public class VariantDebugLabel : MonoBehaviour
4{
5 [SerializeField] private string displayName;
6 [SerializeField] private Color tint = Color.white;
7
8 private void Awake()
9 {
10 Debug.Log($"[{name}] displayName={displayName}, tint={tint}");
11 }
12}이 스크립트를 베이스 프리팹에 붙이고, Variant에서는 displayName과 tint만 바꾼다. Play를 누르면 콘솔에 각 프리팹 인스턴스의 값이 찍힌다. 같은 컴포넌트와 같은 필드지만, Variant가 바꾼 필드만 다른 값으로 직렬화돼 로드된다는 점이 눈으로 확인된다.
엔진 관점에서의 내부 동작
Prefab Variant의 핵심은 런타임이 아니라 에디터의 직렬화 파이프라인에 있다. Unity는 씬/프리팹을 YAML(텍스트) 또는 바이너리로 저장하고, 로드 시 네이티브(C++)에서 오브젝트 그래프를 만든 뒤 C# 래퍼(UnityEngine.Object)로 핸들을 노출한다. Variant는 “부모 에셋 GUID + 오버라이드 목록”을 가진 별도 에셋으로 저장되고, 로드 시 부모를 먼저 확장(expand)한 다음 오버라이드를 적용한 결과를 만든다.
Player Loop 관점에서 Prefab Variant는 Update 같은 루프 단계에서 ‘계산’되지 않는다. Prefab을 인스턴스화(Instantiate)하는 순간, 네이티브 쪽에서 SerializedFile을 읽고 오브젝트를 생성하며, 그 직후 Managed 쪽에서 Awake/OnEnable 메시지가 올라온다. 즉, Variant의 오버라이드 적용은 Awake보다 앞에서 끝난다. Awake에서 읽는 필드는 이미 적용된 값이다.
C#에서 GameObject나 Component를 만지면 실제 데이터는 네이티브 메모리에 있고, C# 객체는 핸들(InstanceID)을 가진 래퍼다. Prefab 로드 과정에서 네이티브가 컴포넌트 배열과 직렬화된 필드 데이터를 채우고, Managed는 필요한 순간에만 래퍼를 만든다. 그래서 Prefab Variant가 많아도 ‘런타임 프레임 비용’이 아니라 ‘로딩/인스턴스화 비용’과 ‘메모리’로 나타나는 경우가 많다.
오버라이드는 ‘필드 전체 스냅샷’이 아니라 ‘부모 대비 변경된 프로퍼티 경로’로 저장되는 경우가 많다. 예를 들어 BoxCollider.size의 x만 바꾸면 size 전체가 아니라 size.x 경로가 오버라이드로 남는다. 이 설계는 프리팹의 공통 변경을 유지시키기 위한 것이다. 부모에서 size.y를 바꿔도 자식이 size.x만 오버라이드했다면 y 변경은 전파된다.
왜 Apply/Revert가 위험한지 엔진 관점으로 보면 납득이 된다. Apply는 자식의 오버라이드 목록을 부모 에셋에 ‘쓰기’ 때문에, 부모를 참조하는 모든 Variant/인스턴스에 연쇄적으로 영향을 준다. Revert는 오버라이드 목록에서 항목을 제거해 부모 값을 다시 따라가게 만든다. 둘 다 런타임 코드가 아니라 에셋 데이터의 변경이며, Git diff로는 YAML의 몇 줄이 바뀌는 형태로 남는다.
처음에 나도 ‘왜 어떤 값은 부모 수정이 전파되고 어떤 값은 안 전파되는지’ 감이 없었다. 3시간 삽질 끝에 알아낸 건 Inspector에서 파란색으로 표시된 항목이 생각보다 많다는 점이었다. 특히 Transform은 프리팹 단계에서 건드리면 거의 항상 오버라이드가 생기고, 그 상태에서 부모의 배치 수정이 전파되지 않아 “프리팹이 말을 안 듣는다”는 느낌이 난다.
1using UnityEngine;
2
3public class LifecycleOrderProbe : MonoBehaviour
4{
5 private void Awake()
6 {
7 Debug.Log($"{name} Awake");
8 }
9
10 private void OnEnable()
11 {
12 Debug.Log($"{name} OnEnable");
13 }
14
15 private void Start()
16 {
17 Debug.Log($"{name} Start");
18 }
19}이 스크립트를 베이스 프리팹에 붙여 Variant를 여러 개 만든 뒤, 씬에 Variant 인스턴스를 3개 배치한다. Play를 누르면 콘솔에 Awake → OnEnable → Start 순서로 찍힌다. 중요한 포인트는 Awake가 찍힐 때 이미 Variant의 오버라이드가 적용된 상태라는 점이다. displayName 같은 직렬화 필드를 Awake에서 읽어도 ‘부모 값’이 아니라 ‘Variant 값’이 나온다.
1using UnityEngine;
2
3public class InstantiateProbe : MonoBehaviour
4{
5 [SerializeField] private GameObject prefab;
6
7 private void Update()
8 {
9 if (Input.GetKeyDown(KeyCode.Space))
10 {
11 var go = Instantiate(prefab);
12 go.name = $"Spawned_{Time.frameCount}";
13 Debug.Log($"Instantiate at frame {Time.frameCount}");
14 }
15 }
16}Space를 누르는 프레임에 Instantiate가 호출되고, 그 프레임 안에서 생성된 오브젝트의 Awake/OnEnable이 바로 실행된다. 내부적으로는 네이티브 Instantiate가 오브젝트 그래프를 복제하고(컴포넌트 배열, 직렬화 필드 포함), Managed 메시지 디스패처가 Awake/OnEnable을 호출한다. Variant든 일반 프리팹이든 인스턴스화 비용의 본질은 ‘그래프 복제 + 직렬화 데이터 적용’이다.
실습하기
1단계: 프로젝트와 폴더 구조 고정
Unity Hub → New project → 3D(Core) 템플릿을 선택한다. 버전은 2022.3 LTS 계열을 권장한다. 프로젝트 생성 후 Project 창에서 Assets 우클릭 → Create → Folder로 폴더를 만든다: Prefabs, Scripts, Materials, Scenes.
Scenes 폴더에서 SampleScene을 저장한다. File → Save As… → Assets/Scenes/VariantLab.unity. 이 실습은 에셋 변경이 많아서 씬 이름을 분리하는 편이 안전하다. Git을 쓴다면 이 시점에 첫 커밋을 찍어두면 Apply 실수에서 되돌리기 쉽다.
2단계: 베이스 프리팹 만들기(공통 설정을 한 군데로 모으기)
Hierarchy 우클릭 → 3D Object → Capsule을 만든다. 이름을 Character_Base_Root로 바꾼다. Inspector에서 Capsule Collider의 Center Y=1, Height=2, Radius=0.3으로 맞춘다. Rigidbody를 Add Component로 추가하고 Use Gravity 체크, Constraints에서 Freeze Rotation X/Z를 체크한다.
그 다음 자식 오브젝트를 만든다. Character_Base_Root 선택 → 우클릭 → 3D Object → Cube. 이름을 Visual로 바꾸고 Transform에서 Position (0,1,0), Scale (0.6,1.8,0.6)로 둔다. 이 Visual은 캐릭터 모델의 자리 표시자다. Project 창에서 Materials 폴더 우클릭 → Create → Material, 이름 BaseMat, Albedo를 회색으로 둔다. Visual의 Mesh Renderer에 BaseMat을 할당한다.
1using UnityEngine;
2
3public class CharacterCommon : MonoBehaviour
4{
5 [Header("Common")]
6 [SerializeField] private float moveSpeed = 3.5f;
7 [SerializeField] private int maxHp = 100;
8
9 [Header("Debug")]
10 [SerializeField] private string characterId = "base";
11
12 private Rigidbody rb;
13
14 private void Awake()
15 {
16 rb = GetComponent<Rigidbody>();
17 Debug.Log($"[{name}] id={characterId}, speed={moveSpeed}, hp={maxHp}, rb={(rb != null)}");
18 }
19
20 private void FixedUpdate()
21 {
22 // 입력은 단순화. 물리 이동이 FixedUpdate에 있는 이유는 Physics.Simulate 단계와 맞추기 위함.
23 float x = Input.GetAxisRaw("Horizontal");
24 float z = Input.GetAxisRaw("Vertical");
25 Vector3 v = new Vector3(x, 0f, z).normalized * moveSpeed;
26 rb.MovePosition(rb.position + v * Time.fixedDeltaTime);
27 }
28}이 스크립트를 Character_Base_Root에 붙인다. Inspector에서 Character Id는 base, Move Speed는 3.5, Max Hp는 100으로 둔다. Play를 누르면 콘솔에 Rigidbody 캐싱 결과와 값이 찍힌다. GetComponent를 Awake에서 한 번만 호출하면 네이티브 컴포넌트 배열 순회 비용이 1회로 고정된다. FixedUpdate에서 매번 GetComponent를 부르면 프레임이 아니라 물리 스텝마다 비용이 반복된다.
이제 프리팹으로 만든다. Hierarchy의 Character_Base_Root를 Project/Prefabs 폴더로 드래그한다. Hierarchy의 원본 오브젝트는 삭제한다. Prefabs 폴더에 Character_Base.prefab이 생기고, 씬에는 아무것도 없는 상태가 된다.
3단계: Variant 만들기(캐릭터/아이템 파생) + 실행 확인
Project/Prefabs에서 Character_Base.prefab 우클릭 → Create → Prefab Variant를 선택한다. 이름을 Character_Warrior_Variant로 바꾼다. 같은 방식으로 Character_Mage_Variant도 만든다. 각각 더블클릭해 Prefab Mode로 들어간다.
Character_Warrior_Variant의 Inspector에서 CharacterCommon의 Move Speed를 4.2로, Max Hp를 180으로 바꾼다. Visual의 Mesh Renderer에서 Material을 복제해 WarriorMat로 만들고 색을 붉게 바꾼 뒤 할당한다. Character Id는 warrior로 바꾼다. Character_Mage_Variant는 Move Speed 3.8, Max Hp 90, 색을 파랗게, Character Id mage로 둔다. 변경된 항목이 파란색으로 표시되는지 확인한다.
1using UnityEngine;
2
3public class VariantSceneSpawner : MonoBehaviour
4{
5 [SerializeField] private GameObject warriorPrefab;
6 [SerializeField] private GameObject magePrefab;
7
8 private void Start()
9 {
10 var w = Instantiate(warriorPrefab, new Vector3(-2f, 0f, 0f), Quaternion.identity);
11 w.name = "Warrior_Instance";
12
13 var m = Instantiate(magePrefab, new Vector3(2f, 0f, 0f), Quaternion.identity);
14 m.name = "Mage_Instance";
15
16 Debug.Log("Spawned variants. Check CharacterCommon Awake logs.");
17 }
18}씬에 스포너를 만든다. Hierarchy 우클릭 → Create Empty, 이름 Spawner. VariantSceneSpawner를 붙이고, Inspector에서 Warrior Prefab에는 Character_Warrior_Variant, Mage Prefab에는 Character_Mage_Variant를 드래그한다. Play를 누르면 두 캐릭터가 좌우에 생성되고 콘솔에 id/speed/hp가 다르게 찍힌다. 같은 베이스 컴포넌트를 공유하지만 오버라이드된 필드만 달라진다.
1using UnityEngine;
2
3public class BaseChangePropagationTest : MonoBehaviour
4{
5 [SerializeField] private CharacterCommon target;
6
7 private void Update()
8 {
9 if (Input.GetKeyDown(KeyCode.P) && target != null)
10 {
11 Debug.Log($"Runtime read: {target.name} (values already baked at Instantiate)");
12 }
13 }
14}이 스크립트는 ‘런타임에서 부모를 바꾸면 자식이 따라오나?’ 같은 오해를 끊기 위해 넣는다. Prefab Variant의 전파는 에디터에서 에셋을 수정할 때 발생하는 데이터 전파이고, 플레이 중에는 이미 인스턴스에 값이 복제돼 있다. Play 중에 Character_Base.prefab을 수정해도 현재 씬 인스턴스가 자동으로 재패치되지 않는다.
심화 활용
패턴 1: ‘베이스는 움직이지 않는 규약’ + Variant에서만 튜닝
팀이 커지면 베이스 프리팹은 사실상 ‘규약’이 된다. Rigidbody, Collider, Layer, Tag, 필수 컴포넌트 구성은 베이스에서만 편집하고, 수치 튜닝은 Variant에서만 한다. 이렇게 나누면 Apply 실수로 전 캐릭터가 흔들리는 사고를 줄일 수 있다. 특히 Layer/Tag는 물리 충돌 매트릭스와 연결돼 있어서 한 번 잘못 적용되면 디버깅이 지옥이 된다.
1using UnityEngine;
2
3[DisallowMultipleComponent]
4public class RequireCharacterSetup : MonoBehaviour
5{
6 private void Awake()
7 {
8 var rb = GetComponent<Rigidbody>();
9 var col = GetComponent<Collider>();
10
11 if (rb == null || col == null)
12 {
13 Debug.LogError($"{name}: Missing Rigidbody/Collider. Base prefab rule broken.");
14 }
15
16 if (gameObject.layer == 0)
17 {
18 Debug.LogWarning($"{name}: Layer is Default. Check base prefab layer policy.");
19 }
20 }
21}이 스크립트를 베이스에 붙이면 Variant가 늘어나도 기본 구성이 깨졌을 때 바로 콘솔에서 잡힌다. 엔진 관점에서 Awake는 Instantiate 직후 호출되고, 그 시점에 컴포넌트 배열은 네이티브에서 이미 구성돼 있다. 누락된 컴포넌트는 ‘Variant에서 삭제했거나’, ‘베이스가 아닌 복사본을 쓰는 경우’가 대부분이다.
패턴 2: 그래픽/이펙트는 Nested, 스탯/룰은 Variant
캐릭터의 룰(HP, 속도, 히트박스)은 Variant로 파생시키고, 무기 이펙트나 장식은 Nested Prefab으로 조립한다. 이유는 오버라이드 폭발 때문이다. Variant에서 자식 트랜스폼을 많이 만지면 override list가 기하급수로 늘고, 부모 변경 전파가 거의 끊긴다. 대신 Visual 하위에 WeaponSocket을 두고, 그 소켓에 무기 프리팹을 중첩으로 꽂는다.
1using UnityEngine;
2
3public class WeaponSocketBinder : MonoBehaviour
4{
5 [SerializeField] private Transform socket;
6 [SerializeField] private GameObject weaponPrefab;
7
8 private GameObject spawned;
9
10 private void Start()
11 {
12 if (socket == null || weaponPrefab == null)
13 {
14 Debug.LogError($"{name}: Assign Socket and WeaponPrefab in Inspector");
15 return;
16 }
17
18 spawned = Instantiate(weaponPrefab, socket);
19 spawned.transform.localPosition = Vector3.zero;
20 spawned.transform.localRotation = Quaternion.identity;
21 Debug.Log($"{name}: weapon nested under socket={socket.name}");
22 }
23}Inspector에서 Socket에는 Character_Base_Root/WeaponSocket 같은 트랜스폼을 드래그하고, Weapon Prefab에는 별도 무기 프리팹을 넣는다. 이 방식은 Variant의 오버라이드를 ‘스탯 필드 몇 개’로 제한하고, 외형 변경은 중첩 프리팹 교체로 처리한다. 런타임 비용은 Instantiate 1회가 늘지만, 에셋 운영 비용과 병합 충돌이 크게 준다.
한 문단 요약: Prefab Variant는 부모 GUID + 오버라이드 목록으로 저장되고, Instantiate 시점에 부모를 확장한 뒤 오버라이드를 적용한 결과가 복제된다. 그래서 공통 수정 전파는 에디터 데이터 문제이고, 런타임 프레임 비용이 아니라 로딩/인스턴스화 비용과 오버라이드 폭발이 핵심 리스크다.
내 흑역사 1. 캐릭터 40종을 운영하던 프로젝트에서 누군가 Variant에서 Collider를 살짝 만졌다. Inspector에선 티가 안 났는데 override로 남아 있었다. 이후 베이스에서 Collider Radius를 0.3→0.28로 바꿨고, 일부 캐릭터만 피격 판정이 두꺼워져서 멀티에서 ‘맞았는데 안 맞음’ 제보가 쏟아졌다. Profiler가 아니라 리플레이 로그로만 드러나서 원인 찾는 데 반나절이 걸렸다.
내 흑역사 2. Apply를 잘못 눌러 Variant의 Material 변경이 베이스로 올라갔다. 다음날 아티스트가 “왜 모든 캐릭터가 빨갛냐”라고 했다. Git diff를 열어보니 YAML에서 m_Materials 배열이 베이스 프리팹에 덮어써져 있었다. 그 이후로는 베이스 프리팹은 잠금(Perforce)하거나, 최소한 Apply는 코드리뷰 대상이라는 룰을 만들었다.
1using UnityEngine;
2
3public class OverrideExplosionDetector : MonoBehaviour
4{
5 [SerializeField] private Transform root;
6
7 private void Awake()
8 {
9 if (root == null) root = transform;
10
11 int tCount = 0;
12 foreach (var t in root.GetComponentsInChildren<Transform>(true))
13 {
14 tCount++;
15 }
16
17 Debug.Log($"{name}: child Transform count={tCount}. Many children often means many potential overrides.");
18 }
19}이 코드는 오버라이드 폭발을 ‘정량’으로 느끼게 만든다. 자식 트랜스폼이 많을수록 누군가 위치/스케일을 만질 때 override 항목이 늘어날 가능성이 커진다. 실제 오버라이드 수는 에디터 API가 필요하지만, 현장에서 가장 흔한 원인은 ‘계층이 너무 복잡한 상태에서 Variant로 모든 걸 해결하려고 한 것’이다.
자주 하는 실수
실수 1: Variant가 아니라 프리팹을 복사(Ctrl+D)해서 파생을 만든다
증상: 베이스 프리팹을 수정했는데 특정 캐릭터/아이템만 변경이 반영되지 않는다. QA에서는 “어떤 건 되고 어떤 건 안 된다”로만 올라온다.
원인: 복사본은 부모 링크가 없다. Unity 입장에서는 완전히 다른 에셋이므로 전파라는 개념이 성립하지 않는다. YAML에서도 GUID가 달라져서 같은 계열이라는 힌트가 사라진다.
해결: Project 창에서 베이스 프리팹 우클릭 → Create → Prefab Variant로만 파생을 만든다. 이미 복사본이 퍼졌다면, 복사본을 Variant로 ‘변환’하는 자동 기능이 없어서 수동 이관이 필요하다. 가장 안전한 절차는 새 Variant를 만들고, 복사본에서 필요한 오버라이드만 하나씩 옮기는 방식이다.
실수 2: Variant에서 Transform을 건드려 부모 레이아웃 변경이 전파되지 않는다
증상: 베이스에서 Visual 위치를 살짝 올렸는데 몇몇 Variant는 그대로다. 씬에서 보면 캐릭터마다 발이 땅에 박히거나 떠 있다.
원인: Transform은 자주 오버라이드된다. Variant에서 한 번이라도 Position/Rotation/Scale을 수정하면 해당 경로가 override로 남고, 이후 부모 변경이 그 축에 대해 전파되지 않는다. 특히 “Reset”을 눌렀는데도 오버라이드가 남는 경우가 있어 혼란이 커진다.
해결: Variant에서 Transform을 만져야 한다면 변경 이유를 남기고 최소 범위로 제한한다. 가능하면 베이스에 ‘Socket’ 트랜스폼을 만들어두고, Variant는 소켓 하위에 Nested 프리팹을 교체하는 방식으로 외형 차이를 처리한다. 이미 꼬였으면 Inspector에서 해당 Transform 항목 우클릭 → Revert로 오버라이드를 제거한다.
실수 3: Apply를 무심코 눌러 베이스가 오염된다
증상: “내 Variant만 바꿨는데” 다음날 모든 캐릭터가 같은 색/같은 스탯이 된다. 콘솔 에러는 없고, 씬 전체가 바뀐다.
원인: Apply는 에셋 데이터를 부모로 올린다. Unity는 이 작업을 ‘편집’으로 취급하고, 해당 베이스를 참조하는 모든 Variant가 그 변경을 받는다. 특히 Material, Renderer 설정, Layer 같은 항목은 파급이 크다.
해결: Apply는 팀 룰로 제한한다. 베이스 프리팹은 변경 담당자를 정하거나, 버전 관리에서 잠금으로 보호한다. 실수했을 때는 YAML diff에서 변경된 컴포넌트 섹션(m_Materials, m_Layer 등)을 찾아 되돌리고, Unity Editor에서 Prefab Mode로 열어 상태를 재검증한다.
실수 4: Variant에서 컴포넌트를 삭제해 런타임 NullReference가 난다
증상: Play를 누르면 NullReferenceException이 뜬다. 예: NullReferenceException: Object reference not set to an instance of an object at CharacterCommon.FixedUpdate().
원인: Variant에서 Rigidbody나 Collider 같은 공통 컴포넌트를 삭제했거나, 다른 Rigidbody로 교체해 설정이 달라졌다. 베이스의 스크립트는 ‘항상 있다’고 가정하고 GetComponent 캐싱을 해둔 상태라서 바로 터진다.
해결: 베이스 규약을 코드로 강제한다(RequireCharacterSetup 같은 검사). 정말로 특정 파생에서 Rigidbody가 필요 없다면, 베이스를 쪼개서 Rigidbody 없는 베이스를 따로 만들거나, 스크립트를 분리해 조건부로 붙인다. Variant로 규약을 깨는 순간 디버깅 비용이 급증한다.
실수 5: 오버라이드가 너무 많아 병합 충돌(YAML)이 잦다
증상: Git에서 프리팹 파일이 충돌하고, 충돌 구간이 길어서 해결이 어렵다. 충돌을 해결해도 씬에서 오브젝트가 사라지거나 컴포넌트 값이 초기화된다.
원인: Variant가 많은 속성을 오버라이드하면 YAML에 수정 라인이 늘고, 여러 사람이 같은 베이스 계열을 건드릴 때 충돌 확률이 폭증한다. 특히 Renderer, Animator, Transform 계층 변경은 파일 구조 자체가 바뀌어 머지 툴이 약하다.
해결: Variant의 책임을 ‘스탯/아이디/소수 필드’로 제한한다. 외형은 Nested로 분리하고, 공통 구조 변경은 베이스에서만 한다. 그리고 Project Settings → Editor → Asset Serialization을 Force Text로 두고, Version Control에서 Smart Merge(UnityYAMLMerge)를 설정한다.
1%YAML 1.1
2%TAG !u! tag:unity3d.com,2011:
3--- !u!1 &100000
4GameObject:
5 m_Name: Character_Warrior_Variant
6 m_IsActive: 1
7--- !u!114 &100114
8MonoBehaviour:
9 m_Script: {fileID: 11500000, guid: YOUR_SCRIPT_GUID, type: 3}
10 moveSpeed: 4.2
11 maxHp: 180YAML을 직접 편집하라는 의미가 아니다. 충돌이 났을 때 어떤 줄이 ‘오버라이드된 필드’인지 감을 잡기 위한 샘플이다. moveSpeed/maxHp처럼 숫자 몇 줄만 바뀌는 구조가 유지되면 머지가 쉽다. 반대로 Transform 계층이 크게 바뀌면 YAML 블록이 통째로 이동해 충돌이 길어진다.
성능 최적화 체크리스트
- 베이스 프리팹은 규약(필수 컴포넌트/Layer/Tag/Collider)만 포함하고, 튜닝 수치는 Variant에서만 바꾸는 룰을 둔다
- Variant 생성은 반드시 Project 창에서 베이스 우클릭 → Create → Prefab Variant로만 한다(복사본 파생 금지)
- Inspector에서 파란색(override) 표시가 예상보다 많으면 Revert로 최소화하고, 변경 이유를 커밋 메시지에 남긴다
- Transform 오버라이드는 전파를 끊는 주범이라서, 위치 조정이 필요하면 Socket 트랜스폼을 베이스에 만들고 Nested로 해결한다
- Apply는 베이스 오염 위험이 커서 권한/락/코드리뷰 대상으로 둔다(특히 Renderer/Material/Layer)
- Awake에서 GetComponent를 캐싱하고 Update/FixedUpdate에서 반복 호출을 금지한다(프레임당 0.01~0.1ms 누적 가능)
- Instantiate가 많은 구간은 Profiler에서 CPU Usage와 GC Alloc을 같이 본다(대량 스폰 시 프레임 드랍은 로딩/복제 비용)
- 프리팹/씬은 Asset Serialization을 Force Text로 두고, Git 사용 시 UnityYAMLMerge 설정으로 충돌 해결력을 올린다
- Variant에서 공통 컴포넌트를 삭제하지 않도록 Awake 검사 스크립트(필수 구성 검증)를 베이스에 둔다
- Addressables/리소스 로딩을 쓰면 Variant도 결국 별도 에셋이므로 그룹/라벨 정책을 베이스 계열로 통일한다
- 프리팹 계층(자식 Transform 수)이 커지면 override 폭발과 머지 충돌이 늘어나는 경향이 있어, 계층 분리(외형/룰)를 우선 검토한다
- Physics 관련 공통 값(레이어 충돌, Rigidbody Constraints)은 베이스에서만 변경하고, Variant에서 건드리면 QA 케이스를 추가한다
자주 묻는 질문
Prefab Variant는 런타임에서 부모 변경을 자동으로 따라가나?
따라가지 않는다. Prefab Variant의 ‘전파’는 에디터에서 에셋을 편집할 때, 부모 에셋의 직렬화 데이터가 바뀌고 그 참조 관계에 의해 자식 에셋이 다시 계산되는 성격이다. 플레이 중 Instantiate가 일어나면 그 시점의 프리팹 데이터가 오브젝트 그래프로 복제되고, 이후에는 씬 인스턴스가 독립적으로 존재한다. 플레이 중 부모 프리팹을 수정해도 이미 생성된 인스턴스는 재직렬화되지 않는다. 관련 키워드는 SerializedFile 로딩, Instantiate 복제, Domain Reload, Enter Play Mode Options이다.
왜 Variant에서 바꾼 값은 부모 수정으로 덮어써지지 않나?
Variant는 ‘현재 값 전체’를 들고 있는 게 아니라 ‘부모 대비 변경된 속성 경로’를 오버라이드 목록으로 저장하는 구조에 가깝다. Unity는 부모를 먼저 확장한 뒤, 그 오버라이드 경로에 해당하는 값만 덮어쓴다. 그래서 부모가 같은 필드를 바꾸면 충돌이 생기는데, 이때 자식이 오버라이드한 경로는 자식 값이 유지되고, 오버라이드하지 않은 경로는 부모 변경이 전파된다. 이 설계는 대규모 프리팹 운영에서 공통 수정의 안정성을 확보하려는 의도가 강하다. 키워드는 Prefab Override, Apply/Revert, property path, YAML diff이다.
Prefab Variant를 많이 쓰면 성능이 느려지나?
프레임 루프 자체가 느려지는 경우는 드물고, 비용은 주로 로딩/인스턴스화 순간에 나온다. Variant든 일반 프리팹이든 Instantiate는 네이티브에서 오브젝트 그래프를 복제하고 직렬화 필드를 적용한다. Variant는 그 전에 ‘부모+오버라이드’로 최종 데이터를 구성해야 하므로 에디터/빌드 파이프라인에서 약간의 처리량이 늘 수 있다. 실제 체감 성능 이슈는 Variant 자체보다, Variant가 늘어나면서 프리팹 계층이 커지고 컴포넌트 수가 늘어 Instantiate가 무거워지는 쪽이 많다. 키워드는 Profiler CPU Usage, Instantiate spike, Async loading, Addressables이다.
Apply와 Revert는 내부적으로 어떤 차이가 있나?
Apply는 자식(Variant 또는 인스턴스)의 변경을 부모 프리팹 에셋에 기록하는 편집 작업이다. 데이터가 부모 쪽 YAML/직렬화 블록에 반영되며, 그 부모를 참조하는 모든 Variant가 영향을 받는다. 반대로 Revert는 자식이 가지고 있던 오버라이드 항목을 제거해 부모 값을 다시 따라가게 만드는 작업이다. 둘 다 런타임 API가 아니라 에디터에서 에셋을 수정하는 동작이며, 버전 관리에서는 텍스트 변경으로 남는다. Apply가 위험한 이유는 영향 범위가 ‘내 오브젝트’가 아니라 ‘부모를 참조하는 전체’로 확장되기 때문이다. 키워드는 PrefabUtility, override list, version control diff이다.
Variant에서 컴포넌트 추가/삭제는 안전한가?
추가 자체는 가능하지만 ‘베이스 규약’이 있는 프로젝트에서는 위험이 커진다. 베이스 스크립트가 특정 컴포넌트 존재를 가정하고 Awake에서 캐싱하거나 FixedUpdate에서 물리 API를 호출하면, Variant에서 삭제한 순간 NullReferenceException이 바로 발생한다. 또 컴포넌트 순서/의존성이 있는 경우(예: Animator와 스테이트머신, NavMeshAgent와 Rigidbody 조합) Variant별로 구성이 달라지면 QA 케이스가 폭발한다. 안전하게 운영하려면 베이스를 더 쪼개서 ‘Rigidbody 있는 베이스/없는 베이스’를 분리하거나, 의존 스크립트를 별도 컴포넌트로 분리해 Variant에서 붙였다 떼는 단위를 명확히 해야 한다. 키워드는 [RequireComponent], DisallowMultipleComponent, composition vs inheritance이다.
프리팹 YAML 충돌이 자주 난다. Variant 구조로 줄일 수 있나?
줄일 수 있다. 충돌의 본질은 ‘같은 파일에서 같은 줄 근처를 여러 사람이 동시에 바꾸는가’다. Variant가 스탯 몇 개만 오버라이드하는 구조라면 YAML 변경 라인이 짧고, 머지 도구가 해결 가능한 수준으로 남는다. 반대로 Variant에서 Transform 계층, Renderer 배열, Animator Controller 같은 큰 구조를 자주 바꾸면 YAML 블록이 이동하고 길게 충돌한다. 운영 전략은 (1) 베이스에서 구조를 고정, (2) Variant는 숫자/아이디/소수 필드만, (3) 외형은 Nested 프리팹 교체로 분리, (4) Force Text + UnityYAMLMerge 설정이다. 키워드는 Smart Merge, prefab structure stability, override minimization이다.
캐릭터/아이템을 Variant로 운영할 때 Addressables는 어떻게 엮나?
Variant도 결국 독립 에셋이므로 Addressables 관점에서는 각각이 로드 대상이다. 베이스를 Addressable로 만든다고 해서 Variant가 자동으로 같은 그룹 정책을 따르지 않는다. 실무에서는 ‘베이스는 직접 로드하지 않고, 항상 Variant만 로드’ 같은 규칙을 두는 편이 사고가 적다. 또한 Variant가 참조하는 Material/Animator/Weapon 프리팹이 Addressables 그룹을 가로지르면 빌드 시 의존성 번들 분리가 꼬일 수 있다. 라벨을 “Character/*”처럼 계열로 통일하고, Analyze에서 Duplicate Bundle을 확인하는 루틴이 필요하다. 키워드는 Addressables dependency, bundle layout, Analyze, label policy이다.