■ 델리게이트(Delegate)
게임 개발을 하다 보면 “어떤 일이 일어났을 때 이 코드를 실행해 줘”라는 패턴이 굉장히 많다. 예를 들어,
private void OnTriggerEnter(Collider other)
{
if(other.Tag("Wall") == true)
{
//충돌된 오브젝트의 태그가 Wall이라면 실행...
}
}
[간단한 충돌 코드]
위 코드처럼 충돌 발생 시 실행할 코드를 작성한다. 하지만 충돌 시 실행할 동작이 상황에 따라 매번 달라져야 한다면 어떻게 될까?
public class Bullet
{
private Player _player;
private Enemy _enemy;
private Manager _manager;
private void OnTriggerEnter(Collider other)
{
if(other.CompareTag("Wall"))
{
// 이런 상황일 땐...
_player.DoSomething();
// 저런 상황일 땐...
_enemy.DoSomething();
// 요런 상황일 땐...
_manager.DoSomething();
}
}
}
[복잡해진 충돌 코드]
이처럼 Bullet이 Player, Enemy, Manager 등 모든 객체를 직접 참조하고 있으면 결합도가 높아진다. 새로운 클래스가 추가될 때마다 Bullet 코드를 수정해야 하고, 어떤 상황에 어떤 메서드가 호출되는지 한눈에 파악하기도 어려워진다.
이런 문제를 해결하기 위해 등장한 것이 바로 델리게이트(Delegate)이다.
1. 델리게이트(Delegate, 대리자)의 사용
변수에 숫자나 문자열을 담듯이, 델리게이트 변수에는 메서드를 담을 수 있다. 메서드를 값처럼 저장하고, 전달하고, 원하는 시점에 호출할 수 있게 되는 것이다.
// 1. 델리게이트 타입 선언 — 반환형과 매개변수를 지정한다
public delegate void MyDelegate(string msg);
// 2. 델리게이트 변수 선언
public MyDelegate onEvent;
// 3. 메서드 등록
onEvent += SayHello;
onEvent += SayBye;
// 4. 호출 — 등록된 메서드가 순서대로 실행된다
onEvent?.Invoke("Unity");
// 결과:
// "안녕, Unity"
// "잘가, Unity"
private void SayHello(string name)
{
Debug.Log($"안녕, {name}");
}
private void SayBye(string name)
{
Debug.Log($"잘가, {name}");
}
[델리게이트 기본 사용법]
델리게이트의 핵심을 정리하면 다음과 같다.
- delegate 반환형 이름(매개변수) : 델리게이트 타입을 선언한다. 어떤 형태의 메서드를 담을 수 있는지를 정의한다.
- 델리게이트타입 변수명 : 델리게이트 변수를 선언한다. 이 변수에 같은 시그니처(반환형, 매개변수)를 가진 메서드를 등록할 수 있다.
- += : 메서드를 등록(구독)한다. 여러 개를 등록하면 모두 순서대로 실행된다.
- -= : 등록된 메서드를 해제한다.
- ?.Invoke() — 등록된 메서드가 있을 때만 안전하게 호출한다. ?를 빼면 등록된 메서드가 없을 때 NullReferenceException이 발생한다.
이처럼 델리게이트를 사용하면 메서드를 변수처럼 사용할 수 있게 된다. 앞서 본 예제 코드를 델리게이트로 수정하면 아래와 같이 간략하게 정리된다.
public class Bullet
{
public delegate void OnHitWallDelegate();
public OnHitWallDelegate OnHitWall;
private void OnTriggerEnter(Collider other)
{
if(other.CompareTag("Wall"))
{
OnHitWall?.Invoke();
}
}
}
public class Player : MonoBehaviour
{
[SerializeField] private Bullet _bullet;
private void Start()
{
_bullet.OnHitWall += HandleHitWall;
}
private void HandleHitWall()
{
Debug.Log("Player: 총알이 벽에 맞았다!");
}
}
[외부에서 동작을 등록]
이제 Bullet은 벽에 부딪혔다는 사실만 알리고, 실제로 무엇을 할지는 외부에서 결정한다.
Bullet이 Player, Enemy, Manager를 직접 알 필요가 없으므로 결합도가 낮아지고, 새로운 동작을 추가할 때도 Bullet 코드를 수정할 필요 없이 OnHitWall +=로 등록만 하면 된다.
하지만 매번 delegate void OnHitWallDelegate();처럼 델리게이트 타입을 직접 선언하는 것은 번거롭다. 시그니처가 달라질 때마다 새로운 델리게이트 타입을 만들어야 하기 때문이다. 이 불편함을 해결하기 위해 C#에서는 Action과 Func를 제공한다.
■ Action<T>, Func<T, TResult>
public delegate void OnHitWallDelegate();
public OnHitWallDelegate OnHitWall;
앞서 본 것처럼 델리게이트를 사용하려면 먼저 타입을 선언하고, 그다음에 변수를 만들어야 했다. 델리게이트가 늘어날수록 타입 선언도 함께 늘어나고, 시그니처를 잘못 정의하는 등 실수가 발생할 가능성도 높아진다.
public class GameEvents
{
public static Action OnGameOver;
public static Action<int> OnScoreChanged;
}
// 구독
GameEvents.OnGameOver += DoSomething;
// 호출
GameEvents.OnGameOver?.Invoke();
따라서 C#에서는 자주 쓰는 시그니처를 매번 선언할 필요 없도록, 제네릭 델리게이트인 Action과 Func를 미리 제공한다. 사용법은 델리게이트와 동일하지만, 타입 선언이 사라진 것을 알 수 있다.
- 무조건 델리게이트를 static으로 선언해야 하는 것은 아님.
// 파라미터가 int, float 형식이고, 반환값이 없음
Action<int, float>
// 파라미터가 int 타입이고, 반환값 형식이 bool 타입.
Func<int, bool>
Action과 Func의 차이는 반환값의 유무다. Action은 반환값이 없고(void), Func는 마지막 제네릭 파라미터가 반환 타입이 된다.
// Action 내부 선언
public delegate void Action();
public delegate void Action<in T>(T obj);
public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2);
// Func 내부 선언
public delegate TResult Func<out TResult>();
public delegate TResult Func<in T, out TResult>(T arg);
Action과 Func의 실제 선언부를 보면 결국 델리게이트라는 것을 알 수 있다. Action과 Func는 특별한 기능이 아니라, 우리가 직접 선언하던 델리게이트를 C#이 미리 만들어둔 것에 불과하다.
- Action은 매개변수를 최대 16개까지 받을 수 있으며, Func 역시 매개변수 최대 16개 + 반환 타입 1개를 지원한다.
콜백(Callback) 함수
"지금 당장 실행하지 말고, 나중에 특정 조건이 되면 이 메서드를 대신 호출해 줘"라는 의미다.
일반적인 메서드 호출은 내가 원하는 시점에 직접 호출한다. 하지만 콜백은 다르다. 메서드를 미리 등록해 두고, 어떤 일이 일어났을 때 상대 쪽에서 대신 호출해 주는 방식이다. 델리게이트는 이 콜백을 구현하는 대표적인 수단이다.
▶ UnityAction과 UnityEvent
UnityAction은 UnityEvent에 메서드를 등록할 때 사용하는 델리게이트이며, 기능적으로는 Action과 동일하다. UnityEvent는 코드와 인스펙터에서 모두 이벤트를 연결할 수 있다는 것이 가장 큰 차이점이다.
// UnityAction — 코드에서만 등록 가능 (Action과 동일)
public UnityAction onDeathAction;
// UnityEvent — 코드 + 인스펙터 둘 다 등록 가능
public UnityEvent onDeathEvent;

