C 프로그래밍 경험이 있다면 대리자(delegate)를 함수 포인터와 유사한 개념으로 이해할 수 있습니다. 하지만 C#의 대리자는 단순한 함수 포인터를 훨씬 뛰어넘는 강력한 기능입니다. 이 글에서는 대리자의 핵심 개념과 일상적인 프로그래밍에서의 활용 방법을 자세히 살펴보겠습니다.
대리자란 무엇인가?
본질적으로 대리자는 간접 참조(indirection) 계층을 제공합니다. 대리자는 코드 조각을 캡슐화하여 타입 안전(type-safe)한 방식으로 전달하고 실행할 수 있게 해줍니다. 동작을 즉시 실행하는 대신, 그 동작을 객체 안에 담아두고 필요한 시점에 여러 작업 중 하나로서 실행하는 방식입니다.
대리자를 활용하면 고차 함수(higher-order function), 즉 함수를 매개변수로 받거나 반환값으로 함수를 돌려주는 함수를 작성할 수 있습니다. 대리자 타입은 해당 대리자가 나타낼 수 있는 메서드 시그니처, 구체적으로는 메서드의 반환 타입과 매개변수 타입을 정의합니다. 다음 예제에서 Transformer는 정수를 하나 받아 정수를 반환하는 모든 메서드를 나타낼 수 있는 대리자입니다.
delegate int Transformer(int x);
시그니처만 일치한다면 람다식, 인스턴스 메서드, 정적 메서드 등 어떤 메서드든 Transformer 인스턴스에 할당할 수 있습니다.
Transformer square = x => x * x; Transformer cube = x => x * x * x; Console.WriteLine(square(3)); // 출력: 9 Console.WriteLine(cube(5)); // 출력: 125
대리자는 언제 사용해야 할까?
대리자는 어떤 동작을 실행하려는 코드가 그 동작의 세부 내용은 모르지만, 동작의 인터페이스(시그니처)는 알고 있을 때 주로 사용됩니다.
프로그래밍을 하다 보면 특정 동작을 실행해야 하는 상황이 발생하지만, 정작 어떤 메서드를 호출해야 할지 미리 알 수 없는 경우가 많습니다. 대리자는 이러한 문제를 해결해 줍니다. 실행할 동작을 대리자로 대체한 뒤, 문맥과 상황에 맞는 적절한 동작을 담은 대리자 인스턴스를 전달하면 되기 때문입니다.
대리자가 실제로 동작하기 위해서는 다음 네 가지 단계가 필요합니다.
1단계: 대리자 타입 선언
대리자 타입은 본질적으로 그것이 나타내는 함수의 정의입니다. 즉, 함수가 받을 매개변수 타입들과 반환 타입으로 구성됩니다.
예를 들어, 두 숫자를 입력받아 숫자를 반환하는 메서드를 나타내는 대리자 타입은 다음과 같이 선언할 수 있습니다.
delegate int Processor(int numOne, int numTwo);
Processor는 클래스로 만든 타입과 마찬가지로 하나의 타입입니다. 이 타입의 인스턴스를 생성하려면 두 숫자를 받아 숫자를 반환하는 메서드가 필요합니다.
2단계: 실행할 코드를 메서드로 작성
위에서 선언한 대리자 타입과 정확히 같은 시그니처를 가진 메서드를 정의합니다. 이 메서드는 런타임 상황에 맞게 원하는 동작을 수행해야 합니다. 예를 들어 아래 메서드들은 모두 두 숫자를 받아 숫자를 반환하므로, Processor 인스턴스 생성에 사용할 수 있습니다.
static int Add(int numOne, int numTwo){
return numOne + numTwo;
}
static int Subtract(int numOne, int numTwo){
return numOne - numTwo;
}3단계: 대리자 인스턴스 생성
대리자 타입과 올바른 시그니처를 가진 메서드가 준비되었다면, 해당 대리자 타입의 인스턴스를 만들 수 있습니다. 이 과정은 사실상 "이 대리자 인스턴스가 호출될 때 이 메서드를 실행하라"라고 C# 컴파일러에게 지시하는 것입니다.
Processor processorOne = new Processor(Add); Processor processorTwo = new Processor(Subtract);
위 예제는 Add와 Subtract 메서드가 대리자 인스턴스를 생성하는 클래스와 같은 클래스에 정의되어 있다고 가정합니다. 만약 메서드가 다른 클래스에 정의되어 있다면, 해당 클래스의 인스턴스가 필요합니다.
4단계: 대리자 인스턴스 호출
마지막 단계는 대리자 인스턴스의 메서드를 호출하는 것입니다. 놀랍지 않게도 이 메서드의 이름은 Invoke입니다. 이 메서드는 대리자 타입 선언에서 명시한 것과 동일한 매개변수 목록과 반환 타입을 가지며, 호출하면 대리자 인스턴스의 동작이 실행됩니다.
int sum = processorOne.Invoke(3, 5);
그런데 C#은 이것보다 더 편리한 문법을 제공합니다. 대리자 인스턴스를 마치 메서드인 것처럼 직접 호출할 수 있습니다.
int difference = processorTwo(10, 6);
대리자 결합과 제거
대리자 인스턴스를 한 번만 호출하면서 서로 다른 여러 동작을 연달아 실행하고 싶다면, C#이 이를 지원합니다. System.Delegate 타입에는 Combine과 Remove라는 두 개의 정적 메서드가 있습니다.
1. Combine
매개변수로 전달된 대리자들의 호출 목록(invocation list)을 이어 붙여 새로운 대리자를 생성합니다. 새 대리자 인스턴스가 호출되면 목록에 있는 모든 동작이 순서대로 실행됩니다.
public static Delegate Combine(params Delegate[] delegates); // 또는 public static Delegate Combine(Delegate a, Delegate b);
주의할 점은, 호출 목록의 어떤 동작이라도 예외를 던지면 이후의 동작들은 실행되지 않는다는 것입니다.
2. Remove
한 대리자의 호출 목록에서 다른 대리자의 호출 목록과 일치하는 마지막 부분을 제거합니다. source의 호출 목록에서 value의 호출 목록과 일치하는 마지막 항목을 삭제한 새로운 대리자를 반환합니다.
public static Delegate Remove(Delegate source, Delegate value);
핵심 요약
대리자는 특정 타입과 매개변수 집합으로 동작을 캡슐화하며, 단일 메서드 인터페이스와 유사한 역할을 합니다.
대리자 타입 선언이 기술하는 시그니처가 어떤 메서드로 대리자 인스턴스를 만들 수 있는지, 그리고 호출 시그니처가 무엇인지 결정합니다.
대리자 인스턴스를 생성하려면 대리자가 호출될 때 실행할 메서드가 필요합니다.
대리자 인스턴스는 문자열(string)처럼 불변(immutable)입니다.
각 대리자 인스턴스는 동작들의 목록인 호출 목록(invocation list)을 포함합니다.
대리자 인스턴스끼리 서로 결합하거나 제거할 수 있습니다.
전체 예제 코드
using System;
class Program{
delegate int Transformer(int x);
delegate int Processor(int numOne, int numTwo);
static void Main(){
Transformer square = x => x * x;
Transformer cube = x => x * x * x;
Console.WriteLine(square(3)); // 출력: 9
Console.WriteLine(cube(5)); // 출력: 125
Processor processorOne = new Processor(Add);
Processor processorTwo = new Processor(Subtract);
int sum = processorOne.Invoke(3, 5);
Console.WriteLine(sum); // 출력: 8
int difference = processorTwo(10, 6);
Console.WriteLine(difference); // 출력: 4
}
static int Add(int numOne, int numTwo){
return numOne + numTwo;
}
static int Subtract(int numOne, int numTwo){
return numOne - numTwo;
}
}실행 결과
9 125 8 4