EveryDay.DevUp

[PART3.상속과 다형성(4/4)] 확장 메서드 — 기존 타입에 메서드를 추가하는 방법 본문

C# 심화

[PART3.상속과 다형성(4/4)] 확장 메서드 — 기존 타입에 메서드를 추가하는 방법

EveryDay.DevUp 2026. 4. 5. 16:32

확장 메서드 — 기존 타입에 메서드를 추가하는 방법

Unity의 Transform이나 GameObject에 "이런 메서드가 있으면 좋겠는데"라고 느낀 적이 있다면, 확장 메서드가 그 답이다. 기존 클래스를 한 줄도 수정하지 않고 새 메서드를 붙이는 방법을 IL 수준까지 들여다본다.


문제 제기

Unity에서 Transform의 X 좌표만 바꾸려면 매번 이런 코드를 반복해야 한다.

C#
var pos = transform.position;
pos.x = 10f;
transform.position = pos;

세 줄이 한 줄의 의도를 표현한다. transform.SetPositionX(10f) 같은 메서드가 있으면 좋겠지만, Transform은 Unity 엔진 내부 클래스라 직접 수정할 수 없고, 상속도 불가능하다(sealed는 아니지만 엔진이 내부적으로 생성하므로 사실상 확장 불가).

유틸리티 클래스에 정적 메서드를 만들 수 있지만, TransformHelper.SetPositionX(transform, 10f) 같은 호출은 코드를 읽는 흐름을 끊는다. 대상 객체가 첫 번째 인자로 밀려나면서 "누구의 X를 바꾸는 건지" 직관적으로 와닿지 않기 때문이다.

확장 메서드는 이 문제를 해결한다. 기존 타입의 소스 코드를 수정하지 않으면서, 마치 그 타입에 원래 있던 메서드처럼 호출할 수 있는 정적 메서드를 만드는 기능이다.


개념 정의

비유 — 스마트폰 케이스

스마트폰의 내부 기판(기존 클래스)을 분해하지 않고도, 케이스(확장 메서드)를 씌우면 거치대·카드 수납·링 홀더 같은 새 기능을 추가할 수 있다. 케이스는 스마트폰의 외부 버튼(public 멤버)만 활용하고, 내부 부품(private 멤버)에는 손대지 않는다. 사용하는 사람 입장에서는 케이스가 폰의 일부처럼 느껴진다 — 확장 메서드가 인스턴스 메서드처럼 호출되는 것과 같은 원리다.

선언 규칙

확장 메서드를 만들려면 세 가지 조건이 필요하다.

  1. 정적 클래스(static class) 안에 선언한다
  2. 메서드 자체도 static이어야 한다
  3. 첫 번째 매개변수에 this 키워드를 붙여 확장할 타입을 지정한다
this 매개변수 수식자 — 확장 메서드 선언 (Extension method this modifier) 정적 메서드의 첫 번째 매개변수 앞에 this를 붙이면, 해당 타입의 인스턴스 메서드처럼 호출할 수 있는 확장 메서드가 된다. 컴파일러가 이 문법을 인식하여 호출부를 정적 메서드 호출로 자동 변환한다.
예시: public static int WordCount(this string str)"hello".WordCount() 형태로 호출 가능
확장 메서드 선언 구조
C#
public static class StringExtensions
{
    public static int WordCount(this string str)
    {
        if (string.IsNullOrEmpty(str))
            return 0;
        return str.Split(' ').Length;
    }
}

