이문회우

[C#] Delegate + 함수 포인터, Event, 익명 함수+람다식, Invoke + 멀티쓰레딩 (2022.06.14.) 본문

개발

[C#] Delegate + 함수 포인터, Event, 익명 함수+람다식, Invoke + 멀티쓰레딩 (2022.06.14.)

Koesob 2022. 6. 14. 20:24

이번에 RhythmicSomething을 개발하면서 UnityAction을 쓸 일이 있었다.

정확히는 이런 상황이었다.

극-혐하며 개선했다

내가 짠 코드를 신행이가 UnityAction 함수로 개선했는데 궁금한 점이 생겼다.

A 객체가 B 객체도 알고, B 객체의 메소드도 아는게 문제라면, 개선한 코드에서 B 객체가 A 객체를 알고 있고, A 객체가 가질 수 있는 상태를 알고 있는 것 또한 문제 아닌가?

이 때 결론으로는, Action 함수가 인터페이스라는 것이다.

A 객체가 B 객체의 메소드를 아는 것은, 수많은 B의 메소드 중에서 필요한 것을 골라야하는 것이지만,

Action 함수를 사용하는 것은, B 객체가 A 객체에 접근할 방법을 딱 정해준 것이란 것이다.

전자는 우주선 속 수많은 버튼들-잘못 누르면 터지는!!!-이라면,

후자는 리모컨이다. 전자 대비 안전하고 꼭 필요한 버튼만 있는 리모컨 말이다.

 

그래도 기왕 궁금해진거 보다 제대로 찾고 정리하고 싶었다.

더욱이 아직 UnityAction 개념이 헷갈린다. 누가 Subcribe고 누가 Publisher고… 아직 잘 몰라서.


Delegate + 함수 포인터

우선, Delgate, 한국어로는 ‘대리자’인 이것은 ‘함수 포인터’의 개념이다.

프로그램(정확히는 프로세스)이 실행될 때 램 위에 올라가는 것은 변수만이 아니다. 함수 또한 램 위에 올라가서 주솟값을 갖는다. ‘함수 포인터’란, 이 주솟값을 가리키는 포인터 변수를 말한다.

 

그렇다면, 이 함수 포인터는 왜 쓸까?

우선, 함수를 변수처럼 쓸 수 있다. 실제로 공식 문서를 보면 Reference Type에서 Delegate Type을 다룬다. 그에 따라 + 연산도 가능하다.

그리고, 입력과 출력이 동일하다면 나머지 구현은 자유롭게 함으로써, 함수를 마음대로 갈아끼울 수 있다. 예를 들어 캐릭터가 공격하는 함수가 있다면, 캐릭터는 함수 포인터만 갖고 있으면 직업별로 다양한 공격 함수를 구현하고, 그 공격 함수를 캐릭터의 함수 포인터에 갈아끼우는 방식으로 재사용성을 높일 수 있다.

 

그런데, 그럼 상속이 떠오르지 않는가? 객체지향, 오버라이딩이 떠오르지 않는가?

그렇네. 그렇다.

공식 문서를 읽어보면, Delegate가 정확히 ‘함수 포인터’를 구현한 것은 아니다.

이 함수 포인터는 C에서 나오는 개념이다.

객체지향을 반영한 C#으로서는 굳이 필요가 없지만, 사실 그렇다고 함수 하나 상속하기 위해 클래스를 추가로 만들어야하는 것은 확실히 귀찮은 일이다. 확실히 불편한 일이다.

그렇기에 함수 포인터 같지만 보다 안전한 형태로 만들어진 것이 Delegate이다.

 

참고로, C에서 포인터를 다루다보면 정말 재밌다. C의 미칠듯한 무한한 개발자 신뢰를 느낄 수 있는 요소다. 대표적으로 C에서 포인터를 사용하는게 배열 다룰 때인데, C는 배열의 Index 넘어서는 것을 감지하지 않는다. 마지막 Index에 도달했어도 개발자가 다음 Index를 읽으라고 하면 정말로 다음 메모리 주소에 접근한다.

그리고. 배경지식이 좀 필요한 내용이긴 한데, character로 이뤄진 배열이라고 해도, 그냥 int로 읽는 것도 가능하다. 그럼에도 어떠한 에러를 띄우지 않는 C의 위엄. 포인터로 접근하는 것이라면 내가 읽는게 실제로 어떤 타입인지 따위는 결코 따지지 않는다. 꼬우면 니가 잘 짜든가.

 

한편으로 Delegate가 Class의 형태로, Class-Instance로 구현되는 것을 보면 C# 나름의 타협점이었나보다.

Event

Delegate의 용례를 보면 알겠지만, event는 Delegate의 한 형태이다. Delegate로 EventHandler를 생성하고, 그렇게 생성된 인스턴스 앞에 event를 붙여주면 event 뚝딱.

실제로는 그냥 event 붙이고 시작해도 Delegate로 Handler를 생성하는 과정은 생략해준다.

뭐, 그러나저러나 Delegate 출신이라 결국 사용법은 Delegate의 흐름을 따른다.

익명 함수(Anonymous Function) + 람다식 + Func, Action 타입

Delegate를 공부하면 익명 함수와 람다식에 대한 언급, 또는 코드가 익명 함수와 람다식으로 구현돼 있는 경우를 많이 보게 된다. 공식 문서가 그렇다.

우선, 람다식은 별개 아니고 익명 함수를 구현하기 위해 쓰는 식이다. 마치 if 함수를 구현하기 위해 대소 비교식(>,<)을 쓰는 것과 마찬가지로 익명 함수를 구현할 때 람다식(’⇒’ 이걸 쓴다. 익명 함수 = (input) ⇒ (output) 의 형태다.)을 쓰는 것이다. 물론, if 함수가 항상 대소 비교만으로 구현되는 것이 아니듯, 꼭 람다식이 아녀도, 평소 쓰던 식들로도 익명 함수를 구현할 수 있다.

 

그렇다면, 익명 함수는 무엇인가?

익명 함수는 말 그대로 함수 이름이 없는 애들이다.

간단한 연산을 하거나, 그저 출력하려고 임시 변수를 선언해본 경험이 있을텐데,

특히 for문을 만들 때 쓰는 그 초깃값 int i 같은 애들.

그런 애들 구현하는데 굳이 변수 만들고, 내부 함수 구현할 필요 있겠느냐-에서 나온 애다.

그런 탓에 굳이 이름을 붙이지 않는 것인데, 이름이 없기 때문에 사실 재사용이 안된다.

호출을 못하니까.

 

그런데 우리 Delegate는 조건만 만족하면 어떤 함수든지 받을 수 있기 때문에 이 익명 함수까지도 받을 수 있다.

그래서 모든 익명 함수는 Delegate로 변환이 가능하다.

그리고 그 중에서 return이 있는 경우, Func 타입, return이 없는 경우를 Action 타입이라고 한다.

즉, Func 타입과 Action 타입 모두 Delegate 타입의 일종이다.

Invoke + 멀티쓰레딩

번외로 이러한 Delegate를 실행하는데 종종 ‘Invoke’라는 함수를 사용한다.

그런데 본래 C#의 Invoke와 유니티의 Invoke는 다르다. 후술하겠지만 유니티의 능력 범위 내에서 본래 Invoke를 흉내낸 느낌이다.

사실 ‘함수 포인터’의 활용 중에 하나는 멀티 쓰레딩이다.

 

자, 그럼 멀티 쓰레딩이 무엇일까?

위에서 프로그램, 정확히는 프로세스가 실행되면, 해당 프로세스의 변수 뿐만 아니라 함수까지도 주소를 갖고 램 위에 올라간다고 말을 했었다.

프로세스가 이제 램 위에 사무실을 차린 기업이라고 친다면, 그래, 사무실을 차렸으니 변수나 함수 같은게 그 사무실 내에 자리(=메모리 램의 주솟값)를 잡았겠지.

이 때 쓰레드가 회사원들이다.

당연히 한 명의 회사원 굴려가며 기업 꾸려가는 것보단 여러 회사원을 굴리는게 더 좋다.

그게 멀티 쓰레드가 좋다좋다 하는 이유다.

 

하지만, 일하는 사람이 많아지면 이것 저것 고려해야할 사항이 늘어난다.

중복돼서 일할 일이 없게 하면서도, 공유할 사항 제대로 공유하고, 사원들끼리 전달할 사항은 또 착착 전달돼야 한다.

자, 그걸 당신이 짜는 것이다. 프로그램의 흐름을 머릿속으로 굴려가면서 이 쓰레드가 중복되는 일 없는지, 공유할거 잘 공유하고 있는지, 전달할 부분 놓칠 것 없는지. 척하면 척.

 

아무튼, 이 쓰레드들도 결국은 기업의 사무실 내에서 일을 한다.

즉, 사무실이 위치한 램의 주솟값 위에서 일을 한단 소리다.

이 때 ‘포인터’의 위엄 넘치는, 그리고 C#이나 Java에서 포인터를 배제한 이유 중 하나가 나타나는데, 포인터는 램의 주솟값에 직접 접근이 가능하다.

 

단도직입적으로, A 회사원이 B 회사원의 업무에, 더 심하게는 B 회사원의 오장육부를 휘저을 수도 있다.

C 언어가 인덱스 범위 넘는 것을 잡지 않는 것을 고려할 때 프로세스 위에서 도쿄 구울을 찍는 것은 아주 ez하단 뜻.

(물론, 프로그램은 터지겠지만 운영체제나 다른 프로세스에 접근하지 못하도록 운영체제가 잡아주긴 한다. 다시 말하지만 프로그램은 터진다.)

 

그렇기 때문에 Delegate를 쓴다. 자, 첫 문장에 Delegate가 한국어로 ‘대리자’ 라고 언급한 것을 떡밥 회수 하겠다.

A 쓰레드가 Invoke를 통해 Delegate에 등록된 B 쓰레드의 함수를 실행한다.

즉, A 쓰레드가 해야할 일이 B에게 있으니, B가 대신 해당 일(함수 실행하는거)을 대신, 대리해달라는 것이다.

말 그대로, B 쓰레드가 A 쓰레드의 대리자가 되어준다.

이 정도 수미상관이면 김소월님이 내 빰 치겠다.

 

말이 길었다.

상기 내용이 기존 C#에서 멀티 쓰레드를 구현하는 과정을 설명한 것이다.

마지막으로 한 번만 더 비유를 하자면, 싱글 쓰레드가 하나의 흐름이라면, 멀티 쓰레드는 한 번에 여러 흐름을 만드는 것이다.

그런데 Unity는 하나의 흐름만을 추구한다.

사실 Start, Update 기타 등등 Monobehaviour 함수들도 제각각 여러 흐름에서 실행되는 것이 아니라 한 곳에서 한 번 싹 모으고 실행되는 것이다. 그래서 private 거는 것은 별 의미가 없다.

Unity의 Invoke 함수는 기존 흐름과 별도의 흐름에서 함수를 반복 실행하는 것처럼 보이지만, 실제론 기존 흐름 내에서 실행하고 있는 것이다.

그리고

Coroutine

Coroutine 또한 마찬가지. (단, Coroutine은 C#에서 제공하는 기능이다.)

Coroutine도 대표적인 기존 흐름에서 벗어난 함수 실행 루틴을 만드는 것처럼 보이지만, 실제로는 역시 기존 흐름 위에서 동작하고 있는 것이다.

참고로, 유니티 공식 문서에 따르면 Invoke보다 Coroutine을 쓰는 것이 퍼포먼스가 더 나온다고 한다.

 

이러한 유니티의 제한적인 멀티 쓰레딩 문제는 유니티로 구현한 시티즈 스카이 라인이나 커벌 스페이스 프로그램 같이 빡센 연산을 요구하는 게임에서는 꾸준히 이슈가 된다고 한다.

 

이상, 나도 하나의 흐름 위에 여러 개의 주제를 올려보았다.

StartCoroutine(Delegate());

StartCoroutine(Event());

...

!!

나쁘지 않게 작동한듯? 다시 이상, 끗


참고문헌

Delegates - C# Programming Guide | Microsoft Docs

delegate operator - C# reference | Microsoft Docs

event - C# 참조 | Microsoft Docs

EventHandler Delegate (System) | Microsoft Docs

Unity - Scripting API: .UnityAction (unity3d.com)

Unity - Scripting API: MonoBehaviour.Invoke (unity3d.com)

 

 

'개발' 카테고리의 다른 글

Powershell로 venv는 안 쓰는게 좋다  (1) 2022.07.06