유니티 입문2026년 03월 06일· 8 min read

27. Prefab 인스턴스와 원본의 관계: Override, Apply, Variant까지

Prefab 원본과 인스턴스가 왜 연결되고 어디서 끊기는지, Override/Apply/Unpack/Variant를 엔진 직렬화·네이티브 오브젝트 관점에서 설명한다. 실습과 디버깅 포인트 포함. 2022 LTS 기준. (150자)‏‏‎‎‏‏‎‎‏‏‎‎‏‏‎‎‏‏‎‎‏‏‎‎‏‏‎

27. Prefab 인스턴스와 원본의 관계: Override, Apply, Variant까지

Prefab 인스턴스와 원본의 관계: Override, Apply, Variant까지

게임 만들다 갑자기 “씬에서만 고친 줄 알았는데 프리팹 원본까지 바뀌어서 모든 몬스터가 동시에 커졌다” 같은 사고가 터진다. 반대로 “원본을 고쳤는데 씬에 있는 애들은 그대로”도 흔하다. Prefab은 에디터에서 편한 재사용 도구처럼 보이지만, 내부는 ‘원본 에셋의 직렬화 데이터’와 ‘씬에 존재하는 인스턴스의 델타(override)’를 합성하는 시스템이다. 이 합성이 언제, 어떤 키로, 어떤 규칙으로 일어나는지 이해하면 Apply/Override/Unpack이 왜 그렇게 동작하는지 납득된다.

핵심 개념

Prefab을 ‘복사본’으로 생각하면 사고가 난다. 씬에 놓인 Prefab 인스턴스는 원본 에셋을 참조하면서, 변경된 값만 별도 기록(Override)한다. 에디터는 원본(Asset) + 델타(Instance Overrides)를 합성해서 Inspector에 보여준다. 그래서 원본을 바꾸면 인스턴스도 바뀌지만, 인스턴스에서 이미 Override된 필드는 원본 변경이 덮어쓰지 못한다.

용어를 엔진 데이터 구조 관점으로 고정한다. Prefab Asset은 Project 창에 있는 .prefab 에셋(YAML 직렬화)이다. Prefab Instance는 씬(.unity) 파일에 직렬화된 GameObject/Component 집합이고, Prefab Asset로 연결되는 링크(내부적으로 GUID+fileID 계열 키)를 가진다. Override는 인스턴스 쪽에 저장된 ‘원본 대비 변경 목록’이다. Apply는 그 변경 목록을 원본 에셋 직렬화 데이터로 이동시키는 작업이다.

왜 이런 설계인가. 씬에 500마리 몬스터를 배치했을 때, 원본을 한 번 고치면 500개가 같이 바뀌어야 한다. 동시에 특정 3마리만 HP를 다르게 주고 싶다. 이 두 요구를 만족시키려면 ‘공유되는 원본’과 ‘개별 인스턴스의 델타’를 분리하는 방식이 가장 싸다. 메모리도 원본을 공유하고, 직렬화도 델타만 저장하면 씬 파일이 작아진다.

Prefab Variant는 원본을 상속하는 또 다른 에셋이다. Variant는 ‘씬 인스턴스’가 아니라 ‘에셋 단계에서의 델타’를 가진다. 그래서 Variant를 고치면 그 Variant를 참조하는 모든 씬 인스턴스가 바뀐다. Unpack은 링크를 끊고(원본 참조 제거) 씬 오브젝트를 독립 직렬화로 바꾼다. Unpack 이후에는 원본 변경이 절대 전파되지 않는다.

PrefabInstanceProbe.cs
1using UnityEngine;
2
3public class PrefabInstanceProbe : MonoBehaviour
4{
5    public GameObject target;
6
7    void Start()
8    {
9        if (target == null)
10        {
11            Debug.Log("target이 비어 있다. Inspector에서 씬의 Prefab 인스턴스를 넣는다.");
12            return;
13        }
14
15        Debug.Log($"name={target.name}, scene={target.scene.name}, active={target.activeSelf}");
16        var t = target.transform;
17        Debug.Log($"pos={t.position}, localScale={t.localScale}");
18    }
19}

이 스크립트는 런타임에서 ‘Prefab 링크’ 자체를 보여주지 못한다. 그 이유가 핵심이다. Prefab 연결과 Override 목록은 에디터 전용 메타데이터로 관리되고, 빌드된 Player에는 PrefabUtility 같은 API가 없다. Player는 씬 로딩 시점에 이미 합성된 결과(최종 Transform/Component 값)만 가진다. Prefab 관계를 디버깅하려면 에디터에서 확인하거나, 에셋 파이프라인 단계에서 로그를 남겨야 한다.

엔진 관점에서의 내부 동작

