반응형

[PART7.클래스와 객체 입문(8/21)] 정적(static) 멤버 — 인스턴스 없이 호출하는 코드와 데이터

형식 자체에 속하는 멤버의 의미 / call vs callvirt / static class · static 생성자 / 언제 쓰고 언제 피하는가


1. 왜 static을 알아야 하는가

Unity 신입 개발자가 가장 먼저 마주치는 의문 중 하나는 이런 것입니다.

C#
Mathf.Clamp(value, 0f, 1f);          // 인스턴스 만든 적 없는데 호출됨
Time.deltaTime;                       // Time 객체를 new 한 적이 없음
Console.WriteLine("hello");          // Console 도 마찬가지

분명히 클래스 멤버를 호출하고 있는데 객체를 만든 흔적이 없습니다. 반대로 transform.position 처럼 객체가 있어야만 접근할 수 있는 멤버도 있습니다. 이 차이를 만드는 키워드가 바로 static 입니다.

문제는 단순히 "객체 없이 부른다" 수준이 아닙니다. static 을 잘못 쓰면 다음과 같은 버그가 실제 프로젝트에서 자주 발생합니다.

  • 메모리 누수: static List<Enemy> _all 에 적군을 계속 등록하기만 하면 게임이 끝날 때까지 객체가 GC(Garbage Collector, 사용하지 않는 메모리를 자동으로 회수하는 런타임 구성요소)에 의해 회수되지 않습니다.
  • 씬 전환 시 데이터가 살아남음: Unity 에디터에서 도메인 리로드를 끄고 플레이 모드를 다시 시작하면 static 변수의 이전 값이 그대로 남아 있어 "왜 점수가 초기화되지 않지?" 같은 현상이 나타납니다.
  • 단위 테스트 불가: static SaveManager.Save() 처럼 외부 의존을 static 으로 박아두면 가짜 객체로 바꿔치기할 수 없어 테스트가 막힙니다.

이 글에서는 "static 멤버는 형식 자체에 속한다" 라는 한 문장의 의미를 메모리 레이아웃, IL 명령어, 컴파일러가 만드는 숨은 코드까지 따라가며 풀어봅니다. 그리고 마지막에는 Unity 환경에서 언제 static 을 써야 하고 언제 ScriptableObject 같은 대안으로 돌려야 하는지 판단 기준을 정리합니다.


2. 개념 정의 — 형식에 속하는 멤버

2.1 비유: 붕어빵 틀과 붕어빵

클래스를 붕어빵 틀, 인스턴스를 붕어빵이라고 해봅시다.

  • 인스턴스 멤버: 각 붕어빵의 속성입니다. 어떤 붕어빵은 팥, 어떤 붕어빵은 슈크림 — 붕어빵마다 다릅니다.
  • static 멤버: 붕어빵 틀 자체에 적힌 정보입니다. "지금까지 이 틀로 몇 개를 구웠는가" 같은 값은 어떤 붕어빵에 속하지 않고 틀 자체에 속합니다.

붕어빵 하나의 정보를 알려면 그 붕어빵을 손에 들어야 하지만, 틀의 정보는 붕어빵을 굽지 않아도 알 수 있습니다. 이것이 "인스턴스 없이 호출한다" 의 의미입니다.

2.2 구조 시각화

Counter 타입 객체 (단 하나)

Total 은 어디에 저장되는지 한 곳뿐이고, Mine 은 인스턴스마다 따로 저장됩니다. static 멤버는 인스턴스를 거치지 않고 타입 객체를 직접 가리킵니다.

2.3 기본 코드와 IL

C#
public class Counter
{
    public static int Total;   // 형식 자체에 속하는 필드
    public int Mine;            // 인스턴스마다 따로 가지는 필드

    public static void IncreaseTotal() => Total++;
    public void IncreaseMine() => Mine++;
}

public class Program
{
    public static void Main()
    {
        Counter c = new Counter();
        Counter.IncreaseTotal();   // 클래스 이름으로 직접 호출
        c.IncreaseMine();          // 인스턴스를 통해 호출
    }
}

