Unity Attribute 핵심 정리

Unity에서 C# 코드를 작성하다 보면 클래스나 필드, 메서드 위에 [Something] 형태의 코드를 붙이는 경우가 많다.

[SerializeField]
private int health;
[RequireComponent(typeof(Rigidbody))]
public class Player : MonoBehaviour
{
}

이런 문법을 Attribute라고 한다.

Attribute는 코드 자체의 실행 로직을 직접 구현하는 것이 아니라, Unity나 C#에게 특정 메타데이터를 전달해 추가적인 동작을 유도하는 기능이다.

Unity에서는 특히 다음 영역에서 많이 사용한다.

Inspector 표시 제어 -> 직렬화 제어 -> 컴포넌트 제약 -> 에디터 동작 제어 -> Asset 생성 및 관리

Unity Attribute를 제대로 사용하면 코드의 기능을 늘리는 것보다 Inspector와 코드의 경계를 깔끔하게 설계할 수 있다.


Attribute란

C# Attribute는 클래스, 필드, 메서드 등에 메타데이터를 붙이는 문법이다.

[Attribute]
public class Example
{
}

Unity에서는 이를 이용해 Inspector의 표시 방법을 변경하거나, 컴포넌트 의존성을 지정하거나, 에디터에서 특정 기능을 실행하도록 만들 수 있다.

대표적으로 다음과 같은 형태가 있다.

[SerializeField]
private int health;
[Header("Player Settings")]
[SerializeField]
private float moveSpeed;
[RequireComponent(typeof(Rigidbody))]
public class Player : MonoBehaviour
{
}

Attribute 자체가 게임 로직을 수행하는 것은 아니다.

Attribute -> Unity가 메타데이터 해석 -> Inspector / Serialization / Editor / Component System 등에 반영

이 구조로 이해하면 된다.


Inspector와 직렬화

Unity Attribute를 이해하려면 먼저 Inspector와 Serialization의 관계를 알아야 한다.

Unity는 Scene, Prefab, Asset 등에 객체의 상태를 저장하기 위해 자체 직렬화 시스템을 사용한다. SerializeField는 private 필드를 Unity의 직렬화 대상으로 만들기 위한 대표적인 Attribute다.

public int publicValue;

private int privateValue;

[SerializeField]
private int serializedValue;

개념적으로 보면 다음과 같다.

public 필드 -> 기본적으로 직렬화 가능

private 필드 -> 기본적으로 Inspector에 노출되지 않음

private + SerializeField -> Unity 직렬화 및 Inspector 노출

다만 Inspector에 보인다는 것과 직렬화된다는 것은 완전히 같은 개념은 아니다.

Unity의 Serialization System 자체를 이해하는 것이 중요하다.


Serializable

[System.Serializable]은 사용자 정의 클래스나 구조체를 Unity가 직렬화할 수 있도록 지정한다.

[System.Serializable]
public class PlayerStats
{
    public int health;
    public int attack;
    public int defense;
}

그리고 이를 MonoBehaviour에서 사용한다.

public class Player : MonoBehaviour
{
    public PlayerStats stats;
}

그러면 Inspector에서 PlayerStats 내부 필드를 편집할 수 있다.

Player
  Stats
    Health
    Attack
    Defense

핵심은 Serializableprivate 필드를 Inspector에 보여주는 Attribute가 아니라 사용자 정의 타입을 Unity Serialization 대상으로 만드는 역할이라는 점이다.


SerializeField

[SerializeField]은 private 필드를 Unity가 직렬화하도록 만든다.

[SerializeField]
private int health;

가장 실무에서 많이 사용하는 Attribute 중 하나다.

특히 다음과 같은 코드에서 유용하다.

[SerializeField]
private float moveSpeed = 5f;

외부에서는 직접 변경하지 못하도록 private으로 유지하면서 Inspector에서는 값을 설정할 수 있다.

Code
  private

Inspector
  Editable

게임 개발에서는 Inspector에서 설정할 데이터와 외부에서 접근할 API를 분리하기 위해 매우 자주 사용한다.

예를 들어 다음과 같이 작성할 수 있다.

[SerializeField]
private int health;