C#에서 GameObject/Transform/Component는 래퍼이다. 실제 데이터는 C++ 네이티브 오브젝트에 있고, C# 객체는 내부 포인터(핸들)를 통해 바인딩 호출로 접근한다. Prefab 시스템의 ‘연결’은 이 네이티브 오브젝트 레벨이 아니라, 에디터 직렬화 레벨(에셋 GUID, fileID, property path)에서 성립한다. 그래서 Play 모드에서 이미 생성된 네이티브 오브젝트를 보고는 “이게 어떤 Prefab에서 왔는지”를 원칙적으로 알 수 없다.

씬을 열 때(또는 씬을 로드할 때) Unity는 .unity YAML을 읽고 네이티브 오브젝트를 생성한다. Prefab 인스턴스인 경우, 씬 파일 안에는 ‘PrefabInstance 레코드’와 ‘원본 참조’와 ‘Override 데이터’가 함께 저장된다. 에디터는 이 정보를 이용해 Inspector에서 굵은 글씨(override 표시), Overrides 드롭다운, Apply/Revert UI를 구성한다.

Player Loop 관점에서 Prefab ‘합성’은 Update에서 매 프레임 일어나는 작업이 아니다. 에디터에서는 씬 로드/도메인 리로드/리임포트/PrefabStage 진입 같은 이벤트에서 직렬화 데이터를 다시 읽고 오브젝트를 재구성한다. 런타임(Player)에서는 씬 로딩(LoadScene) 시점에 디스크(또는 번들)에서 직렬화 데이터를 읽고 네이티브 오브젝트를 만든 뒤, Awake/OnEnable/Start는 그 다음 단계에서 호출된다. 즉, Prefab 관계를 이해하려면 Awake 시점에는 이미 값이 ‘결정’되어 있다고 보면 된다.

Override가 왜 property path 기반인가. 네이티브 오브젝트는 메모리 주소가 실행마다 바뀌고, 씬 저장/로드를 거치면 안정적인 키가 아니다. Unity 직렬화는 각 오브젝트를 fileID로 식별하고, 컴포넌트 필드를 property path(예: m_LocalPosition.x)로 가리킨다. Prefab 인스턴스의 override는 “(원본의 어떤 fileID의 어떤 property path) 값이 무엇으로 바뀌었다”라는 레코드다. 그래서 같은 Prefab을 여러 씬에 두어도 override를 안정적으로 재적용할 수 있다.

메모리 관점에서, Prefab Asset은 에디터에서 임포트된 직렬화 데이터(에셋 DB)로 존재하고, 씬 인스턴스는 네이티브 오브젝트로 존재한다. 인스턴스가 원본을 ‘참조’한다고 해서 네이티브 오브젝트 메모리를 공유하는 형태는 아니다. 공유되는 것은 직렬화 원본(기준값)과 연결 정보이고, 실제 런타임 메모리는 인스턴스마다 별도로 할당된다. ‘원본을 바꾸면 씬 인스턴스가 즉시 바뀐다’는 느낌은 에디터가 재직렬화/재적용을 수행하기 때문이다.

Apply/Revert가 왜 위험한가. Apply는 인스턴스의 override 레코드를 원본 에셋 YAML에 반영한다. 즉, Project의 .prefab 파일이 바뀌고, 그 에셋을 참조하는 모든 씬/프리팹/Variant에 전파될 수 있다. 반대로 Revert는 인스턴스 override 레코드를 삭제한다. 둘 다 ‘현재 씬의 오브젝트 상태’가 아니라 ‘직렬화 데이터 구조’를 바꾸는 작업이라서, 버전 관리(diff)와 충돌에 직접 영향을 준다.

SceneExcerpt.prefabinstance.yaml
1%YAML 1.1
2%TAG !u! tag:unity3d.com,2011:
3--- !u!1001 &100100000
4PrefabInstance:
5  m_ObjectHideFlags: 0
6  m_SourcePrefab: {fileID: 100100000, guid: 0123456789abcdef0123456789abcdef, type: 3}
7  m_Modification:
8    m_Modifications:
9    - target: {fileID: 400000, guid: 0123456789abcdef0123456789abcdef, type: 3}
10      propertyPath: m_LocalScale.x
11      value: 2
12      objectReference: {fileID: 0}
13    - target: {fileID: 400000, guid: 0123456789abcdef0123456789abcdef, type: 3}
14      propertyPath: m_LocalScale.y
15      value: 2
16      objectReference: {fileID: 0}

씬 파일을 텍스트 직렬화로 바꾸면(Prefab/Scene YAML) Override의 실체가 보인다. m_Modification 아래에 propertyPath와 value가 쌓인다. 굵은 글씨로 보이는 필드가 여기 한 줄로 대응된다. 3시간 삽질 끝에 알아낸 건, “Inspector에서 뭔가 이상하게 안 돌아온다”는 문제의 상당수가 이 m_Modifications가 예상보다 많이 쌓이면서 발생한다는 점이었다. 특히 스크립트에서 ExecuteInEditMode로 값을 건드리면, 의도치 않게 override가 자동 생성된다.