이 코드를 컴파일해 IL 로 펼치면 차이가 분명히 보입니다.

IL
// IncreaseTotal — static 메서드
.method public hidebysig static void IncreaseTotal() cil managed
{
    IL_0000: ldsfld   int32 Counter::Total   // static 필드 로드
    IL_0005: ldc.i4.1
    IL_0006: add
    IL_0007: stsfld   int32 Counter::Total   // static 필드 저장
    IL_000c: ret
}

// IncreaseMine — instance 메서드
.method public hidebysig instance void IncreaseMine() cil managed
{
    IL_0000: ldarg.0                          // this 로드 (숨겨진 첫 인자)
    IL_0001: ldarg.0
    IL_0002: ldfld    int32 Counter::Mine    // 인스턴스 필드 로드
    IL_0007: ldc.i4.1
    IL_0008: add
    IL_0009: stfld    int32 Counter::Mine    // 인스턴스 필드 저장
    IL_000e: ret
}

// Main 의 호출 부분
IL_0000: newobj    instance void Counter::.ctor()
IL_0005: call      void Counter::IncreaseTotal()    // call: 객체 없이 직접
IL_000a: callvirt  instance void Counter::IncreaseMine()  // callvirt: this 필요

핵심은 세 가지입니다.

  • ldsfld vs ldfld: 앞에 붙은 s 는 static 의 약자입니다. static 필드는 타입 객체에 직접 접근하지만 인스턴스 필드는 객체 주소(this)를 거쳐야 합니다.
  • this 의 부재: instance 메서드는 IL 첫 줄이 ldarg.0 으로 시작합니다. 이는 숨겨진 첫 매개변수인 this 를 로드하는 코드입니다. static 메서드는 이 줄이 없습니다.
  • call vs callvirt: 다음 섹션에서 자세히 다룹니다.
static — 정적 키워드 멤버를 인스턴스가 아닌 형식(타입) 자체에 속하게 만든다. 클래스 이름으로 직접 접근하며, 모든 인스턴스가 단 하나의 복사본을 공유한다.
예시: public static int Total; Counter.Total 형태로 접근. 인스턴스를 만들지 않아도 됨

3. 내부 동작 — call vs callvirt, 메모리 배치, .cctor

3.1 call 과 callvirt 의 의미

CLR(Common Language Runtime, .NET 코드를 실행하는 가상 머신)에서 메서드 호출 명령어는 두 종류가 있습니다.

call (직접 호출)

static 메서드는 어떤 객체에도 속하지 않으므로 null 검사도, 가상 디스패치도 필요 없습니다. 컴파일 시점에 호출할 메서드의 주소가 확정되므로 CLR 은 가장 단순한 call 명령어를 사용합니다. 이 차이가 핫패스(매 프레임마다 수만 번 호출되는 코드)에서 측정 가능한 성능 차이를 만들 때가 있습니다.

3.2 static 필드의 메모리 배치

static 필드는 GC Heap 이 아니라 Loader Heap (정확히는 High Frequency Heap, 자주 접근하는 타입 메타데이터를 모아두는 영역)의 타입 객체 안에 배치됩니다. 이 영역은 일반 GC 대상이 아닙니다.

C#
public class GameStats
{
    public static int TotalKills;          // Loader Heap 의 타입 객체 안
    public static List<Enemy> RecentKills; // 참조 자체는 Loader Heap, List 객체는 GC Heap
    public int MyKills;                    // 각 인스턴스의 GC Heap 슬롯
}

여기서 함정이 하나 등장합니다. RecentKills 참조 는 Loader Heap 에 있지만, RecentKills 가 가리키는 List<Enemy> 객체와 그 내부의 Enemy 들은 모두 GC Heap 에 있습니다. 그런데 GC 입장에서 static 필드는 GC 루트 이므로 거기서 출발해 도달 가능한 모든 객체는 절대 회수되지 않습니다. 이 점이 static 컬렉션이 메모리 누수의 단골 원인이 되는 이유입니다.

