[PART2.변수와 기본 데이터 타입(9/13)] 상수 — const와 readonly
컴파일 타임에 박히는 값 vs 런타임에 한 번만 결정되는 값 / 버전 호환성 함정 / Unity에서의 직렬화 제약
목차
1. 문제 제기 — "이 숫자는 앞으로 안 바뀌니까 상수로 빼자"
Unity 팀 프로젝트에서 플레이어의 최대 체력 값을 한 줄로 박아 쓰는 코드가 사방에 있습니다. 리팩터링 시간에 "이거 상수로 빼자"는 의견이 나옵니다. 그 순간 선택지가 둘로 갈립니다.
public const int MaxHp = 100; // A안
public static readonly int MaxHp = 100; // B안
둘 다 MaxHp를 밖에서 수정할 수 없게 만듭니다. 겉으로는 똑같아 보입니다. 하지만 이 둘은 컴파일러가 완전히 다르게 취급하며, 그 차이가 다음 세 가지 시나리오에서 뚜렷이 드러납니다.
- 시나리오 1:
CommonConstants.dll에 박힌const int ApiVersion = 1;을2로 바꿔 DLL만 교체 배포했는데, 이 DLL을 참조하는GameClient.exe는 계속1을 읽는다. - 시나리오 2:
static readonly DateTime StartedAt = DateTime.Now;는 되는데const DateTime StartedAt = ...;는 컴파일 에러가 난다. - 시나리오 3:
[SerializeField] private const int MaxHp = 100;은 유니티 인스펙터에 아예 뜨지 않는다.
세 시나리오의 원인은 모두 하나입니다. const는 값이 호출 쪽 IL에 복사(인라인)되는 컴파일 타임 상수이고, readonly는 메모리에 한 번만 찍히는 런타임 상수입니다. 이 차이를 IL 레벨에서 눈으로 확인하고 나면 언제 무엇을 써야 할지가 명확해집니다.
2. 개념 정의 — 도장 찍기 vs 책장에 꽂기
2.1 비유 — 종이 공장과 도서관
const는 도장 찍기와 같습니다. 공장(컴파일러)이 "100"이라는 도장을 파두고, 이 값을 쓰는 모든 인쇄물(사용하는 쪽의 IL)에 같은 숫자를 직접 찍어 냅니다. 인쇄가 끝난 뒤에는 도장이 무엇이었는지 중요하지 않습니다. 종이에는 이미 "100"이 박혀 있기 때문입니다.
readonly는 도서관 책장에 꽂힌 책과 같습니다. "100"이라는 값이 적힌 책이 선반(메모리)에 한 권 있고, 이 값을 쓰는 모든 코드는 매번 책장을 열어 값을 읽어 옵니다. 누군가(라이브러리 배포자)가 책을 교체하면, 다음번 읽기부터는 새 값이 보입니다.