public int Health => health;

외부에서는 값을 읽을 수 있지만 직접 변경할 수 없다.


SerializeReference

Unity의 Serialization에서 한 단계 더 들어가면 [SerializeReference]가 등장한다.

[SerializeReference]
private Effect effect;

이 Attribute는 일반적인 값 기반 직렬화가 아니라 managed reference 방식으로 직렬화하도록 한다. 특히 사용자 정의 클래스의 다형성, 동일 인스턴스에 대한 여러 참조, null 표현 등에 사용할 수 있다.

예를 들어 다음과 같은 구조가 있다고 하자.

[System.Serializable]
public abstract class Effect
{
    public abstract void Execute();
}

[System.Serializable]
public class DamageEffect : Effect
{
    public int damage;

    public override void Execute()
    {
    }
}

[System.Serializable]
public class HealEffect : Effect
{
    public int amount;

    public override void Execute()
    {
    }
}

그리고:

[SerializeReference]
private Effect effect;

이렇게 하면 Effect라는 부모 타입의 필드에 DamageEffect, HealEffect 같은 파생 타입을 저장할 수 있다.

SerializeField -> 일반적인 직렬화

SerializeReference -> Managed Reference 기반 직렬화 및 다형성

다만 SerializeReference가 항상 더 좋은 것은 아니다. Unity 공식 문서에서도 일반적인 값 기반 직렬화가 저장 공간과 메모리, 로딩 및 저장 시간 측면에서 더 효율적이므로 필요한 경우에만 사용하도록 설명한다.


HideInInspector

[HideInInspector]은 public 필드이지만 Inspector에서 숨기고 싶을 때 사용한다.

[HideInInspector]
public int runtimeValue;

코드에서는 public이지만 Inspector에는 표시되지 않는다.

Code
  Public

Inspector
  Hidden

다만 단순히 Inspector에서 숨기는 것과 직렬화에서 제외하는 것은 다르다.

필드의 직렬화 자체를 제어하고 싶다면 NonSerialized 등을 고려해야 한다.


NonSerialized

[System.NonSerialized]는 해당 필드를 직렬화하지 않도록 지정한다.

[System.NonSerialized]
public int runtimeValue;

런타임에서만 사용하는 상태값 등에 사용할 수 있다.

예를 들어:

[SerializeField]
private int maxHealth;

[System.NonSerialized]
public int currentHealth;

maxHealth -> 설정 데이터

currentHealth -> 런타임 상태

처럼 역할을 분리할 수 있다.

중요한 것은 Inspector에서 숨기는 것과 Serialization에서 제외하는 것은 목적이 다르다는 것이다.


FormerlySerializedAs

실무에서 의외로 중요한 Attribute다.

필드 이름을 변경하면 기존 Scene이나 Prefab에 저장되어 있던 데이터가 해당 필드와 연결되지 않는 문제가 발생할 수 있다.

예를 들어 기존 코드가:

[SerializeField]
private int hitpoints;

였다고 하자.

이를:

[SerializeField]
private int health;

로 변경하면 기존 hitpoints 데이터와 새로운 health 필드 사이의 연결이 끊길 수 있다.

이때:

using UnityEngine.Serialization;

[FormerlySerializedAs("hitpoints")]
[SerializeField]
private int health;

를 사용한다.

Unity는 기존 필드명이 hitpoints였다는 정보를 이용해 기존 직렬화 데이터를 새로운 health 필드로 연결한다.

즉:

hitpoints -> health

처럼 필드 이름을 변경하면서 기존 데이터를 보존하기 위한 Attribute다.

대규모 프로젝트에서 변수 이름을 리팩터링할 때 특히 유용하다.


Header

[Header]는 Inspector에 구분용 제목을 표시한다.

[Header("Movement")]
[SerializeField]
private float moveSpeed;

[SerializeField]
private float jumpPower;

Inspector를 다음과 같이 구분할 수 있다.

Movement
Move Speed
Jump Power

필드가 많아질수록 효과가 크다.


Space

[Space]는 Inspector에서 필드 사이에 간격을 추가한다.

[SerializeField]
private float moveSpeed;

[Space(10)]