3.3 static 생성자 (.cctor) 와 beforefieldinit

static 필드를 초기화하는 특별한 생성자가 있습니다. 일반 생성자가 IL 에서 .ctor 으로 표현되듯, static 생성자는 .cctor (class constructor) 로 표현됩니다.

C#
public static class MathUtil
{
    public static readonly double Pi = System.Math.PI;
    public const double E = 2.71828;

    public static int Add(int a, int b) => a + b;
}
IL
.class public auto ansi abstract sealed beforefieldinit MathUtil
    extends [System.Runtime]System.Object
{
    .field public static initonly float64 Pi             // static readonly
    .field public static literal float64 E = float64(2.71828)  // const

    .method public hidebysig static int32 Add(int32 a, int32 b) cil managed
    {
        IL_0000: ldarg.0
        IL_0001: ldarg.1
        IL_0002: add
        IL_0003: ret
    }

    // static 생성자: Pi 를 런타임에 초기화
    .method private hidebysig specialname rtspecialname static
        void .cctor() cil managed
    {
        IL_0000: ldc.r8   3.141592653589793   // Math.PI 값 로드
        IL_0009: stsfld   float64 MathUtil::Pi
        IL_000e: ret
    }
}

여기서 세 가지 사실을 읽어낼 수 있습니다.

  • static class 의 IL 표현: 클래스 헤더에 abstract sealed 가 동시에 붙습니다. abstract 는 인스턴스화를 막고, sealed 는 상속을 막아 "오로지 static 멤버 컨테이너" 라는 의미를 IL 수준에서 강제합니다.
  • initonly (static readonly) vs literal (const): Pi 는 필드 슬롯이 메모리에 잡히고 .cctor 에서 값을 채워 넣는 반면, E 는 IL 메타데이터에 값 자체가 박혀 있습니다. const 를 사용하는 코드는 컴파일 시 2.71828 이라는 리터럴이 그대로 박히므로, 라이브러리 DLL 의 const 값을 바꿔도 그 DLL 을 참조하던 프로그램을 다시 컴파일하지 않으면 변경이 반영되지 않습니다.
  • beforefieldinit 플래그: 클래스 헤더에 붙어 있습니다. 이 플래그는 CLR 에게 "static 필드를 처음 사용하기 직전 어딘가" 라는 느슨한 시점에 .cctor 를 호출해도 된다고 알려줍니다. 이 플래그가 없으면 CLR 은 정확히 "첫 사용 직전" 시점을 보장해야 하므로 JIT 가 호출마다 초기화 체크를 넣어 약간 느려질 수 있습니다.
const vs static readonly const 는 컴파일 타임에 값이 IL 에 박히는 리터럴이다. static readonly 는 런타임에 값이 결정되며 메모리 슬롯을 가진다.
예시: const int Max = 10; → IL 에 10 으로 박힘 예시: static readonly DateTime Start = DateTime.Now; → 실행 시점 값으로 결정

명시적으로 static MathUtil() { ... } 라고 빈 static 생성자를 적으면 컴파일러는 beforefieldinit 플래그를 빼버립니다. 그래서 핫패스에서 이유 없이 static 생성자를 적으면 미세한 성능 손해를 볼 수 있습니다.


4. 실전 적용 — 언제 static 을 쓰는가

4.1 좋은 예: 순수 함수 유틸리티

입력만으로 출력이 결정되고 외부 상태에 의존하지 않는 함수는 static 으로 만드는 게 자연스럽습니다.

C#
// 좋은 예: 순수 함수 — 인스턴스를 만들 이유가 없음
public static class GridUtil
{
    public static int ManhattanDistance(int x1, int y1, int x2, int y2)
        => System.Math.Abs(x1 - x2) + System.Math.Abs(y1 - y2);