2.2 기본 코드 — 선언 문법 비교
const— 컴파일 타임 상수 키워드 선언과 동시에 초기화해야 하고, 그 값은 컴파일러가 바로 평가할 수 있는 리터럴이어야 한다. 암시적으로static이며 필드뿐 아니라 지역 변수에도 쓸 수 있다.
예시:public const int MaxHp = 100;모든 사용처에100이 직접 박힌다.
readonly— 런타임 상수 키워드 필드에만 붙일 수 있다. 선언 시 또는 생성자 안에서만 값을 할당할 수 있고, 한 번 할당된 후에는 변경할 수 없다.static과 조합 가능하며,DateTime.Now같은 런타임 값으로 초기화할 수 있다.
예시:public static readonly DateTime StartedAt = DateTime.Now;프로그램이 뜰 때 한 번만 평가되어 필드에 저장된다.
using System;
public static class Config
{
// const: 컴파일 타임에 값이 IL에 박힘
public const int MaxUsers = 100;
public const string AppName = "MyApp";
public const double Pi = 3.14;
// static readonly: 정적 생성자(.cctor)에서 한 번 초기화
public static readonly int DefaultTimeout = 30;
public static readonly string StartTime = DateTime.Now.ToString();
public static readonly int[] PrimeNumbers = new[] { 2, 3, 5, 7, 11 };
}
public class Session
{
// 인스턴스 readonly: 생성자에서만 할당 가능
public readonly int SessionId;
public readonly string UserName;
public Session(int id, string name)
{
this.SessionId = id;
this.UserName = name;
}
}
이 코드를 C# 10 이상으로 컴파일한 뒤 IL을 확인하면, const 필드와 readonly 필드가 메타데이터 레벨에서부터 다르게 표시되어 있음을 볼 수 있습니다.
.class public abstract sealed beforefieldinit Config
{
// const — literal 플래그: 값이 메타데이터에 박혀 있음
.field public static literal int32 MaxUsers = int32(100)
.field public static literal string AppName = "MyApp"
.field public static literal float64 Pi = float64(3.14)
// static readonly — initonly 플래그: 한 번만 쓸 수 있는 일반 필드
.field public static initonly int32 DefaultTimeout
.field public static initonly string StartTime
.field public static initonly int32[] PrimeNumbers
}
.class public beforefieldinit Session
{
// 인스턴스 readonly도 initonly
.field public initonly int32 SessionId
.field public initonly string UserName
}
관찰할 두 가지 키워드가 있습니다. literal은 "이 필드는 값을 메타데이터에 지니고 있고, 참조하는 쪽은 이 값을 자기 코드에 복사해도 된다"는 뜻입니다. initonly는 "이 필드는 생성자 안에서만 쓸 수 있다"는 뜻입니다. const가 literal로, readonly가 initonly로 컴파일되는 것이 두 키워드의 본질입니다.
핵심 요약: const는 필드 자체가 값을 갖고 있고(literal) 호출 쪽에 인라인됩니다. readonly는 다른 필드와 똑같이 메모리에 존재하되 쓰기 시점만 제한됩니다(initonly).
3. 내부 동작 — 호출 쪽 IL이 다르다
앞 절은 선언부의 IL을 보았습니다. 이번엔 사용부의 IL이 어떻게 다른지 눈으로 확인합니다. 이 부분이 버전 호환성 함정의 직접 원인이 됩니다.
3.1 const를 읽으면 — 값이 호출 쪽에 박힌다
public static void UseConst()
{
int max = Config.MaxUsers;
string appName = Config.AppName;
double pi = Config.Pi;
Console.WriteLine($"{max} {appName} {pi}");
}
컴파일된 IL(실측):
.method public hidebysig static void UseConst() cil managed
{
IL_0000: ldc.i4.s 100 // 100을 스택에 로드 (Config 참조 없음)
IL_0002: stloc.0 // 지역변수 max에 저장
IL_0003: ldstr "MyApp" // "MyApp" 문자열 리터럴 로드
IL_0008: stloc.1
IL_0009: ldc.r8 3.14 // 3.14를 직접 로드
IL_0012: stloc.2
// ... WriteLine 호출
IL_0053: call void [System.Console]System.Console::WriteLine(string)
IL_0058: ret
}
놀라운 점은 이 IL 어디에도 Config 타입을 참조하는 명령이 없다는 것입니다. ldsfld Config::MaxUsers 같은 필드 로드가 전혀 나오지 않습니다. 컴파일러가 Config.MaxUsers를 본 순간, 메타데이터에서 literal 값 100을 꺼내 호출 쪽 IL에 ldc.i4.s 100으로 직접 복사했기 때문입니다.
ldc.i4.s 100— 4바이트 정수 상수 100을 평가 스택에 싣는다(load constant int32, short form).ldstr "MyApp"— 문자열 리터럴을 스택에 싣는다.ldc.r8 3.14— 8바이트 실수(double) 상수를 스택에 싣는다.
이것이 const의 본질입니다. 선언부는 있지만, 호출 쪽에는 의존성이 남지 않습니다.
3.2 static readonly를 읽으면 — 필드 접근 명령이 남는다
public static void UseStaticReadonly()
{
int timeout = Config.DefaultTimeout;
string startTime = Config.StartTime;
int[] primes = Config.PrimeNumbers;
Console.WriteLine($"{timeout} {startTime} {primes.Length}");
}
컴파일된 IL(실측):
.method public hidebysig static void UseStaticReadonly() cil managed
{
IL_0000: ldsfld int32 ILAnalysis.Config::DefaultTimeout // 정적 필드 로드
IL_0005: stloc.0
IL_0006: ldsfld string ILAnalysis.Config::StartTime // 정적 필드 로드
IL_000b: stloc.1
IL_000c: ldsfld int32[] ILAnalysis.Config::PrimeNumbers // 정적 필드 로드
IL_0011: stloc.2
// ... WriteLine 호출
IL_0059: ret
}
이번엔 ldsfld(load static field) 명령이 세 번 등장합니다. 호출 쪽 IL이 런타임에 Config 타입의 필드 주소로 가서 값을 읽어 옵니다. 값 자체는 호출 쪽 IL에 없습니다.
ldsfld—load static field. 지정한 정적 필드의 현재 값을 스택에 싣는다.ldfld—load field. 인스턴스 필드 버전.
그리고 이 값들은 어디서 채워졌을까요? 바로 Config 타입의 정적 생성자(.cctor) 에서입니다.
3.3 .cctor — 정적 생성자의 역할
.cctor는 "class constructor"의 줄임으로, 해당 타입이 처음 사용될 때 CLR이 자동으로 딱 한 번 실행하는 특별한 메서드입니다. static readonly 필드의 초기화 식은 모두 .cctor 본문으로 모입니다.
.method private hidebysig specialname rtspecialname static void .cctor() cil managed
{
IL_0000: ldc.i4.s 30
IL_0002: stsfld int32 ILAnalysis.Config::DefaultTimeout // = 30 저장
IL_0007: call valuetype System.DateTime System.DateTime::get_Now()
IL_000c: stloc.0
IL_000d: ldloca.s 0
IL_000f: call instance string System.DateTime::ToString()
IL_0014: stsfld string ILAnalysis.Config::StartTime // DateTime.Now 저장
IL_0019: ldc.i4.5
IL_001a: newarr [System.Runtime]System.Int32
IL_001f: dup
IL_0020: ldtoken field ... '<PrivateImplementationDetails>'
IL_0025: call void RuntimeHelpers::InitializeArray(...)
IL_002a: stsfld int32[] ILAnalysis.Config::PrimeNumbers // 배열 저장
IL_002f: ret
}
stsfld는store static field로, 스택 상위 값을 정적 필드에 쓴다..cctor안에서만 initonly 필드에stsfld가 허용된다.DateTime.Now처럼 런타임에 평가되는 값은 절대const로 못 쓰지만,.cctor에서는 자연스럽게 실행된다.const가 리터럴만 받는 이유가 여기 있다.
3.4 인스턴스 readonly — 생성자에서 stfld
인스턴스 readonly는 정적 생성자가 아니라 인스턴스 생성자 .ctor 안에서만 쓸 수 있습니다.
.class public beforefieldinit Session
{
.field public initonly int32 SessionId
.field public initonly string UserName
.method public hidebysig specialname rtspecialname
instance void .ctor(int32 id, string name) cil managed
{
IL_0000: ldarg.0 // this
IL_0001: call instance void System.Object::.ctor()
IL_0006: ldarg.0 // this
IL_0007: ldarg.1 // id
IL_0008: stfld int32 ILAnalysis.Session::SessionId // this.SessionId = id
IL_000d: ldarg.0 // this
IL_000e: ldarg.2 // name
IL_000f: stfld string ILAnalysis.Session::UserName // this.UserName = name
IL_0014: ret
}
}
stfld는store field로, 인스턴스 필드에 값을 쓴다. initonly 필드에stfld를 쓰는 것은 해당 타입의 생성자 안에서만 허용된다.- 생성자 바깥에서
session.SessionId = 99;같은 코드를 쓰면 CLR이 아니라 C# 컴파일러가 막는다(CS0191). 만약 IL을 직접 손으로 써서 우회하더라도 CLR 검증기가 런타임에 오류를 낸다.
핵심 요약: const 사용부에는 ldc.*·ldstr만 남는다. readonly 사용부에는 ldsfld/ldfld가 남고, 값은 .cctor/.ctor의 stsfld/stfld에서 한 번 설정된다.
4. 실전 적용 — 언제 const, 언제 readonly인가
4.1 판단 기준 요약