[SerializeField]
private float jumpPower;

Header그룹의 의미를 표시하는 용도라면 Space시각적인 간격을 만드는 용도다.


Tooltip

[Tooltip]은 Inspector에서 필드에 마우스를 올렸을 때 설명을 표시한다.

[Tooltip("플레이어의 이동 속도")]
[SerializeField]
private float moveSpeed;

Inspector에서 변수의 의미를 바로 확인할 수 있다.

특히 협업 프로젝트에서 유용하다.

변수명이 명확하지 않거나 값의 단위가 중요한 경우:

[Tooltip("초당 이동 거리")]
[SerializeField]
private float moveSpeed;

처럼 작성할 수 있다.


Range

[Range]는 숫자 값을 Inspector에서 슬라이더 형태로 편집할 수 있도록 한다.

[Range(0f, 1f)]
[SerializeField]
private float volume;

Inspector에서 0 ~ 1 사이의 값을 슬라이더로 조절할 수 있다.

[Range(0, 100)]
[SerializeField]
private int health;

int와 float 모두 사용할 수 있다.

다만 Range는 단순히 Inspector 편집 방식을 제한하는 용도다.

런타임 코드에서 반드시 해당 범위를 보장하는 것은 아니다.

필드 자체의 최소값을 명시하는 목적이라면 Min을 사용하는 것이 더 적합하다.


Min

[Min]은 Inspector에서 입력할 수 있는 최소값을 지정한다.

[Min(0)]
[SerializeField]
private int health;

음수 값을 허용하지 않는 값이라면 유용하다.

Range -> 최소값 + 최대값

Min -> 최소값

이라는 차이로 이해하면 된다.

예를 들어:

[Range(0f, 1f)]
private float normalizedValue;

[Min(0f)]
private float moveSpeed;

처럼 사용할 수 있다.


Multiline

[Multiline]은 문자열 필드를 여러 줄로 입력할 수 있게 한다.

[Multiline(5)]
[SerializeField]
private string description;

설명문이나 긴 문자열을 Inspector에서 편집할 때 사용할 수 있다.

다만 긴 텍스트를 체계적으로 관리해야 한다면 별도의 데이터 구조나 ScriptableObject를 사용하는 것이 더 적합할 수 있다.


TextArea

문자열을 Inspector에서 여러 줄로 입력해야 한다면 TextArea도 사용할 수 있다.

[TextArea(3, 10)]
[SerializeField]
private string description;

Multiline과 비슷하지만 텍스트 입력 영역을 만들기 위한 용도로 사용한다.

게임의 대사, 설명문, 퀘스트 텍스트처럼 긴 문자열을 Inspector에서 관리할 때 적합하다.


RequireComponent

[RequireComponent]는 특정 Component에 대한 의존성을 선언한다.

[RequireComponent(typeof(Rigidbody))]
public class Player : MonoBehaviour
{
}

이 스크립트를 GameObject에 추가할 때 필요한 Rigidbody가 함께 추가된다. Unity 6에서도 동일한 방식으로 지원된다.

예를 들어:

[RequireComponent(typeof(CharacterController))]
public class PlayerController : MonoBehaviour
{
}

라면 PlayerControllerCharacterController에 의존한다는 사실을 코드에 명시할 수 있다.

이는 단순한 편의 기능이 아니라 컴포넌트 간 의존성을 코드 수준에서 표현하는 방법이다.

주의할 점도 있다.

RequireComponent는 컴포넌트를 추가하는 시점에 의존성을 확인한다. 기존 GameObject에 이미 붙어 있는 컴포넌트에 새로운 RequireComponent를 추가했다고 해서 누락된 의존성이 자동으로 모두 보정되는 것은 아니다.


DisallowMultipleComponent

같은 GameObject에 동일한 Component가 여러 개 붙는 것을 방지하고 싶다면 DisallowMultipleComponent를 사용할 수 있다.

[DisallowMultipleComponent]
public class Player : MonoBehaviour
{
}

이런 컴포넌트는 하나의 GameObject에 중복으로 추가되지 않도록 제한할 수 있다.

RequireComponent -> 필요한 컴포넌트를 요구