PlayerLoopOrderMarker.cs
1using UnityEngine;
2
3public class PlayerLoopOrderMarker : MonoBehaviour
4{
5    void Awake()  { Debug.Log($"{name} Awake frame={Time.frameCount}"); }
6    void OnEnable(){ Debug.Log($"{name} OnEnable frame={Time.frameCount}"); }
7    void Start()  { Debug.Log($"{name} Start frame={Time.frameCount}"); }
8    void Update() { if (Time.frameCount < 3) Debug.Log($"{name} Update frame={Time.frameCount}"); }
9}

이 로그를 Prefab 원본과 인스턴스에 각각 붙여서 비교하면, Prefab 관계는 호출 순서에 영향을 주지 않는다는 점이 드러난다. 씬이 로드된 뒤 네이티브 오브젝트가 만들어지고, 그 다음 MonoBehaviour 메시지가 호출된다. Prefab 합성은 메시지 호출 이전에 끝난다. 그래서 Awake에서 “원본 값을 읽어서 덮어쓴다” 같은 코드는, 인스턴스 override를 다시 덮어써서 더 큰 혼란을 만든다.

한 문단 요약: Prefab 인스턴스는 ‘원본 에셋’과 ‘인스턴스 override 목록’을 직렬화 단계에서 합성한 결과로 씬에 존재한다. Apply/Revert/Unpack은 네이티브 런타임 동작이 아니라, 이 직렬화 구조(에셋 YAML, 씬 YAML, propertyPath)를 바꾸는 작업이다.

실습하기

1단계: 프로젝트와 직렬화 설정

Unity 2022.3 LTS, 템플릿은 3D(Core)로 만든다. 프로젝트 생성 후 Edit → Project Settings → Editor에서 Asset Serialization을 Force Text로 바꾼다. Version Control Mode는 Visible Meta Files로 둔다. 이렇게 해야 Prefab/Scene YAML diff가 보이고, Apply가 실제로 무엇을 바꾸는지 Git에서 확인 가능하다.

Project 창에서 Assets 폴더 우클릭 → Create → Folder로 PrefabLab 폴더를 만든다. 씬도 하나 만든다: File → New Scene → Basic(Built-in) 선택 후 File → Save As…로 PrefabLab.unity로 저장한다. 이 상태에서 Console을 비워둔다(Window → General → Console → Clear).

workflow-notes.sh
1# Git을 쓴다면 커밋 단위를 쪼갠다
2# 1) Force Text / Meta Files 설정 변경 커밋
3# 2) Prefab 생성 커밋
4# 3) Override 생성 커밋
5# 4) Apply/Revert/Unpack 각각 커밋
6
7git status

커밋을 쪼개면 Apply 한 번이 어떤 파일을 바꾸는지 바로 드러난다. 씬(.unity)만 바뀌었는지, 프리팹(.prefab)도 바뀌었는지, 그리고 meta까지 바뀌었는지 확인이 가능하다. Prefab 사고의 절반은 ‘내가 지금 에셋을 바꾸는 중인지 씬을 바꾸는 중인지’가 흐려지면서 터진다.

2단계: 씬 구성과 Prefab 만들기

Hierarchy 우클릭 → 3D Object → Cube로 큐브를 만든다. 이름을 Enemy로 바꾼다. Inspector에서 Transform을 초기화한다(Position 0,0,0 / Rotation 0,0,0 / Scale 1,1,1). Material은 기본값 그대로 둔다.

Enemy를 Project의 Assets/PrefabLab 폴더로 드래그해서 Enemy.prefab을 만든다. 그 다음 Hierarchy의 Enemy를 Ctrl+D로 2개 복제해서 Enemy (1), Enemy (2)를 만든다. Enemy (1)의 Scale을 2,2,2로 바꾼다. Enemy (2)는 Position을 3,0,0으로 바꾼다. 이 시점에서 Inspector의 Transform 일부가 굵게 표시된다.

EnemyLabel.cs
1using UnityEngine;
2
3public class EnemyLabel : MonoBehaviour
4{
5    public string label;
6
7    void OnDrawGizmos()
8    {
9        Gizmos.color = Color.yellow;
10        Gizmos.DrawWireSphere(transform.position, 0.2f);
11#if UNITY_EDITOR
12        UnityEditor.Handles.Label(transform.position + Vector3.up * 0.5f, $"{name} label={label} scale={transform.localScale}");
13#endif
14    }
15}

이 스크립트를 Assets/PrefabLab에 만들고, Enemy.prefab 원본을 더블클릭해서 Prefab Mode로 연 뒤 Add Component로 EnemyLabel을 추가한다. Inspector에서 label을 "base"로 입력한다. 씬으로 돌아오면 Enemy, Enemy (1), Enemy (2) 모두 컴포넌트가 붙는다. Game 뷰가 아니라 Scene 뷰에서 노란 와이어 구와 라벨이 보인다.

