반응형

[PART8.상속과 인터페이스 사용법(8/11)] 정적 추상 인터페이스 멤버 — 타입 자체를 다형성에 끌어들이다

C# 11이 인터페이스에 static abstract를 허용한 진짜 이유 / T.Zero·T.Add(a, b)로 호출되는 새로운 디스패치 / constrained. IL이 박싱을 어떻게 없애는가


1. [문제 제기] 모든 숫자에 동작하는 Sum 하나를 왜 못 쓰는가

Unity 모바일 게임에서 데미지·골드·경험치를 합산하는 코드를 매번 따로 짜본 경험이 있을 겁니다. int 합산용 함수, float 합산용 함수, decimal(통화 계산용) 합산용 함수 — 본질이 같은 "원소를 0에서 시작해 누적한다"인데도 타입마다 코드가 갈라졌습니다.

C# 10까지의 인터페이스로는 이 갈라짐을 막을 수 없었습니다. 인터페이스가 강제할 수 있는 것은 오직 인스턴스 멤버, 즉 객체 하나가 갖는 동작뿐이었기 때문입니다.

C#
// C# 10까지 — 컴파일 오류
public static T Sum<T>(IEnumerable<T> values)
{
    T total = default;
    foreach (var v in values) total = total + v;  // 'T' 타입에 '+' 연산자를 적용할 수 없음
    return total;
}

+, Parse, Zero처럼 타입 자체에 속한 동작(인스턴스 없이 호출)은 인터페이스로 강제할 길이 없었습니다. 그래서 우리는 매번 IntAdder·DoubleAdder 같은 도우미 객체를 만들어 인스턴스 메서드로 우회했습니다.

C#
public class IntAdder { public int Add(int a, int b) => a + b; }

public static int SumInts(int[] arr)
{
    var adder = new IntAdder();   // ❶ 매 호출마다 도우미 객체 할당
    int t = 0;
    foreach (var v in arr) t = adder.Add(t, v);  // ❷ callvirt(가상 호출)
    return t;
}

이 코드의 IL을 뽑아 보면 의심이 사실로 굳어집니다.

IL
IL_0000: newobj instance void IntAdder::.ctor()   // 도우미 객체 힙 할당
...
IL_0017: callvirt instance int32 IntAdder::Add(int32, int32)  // 가상 호출

매번 newobj로 힙 할당이 일어나고, callvirt는 가상 메서드 테이블을 한 단계 거칩니다. Unity 핫패스에서 프레임마다 이런 호출이 쌓이면 GC(Garbage Collector, 도달 불가능해진 힙 객체를 자동 회수하는 런타임 구성요소) 압박과 디스패치 비용이 모두 늘어납니다.

문제의 본질: "타입이 갖춰야 할 정적 동작"을 인터페이스가 표현할 수 없었기 때문에, 우리는 정적인 일을 굳이 인스턴스 객체에 담아 전달하고 있었습니다. C# 11의 static abstract 인터페이스 멤버는 이 우회 자체를 없앱니다.


2. [개념 정의] 타입 자체에 계약을 거는 static abstract

비유 — "회사 차원의 의무 vs 직원 개인의 의무"

기존 인터페이스가 "이 회사에 속한 모든 직원(인스턴스)은 출퇴근 카드를 찍어야 한다"라는 계약이었다면, static abstract 인터페이스 멤버는 "이 인터페이스를 구현한 모든 회사(타입 자체)는 대표 전화번호를 가져야 한다"라는 계약입니다.

직원에게 묻는 게 아닙니다. 회사 자체에 묻습니다.

구조 한눈에 보기