public class Program
{
    public static void Main()
    {
        string sentence = "Hello Extension Methods";
        // 인스턴스 메서드처럼 호출
        int count1 = sentence.WordCount();
        // 정적 메서드로 직접 호출 — 동일한 결과
        int count2 = StringExtensions.WordCount(sentence);
    }
}
IL
.class public auto ansi abstract sealed beforefieldinit StringExtensions
    extends [System.Runtime]System.Object
{
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = (
        01 00 00 00
    )                                                       // 컴파일러가 자동 부여한 ExtensionAttribute

    .method public hidebysig static
        int32 WordCount (
            string str                                      // this 키워드는 사라지고 일반 매개변수가 됨
        ) cil managed
    {
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.ExtensionAttribute::.ctor() = (
            01 00 00 00
        )                                                   // 메서드에도 ExtensionAttribute 부착
        IL_0001: ldarg.0                                    // str을 스택에 로드
        IL_0002: call bool [System.Runtime]System.String::IsNullOrEmpty(string)
        IL_0007: stloc.0
        IL_0008: ldloc.0
        IL_0009: brfalse.s IL_000f
        IL_000b: ldc.i4.0                                   // 빈 문자열이면 0 반환
        IL_000c: stloc.1
        IL_000d: br.s IL_001d
        IL_000f: ldarg.0                                    // str을 다시 로드
        IL_0010: ldc.i4.s 32                                // ' ' (공백 문자)
        IL_0013: callvirt instance string[] [System.Runtime]System.String::Split(char, ...)
        IL_0018: ldlen                                      // 배열 길이 = 단어 수
        IL_0019: conv.i4
        IL_001a: stloc.1
        IL_001d: ldloc.1
        IL_001e: ret
    }
}

// Main 메서드
.method public hidebysig static void Main () cil managed
{
    IL_0001: ldstr "Hello Extension Methods"
    IL_0006: stloc.0                                        // sentence에 저장
    IL_0007: ldloc.0
    IL_0008: call int32 StringExtensions::WordCount(string)  // 인스턴스 호출 → 정적 호출로 변환
    IL_000d: stloc.1
    IL_000e: ldloc.0
    IL_000f: call int32 StringExtensions::WordCount(string)  // 직접 정적 호출 — 위와 완전히 동일
    IL_0014: stloc.2
    IL_0015: ret
}

IL에서 확인할 수 있는 핵심 사실 세 가지:

  1. sentence.WordCount()StringExtensions.WordCount(sentence)는 동일한 IL을 생성한다. 둘 다 call int32 StringExtensions::WordCount(string)이다. 인스턴스 메서드 호출이 아닌 정적 메서드 호출(call)이므로 가상 디스패치(callvirt) 오버헤드가 없다.
  2. 컴파일러는 ExtensionAttribute를 클래스와 메서드 양쪽에 자동 부착한다. 이 속성이 있어야 다른 어셈블리에서도 해당 메서드를 확장 메서드로 인식한다.
  3. 클래스 선언에 abstract sealed가 붙는다. static class는 IL 수준에서 abstract sealed — 인스턴스를 만들 수도, 상속할 수도 없는 클래스로 표현된다.

핵심을 한 줄로 정리하면: 확장 메서드는 컴파일러가 제공하는 구문 설탕(Syntactic Sugar)이다. 런타임에는 평범한 정적 메서드 호출과 완전히 동일하게 동작한다.


내부 동작

컴파일러의 확장 메서드 탐색 과정

컴파일러가 obj.Method() 호출을 만나면 다음 순서로 메서드를 찾는다.

컴파일러의 메서드 탐색 순서

이 우선순위를 코드로 확인해 보자.

C#
public static class IntExtensions
{
    // int에 이미 ToString(string)이 존재하므로 이 확장 메서드는 무시됨
    public static string ToString(this int value, string label)
    {
        return $"{label}: {value}";
    }

    // int에 IsPositive라는 인스턴스 메서드는 없으므로 확장 메서드가 호출됨
    public static bool IsPositive(this int value)
    {
        return value > 0;
    }
}

public class Program
{
    public static void Main()
    {
        int score = 42;
        string s = score.ToString("N0");     // int.ToString(string) 인스턴스 메서드 호출
        bool positive = score.IsPositive();  // IntExtensions.IsPositive(int) 확장 메서드 호출
    }
}
IL
.method public hidebysig static void Main () cil managed
{
    IL_0001: ldc.i4.s 42
    IL_0003: stloc.0                                              // score = 42
    IL_0004: ldloca.s 0                                           // score의 주소를 로드 (값 타입 인스턴스 메서드 호출)
    IL_0006: ldstr "N0"
    IL_000b: call instance string [System.Runtime]System.Int32::ToString(string)
                                                                  // ↑ 인스턴스 메서드 호출 — System.Int32에 정의된 메서드
    IL_0010: stloc.1
    IL_0011: ldloc.0                                              // score 값을 로드 (정적 메서드 호출이므로 값 복사)
    IL_0012: call bool IntExtensions::IsPositive(int32)
                                                                  // ↑ 확장 메서드 호출 — 정적 메서드로 변환됨
    IL_0017: stloc.2
    IL_0018: ret
}