3단계: Override/Apply/Revert/Unpack을 손으로 밟기

Enemy (1)을 선택하고 EnemyLabel.label을 "big"로 바꾼다. Inspector에서 해당 필드가 굵게 표시되고, 상단 Overrides 드롭다운에 1개 변경이 잡힌다. 여기서 Apply All을 누르면 Project의 Enemy.prefab 자체가 바뀐다. Revert All을 누르면 Enemy (1)의 label이 다시 "base"로 돌아간다.

Enemy (2)를 선택하고 GameObject 이름을 EnemyFast로 바꾼다. 이름 변경도 override로 기록된다. Overrides 드롭다운을 열면 어떤 항목이 잡히는지 확인한다. 그 다음 EnemyFast에서 Overrides → Unpack Prefab을 누른다. Hierarchy에서 프리팹 아이콘이 사라지고, Overrides UI도 사라진다. 이후 Enemy.prefab 원본의 label을 "base2"로 바꿔도 EnemyFast는 바뀌지 않는다.

RuntimeValueSnapshot.cs
1using UnityEngine;
2
3public class RuntimeValueSnapshot : MonoBehaviour
4{
5    public EnemyLabel[] enemies;
6
7    void Awake()
8    {
9        if (enemies == null || enemies.Length == 0)
10        {
11            enemies = FindObjectsByType<EnemyLabel>(FindObjectsSortMode.None);
12        }
13
14        foreach (var e in enemies)
15        {
16            Debug.Log($"Awake snapshot: {e.name} label={e.label} scale={e.transform.localScale}");
17        }
18    }
19}

빈 GameObject를 만든다: Hierarchy 우클릭 → Create Empty, 이름을 Lab로 한다. Lab에 RuntimeValueSnapshot을 붙인다. Play를 누르면 Console에 3개 오브젝트의 label/scale이 찍힌다. 여기서 확인되는 값은 ‘Prefab 합성 결과’이며, 런타임에서는 Apply/Revert/Override 같은 개념이 더 이상 존재하지 않는다. 값만 존재한다.

심화 활용

패턴 1: 프리팹은 구조(Topology), 인스턴스는 데이터(Tuning)로 나누기

실무에서 가장 덜 깨지는 규칙은 ‘프리팹은 컴포넌트 구성과 참조 구조를 담당’하고, ‘인스턴스는 수치 튜닝만 담당’하는 분리다. 구조를 인스턴스에서 바꾸기 시작하면 override가 폭발한다. 예를 들어 인스턴스에서 Child를 추가/삭제하고, 컴포넌트를 붙였다 떼고, 참조를 갈아끼우면 propertyPath가 길어지고 깨질 확률이 올라간다.

왜 깨지나. override는 propertyPath와 fileID에 매달린다. 원본 프리팹에서 계층 구조가 바뀌면(Child 순서, 이름, 컴포넌트 추가/삭제) fileID 매핑이 바뀌거나 propertyPath가 더 이상 유효하지 않을 수 있다. 이때 에디터는 override를 적용할 수 없어서 일부가 유실되거나, 다른 대상에 잘못 붙는 것처럼 보이는 상황이 생긴다.

TuningOnly.cs
1using UnityEngine;
2
3public class TuningOnly : MonoBehaviour
4{
5    [Header("인스턴스에서만 바꾸는 값")]
6    public float hp = 10;
7    public float moveSpeed = 3;
8
9    [Header("프리팹에서만 바꾸는 값")]
10    public Animator animator;
11    public Rigidbody rb;
12
13    void Reset()
14    {
15        rb = GetComponent<Rigidbody>();
16        animator = GetComponentInChildren<Animator>();
17    }
18}

Reset은 에디터에서 컴포넌트를 붙일 때 한 번 돌고(또는 컨텍스트 메뉴 Reset), 참조를 자동 채운다. 이 참조는 프리팹 원본에서 채워두는 쪽이 안전하다. 인스턴스에서 참조를 바꾸면 override로 기록되고, 원본 구조 변경에 취약해진다. hp/moveSpeed 같은 수치는 인스턴스 override로 남겨도 구조 변경과 독립이라서 덜 깨진다.

패턴 2: Variant로 ‘제품군’ 만들고 씬 override를 최소화하기

몬스터 20종이 같은 골격을 쓰는 상황에서, 씬 인스턴스 override로만 커버하려고 하면 씬마다 override가 쌓인다. 이때 Variant를 쓰면 ‘종류별 기본값’을 에셋 레벨 델타로 고정할 수 있다. 씬은 배치와 개별 튜닝만 남기고, 기본 성격은 Variant가 책임진다.