인터페이스 멤버 종류 (C# 11 기준)

가장 단순한 예시

static abstract — 정적 추상 멤버 (Static Abstract Member) 인터페이스 안에 본문 없이 선언하는 정적 멤버. 이 인터페이스를 구현하는 타입은 동일한 시그니처의 static 메서드·프로퍼티·연산자를 반드시 제공해야 한다.
예시: static abstract T Zero { get; } 이 인터페이스를 구현한 타입은 자기 자신 타입의 "0" 값을 정적 프로퍼티로 노출해야 한다.
C#
public interface IAdder<T> where T : IAdder<T>
{
    static abstract T Zero { get; }                // 정적 프로퍼티
    static abstract T Add(T a, T b);               // 정적 메서드
    static abstract T operator +(T a, T b);        // 정적 연산자
}

public readonly struct Money : IAdder<Money>
{
    public readonly long Cents;
    public Money(long c) => Cents = c;

    public static Money Zero => new Money(0);
    public static Money Add(Money a, Money b) => new Money(a.Cents + b.Cents);
    public static Money operator +(Money a, Money b) => new Money(a.Cents + b.Cents);
}
where T : IAdder<T> — 호기심 재귀 제약 (CRTP, F-bounded polymorphism) 타입 매개변수 T가 자기 자신을 인자로 받은 인터페이스를 구현해야 한다는 제약. 인터페이스 안에서 T가 등장할 때 그 T가 구현 타입 자신임을 컴파일 타임에 보장한다.
예시: Money : IAdder<Money> Money가 자기 자신을 T로 넘겼기 때문에 IAdder<Money>.Add의 시그니처는 정확히 (Money, Money) → Money로 굳어진다.

쉬운 설명: 인터페이스가 "구현 타입 자체가 가지고 있어야 할 정적인 도구들"을 명시하고, 사용자는 인스턴스를 거치지 않고 타입 매개변수 T를 통해 그 도구들을 호출합니다.

기술 정의: C# 11은 인터페이스 멤버에 static abstractstatic virtual 한정자를 허용하여, 구현 타입이 정적 메서드·정적 프로퍼티·정적 연산자(이항·단항·변환·비교·증감)까지 다형적으로 제공하도록 강제할 수 있습니다.

컴파일러가 만든 인터페이스 IL

IL
.class interface public auto ansi abstract beforefieldinit IAdder`1<(class IAdder`1<!T>) T>
{
    .method public hidebysig specialname abstract virtual static
        !T get_Zero () cil managed { }

    .method public hidebysig abstract virtual static
        !T Add (!T a, !T b) cil managed { }

    .method public hidebysig specialname abstract virtual static
        !T op_Addition (!T a, !T b) cil managed { }
}

핵심 키워드는 abstract virtual static 세 개의 동시 적용입니다. 보통 virtual은 인스턴스 디스패치를 의미하지만, 여기서는 static과 결합되어 "정적 멤버이지만 다형적 디스패치 대상이다"라는 새로운 의미를 가집니다. 이 조합 자체가 C# 11에서 새로 허용된 IL 패턴입니다.


3. [내부 동작] constrained. 접두사가 만드는 박싱 없는 정적 디스패치

호출이 어떻게 풀리는가

Sum<Money>(...)를 호출하면 컴파일러는 T = Money로 인스턴스화한 제네릭 메서드를 만들어내야 합니다. 이때 T.ZeroT.Add(a, b)는 어떤 IL로 변환될까요?

T.Add(a, b) 가 IL로 변환되는 과정

직접 측정한 IL

C#
public static T Sum<T>(IEnumerable<T> values) where T : IAdder<T>
{
    T total = T.Zero;
    foreach (var v in values) total = T.Add(total, v);
    return total;
}

이 메서드를 컴파일하면 다음 IL이 나옵니다.

IL
.method public hidebysig static
    !!T Sum<(class IAdder`1<!!T>) T> (
        class [System.Runtime]System.Collections.Generic.IEnumerable`1<!!T> values
    ) cil managed
{
    IL_0000: constrained. !!T                              // ❶ 다음 호출은 T를 통해
    IL_0006: call !0 class IAdder`1<!!T>::get_Zero()       // ❷ call (callvirt 아님)
    IL_000b: stloc.0
    ...
    IL_001e: constrained. !!T                              // ❸ 루프 안에서도 동일
    IL_0024: call !0 class IAdder`1<!!T>::Add(!0, !0)
    IL_0029: stloc.0
    ...
}