DisallowMultipleComponent -> 동일 컴포넌트의 중복을 방지

컴포넌트 설계에서 자주 함께 고려할 수 있는 Attribute다.


ContextMenu

[ContextMenu]는 MonoBehaviour의 메서드를 Inspector의 컴포넌트 메뉴에서 실행할 수 있도록 한다.

[ContextMenu("Reset Data")]
private void ResetData()
{
    health = 100;
}

Inspector에서 해당 Component의 메뉴를 열고 Reset Data를 실행할 수 있다.

에디터에서 간단한 테스트나 데이터 초기화 기능을 제공할 때 유용하다.

별도의 Editor Window나 Custom Inspector를 만들 정도는 아니지만 개발자가 Inspector에서 직접 실행할 기능이 필요할 때 적합하다.


CreateAssetMenu

ScriptableObject를 사용하는 프로젝트에서는 CreateAssetMenu도 매우 유용하다.

[CreateAssetMenu(
    fileName = "PlayerData",
    menuName = "Game/Player Data"
)]
public class PlayerData : ScriptableObject
{
    public int health;
    public int attack;
}

그러면 Unity의 Asset 생성 메뉴에서 해당 ScriptableObject를 직접 생성할 수 있다.

Create
  Game
    Player Data

게임 데이터가 많아질수록 매우 유용한 패턴이다.

ScriptableObject -> 데이터 정의

CreateAssetMenu -> 해당 데이터 Asset 생성 편의성

으로 연결해서 이해하면 된다.


ExecuteAlways

[ExecuteAlways]는 일반적인 플레이 모드뿐만 아니라 Editor에서도 MonoBehaviour의 일부 콜백이 실행되도록 한다.

[ExecuteAlways]
public class Example : MonoBehaviour
{
}

에디터에서 값을 변경했을 때 즉시 오브젝트의 형태를 갱신해야 하는 툴이나 시각화 시스템 등에 사용할 수 있다.

다만 런타임 코드와 Editor 실행이 섞이기 때문에 무분별하게 사용하는 것은 좋지 않다.

특히 파일 저장, 네트워크 요청, 게임 상태 변경 같은 런타임 전용 로직이 Editor에서도 실행되지 않도록 주의해야 한다.


DefaultExecutionOrder

여러 MonoBehaviour의 실행 순서를 명시적으로 조정해야 하는 경우 DefaultExecutionOrder를 사용할 수 있다.

[DefaultExecutionOrder(-100)]
public class GameManager : MonoBehaviour
{
}

값이 낮을수록 먼저 실행되는 방향으로 사용할 수 있다.

다만 실행 순서 문제를 Attribute로 계속 해결하는 것은 좋은 설계가 아니다.

가능하다면:

명시적인 초기화 -> 의존성 주입 -> 이벤트 -> 실행 순서 의존성 제거

같은 구조를 우선 고려하는 것이 좋다.


Assembly 관련 Attribute

Unity 프로젝트가 커지면 Attribute의 사용 영역은 코드 단위를 넘어 Assembly 수준으로 확장된다.

대표적으로 InternalsVisibleTo 같은 C# Attribute를 활용하면 특정 Assembly에서 다른 Assembly의 internal 멤버에 접근하도록 구성할 수 있다.

Unity의 Assembly Definition을 사용하는 대형 프로젝트에서는 단순히 Attribute 하나를 아는 것보다 Assembly 경계를 어떻게 설계하는지가 더 중요하다.

이 영역은 Inspector Attribute와는 성격이 다르므로 별도의 C# / Assembly 설계 주제로 분리하는 편이 좋다.


실무에서 자주 사용하는 조합

Attribute는 하나씩 사용하는 것보다 여러 개를 조합할 때 가치가 커진다.

예를 들어:

[Header("Movement")]
[Tooltip("초당 이동 속도")]
[Min(0f)]
[SerializeField]
private float moveSpeed = 5f;

이렇게 작성하면 하나의 필드에 대해 다음 정보를 동시에 표현할 수 있다.

Header -> Inspector 그룹

Tooltip -> 변수 설명

Min -> 최소값

SerializeField -> private 필드 직렬화

이것이 Attribute의 실질적인 장점이다.