Variant는 Apply의 전파 범위가 명확하다. Base.prefab을 고치면 Base를 상속한 모든 Variant와 그 인스턴스가 영향을 받는다. Variant를 고치면 그 Variant 인스턴스만 영향을 받는다. “이 변경이 어디까지 퍼지나”를 에셋 트리로 추적 가능해진다.

VariantIdentity.cs
1using UnityEngine;
2
3public class VariantIdentity : MonoBehaviour
4{
5    public string variantName;
6    public Color gizmoColor = Color.cyan;
7
8    void OnDrawGizmosSelected()
9    {
10        Gizmos.color = gizmoColor;
11        Gizmos.DrawCube(transform.position + Vector3.up * 1.0f, Vector3.one * 0.3f);
12    }
13}

Base.prefab에 VariantIdentity를 붙이고 variantName을 "Base"로 둔다. Project 창에서 Base.prefab 우클릭 → Create → Prefab Variant로 FireVariant.prefab을 만든다. FireVariant를 Prefab Mode로 열고 variantName을 "Fire"로, gizmoColor를 빨강으로 바꾼다. 씬에 Base 인스턴스와 FireVariant 인스턴스를 같이 두면, 선택 시 기즈모 색이 다르게 나온다. 이 차이는 씬 override가 아니라 에셋 단계 델타이다.

내 흑역사 1: Apply 눌렀는데 QA 빌드에서 몬스터가 전부 투명해짐

처음에 나도 이게 왜 이렇게 동작하는지 몰랐다. 씬에서 특정 몬스터만 반투명 머티리얼로 바꿔서 연출을 만들었고, Overrides 창에서 Apply를 눌렀다. 그 뒤 다른 씬들까지 몬스터가 전부 반투명해졌다. Git diff를 열어보니 Enemy.prefab의 Renderer 머티리얼 참조가 통째로 바뀌어 있었다.

원인은 ‘인스턴스에서 바꾼 머티리얼’이 override였고, Apply가 그 override를 원본 에셋 YAML로 옮겼기 때문이다. 씬의 연출용 변경이 에셋 공통 변경으로 승격된 셈이다. 더 악질인 점은, 씬에서만 바뀐 줄 알고 QA에 전달했다가 다른 씬을 열어보고 나서야 문제를 발견했다.

해결은 두 갈래였다. (1) 연출용 머티리얼 변경은 인스턴스에서 하지 않고, 별도 연출 프리팹/Variant로 분리했다. (2) Apply 전에는 Overrides 드롭다운에서 변경 목록을 하나씩 확인하고, Apply Selected로 필요한 것만 올렸다. 머티리얼/스프라이트/프리팹 참조 같은 에셋 레퍼런스 override는 특히 경계 대상으로 분류했다.

내 흑역사 2: ExecuteAlways로 자동 정렬하다가 override 500개 생성

“씬 열 때마다 오브젝트 정렬”을 원해서 ExecuteAlways 스크립트로 Transform을 수정했다. 어느 날부터 프리팹 인스턴스의 Overrides가 끝도 없이 늘어났고, Prefab Mode에서 원본을 고치면 인스턴스가 예상대로 안 따라왔다. Console에는 에러가 없어서 더 오래 걸렸다.

원인은 에디터 상태에서 Transform을 만지는 코드가 인스턴스의 값을 계속 바꾸면서 override를 자동 생성한 것이다. override는 ‘사용자가 의도적으로 바꿨다’만 저장하는 게 아니라, 결과적으로 값이 달라지면 기록된다. 3시간 삽질 끝에 알아낸 건, “에디터에서 자동으로 값을 만지는 코드”는 Prefab override 시스템과 정면충돌한다는 점이었다.

해결은 OnValidate에서 최소 변경만 하도록 조건을 걸고, Prefab Mode에서는 동작하지 않게 막았다. 또, 정렬 결과를 원본에 적용하려면 씬 인스턴스에서 Apply가 아니라 Prefab Mode에서 직접 정렬을 수행해야 했다. Prefab Stage 여부를 체크해서 인스턴스 오염을 줄였다.

자주 하는 실수

실수 1: 씬 인스턴스에서 Apply All을 습관처럼 누름

증상: 특정 씬에서만 바뀌어야 할 값(머티리얼, 스프라이트, 레이어, 태그, 스크립트 참조)이 다른 씬까지 전파된다. 다음 날 동료가 “왜 모든 프리팹이 바뀌었냐”라고 묻는다. Git diff에서 .prefab 파일이 대량 변경된다.

원인: Apply는 인스턴스의 override를 원본 에셋 직렬화로 승격한다. 에셋은 공유 자원이라서, 참조하는 모든 씬/프리팹에 영향을 준다. Inspector에서 변경한 순간에는 ‘씬 오브젝트를 편집 중’처럼 보이지만, Apply는 ‘에셋 편집’이다.

