개발을 하다보면 UI, 로직, 매니저 간의 의존성이 점점 복잡해지기 마련이다. 예를 들면, 버튼 하나를 눌렀을 뿐인데 여러 시스템이 동시에 반응해야 하는 구조가 흔해진다.
이때 가장 많이 사용되는 구조가 delegate, Action, event 기반의 이벤트 시스템이다.
그래서 오늘은 이 세 가지의 개념부터, 어떤 차이가 있고, 실무에서는 어떻게 사용하는지 정리해보려고 한다.
Unity 프로젝트는 보통 다음과 같은 구조를 가진다.
버튼 클릭 -> 로직 처리 -> 데이터 변경 -> UI 갱신
이 과정에서 각 객체가 서로를 직접 참조하게 되면 객체 간 강결합이 생기는 문제가 있다.
- 하나의 수정이 여러 스크립트에 영향을 줌
- 테스트와 유지보수가 어려워짐
이때 이벤트 기반 구조를 사용하면
"알림만 보내고, 누가 받을지는 신경 쓰지 않는 구조"로 만들 수 있다.
Unity의 이벤트 시스템은 결국 C#의 delegate를 기반으로 동작한다.
delegate는 한 문장으로 정의하면 다음과 같다.
"메서드를 타입처럼 다룰 수 있게 해주는 기능"
다음은 델리게이트 코드 예시이다.
public delegate void DamageHandler(int damage);
이 코드는
"int 값 하나를 받아서 실행되는 void 함수의 형태를 하나의 타입으로 정의한다."라는 의미이다.
즉, 아래와 같은 메서드만 이 타입에 연결할 수 있다.
void TakeDamage(int damage) { hp -= damage; }
이제 이 delegate 타입으로 변수를 만들고, 메서드를 연결하면 다음과 같다.
DamageHandler onDamage; onDamage = TakeDamage; onDamage?.Invoke(10);
이 코드는 onDamage라는 변수가 TakeDamage()를 대신 실행해주는 창구 역할을 하도록 만든 것이다.
또한 delegate는 여러 메서드를 동시에 연결할 수 있기 때문에
onDamage += PlayHitEffect; onDamage += UpdateUI;
이런 식으로 연결해서 하나의 호출로 여러 시스템이 동시에 반응하도록 만들 수 있는데, 이 구조가 이벤트 구조의 기반이 된다.
delegate는 매번 타입을 직접 정의해야 하기 때문에 코드가 길어진다. 이를 대체하기 위해 .NET에서 제공하는 것이 Action 이다.
Action<int> onDamage;
이 한줄은 아까 예시로 나왔던 delegate와 완전히 동일한 의미이다.
public delegate void DamageHandler(int damage);
<delegate vs Action 차이 요약>
| 구분 | delegate | Action |
|---|---|---|
| 타입 정의 | 직접 정의 | 제네릭 제공 |
| 반환값 | 가능 | 불가능(void 고정) |
| 가독성 | 보통 | 매우 좋음 |
| 실무 사용 | 거의 안씀 | 매우 자주 씀 |
delegate나 Action은 외부에서 직접 호출이 가능하다.
public Action OnClick;
이 경우 외부 객체가 다음과 같이 강제로 실행할 수 있다.
button.OnClick();
이건 이벤트로서는 매우 위험한 구조이다.
그래서 등장한 것이 event 키워드이다.
public event Action OnClick;
이렇게 하면
"외부에서는 등록 및 해제만 가능하고, 직접 호출은 불가능해진다."
즉, event는 이벤트 호출 권한을 캡슐화하는 안전장치이다.
| 구분 | delegate | Action | event |
|---|---|---|---|
| 함수 타입 정의 | O | O | X |
| 직접 호출 가능 | O | O | X |
| 외부 구독 | O | O | O |
| 안전성 | 낮음 | 낮음 | 매우 높음 |
| 실무 권장 | X | △ | O |
-> 실무에서는 반드시 event Action 형태를 사용하자.
Unity에서는 이벤트 등록과 해제를 반드시 다음 위치에서 관리해야 한다.
void OnEnable() { Player.OnDamaged += HandleDamage; } void OnDisable() { Player.OnDamaged -= HandleDamage; } void HandleDamage(int damage) { hp -= damage; }
이 패턴을 지키지 않으면 다음과 같은 문제가 발생한다.
static event는 일반 이벤트와 달리
씬이 바뀌어도 메모리에서 해제되지 않고 계속 유지된다.
이 특성 때문에 다음과 같은 상황에서 매우 위험해진다.
- 구독 해제를 하지 않은 경우
DontDestroyOnLoad객체와 함께 사용하는 경우- UI, 매니저 계층에서 static event를 남발하는 경우
이때 가장 큰 문제는
이벤트는 살아 있는데, 호출 대상 오브젝트는 이미 파괴된 상태가 된다는 점이다.
그 결과
- 이미 사라진 오브젝트의 메서드가 계속 호출됨
- NullReferenceException이 발생
- Memory Profiler에서 참조가 계속 남아 있음
- 씬을 반복해서 오갈수록 메모리 사용량이 점점 증가
즉, static event는
"전역 신호"가 필요한 상황이 아니라면 사용을 최대한 제한해야 하는 설계 요소이다.
UnityEvent는 코드가 아닌 인스펙터 기반 연결이 필요한 경우에 사용한다.
|상황|선택|
|디자이너가 버튼에 기능 연결|UnityEvent|
|코드 기반 시스템 이벤트|event Action|
|성능 중요|event Action|
|유지보수 중요|event Action|
Unity에서 delegate, Action, event는
단순한 문법이 아니라 프로젝트 구조를 좌우하는 핵심 설계 요소이다.
이 세 가지를 어떻게 쓰느냐에 따라
- 프로젝트의 결합도
- 메모리 안정성
- 유지보수 난이도
- 디버깅 난이도
가 전부 달라진다.
따라서 다음과 같은 규칙을 잊지 말고 잘 활용하자.
- 외부 호출을 막고 싶으면 event 사용
- 반드시 등록/해제 쌍으로 관리
- static event는 정말 필요할 때만 사용
- 이벤트는 "신호", 로직 처리는 구독자 쪽에서
- 인스펙터 기반 연결이 필요할 땐 UnityEvent 사용