■ event 키워드
public Action OnDeath;
// 외부 클래스에서
player.OnDeath = DoSomething; //기존 등록이 전부 삭제됨
player.OnDeath?.Invoke(); //아무 곳에서나 호출 가능
델리게이트를 public으로 선언하면, 외부에서 두 가지 위험한 동작이 가능하다.
- =로 덮어쓰기 : 다른 곳에서 등록한 메서드가 전부 날아간다.
- 외부에서 직접 Invoke() 호출 : 의도하지 않은 시점에 이벤트 발동.
콜백의 핵심은 특정 상황이 발생했을 때 선언한 쪽에서 대신 호출하는 것인데, 위처럼 외부에서 마음대로 덮어쓰거나 호출할 수 있다면 일반 메서드를 직접 호출하는 것과 다를 바가 없다.
public event Action OnDeath;
// 외부 클래스에서
player.OnDeath = DoSomething; //컴파일 에러!
player.OnDeath?.Invoke(); //컴파일 에러!
player.OnDeath += DoSomething; //외부에서 이벤트 구독과 해제만 가능
player.OnDeath -= DoSomething;
델리게이트에 event 키워드를 붙이면, 외부에서는 +=(구독)과 -=(해제)만 가능해진다. Invoke()는 오직 이벤트를 선언한 클래스 내부에서만 호출할 수 있으므로, "언제 이벤트를 발동할지"는 해당 클래스가 온전히 제어하게 된다.
즉 event 키워드는 델리게이트의 기능을 바꾸는 것이 아니라, 외부 접근을 제한하는 보호 장치라고 이해하면 된다.
■ 람다식(Lambda Expression)
앞서 델리게이트에 메서드를 등록할 때, 매번 별도의 메서드를 작성해야 했다.
public event Action OnDeath;
private void Start()
{
OnDeath += HandleDeath;
}
private void HandleDeath()
{
Debug.Log("사망!");
}
간단한 동작 하나를 등록하기 위해 메서드를 따로 만들고, 이름을 짓고, 등록하는 과정을 거쳐야 한다. 람다식을 사용하면 이 과정을 한 줄로 줄일 수 있다.
- 람다식은 “익명 함수”를 간결하게 작성하는 문법이다.
OnDeath += () => Debug.Log("사망!");
람다식은 이름 없는 메서드를 그 자리에서 바로 작성하는 문법이다. => 기호를 기준으로 왼쪽이 매개변수, 오른쪽이 실행할 코드다.
1. 기본 문법
// 매개변수 없음
() => Debug.Log("Hello");
// 매개변수 1개 — 괄호 생략 가능
x => Debug.Log(x);
// 매개변수 2개 이상 — 괄호 필수
(x, y) => Debug.Log(x + y);
OnDeath += () =>
{
Debug.Log("사망!");
Debug.Log("게임 오버 화면 표시");
GameManager.Instance.GameOver();
};
- 실행할 코드가 여러 줄이면 {}로 감싸면 된다.
2. Action, Func와 사용하기
// Action — 반환값 없음
Action<string> greet = (name) => Debug.Log($"안녕, {name}!");
greet("Unity"); // "안녕, Unity!"
// Func — 반환값 있음
Func<int, int, int> add = (a, b) => a + b;
int result = add(3, 5); // 8
람다식은 델리게이트 타입 변수에 바로 대입할 수 있다.
// return 생략 가능
Func<int, int> double1 = x => x * 2;
// {} 사용 시 return 필수
Func<int, int> double2 = x =>
{
return x * 2;
};
Func에서 실행할 코드가 한 줄이면 return을 생략할 수 있다. 하지만 {}를 사용하면 return을 명시해야 한다.
▶ 람다식은 언제 사용하면 좋을까?
// 간단한 동작 — 람다식이 적합
OnDeath += () => Debug.Log("사망!");
// 복잡한 동작 — 별도 메서드가 적합
OnDeath += HandleDeath;
private void HandleDeath()
{
SavePlayerData();
ShowGameOverUI();
PlayDeathAnimation();
ReportToServer();
}
짧고 단순한 동작을 등록할 때는 람다식이 편리하다. 하지만 로직이 복잡하거나 여러 곳에서 재사용해야 한다면, 별도의 메서드로 분리하는 것이 가독성과 유지보수 면에서 더 좋다.
// 해제 불가 — 등록할 때와 해제할 때의 람다가 서로 다른 인스턴스
OnDeath += () => Debug.Log("사망!");
OnDeath -= () => Debug.Log("사망!"); // 해제되지 않음!
// 해제가 필요하면 변수에 담아두기
Action deathHandler = () => Debug.Log("사망!");
OnDeath += deathHandler;
OnDeath -= deathHandler; // 정상적으로 해제됨
또한 람다식으로 등록한 메서드는 -=로 해제하기 어렵다는 점도 주의해야 한다.
■ 캡쳐(Capture)
람다식은 자신이 선언된 위치의 외부 변수를 사용할 수 있다. 이때 람다식이 외부 변수를 기억하고 가져가는 것을 캡처(Capture)라고 한다.
private void Start()
{
string playerName = "용사";
Action greet = () => Debug.Log($"안녕, {playerName}!");
greet(); // "안녕, 용사!"
}
greet 람다식은 자신의 매개변수가 아닌 외부 변수 playerName을 사용하고 있다. 이것이 캡처다. 얼핏 보면 당연한 동작 같지만, 여기에는 주의해야 할 함정이 있다.
▶ 캡처는 값이 아니라 변수 자체를 기억
private void Start()
{
int count = 0;
Action increase = () => count++;
increase();
increase();
increase();
Debug.Log(count); // 3 — 람다 안에서 수정한 값이 반영됨
}
람다식은 캡처 시점의 값을 복사하는 것이 아니라, 변수 자체를 참조한다. 따라서 변수의 값이 변하면 람다식의 결과도 달라진다. 이 개념을 모르면 아래와 같은 문제가 발생할 수 있다.
private void Start()
{
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
{
actions[i] = () => Debug.Log(i);
}
actions[0](); // 3
actions[1](); // 3
actions[2](); // 3
}
0, 1, 2가 출력될 것 같지만, 실제로는 전부 3이 출력된다. 세 개의 람다식이 모두 같은 변수 i를 캡처하고 있고, 반복문이 끝난 시점에 i의 값은 3이기 때문이다.
이 문제를 해결하려면 반복문 안에서 지역 변수에 값을 복사해 두면 된다.
private void Start()
{
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
{
int captured = i; // 값을 복사
actions[i] = () => Debug.Log(captured);
}
actions[0](); // 0
actions[1](); // 1
actions[2](); // 2
}
int captured = i;로 매 반복마다 새로운 지역 변수를 만들면, 각 람다식이 서로 다른 변수를 캡처하게 되어 의도한 대로 동작한다.
private void Start()
{
GameObject effect = Instantiate(effectPrefab);
DOTween.Sequence()
.AppendInterval(3f)
.OnComplete(() =>
{
// 3초 후 실행 — 이 시점에 effect가 이미 Destroy 되었다면?
effect.SetActive(false); // MissingReferenceException!
});
}
Unity에서 캡처를 사용할 땐 특히 더 조심해야 하는 점이 있는데, 람다식이 effect를 캡처한 뒤, 실제 호출 시점에 해당 오브젝트가 파괴되어 있으면 MissingReferenceException이 발생한다. 이런 경우 null 체크를 추가해야 한다.
.OnComplete(() =>
{
if (effect != null)
{
effect.SetActive(false);
}
});
▶ 캡처를 활용한 매개변수 바인딩
캡처는 단순히 외부 변수를 읽는 것뿐만 아니라, 구독하는 시점에 데이터를 미리 전달해 두고, Invoke 될 때 해당 데이터와 함께 실행되도록 할 수 있다.
public Action OnFire;
// 구독 시점 — data를 캡처해서 미리 묶어둠
BulletData data = new BulletData(speed: 10, damage: 50);
OnFire += () => CreateBullet(data);
// Invoke 시점 — 캡처해둔 data와 함께 실행됨
OnFire?.Invoke();
OnFire는 매개변수가 없는 Action이지만, 람다식이 data를 캡처하고 있기 때문에 Invoke 시점에 CreateBullet에 데이터를 함께 넘길 수 있다.
이 방식은 시그니처가 맞지 않는 메서드를 델리게이트에 등록할 때도 활용할 수 있다.
public Action OnHit;
// PlaySound는 string 매개변수가 필요하지만, OnHit은 매개변수가 없다
string soundName = "Hit_SFX";
OnHit += () => PlaySound(soundName); // 캡처로 해결
단, 앞서 설명한 것처럼 캡처는 값이 아니라 변수를 참조한다는 점을 잊지 말자. 캡처 이후 변수의 값이 바뀌면 Invoke 시점의 결과도 달라진다.
'Unity,C# > Unity 정보' 카테고리의 다른 글
| [Unity] 총알 시스템으로 알아보는 Object Pool (1) | 2026.05.07 |
|---|---|
| [Unity, C#] SOLID 원칙 (1) | 2025.11.21 |
| [Unity] UI Toolkit 기본 사용법 (0) | 2025.11.21 |
| [Unity, C#] ScriptableObject - 스크립터블 오브젝트 (6) | 2025.08.02 |
| [Unity, C#] EditorWindow - 커스텀 에디터 윈도우 (1) | 2025.08.02 |