    public static bool InBounds(int x, int y, int width, int height)
        => x >= 0 && x < width && y >= 0 && y < height;
}

// 호출 측 — Unity 스크립트 어디에서나 깔끔하게 사용
if (GridUtil.InBounds(targetX, targetY, mapWidth, mapHeight))
{
    int dist = GridUtil.ManhattanDistance(playerX, playerY, targetX, targetY);
}

이런 함수들은 어떤 인스턴스에도 속하지 않는 본래 의미상 정적입니다. Mathf.Clamp, Path.Combine, string.IsNullOrEmpty 같은 표준 라이브러리도 같은 맥락입니다.

4.2 Before / After — Unity 핫패스에서 static vs instance

Unity 의 매 프레임 호출 함수에서 단순 헬퍼를 인스턴스 메서드로 만들어 버리면 의도치 않은 비용이 생깁니다.

Before — 인스턴스 헬퍼

C#
public class DamageCalculator
{
    public float ComputeFinalDamage(float baseDamage, float armor)
        => baseDamage * (100f / (100f + armor));
}

public class Enemy : MonoBehaviour
{
    private DamageCalculator _calc = new();   // 객체를 만들어야 함

    void OnHit(float damage, float armor)
    {
        float final = _calc.ComputeFinalDamage(damage, armor);
        ApplyDamage(final);
    }
}
IL
// ComputeFinalDamage 의 호출 부분
IL_0000: ldarg.0
IL_0001: ldfld     class DamageCalculator Enemy::_calc
IL_0006: ldarg.1
IL_0007: ldarg.2
IL_0008: callvirt  instance float32 DamageCalculator::ComputeFinalDamage(float32, float32)

callvirt 는 호출 직전에 _calc 가 null 인지 검사하고, 메서드 테이블을 거쳐 실제 주소를 찾습니다. 적군이 수백 마리라면 모든 적이 자신만의 DamageCalculator 인스턴스를 들고 있어 GC Heap 도 그만큼 늘어납니다.

After — static 헬퍼

C#
public static class DamageCalculator
{
    public static float ComputeFinalDamage(float baseDamage, float armor)
        => baseDamage * (100f / (100f + armor));
}

public class Enemy : MonoBehaviour
{
    void OnHit(float damage, float armor)
    {
        float final = DamageCalculator.ComputeFinalDamage(damage, armor);
        ApplyDamage(final);
    }
}
IL
// 호출 부분
IL_0000: ldarg.1
IL_0001: ldarg.2
IL_0002: call      float32 DamageCalculator::ComputeFinalDamage(float32, float32)

call 한 줄로 끝납니다. null 검사도, 가상 디스패치도, 인스턴스 필드 접근도 없습니다. 적군마다 DamageCalculator 객체를 만들 필요도 사라집니다. 이 코드는 상태가 없으므로 static 으로 만드는 것이 정직한 표현 입니다.

4.3 확장 메서드 — static 의 또 다른 모습

Unity 에서 자주 쓰는 확장 메서드는 사실 static 메서드입니다.

C#
public static class Vector3Extensions
{
    // 첫 매개변수 앞의 this 키워드가 확장 메서드를 만든다
    public static Vector3 WithY(this Vector3 v, float y)
        => new Vector3(v.x, y, v.z);
}

// 호출 측
transform.position = transform.position.WithY(10f);
// 컴파일러가 → Vector3Extensions.WithY(transform.position, 10f) 로 변환

IL 수준에서는 단순한 static 메서드 호출이며 this 키워드는 컴파일러 신택스 슈가일 뿐입니다. 확장 메서드를 만들려면 반드시 static class 안의 static 메서드여야 한다는 제약이 있습니다.

4.4 static readonly 로 게임 설정 외부화

Unity 프로젝트에서 자주 쓰는 패턴입니다.

C#
public static class GameConfig
{
    public const int MaxPlayers = 4;                          // 변경 시 재컴파일 필요
    public static readonly string SaveFolder = ApplicationPath();
    public static readonly TimeSpan AutoSaveInterval = TimeSpan.FromMinutes(5);