4.2 Before — const를 공개 API로 잘못 쓴 경우
여러 팀이 공용으로 쓰는 DLL에서 게임 API 버전을 const로 내보낸 상황을 가정합니다.
// [CommonConstants.dll] — 팀이 공유하는 공용 DLL
public static class ServerConfig
{
public const int ApiVersion = 1;
public const string ServerUrl = "https://api.example.com/v1";
}
// [GameClient.exe] — CommonConstants.dll 을 참조
public class ApiCaller
{
public void PrintVersion()
{
Console.WriteLine($"API: {ServerConfig.ApiVersion}, URL: {ServerConfig.ServerUrl}");
}
}
GameClient.exe를 빌드한 IL 조각:
.method public hidebysig instance void PrintVersion() cil managed
{
// ServerConfig 참조 없이 값이 그대로 박혀 있음
IL_0000: ldstr "API: "
IL_0005: ldc.i4.1 // ← ApiVersion 값 1이 박힘
IL_0006: box [System.Runtime]System.Int32
IL_000b: ldstr ", URL: "
IL_0010: ldstr "https://api.example.com/v1" // ← ServerUrl이 통째로 박힘
IL_0015: call string System.String::Concat(...)
IL_001a: call void System.Console::WriteLine(string)
IL_001f: ret
}
관찰: IL에 ServerConfig.ApiVersion·ServerConfig.ServerUrl 같은 필드 참조가 없습니다. 값 1과 문자열 "https://api.example.com/v1"이 GameClient.exe 바이너리 안에 직접 박혀 있습니다.
문제 상황: 서버 팀이 CommonConstants.dll을 v2로 업데이트해 ApiVersion = 2, ServerUrl = "https://api.example.com/v2"로 바꾸고 새 DLL만 배포했습니다. 그런데 GameClient.exe는 재빌드되지 않았으므로 여전히 IL에 ldc.i4.1과 예전 URL이 박혀 있습니다. 결과: 클라이언트가 여전히 v1 URL로 호출하며, 겉보기엔 버그지만 컴파일러는 설계대로 동작한 것입니다.
4.3 After — static readonly로 런타임 참조
// [CommonConstants.dll] — 수정본
public static class ServerConfig
{
public static readonly int ApiVersion = 1;
public static readonly string ServerUrl = "https://api.example.com/v1";
}
// [GameClient.exe] — 코드 변경 없음
public class ApiCaller
{
public void PrintVersion()
{
Console.WriteLine($"API: {ServerConfig.ApiVersion}, URL: {ServerConfig.ServerUrl}");
}
}
빌드한 IL 조각:
.method public hidebysig instance void PrintVersion() cil managed
{
IL_0000: ldstr "API: "
IL_0005: ldsfld int32 ServerConfig::ApiVersion // ← 런타임에 DLL에서 읽음
IL_000a: box [System.Runtime]System.Int32
IL_000f: ldstr ", URL: "
IL_0014: ldsfld string ServerConfig::ServerUrl // ← 런타임에 DLL에서 읽음
IL_0019: call string System.String::Concat(...)
IL_001e: call void System.Console::WriteLine(string)
IL_0023: ret
}
ldsfld가 남아 있으므로, CommonConstants.dll만 v2로 교체해도 GameClient.exe는 다음 실행 시 새 값을 읽습니다. 재빌드 없이 DLL만 교체하는 운영 시나리오가 가능해집니다.
4.4 Unity 실전 — Boehm GC, IL2CPP, 핫패스
Unity 신입 개발자가 꼭 알아야 하는 Unity 특유의 고려사항입니다.
Boehm GC: Unity의 Mono 런타임이 쓰는 Garbage Collector 구현. 참조 타입 할당이 누적되면 큰 GC 스파이크를 만든다. IL2CPP: Unity가 C# IL을 C++로 트랜스파일해 네이티브로 컴파일하는 백엔드. 모바일·콘솔 배포 시 기본이다. 컴파일 타임 최적화가 강하게 들어간다. 핫패스(hot path): 매 프레임(Update, FixedUpdate 등)마다 실행되는 코드 경로. 여기서의 할당·박싱은 프레임 스파이크로 이어진다.
핫패스에서 상수를 어떻게 다뤄야 하는지 Before/After로 보겠습니다.
// ❌ Before — 핫패스에서 매번 박싱이 섞인 문자열 조립
public class EnemyAI : MonoBehaviour
{
public static readonly float AggroRange = 10f; // 부적절: 리터럴이라면 const가 낫다
private void Update()
{
Debug.Log("Aggro range: " + AggroRange); // 매 프레임 ldsfld + box
}
}
// Update() IL의 핵심 부분
IL_0000: ldstr "Aggro range: "
IL_0005: ldsfld float32 EnemyAI::AggroRange // 매 호출 필드 접근
IL_000a: box [System.Runtime]System.Single // float → object 박싱
IL_000f: call string System.String::Concat(object, object)
IL_0014: call void [UnityEngine.CoreModule]UnityEngine.Debug::Log(object)
AggroRange가 프로젝트 내부 전용이고 값이 절대 안 변한다면 const로 바꿔 ldsfld를 제거할 수 있습니다. 박싱 자체는 Concat(object, object)이 원인이라 $"..." 보간으로 바꾸는 별개 조치가 필요합니다.
// ✅ After — 리터럴은 const, 로그는 프레임 루프에서 제거
public class EnemyAI : MonoBehaviour
{
public const float AggroRange = 10f; // 내부 전용 리터럴 → const
private void Awake()
{
Debug.Log($"Aggro range: {AggroRange}"); // 로그는 한 번만
}
}
// Awake() IL의 핵심 부분
IL_0000: ldstr "Aggro range: "
IL_0005: ldc.r4 10 // ← const가 직접 박힘
IL_000a: box [System.Runtime]System.Single // 보간 시 여전히 박싱되지만
IL_000f: call string System.String::Concat(object, object)
IL_0014: call void Debug::Log(object)
IL_0019: ret
// → Update()에 코드 자체가 없음. 프레임당 실행 0회.
핵심은 두 가지입니다. (1) 내부 전용 리터럴은 const로 두는 편이 ldsfld가 사라져 약간이라도 가볍습니다(IL2CPP가 잘 인라이닝하긴 하지만 IL 관점에서도 명시적입니다). (2) 프레임 루프에서는 상수 종류를 고민하기 전에 호출 빈도부터 줄이는 것이 우선입니다.
반대로 Unity에서 const를 쓰면 안 되는 경우:
// ❌ public const — 여러 DLL이 공유하는 게임 설정
public static class GameBalance
{
public const int MaxPlayerHp = 100; // 밸런스 패치 때마다 모든 DLL 재빌드 필요
}
// ✅ public static readonly — DLL 교체만으로 밸런스 반영
public static class GameBalance
{
public static readonly int MaxPlayerHp = 100;
}
혹은 밸런스 값이라면 애초에 ScriptableObject로 빼서 에디터에서 기획자가 바로 수정하게 합니다(4.5에서 설명).
4.5 Unity 실전 — 인스펙터에 노출하고 싶을 때는 ScriptableObject
const와 readonly는 둘 다 Unity 인스펙터에 나오지 않습니다. 이유는 서로 다릅니다.
const: 필드가 아니라 메타데이터의 literal이라 런타임 리플렉션으로 접근 대상이 아닙니다.SerializeField는 필드에만 적용되므로 어트리뷰트 자체를 붙일 수 없습니다(컴파일 오류는 안 나지만 직렬화 시스템이 무시합니다).readonly: 필드는 있지만 Unity의 직렬화 시스템은 런타임에 필드에 값을 씀으로써 인스펙터 값을 반영합니다.readonly는 생성자 밖에서 쓰기를 금지하므로 직렬화 시스템의 쓰기가 실패합니다. Unity는 경고와 함께 직렬화 대상에서 제외합니다.
// ❌ 직렬화 안 됨
public class EnemyStats : MonoBehaviour
{
[SerializeField] private const int MaxHp = 100; // 컴파일 에러: const는 static
[SerializeField] private readonly float Speed = 5f; // 인스펙터에 뜨지 않음
}
이럴 때의 실전 패턴은 ScriptableObject에 일반 필드로 담는 것입니다.
// ✅ ScriptableObject — 기획자가 에디터에서 편집 가능한 "상수 같은" 데이터
[CreateAssetMenu(fileName = "EnemyStats", menuName = "Game/Enemy Stats")]
public class EnemyStatsAsset : ScriptableObject
{
// 일반 필드 — 에셋 파일에 직렬화됨. 런타임에는 수정하지 않음으로써 사실상 상수.
public int maxHp = 100;
public float speed = 5f;
}
public class Enemy : MonoBehaviour
{
[SerializeField] private EnemyStatsAsset stats; // 인스펙터에서 .asset 드래그
private void Start()
{
Debug.Log($"MaxHp: {stats.maxHp}, Speed: {stats.speed}");
}
}
장점:
- 기획자/디자이너가 코드 변경 없이 값을 튜닝할 수 있다.
- 한 에셋을 여러 적 오브젝트가 공유하므로
static readonly와 비슷하게 메모리에 한 개만 존재한다. - Addressable로 비동기 로드·교체도 가능하다.
핵심 요약: 내부 전용 리터럴은 const, 공개 API나 DateTime.Now처럼 런타임 값이 필요하면 static readonly, 기획자가 값을 바꿔야 하면 ScriptableObject로 넘긴다.
5. 함정과 주의사항
5.1 함정 1 — readonly는 "필드 재할당" 금지일 뿐, 내부 상태 불변이 아니다
// ❌ 오해: 배열 readonly이면 내용도 못 바꾼다고 생각
public static class Config
{
public static readonly int[] PrimeNumbers = { 2, 3, 5, 7, 11 };
}
// 어디선가...
Config.PrimeNumbers[0] = 999; // 컴파일·런타임 모두 통과!
// 문제 없음 — readonly가 막는 건 "참조 재할당"뿐
IL_0000: ldsfld int32[] Config::PrimeNumbers // 배열 참조 로드
IL_0005: ldc.i4.0 // 인덱스 0
IL_0006: ldc.i4 999
IL_0011: stelem.i4 // 요소 쓰기 — 허용
stelem.i4는 "배열의 정수 요소에 값 저장"입니다. readonly는 PrimeNumbers = new int[]{...} 같은 필드 재할당만 막고, 배열 요소 수정은 막지 못합니다. 컬렉션의 내용 자체를 불변으로 만들려면 ImmutableArray<T>·ReadOnlyCollection<T>·IReadOnlyList<T> 같은 타입을 써야 합니다.
// ✅ 내용도 불변으로
using System.Collections.Immutable;
public static class Config
{
public static readonly ImmutableArray<int> PrimeNumbers =
ImmutableArray.Create(2, 3, 5, 7, 11);
}
// Config.PrimeNumbers[0] = 999; // 컴파일 에러: indexer has no set
5.2 함정 2 — 인터페이스에는 const를 못 박는다(C# 7.3 이하) / static abstract 한계
C# 7.3까지 인터페이스에는 const를 선언할 수 없었습니다. C# 8부터는 인터페이스 멤버에 기본 구현이 가능해져 const를 둘 수 있고, C# 11의 static abstract 멤버로는 구현체별 상수 계약을 표현할 수 있습니다.
// ❌ C# 7.3 이하: 컴파일 에러
public interface IConfig
{
// const int Version = 1; // CS0525 (interface cannot contain fields)
}
// ✅ C# 11 — static abstract로 "각 구현체의 상수 같은 값" 계약
public interface IConfig<TSelf> where TSelf : IConfig<TSelf>
{
static abstract int Version { get; }
}
public class ServerConfig : IConfig<ServerConfig>
{
public static int Version => 2; // 구현체가 자신의 버전을 선언
}
이 함정의 핵심은 "상수를 인터페이스 계약에 담고 싶다"는 요구가 있을 때 const가 답이 아니라는 것입니다. 구현체별로 값이 달라야 하므로 static abstract가 맞습니다.
5.3 함정 3 — const를 switch 케이스에 썼는데 값이 안 바뀐다
// [LibA.dll]
public static class ErrorCode
{
public const int Unknown = 0;
public const int NotFound = 404;
}
// [AppB.exe]
public void Handle(int code)
{
switch (code)
{
case ErrorCode.NotFound: // IL에는 case 404: 로 박힘
Console.WriteLine("Not found");
break;
}
}
IL_0000: ldarg.1 // code 로드
IL_0001: ldc.i4 404 // ← ErrorCode.NotFound 대신 404가 박혔다
IL_0006: bne.un.s IL_000f
IL_0008: ldstr "Not found"
IL_000d: call void Console::WriteLine(string)
IL_000e: ret
switch/case의 label은 컴파일 타임 상수여야 하므로 const가 맞습니다. 그러나 LibA에서 NotFound = 410으로 바꾸고 AppB를 재컴파일하지 않으면 AppB는 계속 404를 비교합니다. case label은 재컴파일 없이는 값이 절대 안 따라옵니다.
case에 static readonly를 쓸 수 없으므로, 해결은 보통 switch를 if/else로 바꾸거나 AppB를 동일 솔루션에서 같이 빌드하는 규칙을 만드는 것입니다. 이 함정은 단순한 버전 호환성 함정의 한 변종입니다.
5.4 함정 4 — readonly struct와 readonly 필드는 다른 이야기
readonly가 붙을 수 있는 위치가 여럿입니다.
readonly필드: 이 글의 주제. 필드 재할당 금지.readonly struct: C# 7.2 추가. struct 전체를 불변으로 선언. 모든 필드가 암묵적으로 readonly가 되고, defensive copy(방어적 복사) 이슈가 사라진다.readonly멤버 메서드: C# 8 추가. struct 메서드가 this 상태를 바꾸지 않음을 선언.
Unity의 Vector3도 현재는 readonly struct가 아닙니다(역사적 이유로 가변 struct). 그래서 Vector3.zero.x = 5; 같은 식을 쓰면 컴파일 오류가 아니라 말 없이 복사본을 수정하게 됩니다. 이건 다른 주제이지만 readonly 키워드의 쓰임새가 섞여 혼동되는 전형적인 지점입니다.
5.5 함정 5 — const enum 멤버는 자동으로 "컴파일 타임 상수"
enum 값을 상수로 내보내는 건 문제없어 보이지만, 실은 public const를 공개 API로 내놓는 것과 같은 버전 호환성 함정을 가집니다.
// [LibA.dll]
public enum LogLevel { Trace = 0, Debug = 1, Info = 2, Warn = 3, Error = 4 }
// [AppB.exe]
if (level == LogLevel.Warn) { ... }
IL_0000: ldarg.1
IL_0001: ldc.i4.3 // ← LogLevel.Warn = 3이 그대로 박힘
IL_0002: bne.un.s IL_0008
LibA가 enum 순서를 { Trace, Info, Debug, Warn, Error }로 바꾸면(Debug = 2, Info = 1) AppB는 여전히 3을 비교해 엉뚱한 값을 Warn으로 인식합니다. enum 값은 항상 명시적 숫자를 고정하고 중간 삽입을 금지하는 규칙이 필요한 이유입니다.
6. C# 버전별 변화
상수 키워드 자체의 문법은 C# 1.0부터 현재까지 크게 변하지 않았지만, 주변 문법이 바뀌며 const/readonly의 상대적 편의성이 조금씩 달라졌습니다.
6.1 C# 6 (2015) — readonly 자동 구현 프로퍼티
// C# 5 이전 — 인스턴스 readonly 필드 + 프로퍼티 중복 작성
public class Session5
{
private readonly int _id;
public int Id { get { return _id; } }
public Session5(int id) { _id = id; }
}
// C# 6 이후 — get-only 자동 구현 프로퍼티가 readonly 필드를 대체
public class Session6
{
public int Id { get; } // 생성자 외에서 쓰기 불가
public Session6(int id) { Id = id; }
}
// C# 6 Id get; 컴파일 결과
.field private initonly int32 '<Id>k__BackingField'
.method public hidebysig specialname instance int32 get_Id() cil managed
{
IL_0000: ldarg.0
IL_0001: ldfld int32 Session6::'<Id>k__BackingField'
IL_0006: ret
}
관찰: 컴파일러는 내부적으로 initonly 백킹 필드를 만듭니다. 즉 get-only 자동 프로퍼티는 readonly 필드의 편의 문법입니다.
6.2 C# 7.3 (2018) — const 지역 변수의 패턴 활용 확대
C# 7의 패턴 매칭에서 case const_value: / case { X: const_value } 형태로 const가 쓰이는 범위가 넓어졌지만, 여전히 case label에는 컴파일 타임 상수만 쓸 수 있습니다. 공개 const의 버전 호환성 함정이 패턴 매칭에도 동일하게 적용됩니다.
6.3 C# 9 (2020) — init 접근자
// C# 9 이후 — readonly의 완화 버전: "생성자 또는 object initializer에서만 쓰기 가능"
public class Session9
{
public int Id { get; init; }
}
// 호출 쪽
var s = new Session9 { Id = 42 }; // OK
// s.Id = 99; // 컴파일 에러
IL 레벨에서 init 접근자는 modreq(IsExternalInit)로 표시된 일반 setter이며, 컴파일러가 호출 위치를 제한합니다. 런타임 CLR은 일반 setter로 취급하므로 readonly만큼 강하지 않지만, 소스 코드 관점에서는 "사실상 readonly"를 지키면서 object initializer까지 허용하는 편의 문법입니다.
6.4 C# 10 (2021) — record struct의 암시적 readonly
// C# 10
public readonly record struct Point(int X, int Y);
record struct에 readonly를 붙이면 모든 프로퍼티가 자동으로 get-only가 되어 전체가 불변 값 타입이 됩니다.
6.5 C# 12 (2023) — 컬렉션 식의 제약은 유지
C# 12 컬렉션 식 [1, 2, 3]은 표현식일 뿐, const에는 여전히 못 씁니다. 컬렉션은 참조 타입이고 const는 리터럴(값 타입 리터럴과 string, null)만 허용하기 때문입니다.
// ❌ C# 12에서도 에러
public const int[] Nums = [1, 2, 3]; // CS0133
// ✅ static readonly는 가능
public static readonly int[] Nums = [1, 2, 3];
// static readonly 배열 초기화 — .cctor에서 newarr + InitializeArray
.method static void .cctor()
{
IL_0000: ldc.i4.3
IL_0001: newarr [System.Runtime]System.Int32
IL_0006: dup
IL_0007: ldtoken field valuetype '<PrivateImplementationDetails>'...
IL_000c: call void RuntimeHelpers::InitializeArray(...)
IL_0011: stsfld int32[] Demo::Nums
IL_0016: ret
}
컴파일러는 [1, 2, 3]을 바이트 블롭으로 메타데이터에 심고 .cctor에서 newarr + RuntimeHelpers.InitializeArray로 단번에 복제합니다. 문법은 깔끔해졌지만 내부 동작은 C# 1.0부터 써 오던 new int[]{1, 2, 3}과 같습니다.
종합: const의 핵심 동작(literal + IL 인라인)과 readonly의 핵심 동작(initonly 필드 + 생성자 할당)은 C# 1.0부터 지금까지 바뀌지 않았습니다. 바뀐 것은 "readonly처럼 쓰되 문법을 줄이기 위한 편의(get-only 프로퍼티, init, record)"입니다.
7. 정리
const는 컴파일 타임 상수. 값이 사용처 IL에ldc.*·ldstr로 박힌다. 호출 쪽은 선언부 어셈블리를 런타임에 참조하지 않는다. 리터럴(숫자/문자열/bool/null/enum)만 허용된다.readonly는 런타임 상수. IL에서는initonly필드이며, 사용처는ldsfld/ldfld로 값을 읽는다. 값은.cctor(static) 또는.ctor(인스턴스) 안의stsfld/stfld로 한 번만 쓴다. 런타임 값(DateTime.Now등)과 모든 타입을 받을 수 있다.- 버전 호환성의 갈림길. 다른 어셈블리에서 참조되는 상수를
const로 두면, 값이 바뀔 때마다 의존 어셈블리 전체 재컴파일이 강제된다.static readonly는 DLL 교체만으로 반영된다. 공개 API 상수는 기본적으로static readonly가 안전하다. readonly는 필드 재할당 금지일 뿐 내용 불변이 아니다. 배열·컬렉션의 내부는 여전히 수정 가능하다. 내용까지 불변으로 하려면ImmutableArray<T>등 불변 타입을 쓴다.- Unity 직렬화와 상극.
const·readonly둘 다[SerializeField]로 인스펙터에 노출할 수 없다. 기획자가 튜닝할 값은ScriptableObject로 빼는 것이 실전 패턴이다. - enum 값도
const와 같은 함정. 공개 enum의 멤버 순서/값이 바뀌면 의존 어셈블리는 옛 숫자를 비교한다. enum 값은 숫자를 명시 고정한다. - C# 12에서도 컬렉션은
const불가.[1, 2, 3]이라는 문법 설탕이 추가됐을 뿐,const의 타입 제약은 C# 1.0부터 변하지 않았다.
다음 단계 질문
- 우리 팀의 공용 DLL에서
public const로 노출된 값을 찾아보자. 그중 밸런스 패치 가능성이 있는 값은 몇 개인가? 이걸static readonly로 바꾸면 어떤 파생 효과가 생기는가? - Unity 프로젝트에서
const와readonly중 현재 핫패스의 로그/디버그 문자열이 어느 쪽을 쓰고 있는가? 이 값을 바꿨을 때 프레임당 IL 명령이 몇 개 줄어드는가? - 기획자가 인스펙터에서 튜닝하는 값은 현재
readonly로 막혀 있는가, 아니면ScriptableObject로 빠져 있는가? 둘 사이의 이주 비용은 얼마인가?
'C# 기초' 카테고리의 다른 글
| [PART2.변수와 기본 데이터 타입(11/13)] 기본 형식 변환 — 암시적·명시적·Parse·TryParse·Convert (0) | 2026.04.24 |
|---|---|
| [PART2.변수와 기본 데이터 타입(10/13)] 상수 문자열 보간 — const 문자열에 $"..." 허용 (C# 10) (0) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(8/13)] var — 타입 추론의 기본 사용법 (1) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(7/13)] string — 문자의 묶음 (0) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(6/13)] bool과 char — 참·거짓 한 비트와 유니코드 한 조각 (0) | 2026.04.24 |