두 호출의 IL을 비교하면 차이가 명확하다.

  • ToString("N0"): ldloca.s로 주소를 로드한 뒤 call instance string System.Int32::ToString(string)인스턴스 메서드 호출이다. int에 이미 같은 시그니처의 인스턴스 메서드가 있으므로 확장 메서드 IntExtensions.ToString은 완전히 무시된다.
  • IsPositive(): ldloc.0으로 값을 로드한 뒤 call bool IntExtensions::IsPositive(int32)정적 메서드 호출이다. intIsPositive라는 인스턴스 메서드가 없으므로 확장 메서드가 사용된다.

null에서 확장 메서드 호출

확장 메서드의 흥미로운 특성 하나가 있다. 일반 인스턴스 메서드를 null 객체에서 호출하면 NullReferenceException이 발생하지만, 확장 메서드는 null에서도 호출할 수 있다.

C#
public static class StringSafeExtensions
{
    public static bool IsNullOrEmpty(this string str)
    {
        return string.IsNullOrEmpty(str);
    }

    public static string OrDefault(this string str, string fallback)
    {
        return string.IsNullOrEmpty(str) ? fallback : str;
    }
}

public class Program
{
    public static void Main()
    {
        string name = null;
        bool empty = name.IsNullOrEmpty();       // true — 예외 없음!
        string display = name.OrDefault("Unknown"); // "Unknown"
    }
}
IL
.method public hidebysig static void Main () cil managed
{
    IL_0001: ldnull                                              // null을 스택에 로드
    IL_0002: stloc.0                                             // name = null
    IL_0003: ldloc.0                                             // null을 그대로 인자로 전달
    IL_0004: call bool StringSafeExtensions::IsNullOrEmpty(string)
                                                                 // ↑ 정적 메서드 호출 — null이 첫 번째 인자로 들어갈 뿐
    IL_0009: stloc.1
    IL_000a: ldloc.0
    IL_000b: ldstr "Unknown"
    IL_0010: call string StringSafeExtensions::OrDefault(string, string)
    IL_0015: stloc.2
    IL_0016: ret
}

인스턴스 메서드 호출이었다면 callvirt가 사용되고, callvirt는 호출 전에 null 체크를 수행하여 NullReferenceException을 던진다. 하지만 확장 메서드는 call(정적 호출)이므로 null 체크가 없다. null은 그저 매개변수로 전달될 뿐이고, 메서드 내부에서 안전하게 처리할 수 있다.

이 특성은 Unity에서 null-safe 유틸리티를 만들 때 유용하지만, 동시에 함정이 될 수도 있다 — 이는 [함정과 주의사항] 섹션에서 다룬다.


실전 적용

Unity 내장 타입 확장 — Transform, GameObject

Unity 개발에서 가장 자주 쓰이는 확장 메서드 패턴을 Before/After로 비교한다.

Before — 매번 반복하는 세 줄 코드:

C#
// Update에서 매 프레임 실행된다고 가정
void Update()
{
    // X 좌표만 변경하려면 이 패턴을 반복해야 한다
    var pos = transform.position;
    pos.x = Mathf.Lerp(pos.x, targetX, Time.deltaTime * speed);
    transform.position = pos;
}

After — 확장 메서드로 깔끔하게:

C#
public static class TransformExtensions
{
    public static void SetPositionX(this Transform t, float x)
    {
        var pos = t.position;
        pos.x = x;
        t.position = pos;
    }

    public static void SetPositionY(this Transform t, float y)
    {
        var pos = t.position;
        pos.y = y;
        t.position = pos;
    }
}