    private static string ApplicationPath()
        => System.IO.Path.Combine(UnityEngine.Application.persistentDataPath, "saves");
}

MaxPlayers 처럼 절대 바뀌지 않는 진짜 상수는 const, SaveFolder 처럼 런타임 값으로 결정되는 값은 static readonly 를 씁니다. const 를 다른 어셈블리로 export 한 뒤 값을 바꾸면 참조 측을 다시 빌드해야 한다는 점만 기억하면 됩니다.


5. 함정과 주의사항

5.1 함정 1 — static 컬렉션이 GC 루트가 되어 메모리 누수

가장 흔한 실수 중 하나입니다.

Loader Heap (GC 루트)

❌ 잘못된 패턴

C#
public class EnemyTracker
{
    // 모든 적군을 추적하기 위해 static 컬렉션에 등록
    public static readonly List<Enemy> All = new();
}

public class Enemy : MonoBehaviour
{
    void Awake()  => EnemyTracker.All.Add(this);
    // OnDestroy 가 없다 — Remove 를 안 함!
}
IL
// EnemyTracker 클래스
.class public auto ansi beforefieldinit EnemyTracker
{
    .field public static initonly class [System.Collections]...List`1<Enemy> All

    .method private hidebysig specialname rtspecialname static
        void .cctor() cil managed
    {
        IL_0000: newobj    instance void List`1<Enemy>::.ctor()
        IL_0005: stsfld    class List`1<Enemy> EnemyTracker::All
        IL_000a: ret
    }
}

stsfld 로 채워진 List 는 GC 루트입니다. 적군이 죽거나 씬이 바뀌어 Unity 의 GameObject 가 destroy 되어도, All 리스트는 그 적군 객체에 대한 참조를 계속 들고 있어 메모리에서 사라지지 않습니다. 적군 1만 마리를 만들면 1만 마리의 C# 객체가 영원히 남습니다.

✅ 올바른 패턴 — 등록 해제와 명시적 초기화

C#
public class EnemyTracker
{
    public static readonly List<Enemy> All = new();

    // 씬 전환 시 강제 초기화 (Unity 도메인 리로드 비활성화 환경 대응)
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    private static void Reset() => All.Clear();
}

public class Enemy : MonoBehaviour
{
    void Awake()      => EnemyTracker.All.Add(this);
    void OnDestroy()  => EnemyTracker.All.Remove(this);   // 반드시 짝을 맞춤
}

OnDestroy 에서 Remove 를 호출해 GC 가 객체를 회수할 수 있게 길을 터줍니다. 그리고 RuntimeInitializeOnLoadMethod 어트리뷰트로 게임 시작 시점에 리스트를 비웁니다 (도메인 리로드를 끈 환경에서도 안전).

5.2 함정 2 — Unity 도메인 리로드 비활성화 시 static 이 살아남음

Unity 에디터 설정의 *Project Settings → Editor → Enter Play Mode Options* 에서 *Reload Domain* 을 끄면 플레이 모드 진입이 빨라지지만 static 변수가 이전 세션 값을 그대로 유지합니다.

❌ 잘못된 패턴 — 리셋 없이 static 사용

C#
public class GameManager
{
    public static int Score = 0;
    public static event Action<int> OnScoreChanged;

    public static void AddScore(int v)
    {
        Score += v;
        OnScoreChanged?.Invoke(Score);
    }
}

public class UI : MonoBehaviour
{
    void OnEnable()  => GameManager.OnScoreChanged += UpdateLabel;
    void OnDisable() => GameManager.OnScoreChanged -= UpdateLabel;

    void UpdateLabel(int s) { /* ... */ }
}

도메인 리로드를 끈 채 플레이를 한 번 돌리고 멈춘 뒤 다시 시작하면:

  • Score 가 이전 세션 값에서 시작합니다.
  • OnScoreChanged 이벤트에는 이전 세션의 UI 인스턴스 핸들러까지 그대로 살아남아 MissingReferenceException 이 발생합니다.

✅ 올바른 패턴 — RuntimeInitializeOnLoadMethod 로 명시적 리셋

C#
public class GameManager
{
    public static int Score;
    public static event Action<int> OnScoreChanged;

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    private static void ResetStatics()
    {
        Score = 0;
        OnScoreChanged = null;   // 이전 세션의 구독 흔적 제거
    }
}

SubsystemRegistration 단계는 도메인 리로드 여부와 무관하게 매 플레이 시작마다 호출됩니다. static 을 가진 클래스마다 이런 리셋 함수를 두는 것이 도메인 리로드 비활성화 환경의 표준 패턴입니다.

5.3 함정 3 — 의존성 주입을 막는 static

❌ 잘못된 패턴 — static 으로 박힌 외부 의존

C#
public class SaveManager
{
    public static void Save(string key, int value)
    {
        // PlayerPrefs 에 직접 의존 — Mock 으로 바꿀 방법이 없음
        UnityEngine.PlayerPrefs.SetInt(key, value);
    }
}

public class Inventory
{
    public void Persist()
    {
        SaveManager.Save("gold", 100);   // 테스트 시 우회 불가
    }
}

Inventory 의 단위 테스트를 작성하려고 하면 PlayerPrefs 를 호출하는 실제 디스크 I/O 가 발생합니다. SaveManager.Save 를 가짜 구현으로 바꿀 수가 없기 때문입니다.

✅ 올바른 패턴 — 인터페이스 + 의존성 주입

C#
public interface ISaveStore
{
    void Save(string key, int value);
}

public class PlayerPrefsStore : ISaveStore
{
    public void Save(string key, int value) => UnityEngine.PlayerPrefs.SetInt(key, value);
}

public class Inventory
{
    private readonly ISaveStore _store;
    public Inventory(ISaveStore store) => _store = store;

    public void Persist()
    {
        _store.Save("gold", 100);   // 테스트에서는 가짜 구현을 주입 가능
    }
}

상태나 외부 자원에 의존하는 동작은 인터페이스로 추상화한 뒤 인스턴스 멤버로 받는 것이 테스트와 유지보수에 훨씬 유리합니다. static 은 이런 경우 피해야 합니다.

5.4 함정 4 — static 생성자에서 예외 던지기

C#
public class Config
{
    public static readonly string DataPath = LoadFromFile();

    private static string LoadFromFile()
    {
        // 파일이 없으면 예외 발생
        return System.IO.File.ReadAllText("config.txt");
    }
}

Config.DataPath 를 처음 접근하는 순간 .cctor 가 실행되며 FileNotFoundException 이 발생합니다. 문제는 그다음입니다 — CLR 은 static 생성자가 한 번 실패하면 그 타입을 영구적으로 사용 불가 상태로 마킹합니다. 이후 어디에서든 Config 의 어떤 멤버에 접근해도 TypeInitializationException 이 발생하며, 앱이 종료될 때까지 복구되지 않습니다. static 생성자에서는 절대 실패할 수 있는 코드를 두지 않는 것이 원칙입니다.


6. C# 버전별 변화

6.1 C# 1.0 — static 클래스 도입 전

초기 C# 에는 static class 키워드가 없었습니다. 유틸리티 클래스를 만들려면 모든 멤버를 static 으로 선언하고 private 생성자로 인스턴스화를 막아야 했습니다.

C#
// C# 1.x 시대의 유틸리티 클래스
public sealed class MathHelper
{
    private MathHelper() { }   // 인스턴스화 차단

    public static int Square(int x) => x * x;
}

6.2 C# 2.0 — static class 키워드 도입

C# 2.0 부터 클래스 자체에 static 을 붙일 수 있게 되었고, 컴파일러가 IL 수준에서 abstract sealed 를 자동으로 붙여 인스턴스화·상속을 모두 차단합니다.

C#
// C# 2.0+
public static class MathHelper
{
    public static int Square(int x) => x * x;
    // 인스턴스 필드를 두려고 하면 컴파일 에러
}

6.3 C# 9.0 — static 람다와 익명 함수

C# 9.0 부터 람다와 익명 메서드 앞에 static 을 붙여 외부 변수 캡처를 금지할 수 있습니다. 의도치 않은 클로저(closure, 람다가 외부 변수를 붙잡기 위해 컴파일러가 생성하는 숨은 객체)로 인한 GC 할당을 방지하는 용도입니다.

Before — 인스턴스 필드를 캡처하는 람다

C#
public class LambdaDemo
{
    public int multiplier = 3;

    public Func<int, int> CapturingLambda()
        => x => x * multiplier;   // multiplier 가 인스턴스 필드 → this 캡처
}
IL
// CapturingLambda
IL_0000: ldarg.0                        // this 로드
IL_0001: ldftn   instance int32 LambdaDemo::'<CapturingLambda>b__1_0'(int32)
IL_0007: newobj  instance void Func`2<int32, int32>::.ctor(object, native int)
IL_000c: ret

람다가 multiplier 를 캡처하기 때문에 컴파일러는 this 를 함께 묶은 Func 델리게이트를 매번 새로 만듭니다 (newobj). CapturingLambda() 호출마다 GC Heap 할당이 발생합니다.

After — static 람다

C#
public class LambdaDemo
{
    public int multiplier = 3;

    public Func<int, int> NonCapturingStaticLambda()
        => static x => x * 3;   // static — 외부 캡처 금지
}
IL
// NonCapturingStaticLambda
IL_0000: ldsfld  class Func`2<int32, int32> LambdaDemo/'<>c'::'<>9__2_0'
IL_0005: dup
IL_0006: brtrue.s IL_001f                // 캐시된 델리게이트가 있으면 그대로 반환
IL_0008: pop
IL_0009: ldsfld  class LambdaDemo/'<>c' LambdaDemo/'<>c'::'<>9'
IL_000e: ldftn   instance int32 LambdaDemo/'<>c'::'<NonCapturingStaticLambda>b__2_0'(int32)
IL_0014: newobj  instance void Func`2<int32, int32>::.ctor(object, native int)
IL_0019: dup
IL_001a: stsfld  class Func`2<int32, int32> LambdaDemo/'<>c'::'<>9__2_0'
IL_001f: ret

컴파일러는 캡처가 없는 람다를 위해 <>c 라는 nested static 클래스를 만들고, 델리게이트를 static 필드 <>9__2_0 에 캐시합니다. brtrue.s 분기로 이미 만들어진 델리게이트가 있으면 그대로 반환하므로 두 번째 호출부터는 GC 할당이 0 입니다. Unity 의 매 프레임 코드에서 의미 있는 차이가 됩니다.

static 키워드를 빼면 컴파일러는 캡처가 실제로 있는지 모르므로 이런 캐싱 최적화를 적용하지 않습니다. "캡처 안 한다" 는 의도를 컴파일러에게 명시적으로 알려주는 장치가 static 람다입니다.

6.4 C# 8.0 / C# 11.0 — 인터페이스의 static 멤버

C# 8.0 부터 인터페이스가 default implementation 을 가질 수 있게 되면서 static 멤버도 가질 수 있게 되었습니다. C# 11.0 에서는 한 발 더 나아가 static abstract 멤버가 추가되어 구현체에 static 메서드·연산자 구현을 강제할 수 있습니다.

C#
// C# 11+: 제네릭 수학 (Generic Math) 의 핵심
public interface IAddable<T> where T : IAddable<T>
{
    static abstract T operator +(T left, T right);
    static abstract T Zero { get; }
}

public readonly struct Money : IAddable<Money>
{
    public readonly decimal Amount;
    public Money(decimal amount) => Amount = amount;

    public static Money operator +(Money left, Money right)
        => new(left.Amount + right.Amount);

    public static Money Zero => new(0m);
}

// 제네릭 함수가 + 연산자를 호출 가능
public static T Sum<T>(IEnumerable<T> items) where T : IAddable<T>
{
    T total = T.Zero;
    foreach (var item in items)
        total = total + item;
    return total;
}

이전에는 제네릭 코드에서 + 연산자나 0 같은 항등원을 일반화할 방법이 없었습니다. C# 11 의 static abstract 가 이 한계를 풀어주었고, .NET 7+ 의 INumber<T>, IAdditionOperators<T,U,V> 같은 인터페이스가 이 기능을 활용합니다.


7. 정리

핵심을 다시 정리합니다.

  • static = 형식 자체에 속한다. 인스턴스 없이 클래스 이름으로 직접 접근하며, 모든 호출이 단 하나의 데이터·코드 슬롯을 공유합니다.
  • IL 레벨 차이: ldsfld / stsfld / call (static) ↔ ldfld / stfld / callvirt (instance). static 메서드는 this 가 없어 null 검사·가상 디스패치 없이 가장 빠른 경로로 실행됩니다.
  • static class 는 IL 에서 abstract sealed 입니다. 모든 멤버가 static 이어야 하며 인스턴스화·상속이 모두 막힙니다.
  • .cctor (static 생성자) 는 첫 사용 직전에 단 한 번 호출됩니다. CLR 이 스레드 안전을 보장하지만, 예외가 발생하면 해당 타입이 앱 종료까지 사용 불가가 됩니다.
  • const vs static readonly: const 는 IL 에 박히는 컴파일 타임 리터럴, static readonly 는 런타임에 결정되는 런타임 상수. const 를 외부 어셈블리로 export 한다면 변경 시 참조 측 재컴파일이 필요합니다.
  • 메모리 누수의 단골 원인: static 컬렉션은 GC 루트라서 도달 가능한 객체를 영원히 잡아둡니다. Add 와 Remove 를 짝지어 호출하고, Unity 에서는 RuntimeInitializeOnLoadMethod 로 명시적 리셋을 추가합니다.
  • 언제 쓰나: 순수 함수 유틸리티, 진짜 상수, 확장 메서드, static 람다 (C# 9+).
  • 언제 피하나: 가변 상태를 들고 있어야 할 때, 외부 자원·다른 객체에 의존해 모킹이 필요할 때, 단위 테스트 대상일 때.
  • C# 버전 발전: 2.0 static class → 9.0 static 람다 → 11.0 static abstract 인터페이스 멤버. 각 단계마다 "static 의 의도를 더 정밀하게 표현" 하는 방향으로 진화했습니다.

체크리스트로 줄이면 다음과 같습니다.

  • [ ] 이 멤버가 정말 어떤 인스턴스에도 속하지 않는가? (속한다면 인스턴스 멤버)
  • [ ] 가변 상태를 가지지는 않는가? (가진다면 상태 공유로 인한 race condition·메모리 누수 위험)
  • [ ] 단위 테스트에서 가짜 구현으로 바꿀 필요가 있는가? (있다면 인터페이스 + DI)
  • [ ] Unity 도메인 리로드 비활성화 환경을 고려했는가? (RuntimeInitializeOnLoadMethod 로 리셋)
  • [ ] static 컬렉션이라면 등록 해제 짝이 있는가? (Add/Remove)
  • [ ] 진짜 상수인가, 런타임에 결정되는 값인가? (const vs static readonly 선택)
  • [ ] static 생성자에 실패할 수 있는 코드를 넣지 않았는가?

static 은 양날의 검입니다. 잘 쓰면 코드가 간결하고 호출이 빠르지만, 무심코 쓰면 전역 상태와 메모리 누수의 통로가 됩니다. "이건 객체에 속해야 하는 데이터인가, 형식 자체에 속해야 하는 데이터인가" 를 매 순간 자문하는 습관을 들이는 것이 가장 좋은 방어막입니다.

반응형

+ Recent posts