[PART7.클래스와 객체 입문(8/21)] 정적(static) 멤버 — 인스턴스 없이 호출하는 코드와 데이터
형식 자체에 속하는 멤버의 의미 / call vs callvirt / static class · static 생성자 / 언제 쓰고 언제 피하는가
목차
1. 왜 static을 알아야 하는가
Unity 신입 개발자가 가장 먼저 마주치는 의문 중 하나는 이런 것입니다.
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 구조 시각화
Total 은 어디에 저장되는지 한 곳뿐이고, Mine 은 인스턴스마다 따로 저장됩니다. static 멤버는 인스턴스를 거치지 않고 타입 객체를 직접 가리킵니다.
2.3 기본 코드와 IL
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 로 펼치면 차이가 분명히 보입니다.
// 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 필요
핵심은 세 가지입니다.
ldsfldvsldfld: 앞에 붙은s는 static 의 약자입니다. static 필드는 타입 객체에 직접 접근하지만 인스턴스 필드는 객체 주소(this)를 거쳐야 합니다.this의 부재: instance 메서드는 IL 첫 줄이ldarg.0으로 시작합니다. 이는 숨겨진 첫 매개변수인this를 로드하는 코드입니다. static 메서드는 이 줄이 없습니다.callvscallvirt: 다음 섹션에서 자세히 다룹니다.
static— 정적 키워드 멤버를 인스턴스가 아닌 형식(타입) 자체에 속하게 만든다. 클래스 이름으로 직접 접근하며, 모든 인스턴스가 단 하나의 복사본을 공유한다.
예시:public static int Total;Counter.Total형태로 접근. 인스턴스를 만들지 않아도 됨
3. 내부 동작 — call vs callvirt, 메모리 배치, .cctor
3.1 call 과 callvirt 의 의미
CLR(Common Language Runtime, .NET 코드를 실행하는 가상 머신)에서 메서드 호출 명령어는 두 종류가 있습니다.
static 메서드는 어떤 객체에도 속하지 않으므로 null 검사도, 가상 디스패치도 필요 없습니다. 컴파일 시점에 호출할 메서드의 주소가 확정되므로 CLR 은 가장 단순한 call 명령어를 사용합니다. 이 차이가 핫패스(매 프레임마다 수만 번 호출되는 코드)에서 측정 가능한 성능 차이를 만들 때가 있습니다.
3.2 static 필드의 메모리 배치
static 필드는 GC Heap 이 아니라 Loader Heap (정확히는 High Frequency Heap, 자주 접근하는 타입 메타데이터를 모아두는 영역)의 타입 객체 안에 배치됩니다. 이 영역은 일반 GC 대상이 아닙니다.
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) 로 표현됩니다.
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;
}
.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) vsliteral(const):Pi는 필드 슬롯이 메모리에 잡히고.cctor에서 값을 채워 넣는 반면,E는 IL 메타데이터에 값 자체가 박혀 있습니다. const 를 사용하는 코드는 컴파일 시2.71828이라는 리터럴이 그대로 박히므로, 라이브러리 DLL 의 const 값을 바꿔도 그 DLL 을 참조하던 프로그램을 다시 컴파일하지 않으면 변경이 반영되지 않습니다.beforefieldinit플래그: 클래스 헤더에 붙어 있습니다. 이 플래그는 CLR 에게 "static 필드를 처음 사용하기 직전 어딘가" 라는 느슨한 시점에.cctor를 호출해도 된다고 알려줍니다. 이 플래그가 없으면 CLR 은 정확히 "첫 사용 직전" 시점을 보장해야 하므로 JIT 가 호출마다 초기화 체크를 넣어 약간 느려질 수 있습니다.
constvsstatic readonlyconst는 컴파일 타임에 값이 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 으로 만드는 게 자연스럽습니다.
// 좋은 예: 순수 함수 — 인스턴스를 만들 이유가 없음
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 — 인스턴스 헬퍼
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);
}
}
// 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 헬퍼
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_0000: ldarg.1
IL_0001: ldarg.2
IL_0002: call float32 DamageCalculator::ComputeFinalDamage(float32, float32)
call 한 줄로 끝납니다. null 검사도, 가상 디스패치도, 인스턴스 필드 접근도 없습니다. 적군마다 DamageCalculator 객체를 만들 필요도 사라집니다. 이 코드는 상태가 없으므로 static 으로 만드는 것이 정직한 표현 입니다.
4.3 확장 메서드 — static 의 또 다른 모습
Unity 에서 자주 쓰는 확장 메서드는 사실 static 메서드입니다.
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 프로젝트에서 자주 쓰는 패턴입니다.
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 루트가 되어 메모리 누수
가장 흔한 실수 중 하나입니다.
❌ 잘못된 패턴
public class EnemyTracker
{
// 모든 적군을 추적하기 위해 static 컬렉션에 등록
public static readonly List<Enemy> All = new();
}
public class Enemy : MonoBehaviour
{
void Awake() => EnemyTracker.All.Add(this);
// OnDestroy 가 없다 — Remove 를 안 함!
}
// 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# 객체가 영원히 남습니다.
✅ 올바른 패턴 — 등록 해제와 명시적 초기화
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 사용
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 로 명시적 리셋
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 으로 박힌 외부 의존
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 를 가짜 구현으로 바꿀 수가 없기 때문입니다.
✅ 올바른 패턴 — 인터페이스 + 의존성 주입
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 생성자에서 예외 던지기
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# 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# 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 — 인스턴스 필드를 캡처하는 람다
public class LambdaDemo
{
public int multiplier = 3;
public Func<int, int> CapturingLambda()
=> x => x * multiplier; // multiplier 가 인스턴스 필드 → this 캡처
}
// 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 람다
public class LambdaDemo
{
public int multiplier = 3;
public Func<int, int> NonCapturingStaticLambda()
=> static x => x * 3; // static — 외부 캡처 금지
}
// 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# 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 이 스레드 안전을 보장하지만, 예외가 발생하면 해당 타입이 앱 종료까지 사용 불가가 됩니다.constvsstatic 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 은 양날의 검입니다. 잘 쓰면 코드가 간결하고 호출이 빠르지만, 무심코 쓰면 전역 상태와 메모리 누수의 통로가 됩니다. "이건 객체에 속해야 하는 데이터인가, 형식 자체에 속해야 하는 데이터인가" 를 매 순간 자문하는 습관을 들이는 것이 가장 좋은 방어막입니다.
'C# 기초' 카테고리의 다른 글
| [PART7.클래스와 객체 입문(10/21)] Target-typed new — 왼쪽 타입을 반복하지 않는 생성 (C# 9) (0) | 2026.05.01 |
|---|---|
| [PART7.클래스와 객체 입문(9/21)] 객체 초기화자 — 생성과 설정을 한 표현식으로 (0) | 2026.05.01 |
| [PART7.클래스와 객체 입문(7/21)] 생성자와 this — 객체가 태어나는 순간 무슨 일이 벌어지는가 (0) | 2026.05.01 |
| [PART7.클래스와 객체 입문(6/21)] required 멤버 — 반드시 초기화해야 하는 프로퍼티 (C# 11) (0) | 2026.05.01 |
| [PART7.클래스와 객체 입문(5/21)] `init` 접근자 — 객체 초기화자의 편의성과 불변성을 동시에 (C# 9) (0) | 2026.05.01 |