// Update에서 한 줄로 표현
void Update()
{
    transform.SetPositionX(Mathf.Lerp(transform.position.x, targetX, Time.deltaTime * speed));
}

이 패턴의 IL은 단순한 정적 호출이므로 성능 오버헤드가 없다. 코드 가독성만 높아진다.

GetOrAddComponent 패턴

Unity에서 가장 실용적인 확장 메서드 중 하나다.

C#
public static class GameObjectExtensions
{
    public static T GetOrAddComponent<T>(this GameObject go) where T : Component
    {
        if (!go.TryGetComponent<T>(out var comp))
            comp = go.AddComponent<T>();
        return comp;
    }
}

// 사용 — Rigidbody가 없으면 자동으로 추가
Rigidbody rb = gameObject.GetOrAddComponent<Rigidbody>();

where T : Component 제네릭 제약 조건은 컴파일 타임에 타입 안전성을 보장하므로, 런타임 타입 검사 비용이 발생하지 않는다.

메서드 체이닝 (Fluent API)

확장 메서드가 자기 자신(또는 동일 타입)을 반환하면 메서드 체이닝이 가능하다.

C#
public static class GameObjectChainExtensions
{
    public static GameObject SetLayer(this GameObject go, int layer)
    {
        go.layer = layer;
        return go;
    }

    public static GameObject SetTag(this GameObject go, string tag)
    {
        go.tag = tag;
        return go;
    }

    public static GameObject SetName(this GameObject go, string name)
    {
        go.name = name;
        return go;
    }
}

// 한 줄로 여러 설정을 연쇄 적용
gameObject
    .SetLayer(LayerMask.NameToLayer("Enemy"))
    .SetTag("Enemy")
    .SetName("Goblin_01");

LINQ가 바로 이 패턴의 대표적 사례다. Where, Select, OrderBy 같은 LINQ 메서드는 모두 IEnumerable<T>에 대한 확장 메서드이며, 각각이 IEnumerable<T>를 반환하여 체이닝을 가능하게 한다.

LINQ 확장 메서드 — Unity 핫패스에서의 주의

LINQ의 편리한 체이닝에는 비용이 숨어 있다. Update 같은 핫패스(hot path, 매 프레임 반복 실행되는 코드 경로)에서 LINQ를 사용하면 GC(Garbage Collector, 더 이상 사용하지 않는 메모리를 자동으로 회수하는 런타임 구성요소) 압박이 발생할 수 있다.

Before — LINQ 체이닝 (GC 할당 발생):

C#
using System.Linq;
using System.Collections.Generic;