두 가지 결정적 사실

constrained. !!T 접두사: JIT/AOT 컴파일러에게 "다음 호출은 일반 호출이 아니라 제네릭 타입 매개변수 T의 정적 슬롯을 통해 디스패치하라"고 알리는 명령입니다. 런타임은 T가 인스턴스화된 구체 타입(여기서는 Money 또는 int)의 정적 메서드 테이블을 참조해 호출 대상을 결정합니다.

callvirt가 아니라 call: 일반 인터페이스 인스턴스 호출은 가상 디스패치인 callvirt를 씁니다. 그러나 정적 추상은 인스턴스 객체가 없으므로 가상 호출이 의미가 없고, constrained.이 디스패치 결정을 끝내준 뒤 단순 call로 정적 함수가 호출됩니다.

이 차이는 단순히 IL 표기 차이가 아니라 박싱 제거라는 실용적 결과를 만듭니다. 인스턴스 인터페이스 호출이라면 T가 값 타입(Money, int)일 때 this 매개변수를 인터페이스 참조로 변환하기 위해 박싱이 일어납니다. constrained. + call 조합은 this 자체가 없으므로 박싱이 발생할 자리가 사라집니다.

인터페이스 구현 측의 .override

Money에서 정적 메서드를 정의할 때, 컴파일러는 명시적 .override 메타데이터로 인터페이스의 정적 슬롯에 묶어 둡니다.