해결: Overrides 드롭다운에서 변경 목록을 펼쳐서 Apply Selected만 사용한다. 에셋 레퍼런스(머티리얼/프리팹/오디오클립) 변경은 별도 Variant나 연출 프리팹으로 분리한다. 적용 범위를 확신 못 하면 Apply 대신 Revert 후 다른 방식으로 구현한다.

실수 2: Unpack을 해놓고 원본 수정이 안 먹는다고 착각

증상: 원본 Prefab을 고쳤는데 씬에 배치된 오브젝트가 그대로다. Inspector 상단에 Prefab 표시(파란 큐브 아이콘)가 없고, Overrides 메뉴도 없다. 팀원이 “이 씬만 왜 옛날 버전이냐”라고 한다.

원인: Unpack은 Prefab 링크를 제거한다. 씬 오브젝트는 독립 직렬화가 되고, 원본과의 관계가 엔진 데이터에서 사라진다. 이후에는 원본 변경을 전파할 통로가 없다.

해결: Unpack은 정말로 독립시켜야 하는 경우에만 쓴다(레벨 아트 고정, 프로토타입 임시). 원본 수정 전파가 필요하면 Unpack 대신 Override로 유지하거나, Variant로 분기한다. 이미 Unpack된 경우에는 새 Prefab 인스턴스로 교체하는 편이 안전하다.

실수 3: 인스턴스에서 컴포넌트 구조를 바꾸고 원본 변경과 충돌

증상: 원본에 컴포넌트를 추가했는데 특정 인스턴스만 누락된다. 또는 원본에서 Child를 이동했더니 인스턴스의 override가 엉뚱한 오브젝트에 적용된 것처럼 보인다. Prefab Overrides 창에 “Missing” 류 항목이 보이기도 한다.

원인: override는 fileID와 propertyPath에 의존한다. 인스턴스에서 구조를 바꾸면(컴포넌트 추가/삭제, Child 추가/삭제) 원본 구조 변경과 합성 규칙이 복잡해진다. 직렬화 키 매핑이 깨지면 override 적용이 실패하거나 의도와 다르게 보일 수 있다.

해결: 구조 변경은 Prefab Mode에서 원본/Variant 에셋에서 처리한다. 씬 인스턴스에서는 수치 튜닝 중심으로 제한한다. 구조를 인스턴스에서 바꿔야 한다면, 그 인스턴스는 Unpack으로 고정하고 원본 전파를 포기하는 선택이 더 낫다.

실수 4: Awake/Start에서 값을 강제로 세팅해서 override를 무력화

증상: Inspector에서 scale을 바꿨는데 Play를 누르면 다시 1,1,1로 돌아간다. “Prefab이 override를 저장 안 한다”라고 오해한다. Console에는 별 로그가 없다.

원인: Prefab override는 씬 로드 직렬화 단계에서 값이 이미 반영된 상태로 런타임에 들어온다. 그런데 Awake/Start에서 코드가 값을 다시 덮어쓰면, 에디터에서 설정한 override가 런타임에 의미가 없어진다. Player Loop 상 Awake는 초기화 타이밍이라 덮어쓰기가 자주 발생한다.

해결: 런타임 초기화는 ‘Inspector 값을 기본으로 사용’하도록 설계한다. 코드에서 기본값을 강제할 필요가 있으면, 그 값이 디자이너 튜닝 대상인지부터 결정한다. 튜닝 대상이면 SerializeField로 노출하고, 코드에서는 비어 있을 때만 채우는 방식으로 바꾼다.

실수 5: 에디터 자동화(ExecuteAlways/OnValidate)로 override를 무한 생성

증상: Prefab 인스턴스 하나를 클릭했는데 Overrides가 수십 개다. 씬 저장할 때마다 .unity 파일 diff가 커진다. 어떤 날은 Inspector가 느려지고, Undo도 버벅인다.

원인: 에디터 상태에서 Transform/SerializedField 값을 코드로 계속 변경하면, 그 변경이 인스턴스의 override로 기록된다. 특히 OnValidate는 값이 바뀔 때마다 호출되고, ExecuteAlways는 씬 로드/리컴파일 등 다양한 이벤트에서 다시 돈다. 의도치 않은 propertyPath 변경이 누적된다.

해결: OnValidate에서는 값이 이미 원하는 상태인지 비교해서 불필요한 대입을 막는다. Prefab Mode(Prefab Stage)에서는 동작하지 않게 조건을 건다. 자동화가 꼭 필요하면, 인스턴스가 아니라 원본 에셋을 대상으로 동작하도록 에디터 툴(PrefabUtility 기반)로 분리한다.

EditModeGuardExample.cs
1using UnityEngine;
2
3[ExecuteAlways]
4public class EditModeGuardExample : MonoBehaviour
5{
6    public bool autoSnap;
7
8    void OnValidate()
9    {
10        if (!autoSnap) return;
11        if (Application.isPlaying) return;
12
13        var p = transform.position;
14        var snapped = new Vector3(Mathf.Round(p.x), Mathf.Round(p.y), Mathf.Round(p.z));
15        if (snapped == p) return;
16
17        transform.position = snapped;
18        Debug.Log($"OnValidate snapped: {name} -> {snapped}");
19    }
20}