public class Program
{
    public static int SumEvenScores_Linq(List<int> scores)
    {
        return scores.Where(s => s % 2 == 0).Sum();
    }
}
IL
.method public hidebysig static
    int32 SumEvenScores_Linq (
        class [System.Collections]System.Collections.Generic.List`1<int32> scores
    ) cil managed
{
    IL_0001: ldarg.0
    IL_0002: ldsfld class [System.Runtime]System.Func`2<int32, bool> Program/'<>c'::'<>9__0_0'
                                                          // 캐싱된 Func 대리자 확인
    IL_0007: dup
    IL_0008: brtrue.s IL_0021                             // 이미 캐싱되어 있으면 재생성 건너뜀
    IL_000a: pop
    IL_000b: ldsfld class Program/'<>c' Program/'<>c'::'<>9'
    IL_0010: ldftn instance bool Program/'<>c'::'<SumEvenScores_Linq>b__0_0'(int32)
    IL_0016: newobj instance void class [System.Runtime]System.Func`2<int32, bool>::.ctor(object, native int)
                                                          // ↑ 첫 호출 시 Func 대리자 객체 생성 (힙 할당)
    IL_001b: dup
    IL_001c: stsfld class [System.Runtime]System.Func`2<int32, bool> Program/'<>c'::'<>9__0_0'
                                                          // 정적 필드에 캐싱
    IL_0021: call class [System.Runtime]System.Collections.Generic.IEnumerable`1<!!0>
        [System.Linq]System.Linq.Enumerable::Where<int32>(...)
                                                          // ↑ Where가 IEnumerable 반복자 객체 생성 (힙 할당)
    IL_0026: call int32 [System.Linq]System.Linq.Enumerable::Sum(...)
                                                          // ↑ Sum이 GetEnumerator 호출 (추가 할당 가능)
    IL_002b: stloc.0
    IL_002e: ldloc.0
    IL_002f: ret
}

Where는 매번 새로운 IEnumerable 반복자 객체를 힙에 생성한다. 이 코드가 Update에서 매 프레임 호출되면 GC 압박의 원인이 된다.

After — for 루프 (GC 할당 없음):

C#
using System.Collections.Generic;

public class Program
{
    public static int SumEvenScores_Loop(List<int> scores)
    {
        int sum = 0;
        for (int i = 0; i < scores.Count; i++)
        {
            if (scores[i] % 2 == 0)
                sum += scores[i];
        }
        return sum;
    }
}
IL
.method public hidebysig static
    int32 SumEvenScores_Loop (
        class [System.Collections]System.Collections.Generic.List`1<int32> scores
    ) cil managed
{
    IL_0000: nop
    IL_0001: ldc.i4.0
    IL_0002: stloc.0                                       // sum = 0
    IL_0003: ldc.i4.0
    IL_0004: stloc.1                                       // i = 0
    IL_0005: br.s IL_0027
    // loop start
        IL_0008: ldarg.0
        IL_0009: ldloc.1
        IL_000a: callvirt instance !0 class List`1<int32>::get_Item(int32)
                                                           // ↑ 인덱서로 직접 접근 — 반복자 객체 없음
        IL_000f: ldc.i4.2
        IL_0010: rem                                       // % 2
        IL_0011: ldc.i4.0
        IL_0012: ceq                                       // == 0
        IL_0016: brfalse.s IL_0022                         // 홀수면 건너뜀
        IL_0018: ldloc.0
        IL_0019: ldarg.0
        IL_001a: ldloc.1
        IL_001b: callvirt instance !0 class List`1<int32>::get_Item(int32)
        IL_0020: add                                       // sum += scores[i]
        IL_0021: stloc.0
        IL_0023: ldloc.1
        IL_0024: ldc.i4.1
        IL_0025: add                                       // i++
        IL_0026: stloc.1
        IL_0027: ldloc.1
        IL_0028: ldarg.0
        IL_0029: callvirt instance int32 class List`1<int32>::get_Count()
        IL_002e: clt                                       // i < scores.Count
        IL_0032: brtrue.s IL_0007
    // end loop
    IL_0034: ldloc.0
    IL_003b: ret
}

for 루프 버전에는 newobj가 없다. 반복자 객체를 생성하지 않으므로 힙 할당이 발생하지 않는다. Unity의 Update 루프처럼 매 프레임 호출되는 코드에서는 이 차이가 GC 스파이크(Garbage Collection Spike, GC가 한꺼번에 많은 메모리를 회수할 때 발생하는 프레임 끊김 현상)로 이어질 수 있다.

판단 기준: 확장 메서드 자체에는 성능 비용이 없다. 문제는 확장 메서드 내부에서 무엇을 하느냐다. LINQ 확장 메서드는 내부적으로 힙 할당을 수반하므로, 핫패스에서는 직접 루프를 작성하는 것이 안전하다.


함정과 주의사항

함정 1: 값 타입 확장 시 복사 문제

Unity에서 Vector3는 구조체(값 타입)다. 확장 메서드의 this 매개변수는 기본적으로 값을 복사하여 전달한다.

❌ 잘못된 패턴 — 원본이 수정되지 않음:

C#
public static class Vector3Extensions
{
    public static void SetX_Bad(this Vector3 v, float x)
    {
        v.X = x; // 복사본만 수정됨 — 원본은 그대로!
    }
}
IL
// SetX_Bad 메서드 시그니처
.method public hidebysig static
    void SetX_Bad (
        valuetype Vector3 v,              // ← 값 타입이 값으로 전달됨 (복사)
        float32 x
    ) cil managed

// Main에서 호출
IL_0017: ldloc.0                          // pos의 값을 스택에 복사
IL_0018: ldc.r4 10
IL_001d: call void Vector3Extensions::SetX_Bad(valuetype Vector3, float32)
                                          // ↑ 복사본이 전달됨 — 원본 pos는 변경되지 않음
ref this — 참조 확장 메서드 (Ref extension method, C# 7.2+) 확장 메서드의 첫 번째 매개변수에 ref this를 붙이면 값 타입의 원본을 참조로 전달한다. 불필요한 복사를 방지하고 원본을 직접 수정할 수 있다.
예시: public static void SetX(ref this Vector3 v, float x) — v가 원본을 가리킴

✅ 올바른 패턴 — ref this로 원본 참조:

C#
public static class Vector3Extensions
{
    public static void SetX_Good(ref this Vector3 v, float x)
    {
        v.X = x; // 원본이 수정됨
    }
}
IL
// SetX_Good 메서드 시그니처
.method public hidebysig static
    void SetX_Good (
        valuetype Vector3& v,             // ← 참조로 전달됨 (Vector3&)
        float32 x
    ) cil managed

// Main에서 호출
IL_0023: ldloca.s 0                       // pos의 주소를 스택에 로드
IL_0025: ldc.r4 10
IL_002a: call void Vector3Extensions::SetX_Good(valuetype Vector3&, float32)
                                          // ↑ 주소가 전달됨 — 원본 pos가 직접 수정됨

핵심은 ldloc.0(값 복사) vs ldloca.s 0(주소 전달)의 차이다. ref this를 사용하면 IL에서 valuetype Vector3&(참조)로 전달되어 원본을 직접 수정할 수 있다.

함정 2: 인스턴스 메서드가 나중에 추가되면 확장 메서드가 사라진다

확장 메서드를 잘 사용하고 있었는데, 라이브러리 업데이트로 해당 타입에 같은 시그니처의 인스턴스 메서드가 추가되면 — 아무 경고 없이 확장 메서드 대신 인스턴스 메서드가 호출된다.

C#
// v1.0 — Transform에 Reset이 없으므로 확장 메서드가 호출됨
public static class TransformExtensions
{
    public static void Reset(this Transform t)
    {
        t.position = Vector3.zero;
        t.rotation = Quaternion.identity;
        t.localScale = Vector3.one;
    }
}

transform.Reset(); // TransformExtensions.Reset(transform) 호출

// v2.0 — Unity가 Transform.Reset() 인스턴스 메서드를 추가했다면?
// transform.Reset(); → Transform.Reset() 인스턴스 메서드 호출
// 확장 메서드는 경고 없이 무시됨

대응 방법: 확장 메서드 이름이 기존 타입의 메서드와 충돌할 가능성이 있다면, 프로젝트 고유의 접두사를 붙이거나 더 구체적인 이름을 사용한다.

함정 3: null 안전성의 착각

확장 메서드는 null에서 호출 가능하다는 점이 오히려 함정이 될 수 있다. 개발자가 "메서드가 호출되었으니 객체는 null이 아닐 것"이라고 착각하는 상황이 생긴다.

C#
// ❌ null에서 호출되는 것을 모르고 내부에서 바로 멤버 접근
public static class ListExtensions
{
    public static T FirstOrDefault_Bad<T>(this List<T> list)
    {
        return list[0]; // list가 null이면 NullReferenceException!
    }
}

// ✅ null 검사를 포함한 안전한 버전
public static class ListExtensions
{
    public static T FirstOrDefault_Safe<T>(this List<T> list)
    {
        if (list == null || list.Count == 0)
            return default;
        return list[0];
    }
}

확장 메서드 내부에서 this 매개변수에 대해 null 검사를 수행하는 습관을 들이는 것이 안전하다.

함정 4: dynamic과 확장 메서드

확장 메서드는 dynamic 타입의 변수에서 호출할 수 없다. DLR(Dynamic Language Runtime, 런타임에 타입을 결정하는 동적 언어 기반 구성요소)은 런타임에 메서드를 바인딩하는데, 확장 메서드 탐색은 using 지시문에 의존하는 컴파일 타임 개념이므로 호환되지 않는다.

C#
dynamic value = 42;
// value.IsPositive(); // 컴파일은 되지만 런타임에 RuntimeBinderException 발생
bool result = IntExtensions.IsPositive((int)value); // 명시적 정적 호출은 가능

Unity에서 dynamic을 사용하는 경우는 거의 없지만, 리플렉션이나 서드파티 라이브러리와 연동할 때 주의해야 한다.


C# 버전별 변화

C# 3.0 (.NET Framework 3.5, 2008) — 확장 메서드 도입

확장 메서드는 LINQ를 구현하기 위해 C# 3.0에서 도입되었다. IEnumerable<T>에 대한 확장 메서드로 Where, Select, OrderBy 등을 정의하여, 기존 컬렉션 타입을 수정하지 않고도 쿼리 기능을 추가했다.

C#
// C# 3.0 — LINQ의 시작
using System.Linq;

var evenNumbers = numbers.Where(n => n % 2 == 0)
                         .Select(n => n * 10)
                         .OrderBy(n => n);

C# 7.2 (.NET Core 2.0, 2017) — ref/in this 확장 메서드

값 타입에 대한 확장 메서드에서 불필요한 복사를 방지하기 위해 ref thisin this가 추가되었다.

Before (C# 3.0~7.1) — 값 타입 복사 불가피:

C#
// 구조체가 크면 매 호출마다 전체 복사 발생
public static float MagnitudeSlow(this LargeStruct v)
{
    return (float)Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
}

After (C# 7.2+) — 복사 없는 참조 전달:

C#
// in this — 읽기 전용 참조로 전달, 복사 없음
public static float MagnitudeFast(in this LargeStruct v)
{
    return (float)Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
}

// ref this — 쓰기 가능 참조 (앞서 SetX_Good에서 확인)
public static void SetX(ref this Vector3 v, float x)
{
    v.X = x;
}

IL에서 in thisvaluetype LargeStruct&(참조)로 전달되어 복사가 발생하지 않는다. Unity의 Vector3처럼 자주 전달되는 구조체에 유용하다.

C# 10 (.NET 6, 2021) — global using

프로젝트 전역에 using을 선언할 수 있게 되었다. 확장 메서드를 매 파일에서 using하는 번거로움이 사라졌다.

C#
// GlobalUsings.cs (프로젝트 루트에 한 번만 선언)
global using static MyProject.TransformExtensions;
global using static MyProject.GameObjectExtensions;

// 이후 모든 .cs 파일에서 using 없이 바로 사용 가능
transform.SetPositionX(10f);
gameObject.GetOrAddComponent<Rigidbody>();

정리

확장 메서드의 핵심을 체크리스트로 정리한다.

  • [ ] 확장 메서드는 구문 설탕이다 — 컴파일 타임에 정적 메서드 호출(call)로 변환되며, 런타임 성능 오버헤드가 없다
  • [ ] 선언 규칙 세 가지 — static class + static method + this 첫 번째 매개변수
  • [ ] 인스턴스 메서드가 항상 이긴다 — 같은 시그니처의 인스턴스 메서드가 있으면 확장 메서드는 무시되고, 경고도 없다
  • [ ] null에서 호출 가능하다call(정적 호출)이므로 null 체크가 없다. 메서드 내부에서 직접 null 검사가 필요하다
  • [ ] 값 타입 확장 시 ref this 사용 — 기본적으로 값이 복사되므로, 원본 수정이나 큰 구조체 전달 시 ref this 또는 in this를 쓴다
  • [ ] Unity 핫패스에서 LINQ 주의 — LINQ 확장 메서드 자체가 아니라, 내부에서 생성하는 반복자 객체가 GC 압박의 원인이다
  • [ ] public 멤버만 접근 가능 — 캡슐화를 깨지 않는다. private/protected 멤버에는 접근할 수 없다