IL
.method public hidebysig static
    valuetype Money Add (valuetype Money a, valuetype Money b) cil managed
{
    .override method !0 class IAdder`1<valuetype Money>::Add(!0, !0)
    ...
}

일반 인터페이스의 인스턴스 메서드와 같은 매핑 메커니즘을 쓰지만, 디스패치 자체가 정적이라 결과적으로 가상 함수 테이블을 거치지 않습니다.


4. [실전 적용] 도우미 클래스를 제거하는 Before / After

Before — C# 10 도우미 클래스 패턴

Unity에서 데미지·골드 합산 로직이 타입별로 갈라져 있는 상황입니다.

C#
// 한 가지 타입(int)만 처리하는 합산 함수
public class IntAdder { public int Add(int a, int b) => a + b; }

public static int SumInts(int[] arr)
{
    var adder = new IntAdder();         // 매 호출마다 힙 할당
    int t = 0;
    foreach (var v in arr) t = adder.Add(t, v);
    return t;
}

이 코드의 IL은 매번 객체 할당과 가상 호출을 만듭니다.

IL
IL_0000: newobj instance void IntAdder::.ctor()        // ← 힙 할당
...
IL_0017: callvirt instance int32 IntAdder::Add(int32, int32)   // ← 가상 호출

float·decimal 합산을 추가로 지원하려면 FloatAdder·DecimalAdder 클래스를 더 만들어야 하고, 코드는 곱하기로 늘어납니다.

After — C# 11 정적 추상 인터페이스 + 제네릭

C#
using System.Numerics;

public static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T total = T.Zero;                    // 타입 자체가 0을 안다
    foreach (var v in values) total += v;
    return total;
}

// 호출 측 — 같은 함수를 모든 숫자 타입에 사용
int gold     = Sum(new[] { 100, 200, 300 });            // 600
float damage = Sum(new[] { 1.5f, 2.0f, 0.5f });          // 4.0
decimal cash = Sum(new[] { 100.50m, 99.50m });           // 200.00
INumber<T> — .NET 7 제네릭 수학 인터페이스 모든 숫자 타입의 공통 계약. Zero, One, Abs, Min, Max 같은 기본값과, +, -, *, / 같은 모든 산술 연산자를 정적 추상으로 모아 놓은 인터페이스. CRTP(where T : INumber<T>)로 정의된다.
예시: where T : INumber<T> 한 줄이면 T.Zero, a + b, a * b가 모두 가능

After 측 IL은 도우미 객체 할당이 사라지고, constrained. 접두사로 매 호출이 직접 디스패치됩니다.

IL
IL_0000: constrained. !!T
IL_0006: call !0 class [System.Runtime]System.Numerics.INumberBase`1<!!T>::get_Zero()
IL_000b: stloc.0
...
IL_001e: constrained. !!T
IL_0024: call !0 class [System.Runtime]System.Numerics.IAdditionOperators`3<!!T,!!T,!!T>::op_Addition(!0, !0)

Unity 핫패스 관점

  Before (도우미 클래스) After (정적 추상 + 제네릭)
호출당 할당 도우미 객체 1개(힙) 0
디스패치 callvirt 가상 호출 constrained. + call 정적 호출
GC 압박 O — 매 호출 Gen0 쓰레기 발생 X
타입 추가 클래스 1개 추가 작성 INumber<T>라면 자동 지원
IL2CPP AOT 정상 정상(Unity 6 이상 안정)

Unity 모바일에서 매 프레임 점수·자원·재화를 합산하는 함수가 도우미 객체 패턴이라면, INumber<T> 또는 사용자 정의 정적 추상 인터페이스로 옮기는 것만으로 GC Gen0 쓰레기와 가상 호출 비용을 동시에 제거할 수 있습니다.

사용자 정의 인터페이스 — 도메인에 맞춘 정적 추상

게임 내 합성 가능한 자원이 있다고 가정합시다. 골드와 보석은 다른 타입이지만 둘 다 "0"이 있고 더할 수 있다는 점은 같습니다.

C#
public interface ICombinable<T> where T : ICombinable<T>
{
    static abstract T Empty { get; }
    static abstract T operator +(T a, T b);
}

public readonly struct Gold : ICombinable<Gold>
{
    public readonly int Amount;
    public Gold(int a) => Amount = a;
    public static Gold Empty => new Gold(0);
    public static Gold operator +(Gold a, Gold b) => new Gold(a.Amount + b.Amount);
}

public readonly struct Gem : ICombinable<Gem>
{
    public readonly int Count;
    public Gem(int c) => Count = c;
    public static Gem Empty => new Gem(0);
    public static Gem operator +(Gem a, Gem b) => new Gem(a.Count + b.Count);
}

public static T Combine<T>(IEnumerable<T> items) where T : ICombinable<T>
{
    T total = T.Empty;
    foreach (var x in items) total = total + x;
    return total;
}

Combine<Gold>Combine<Gem>을 한 함수로 처리하면서도, 두 타입을 섞어 부르는 실수는 컴파일러가 막아 줍니다 — GoldGem이 같은 인터페이스를 구현해도 자신을 T로 묶었기 때문에 서로 호환되지 않습니다.


5. [함정과 주의사항] 인터페이스를 직접 변수 타입으로 쓰지 마라

함정 1 — INumber<int> 같은 인터페이스를 변수 타입으로 사용

C#
// ❌ 잘못된 패턴
public static int SumWithBoxing(IEnumerable<INumber<int>> values)
{
    int total = 0;
    foreach (var v in values) total += (int)v;
    return total;
}

위 코드는 컴파일조차 되지 않습니다. C# 11 컴파일러가 다음 오류를 던집니다.

error CS8920: 인터페이스 'INumber<int>'은(는) 형식 인수로 사용할 수 없습니다.
정적 멤버 'IParsable<int>.Parse(string, IFormatProvider?)'은(는) 인터페이스에
가장 구체적인 구현이 없습니다.

이건 단순한 경고가 아닙니다. static abstract 멤버를 가진 인터페이스는 형식 인자(type argument)로 사용할 수 없다는 언어 차원의 제약입니다. 인터페이스는 어디까지나 "구현 계약"이고 자기 자신이 호출 대상이 될 수 없기 때문입니다. 이 기능은 반드시 제네릭 제약 형태(where T : INumber<T>)로만 의미가 있습니다.

C#
// ✅ 올바른 패턴 — 제네릭 메서드 + CRTP 제약
public static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (var v in values) total += v;
    return total;
}

함정 2 — CRTP 제약 누락

C#
// ❌ where T : IAdder만 있고 <T>가 빠지면
public static T Sum<T>(IEnumerable<T> v) where T : IAdder<???>  // T 자기자신을 어떻게 가리키지?

T 안에서 T 자신을 다시 인자로 넘기는 패턴 없이는 인터페이스에 정의된 static abstract T Add(T a, T b)T가 무엇인지 컴파일러가 결정할 수 없습니다. 그래서 정적 추상 인터페이스는 거의 항상 다음 형태로 선언됩니다.

C#
// ✅ CRTP 패턴
public interface IAdder<T> where T : IAdder<T>
{
    static abstract T Zero { get; }
    static abstract T Add(T a, T b);
}

이 한 줄이 "내가 강제하는 정적 메서드의 시그니처에 등장하는 T는 정확히 나를 구현하는 그 타입이다"라는 보장을 만듭니다.

함정 3 — 인스턴스로 정적 멤버 호출 시도

C#
// ❌ 컴파일 오류
public static T Add<T>(T a, T b) where T : IAdder<T>
{
    return a.Add(a, b);    // 인스턴스를 통해 정적 멤버를 호출할 수 없음
}

// ✅ 타입 매개변수를 통해 호출
public static T Add<T>(T a, T b) where T : IAdder<T>
{
    return T.Add(a, b);    // 또는 a + b
}

호출자 머릿속에서 "객체가 가진 메서드"라는 옛 모델을 버려야 합니다. static abstract타입 자체가 가진 메서드입니다.

함정 4 — 값 타입을 인터페이스 참조에 담아 박싱 발생

C#
// ❌ 박싱 발생 (개념적 예시)
IAdder<Money> a = new Money(100);    // ← 값 타입을 인터페이스 참조에 담음 = 박싱
IAdder<Money> b = new Money(200);
// 게다가 정적 멤버라 a.Add 같은 인스턴스 호출도 의미 없음

값 타입(struct)을 인터페이스 참조 타입 변수에 대입하는 순간 박싱이 일어나 힙 할당이 발생합니다. static abstract의 박싱 없는 디스패치는 제네릭 + CRTP 제약으로 호출할 때만 보장됩니다.

C#
// ✅ 제네릭 메서드를 통해 호출 — 박싱 없음
public static T DoSomething<T>(T a, T b) where T : IAdder<T>
{
    return T.Add(a, b);   // constrained. + call
}

DoSomething(new Money(100), new Money(200));   // 박싱 없음

함정 5 — Unity IL2CPP에서 코드 팽창

static abstract는 IL2CPP(Intermediate Language To C++ — Unity의 AOT 컴파일러)에서도 정상 동작합니다. 그러나 AOT는 사용된 모든 T 조합에 대해 별도의 네이티브 코드를 미리 생성해야 합니다.

C#
Sum<int>(...);        // → Sum_int 코드 생성
Sum<float>(...);      // → Sum_float 코드 생성
Sum<double>(...);     // → Sum_double 코드 생성
Sum<decimal>(...);    // → Sum_decimal 코드 생성
Sum<Gold>(...);       // → Sum_Gold 코드 생성

int/float 한두 가지를 일반화하는 데 굳이 INumber<T>를 쓰면 빌드 크기와 빌드 시간만 늘어날 수 있습니다. 여러 도메인 타입을 진짜로 폴리모픽하게 다루어야 할 때(여러 자원 타입의 합산, 여러 점수 타입의 비교 등)에 가치가 있습니다. 단일 타입 한 줄짜리 함수까지 일반화하는 건 과설계입니다.


6. [C# 버전별 변화] 인터페이스 멤버가 정적 영역까지 확장된 흐름

C# 7까지 — 인터페이스는 인스턴스 멤버 계약만

C#
// 인터페이스: 본문도, 정적 멤버도 불가
public interface IDamageable
{
    void TakeDamage(int amount);
    int Hp { get; }
}

본문 자체가 금지였고, 정적 멤버는 아예 선언 불가. 정적 동작을 강제하려면 abstract class로 우회해야 했습니다.

C# 8 — 인터페이스 기본 구현(DIM) 도입

C#
public interface IDamageable
{
    void TakeDamage(int amount);
    int Hp { get; }
    bool IsDead => Hp <= 0;          // 인스턴스 기본 구현 가능
}

인스턴스 메서드의 본문이 허용됐지만 정적 멤버를 강제하는 능력은 여전히 없었습니다. Parse, + 같은 타입 차원의 동작은 표현 불가능.

C# 11 — 정적 추상 / 정적 가상 인터페이스 멤버

C#
public interface IAdder<T> where T : IAdder<T>
{
    static abstract T Zero { get; }
    static abstract T Add(T a, T b);
    static abstract T operator +(T a, T b);

    // 정적 가상 — 본문 있는 기본 구현
    static virtual T Triple(T x) => T.Add(T.Add(x, x), x);
}

비로소 "타입 자체가 가져야 할 정적 동작"을 인터페이스 계약으로 강제할 수 있게 됐습니다. .NET 7의 INumber<T>·IAdditionOperators<TSelf, TOther, TResult>·IParsable<T> 같은 제네릭 수학 인터페이스 군이 이 위에서 만들어졌습니다.

IL 비교

C# 8까지의 인터페이스 메서드는 IL상 abstract virtual로 선언되었습니다.

IL
.method public hidebysig newslot abstract virtual
    instance void TakeDamage(int32 amount) cil managed

C# 11의 정적 추상은 abstract virtual static 세 단어가 동시에 나타납니다.

IL
.method public hidebysig abstract virtual static
    !T Add(!T a, !T b) cil managed

virtual static이라는 조합 자체가 C# 11에서 새로 허용된 IL 패턴이며, 이 표시가 있는 멤버는 호출자 측에서 constrained. + call로 디스패치됩니다.

호출자 측 IL 변화 (개념 비교)

버전 호출 IL 의미
C# 7까지 인스턴스 멤버 callvirt instance ... 객체의 vtable로 디스패치
C# 8 DIM 인스턴스 callvirt instance ... 동일, 단 인터페이스가 본문 제공
C# 11 정적 추상 constrained. !!T call !0 ... 타입 매개변수의 정적 슬롯으로 디스패치, 박싱 없음

7. [정리] 핵심 체크리스트

  • [ ] static abstract 인터페이스 멤버는 타입 자체에 거는 계약. 인스턴스가 아니라 T.Zero, T.Add(a, b), a + b로 호출한다.
  • [ ] 거의 항상 where T : IFoo<T> CRTP 패턴으로 선언한다 — 인터페이스 안의 T가 구현 타입 자신임을 보장하기 위해.
  • [ ] static virtual은 본문 있는 기본 구현(C# 8 DIM의 정적 버전), static abstract는 본문 없는 강제 계약.
  • [ ] IL 디스패치는 constrained. !!T + call 조합. callvirt가 아니므로 vtable을 거치지 않고 박싱도 없다.
  • [ ] INumber<T> 같은 인터페이스를 형식 인자(List<INumber<int>>)로 쓰면 CS8920 컴파일 오류. 반드시 제네릭 제약 형태로만 사용한다.
  • [ ] .NET 7의 INumber<T>·IAdditionOperators<TSelf, TOther, TResult>·IParsable<T> 등이 대표 활용. Sum<T>(IEnumerable<T>) where T : INumber<T> 한 함수로 모든 숫자 타입 처리.
  • [ ] Unity IL2CPP는 constrained.를 정상 처리하므로 동작은 안전하지만, 타입 조합마다 네이티브 코드가 별도 생성되어 코드 팽창에 주의. 단일 타입 함수에 굳이 일반화하지 않는다.
  • [ ] 값 타입을 인터페이스 참조 변수에 담는 순간 박싱이 일어난다. 박싱 없는 디스패치는 제네릭 + CRTP 호출에서만 보장.
반응형

+ Recent posts