이 코드를 그대로 쓰면 여전히 override를 만든다. 다만 ‘같은 값 재대입’을 막아서 폭발을 줄인다. Prefab 인스턴스를 오염시키지 않으려면, 스냅 같은 작업은 버튼을 눌렀을 때만 실행되는 에디터 툴로 옮기는 편이 낫다. 자동으로 돌아가는 코드일수록 override 시스템과 충돌한다.

성능 최적화 체크리스트

  • Project Settings → Editor에서 Force Text + Visible Meta Files로 YAML diff 가능 상태를 만든다
  • Overrides 드롭다운에서 Apply All 대신 Apply Selected를 기본 습관으로 둔다
  • 머티리얼/스프라이트/프리팹/오디오클립 같은 에셋 레퍼런스 override는 전파 범위를 먼저 계산한다
  • 씬 인스턴스에서 컴포넌트 추가/삭제, Child 구조 변경을 최소화하고 구조는 Prefab Mode에서 처리한다
  • Unpack을 쓰면 원본 전파가 끊긴다는 점을 작업 티켓에 명시한다
  • Variant로 제품군(예: Fire/Ice/Elite) 기본값을 에셋 레벨에서 관리하고 씬 override를 줄인다
  • OnValidate/ExecuteAlways가 Transform이나 SerializedField를 바꾸면 override가 생성된다는 전제를 둔다
  • Profiler에서 EditorLoop가 느려지면(Inspector/Undo 지연) override 폭발과 YAML diff 크기를 같이 확인한다
  • 런타임 초기화(Awake/Start)에서 Inspector 값을 덮어쓰는 코드가 있는지 검색한다
  • 씬 저장 전 Console에 자동화 스크립트 로그가 쌓이는지 확인하고, 불필요한 대입을 제거한다
  • 대량 Apply 작업 전 Git에서 .prefab/.unity 파일 변경 목록을 먼저 확인한다
  • 팀 규칙으로 ‘연출용 변경은 별도 Prefab/Variant로 분리’ 원칙을 둔다

자주 묻는 질문

Prefab 인스턴스의 Override는 런타임에도 남아 있나?

남지 않는다. Player(빌드)에는 PrefabUtility 같은 에디터 전용 시스템이 포함되지 않고, 씬 로딩 시점에 직렬화 데이터가 네이티브 오브젝트로 풀리면서 최종 값만 남는다. Prefab 인스턴스가 원본을 참조한다는 개념은 ‘에디터가 씬 YAML과 프리팹 YAML을 합성하는 규칙’에 가깝다. 그래서 Play 모드에서 볼 수 있는 것은 Transform/Component의 현재 값뿐이고, 그 값이 원본에서 왔는지 인스턴스에서 override됐는지는 원칙적으로 추적 불가하다. 다음 학습 키워드는 Unity serialization, PrefabUtility, build stripping이다.

왜 원본 Prefab을 고쳤는데 특정 인스턴스는 안 바뀌나?

대부분 그 필드가 인스턴스에서 이미 override됐기 때문이다. Prefab 합성 규칙은 ‘원본 값 적용 → 인스턴스 override 재적용’ 순서로 이해하면 된다. 인스턴스 override가 존재하면, 원본에서 같은 propertyPath를 바꿔도 마지막에 override가 다시 덮는다. Inspector에서 굵게 표시되는 항목이 그 신호다. 해결은 Overrides 드롭다운에서 해당 항목을 Revert하거나, 원본 변경이 맞는지(Apply가 필요한지) 판단하는 것이다. 다음 학습 키워드는 propertyPath, modification list, revert semantics이다.

Apply는 정확히 어떤 파일을 바꾸나? 씬이 바뀌는 건가 프리팹이 바뀌는 건가?

Apply는 프리팹 에셋(.prefab)을 바꾼다. 인스턴스에서 생긴 m_Modifications(override 목록)을 원본 YAML의 해당 오브젝트/컴포넌트 필드로 반영한다. 그래서 Git diff에서 .prefab 파일이 바뀌고, 그 프리팹을 참조하는 다른 씬들도 다음 로드/리임포트 시점에 영향을 받는다. 반대로 씬에서 단순히 값을 바꾸기만 하면 .unity 파일에 override가 기록된다. ‘지금 편집이 씬에 남는가, 에셋에 남는가’를 구분하는 것이 Apply 사고를 막는 핵심이다. 다음 학습 키워드는 Force Text YAML diff, asset database, prefab import pipeline이다.

Unpack Prefab을 하면 왜 되돌릴 수 없다는 느낌이 드나?