코드의 데이터 구조와 Inspector의 사용성을 동시에 설계할 수 있다.


자주 사용하는 Attribute 정리

Attribute목적
Serializable사용자 정의 타입 직렬화
SerializeFieldprivate 필드 직렬화 및 Inspector 노출
SerializeReferenceManaged Reference 직렬화 및 다형성
HideInInspectorInspector에서 숨김
NonSerialized직렬화 제외
FormerlySerializedAs필드 이름 변경 시 기존 데이터 보존
HeaderInspector 그룹 제목
SpaceInspector 간격
TooltipInspector 설명
Range최소~최대 범위의 슬라이더
Min최소값 지정
Multiline여러 줄 문자열 입력
TextArea긴 문자열 입력 영역
RequireComponent필수 Component 지정
DisallowMultipleComponent동일 Component 중복 방지
ContextMenuInspector 메뉴에서 메서드 실행
CreateAssetMenuScriptableObject Asset 생성 메뉴
ExecuteAlwaysEditor에서도 컴포넌트 실행
DefaultExecutionOrderMonoBehaviour 실행 순서 지정

Attribute를 목적별로 분류하면

Attribute를 단순히 암기할 필요는 없다.

목적을 기준으로 분류하면 훨씬 간단하다.

직렬화 -> Serializable / SerializeField / SerializeReference / NonSerialized / FormerlySerializedAs

Inspector 표시 -> Header / Space / Tooltip / HideInInspector / Range / Min / Multiline / TextArea

컴포넌트 제약 -> RequireComponent / DisallowMultipleComponent

Editor 실행 -> ContextMenu / ExecuteAlways / CreateAssetMenu

실행 순서 -> DefaultExecutionOrder

이렇게 분류하면 새로운 Attribute를 봤을 때도 어떤 문제를 해결하기 위한 기능인지 쉽게 판단할 수 있다.


Attribute를 남용하면 안 되는 이유

Attribute가 많다고 좋은 Inspector가 만들어지는 것은 아니다.

예를 들어:

[Header("Player")]
[Tooltip("Player Health")]
[Range(0, 100)]
[Space(10)]
[Tooltip("Current Player Health")]
[Min(0)]
[SerializeField]
private int health;

이런 식으로 의미 없이 Attribute를 계속 붙이면 오히려 Inspector가 복잡해진다.

Attribute의 목적은 코드를 꾸미는 것이 아니라 개발자가 데이터를 안전하고 명확하게 다룰 수 있도록 만드는 것이다.

특히 다음 원칙을 지키는 것이 좋다.

Inspector에 노출할 데이터 -> SerializeField

외부에서 변경하면 안 되는 데이터 -> private

런타임 상태 -> 직렬화 여부를 명확하게 결정

컴포넌트 의존성 -> RequireComponent

데이터 타입의 다형성 -> SerializeReference

리팩터링으로 필드명이 변경됨 -> FormerlySerializedAs

Inspector의 가독성 -> Header / Tooltip / Space

이 정도만 확실하게 구분해도 대부분의 실무 상황을 처리할 수 있다.


핵심

Unity Attribute는 단순한 문법 장식이 아니다.

코드와 Unity Editor 사이의 계약을 선언하는 수단에 가깝다.

C# Code -> Attribute -> Unity Serialization / Inspector / Component System / Editor

특히 Unity 프로젝트에서는 코드만 잘 작성하는 것으로 끝나지 않는다.

게임 데이터는 Inspector에서 설정되고, Scene과 Prefab에 저장되며, ScriptableObject를 통해 공유되고, 프로젝트가 커지면 리팩터링 과정에서 기존 직렬화 데이터까지 보존해야 한다.

이 과정에서 Attribute가 사용된다.

따라서 Attribute를 개별 기능으로 외우기보다 다음 네 가지 축으로 이해하는 것이 효율적이다.

Serialization -> Inspector -> Component Dependency -> Editor

이 구조를 이해하면 SerializeField부터 SerializeReference, FormerlySerializedAs, RequireComponent, CreateAssetMenu까지 각각의 Attribute가 왜 존재하는지 자연스럽게 연결된다.