Unpack은 링크를 끊는 작업이라서, 이후에는 원본 에셋 GUID/fileID 기반의 연결 정보가 씬 오브젝트에서 제거된다. 되돌린다는 것은 다시 원본과 동일한 구조/값을 가진 오브젝트를 만들어서 링크를 재설정하는 것인데, 이미 인스턴스가 원본과 달라졌다면 자동으로 완벽히 복원하기 어렵다. 에디터의 Undo로 직후에는 복구 가능하지만, 저장/재시작/협업 상황에서는 사실상 ‘독립 오브젝트로 확정’에 가깝다. Unpack은 레벨에 박제해야 하는 오브젝트에만 쓰는 편이 안전하다. 다음 학습 키워드는 prefab connection, unpack modes, scene serialization이다.

Prefab Variant를 쓰면 씬 override가 줄어드는 이유가 뭔가?

Variant는 ‘씬 인스턴스의 델타’가 아니라 ‘에셋 단계의 델타’를 가진다. FireVariant.prefab이 Base.prefab을 상속하면, FireVariant 자체가 Base 대비 변경 목록을 가진 하나의 에셋이 된다. 씬에서는 FireVariant의 인스턴스를 배치하므로, 씬 override는 “FireVariant 인스턴스에서만 필요한 추가 튜닝”으로 좁아진다. 전파 범위도 명확해져서 Base 변경은 전체, Variant 변경은 그 계열만, 씬 override는 그 한 개만 영향을 준다. 다음 학습 키워드는 prefab inheritance, variant override, propagation graph이다.

Prefab 인스턴스에서 컴포넌트를 추가했는데 왜 원본 수정이 꼬이나?

override는 안정적인 키(fileID, propertyPath)에 매달리는데, 컴포넌트 추가/삭제와 같은 구조 변경은 이 키 매핑을 흔든다. 원본 프리팹에서 컴포넌트 순서가 바뀌거나, Child가 이동하면 fileID가 재배치되는 경우가 있고, 그 결과 인스턴스의 modification이 더 이상 정확히 같은 대상을 가리키지 못한다. 에디터는 최대한 매칭하려 하지만 100% 보장되지 않는다. 구조는 원본/Variant에서 고정하고, 인스턴스에서는 수치만 바꾸는 규칙이 여기서 나온다. 다음 학습 키워드는 fileID stability, prefab merge, missing modifications이다.

왜 Play 모드에서 Prefab 관계를 코드로 확인하기 어렵나?

Prefab 관계는 에디터의 직렬화/에셋 파이프라인 시스템이 관리하는 메타데이터이고, 런타임 네이티브 오브젝트에는 ‘이 값이 원본에서 왔다’라는 태그가 남지 않는다. C# 래퍼는 네이티브 오브젝트의 현재 상태를 읽을 뿐이고, 그 상태가 어떤 직렬화 합성 과정을 거쳤는지까지는 보존하지 않는다. 빌드 크기와 성능 측면에서도 런타임에 Prefab 편집 기능은 필요 없어서 대부분 에디터 전용으로 분리됐다. 런타임에서 유사 기능이 필요하면 Addressables/Instantiate 시점에 별도 메타를 붙이는 방식으로 설계해야 한다. 다음 학습 키워드는 editor-only API, native object lifecycle, Addressables instantiate hooks이다.

관련 글

27. Prefab 인스턴스와 원본의 관계: Override, Apply, Variant까지

27. Prefab 인스턴스와 원본의 관계: Override, Apply, Variant까지

Prefab 원본과 인스턴스가 왜 연결되고 어디서 끊기는지, Override/Apply/Unpack/Variant를 엔진 직렬화·네이티브 오브젝트 관점에서 설명한다. 실습과 디버깅 포인트 포함. 2022 LTS 기준. (150자)‏‏‎‎‏‏‎‎‏‏‎‎‏‏‎‎‏‏‎‎‏‏‎‎‏‏‎

28. Prefab Variant로 공통 설정 유지하며 개별 오버라이드 적용하기

28. Prefab Variant로 공통 설정 유지하며 개별 오버라이드 적용하기

Prefab Variant로 공통 프리팹 설정을 유지하면서 개별 오버라이드를 적용하는 방법을 엔진 직렬화, 오버라이드 저장 구조, Player Loop 관점에서 설명한다. 실습 포함. 154자 내외 조정됨: Prefab Variant로 공통 설정을 유지하면서 개별 오버라이드를 적용하는 방법을 엔진 직렬화와 오버라이드 저장

29. Prefab Variant로 공통 설정 유지하며 파생 프리팹 운영하기

29. Prefab Variant로 공통 설정 유지하며 파생 프리팹 운영하기

Unity Prefab Variant를 엔진 직렬화·오버라이드 관점에서 설명하고, 캐릭터/아이템 파생 프리팹을 공통 설정 유지하며 운영하는 실습과 성능 함정을 다룬다. 2022 LTS 기준 실무 패턴 포함.