| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- base64
- TextMeshPro
- 환급챌린지
- adfit
- ui
- DotsTween
- Unity Editor
- Tween
- Framework
- Custom Package
- 패스트캠퍼스
- AES
- sha
- RSA
- 게임개발
- Dots
- 오공완
- unity
- 프레임워크
- C#
- 샘플
- 직장인자기계발
- 최적화
- Job 시스템
- job
- 패스트캠퍼스후기
- 직장인공부
- 2D Camera
- 가이드
- 암호화
- Today
- Total
EveryDay.DevUp
readonly vs const — 무엇이 다른가 본문
readonly vs const — 무엇이 다른가
컴파일 타임 상수와 런타임 상수, 같은 '불변'이지만 동작 원리가 완전히 다르다.
C#에서 "바꿀 수 없는 값"을 만드는 키워드는 두 가지다 — const와 readonly. 둘 다 값을 고정한다는 점은 같지만, 값이 결정되는 시점이 근본적으로 다르다. const는 컴파일러가 코드를 빌드할 때, readonly는 프로그램이 실행될 때 값이 정해진다. 이 차이가 메모리, 성능, 버전 관리까지 모든 것을 바꾼다.
const — 컴파일 타임 상수
문제 제기
Unity 프로젝트에서 레이어 번호, 최대 플레이어 수, 프로토콜 매직넘버처럼 절대로 변하지 않는 값을 코드 곳곳에 숫자 그대로 적으면 어떻게 될까? 나중에 값을 바꿀 때 모든 파일을 뒤져야 하고, 오타 하나로 버그가 생긴다. 이런 값을 한 곳에 모아 이름을 붙이되, 런타임에 메모리를 전혀 쓰지 않는 방법이 필요하다. 그게 const다.
개념 정의
const— 상수 (constant) 선언과 동시에 초기화하며, 이후 값을 변경할 수 없는 컴파일 타임 상수. 컴파일러가 사용된 모든 위치에 값을 직접 치환한다. 암묵적으로static이다.
const를 가장 쉽게 이해하는 비유는 도장이다. 일반 변수가 "금고에 값을 넣어두고 필요할 때 꺼내 보는 것"이라면, const는 "컴파일러가 도장을 찍듯 소스 코드의 모든 사용처에 값을 직접 새겨넣는 것"이다. 프로그램이 실행될 때 금고(메모리)를 열 필요가 없다 — 이미 코드 자체에 값이 박혀있기 때문이다.
const로 선언할 수 있는 타입은 제한적이다. 컴파일 타임에 값이 확정되어야 하므로 기본 숫자 타입(int, float, double, long 등), bool, char, string, enum, 그리고 null로 초기화된 참조 타입만 허용된다. new 연산자나 메서드 호출이 필요한 타입(DateTime, Vector3, List<T> 등)은 사용할 수 없다.
public class Constants
{
// ✅ 가능 — 컴파일 타임에 결정되는 값
public const int MaxPlayers = 4;
public const float Gravity = 9.81f;
public const string GameTitle = "My RPG";
public const bool IsDebug = false;
// ✅ 가능 — const끼리의 계산도 컴파일 타임
public const int MaxTeamSize = MaxPlayers / 2;
public const int PlayerLayer = 8;
public const int PlayerMask = 1 << PlayerLayer;
// ❌ 불가능 — 런타임 값이나 new가 필요한 타입
// public const DateTime Launch = DateTime.Now;
// public const int[] Sizes = new int[3];
}
const는 암묵적으로 static이다. static const라고 쓸 수 없으며, 인스턴스마다 다른 const 값을 가질 수도 없다.
내부 동작
컴파일러는 const 값을 사용하는 모든 위치에 리터럴 값을 직접 삽입한다. IL(Intermediate Language, .NET 컴파일러가 생성하는 중간 코드) 수준에서 확인하면 이 동작이 명확히 보인다.
public class Constants
{
public const int MaxPlayers = 4;
public static readonly int MaxEnemies = 10;
}
public class Program
{
public static void Main()
{
int a = Constants.MaxPlayers; // const 읽기
int b = Constants.MaxEnemies; // static readonly 읽기
Console.WriteLine(a + b);
}
}
// ── Constants 클래스 필드 선언 ──
.field public static literal int32 MaxPlayers = int32(4) // const → literal (메타데이터에만 존재)
.field public static initonly int32 MaxEnemies // static readonly → initonly (실제 메모리 차지)
// ── 정적 생성자: static readonly 값 초기화 ──
.method private static void .cctor() cil managed
{
ldc.i4.s 10 // 10을 스택에 올리고
stsfld int32 Constants::MaxEnemies // MaxEnemies 필드에 저장
ret
}
// ── Main 메서드 ──
.method public static void Main() cil managed
{
ldc.i4.4 // ← const MaxPlayers: 4가 직접 박힘 (필드 접근 없음!)
ldsfld int32 Constants::MaxEnemies // ← static readonly: 런타임에 필드를 읽음
stloc.0
ldloc.0
add
call void System.Console::WriteLine(int32)
ret
}
핵심 차이를 정리하면:
const→ IL에literal로 선언되고, 읽는 쪽에서는ldc.i4.4처럼 값이 직접 삽입된다. 필드를 읽는 명령어(ldsfld)가 아예 없다.static readonly→ IL에initonly로 선언되고, 읽는 쪽에서는ldsfld로 런타임에 필드 값을 가져온다.
const가 미세하게 더 빠른 이유가 여기에 있다. 메모리 접근 자체가 사라지기 때문이다. 하지만 이 성능 차이는 대부분의 상황에서 측정할 수 없을 만큼 작다.
실전 적용
const가 빛을 발하는 곳은 절대 바뀌지 않는 값에 의미 있는 이름을 붙이는 것이다. 특히 Unity에서 레이어, 비트마스크, 프로토콜 상수에 유용하다.
레이어 마스크 상수:
// ❌ Before: 매직 넘버가 코드 곳곳에 흩어져 있다
Physics.Raycast(origin, direction, 100f, 1 << 8);
// ✅ After: const로 이름을 붙여 의미를 드러낸다
public static class Layers
{
public const int Player = 8;
public const int Enemy = 9;
public const int Ground = 10;
// 비트마스크 계산도 컴파일 타임에 완료
public const int PlayerMask = 1 << Player; // 256
public const int GroundMask = 1 << Ground; // 1024
}
Physics.Raycast(origin, direction, 100f, Layers.GroundMask);
const는 switch 문의 case 레이블이나 어트리뷰트(Attribute, 클래스·메서드에 메타데이터를 붙이는 기능) 인자처럼 컴파일 타임 상수가 필수인 문맥에서 유일한 선택지이기도 하다.
public static class Mode
{
public const int Attack = 1;
public const int Defense = 2;
public const int Heal = 3;
}
// switch case에는 const만 사용 가능
public string GetModeName(int mode)
{
switch (mode)
{
case Mode.Attack: return "공격"; // ✅ const 가능
case Mode.Defense: return "방어";
case Mode.Heal: return "회복";
default: return "알 수 없음";
}
}
// switch가 const 값을 인라인하여 점프 테이블로 변환
.method public instance string GetModeName(int32 mode) cil managed
{
ldarg.1
ldc.i4.1 // Mode.Attack = 1 인라인
sub
switch (IL_0016, IL_001c, IL_0022) // 점프 테이블
br.s IL_0028
ldstr "공격"
ret
ldstr "방어"
ret
ldstr "회복"
ret
ldstr "알 수 없음"
ret
}
컴파일러가 const 값을 인라인하여 switch 점프 테이블을 효율적으로 생성한다. static readonly는 런타임에 값이 결정되므로 case 레이블에 쓸 수 없다.
함정과 주의사항
const의 가장 위험한 함정: 어셈블리 경계 문제
이것은 const를 사용할 때 반드시 알아야 할 함정이다. 다른 어셈블리(DLL)에서 public const를 참조하면, 컴파일러가 그 값을 소비자 코드에 직접 복사한다. 원본 DLL의 값을 바꿔도 소비자를 재컴파일하지 않으면 예전 값이 그대로 남는다.
// ❌ 위험: 다른 어셈블리에서 참조하는 public const
// Library.dll
public class ApiConfig
{
public const int RequestTimeout = 30; // v1.0: 30초
// 나중에 60으로 바꿔도, 소비자가 재컴파일하지 않으면 30이 그대로 남음
}
// ✅ 안전: static readonly로 교체
public class ApiConfig
{
public static readonly int RequestTimeout = 30; // 런타임에 읽으므로 DLL 교체만으로 반영
}
const 사용 판단 기준:
private이거나internal이면 →const안전 (같은 어셈블리 내에서는 항상 함께 컴파일됨)public으로 외부에 노출하면 → 절대 불변인 값(Math.PI, 비트마스크)만const, 나머지는static readonly
readonly — 런타임 상수
문제 제기
게임 캐릭터의 최대 체력, 서버 세션의 시작 시간, 의존성 주입으로 받은 서비스 객체 — 이런 값들은 프로그램이 실행되고 나서야 정해지지만, 한 번 정해지면 바뀌어서는 안 된다. const로는 런타임 값을 담을 수 없고, 일반 필드는 누구든 바꿀 수 있다. "초기화 이후 변경 불가"라는 제약을 컴파일러가 강제해주는 키워드가 필요하다.
개념 정의
readonly — 읽기 전용 필드 선언 시 또는 생성자에서만 값을 할당할 수 있으며, 이후에는 변경할 수 없는 런타임 상수. 모든 타입에 사용 가능하다.
const가 "도장"이었다면, readonly는 "콘크리트로 굳히는 것"이다. 건물(객체)을 지을 때 기둥(필드)에 콘크리트를 붓는 시점이 생성자다. 콘크리트가 굳고 나면(생성자 실행이 끝나면) 기둥은 더 이상 바꿀 수 없다. 하지만 기둥 위에 올려놓은 물건(참조 타입의 내부 상태)은 여전히 바꿀 수 있다.
public class Player
{
public readonly int MaxHp; // 선언 시 또는 생성자에서 초기화
public readonly string Name;
public Player(int maxHp, string name)
{
MaxHp = maxHp; // ✅ 생성자에서 할당 — 가능
Name = name;
}
public void TakeDamage()
{
// MaxHp = 200; // ❌ 컴파일 오류: 생성자 밖에서 변경 불가
}
}
const와 달리 readonly는 모든 타입에 사용할 수 있다. DateTime, Vector3, 사용자 정의 클래스, 배열, 제네릭 컬렉션까지 가능하다. 또한 인스턴스 필드로 선언하면 인스턴스마다 다른 값을 가질 수 있다.
public class GameSession
{
public readonly DateTime StartTime; // 런타임 값 — const 불가
public readonly Guid SessionId; // 매번 다른 값
public GameSession()
{
StartTime = DateTime.UtcNow;
SessionId = Guid.NewGuid();
}
}
내부 동작
readonly 필드는 실제 메모리에 존재하며, 런타임에 읽을 때 필드 접근 명령어가 사용된다.
public class Player
{
public readonly int MaxHp;
public Player(int hp)
{
MaxHp = hp;
}
public int GetMaxHp()
{
return MaxHp;
}
}
// ── 필드 선언 ──
.field public initonly int32 MaxHp // initonly = readonly (생성자에서만 쓰기 가능)
// ── 생성자: 값 할당 ──
.method public instance void .ctor(int32 hp) cil managed
{
ldarg.0 // this 로드
call instance void System.Object::.ctor()
ldarg.0 // this 로드
ldarg.1 // hp 매개변수 로드
stfld int32 Player::MaxHp // 필드에 저장 (생성자 안이므로 허용)
ret
}
// ── GetMaxHp: 값 읽기 ──
.method public instance int32 GetMaxHp() cil managed
{
ldarg.0 // this 로드
ldfld int32 Player::MaxHp // 인스턴스 필드 읽기
ret
}
const의 ldc.i4(값 직접 삽입)와 비교하면, readonly는 ldfld(인스턴스 필드 로드) 또는 ldsfld(정적 필드 로드)로 런타임에 메모리에서 값을 읽어온다. IL 메타데이터의 initonly 키워드가 CLR(Common Language Runtime, .NET의 실행 환경)에 "이 필드는 생성자에서만 쓸 수 있다"고 알려준다.
| 키워드 | IL 메타데이터 | 읽기 IL 명령어 | 메모리 사용 |
|---|---|---|---|
const |
literal |
ldc.* (값 직접 삽입) |
없음 |
readonly (인스턴스) |
initonly |
ldfld (필드 로드) |
인스턴스마다 할당 |
static readonly |
static initonly |
ldsfld (정적 필드 로드) |
한 번 할당 |
실전 적용
Unity에서 Animator 해시 캐싱:
Animator.StringToHash— 문자열을 정수 해시로 변환 문자열 비교 대신 정수 비교를 사용하여 애니메이터 파라미터 접근 성능을 높인다. 런타임 메서드이므로const가 아닌static readonly를 써야 한다.
// ❌ Before: Update마다 문자열 해싱이 반복됨
public class PlayerAnimator : MonoBehaviour
{
private Animator _animator;
void Update()
{
_animator.SetFloat("Speed", GetSpeed()); // 매 프레임 문자열 해싱
_animator.SetBool("IsGrounded", CheckGround()); // 매 프레임 문자열 해싱
}
}
// ✅ After: static readonly로 해시를 한 번만 계산
public class PlayerAnimator : MonoBehaviour
{
// Animator.StringToHash는 런타임 메서드 → const 불가, static readonly 사용
private static readonly int SpeedHash = Animator.StringToHash("Speed");
private static readonly int IsGroundedHash = Animator.StringToHash("IsGrounded");
private Animator _animator;
void Update()
{
_animator.SetFloat(SpeedHash, GetSpeed()); // 정수 비교만 수행
_animator.SetBool(IsGroundedHash, CheckGround());
}
}
의존성 캐싱:
// ❌ Before: GetComponent를 Update에서 반복 호출
public class EnemyAI : MonoBehaviour
{
void Update()
{
var rb = GetComponent<Rigidbody>(); // 매 프레임 검색
rb.AddForce(Vector3.forward * 10f);
}
}
// ✅ After: 한 번 캐싱하고 변경 불가로 표시
public class EnemyAI : MonoBehaviour
{
// Unity MonoBehaviour에서는 생성자 대신 Awake에서 초기화
// readonly를 직접 쓸 수는 없지만, private + 명명 규칙으로 의도를 표현
private Rigidbody _rb;
void Awake()
{
_rb = GetComponent<Rigidbody>(); // 한 번만 검색
}
void Update()
{
_rb.AddForce(Vector3.forward * 10f);
}
}
💡 Unity의MonoBehaviour는 C# 생성자를 직접 호출하지 않고Instantiate나 씬 로드로 생성되기 때문에,readonly필드를 생성자에서 초기화하는 패턴이 제한된다. 대신Awake에서 초기화하고private으로 접근을 제한하는 것이 실무적인 대안이다.
함정과 주의사항
참조 타입 readonly의 함정: 참조만 불변이다
readonly가 참조 타입에 붙으면, 참조(화살표) 자체만 바꿀 수 없을 뿐 참조가 가리키는 객체의 내부는 자유롭게 수정할 수 있다. 이것을 모르면 "readonly니까 안전하겠지"라는 착각에 빠진다.
public class Inventory
{
private readonly List<string> _items = new List<string>();
public void AddItem(string item)
{
_items.Add(item); // ✅ 컴파일 성공 — 리스트 내부 수정은 가능
}
public void Reset()
{
// _items = new List<string>(); // ❌ 컴파일 오류 — 참조 자체 변경 불가
_items.Clear(); // ✅ 이렇게 비워야 한다
}
}
// ── 필드 선언 ──
.field private initonly class System.Collections.Generic.List`1<string> _items
// ── AddItem: 참조를 읽어서 내부 메서드 호출 ──
.method public instance void AddItem(string item) cil managed
{
ldarg.0
ldfld class List`1<string> Inventory::_items // 참조를 읽고 (이건 허용됨)
ldarg.1
callvirt instance void List`1<string>::Add(!0) // 내부 Add 호출 (이것도 허용됨)
ret
}
IL 수준에서 보면 initonly는 stfld(필드에 쓰기)만 차단한다. ldfld로 참조를 읽은 뒤 그 참조를 통해 callvirt로 메서드를 호출하는 것은 전혀 막지 않는다. readonly는 "이 참조가 다른 객체를 가리키게 만들지 마라"는 뜻이지, "이 참조가 가리키는 객체를 바꾸지 마라"는 뜻이 아니다.
readonly struct — 불변 값 타입의 완성
문제 제기
큰 구조체를 readonly 필드에 넣거나 in 매개변수(읽기 전용 참조로 전달하는 키워드)로 전달하면 성능이 좋아질 것 같다. 하지만 실제로는 눈에 보이지 않는 방어적 복사(defensive copy)가 발생하여 오히려 성능이 나빠질 수 있다. 이 문제를 근본적으로 해결하는 것이 C# 7.2에서 도입된 readonly struct다.
개념 정의
readonly struct— 읽기 전용 구조체 모든 필드가readonly로 강제되는 구조체. 인스턴스 생성 이후 어떤 멤버도 내부 상태를 변경할 수 없음을 컴파일러가 보장한다.
in— 읽기 전용 참조 전달 (C# 7.2) 값 타입을 복사 없이 참조로 전달하되, 수신 측에서 값을 수정하지 못하도록 컴파일러가 강제한다.ref readonly의 편의 문법이다.
예시:float Calculate(in Vector3 pos)→ pos를 복사 없이 전달, 수정 불가
방어적 복사 문제를 이해하기 위한 비유를 하나 들자. 도서관에서 "귀중 서적"(readonly 필드)을 대출할 때를 생각해보자. 사서는 이 책이 훼손될까 두렵다. 일반 서적은 복사본을 만들어서 빌려주고(방어적 복사), 원본은 금고에 보관한다. 하지만 "원래부터 수정 불가능한 전자책"(readonly struct)이라면? 복사할 필요 없이 원본을 바로 보여줘도 안전하다.
내부 동작
왜 방어적 복사가 발생하는지 IL 수준에서 확인해보자.
public struct MutablePoint // 일반 struct
{
public float X;
public float Y;
public float GetSum() => X + Y;
}
public readonly struct ImmutablePoint // readonly struct
{
public float X { get; }
public float Y { get; }
public ImmutablePoint(float x, float y) { X = x; Y = y; }
public float GetSum() => X + Y;
}
public class Holder
{
private readonly MutablePoint _mutable;
private readonly ImmutablePoint _immutable;
public Holder()
{
_mutable = new MutablePoint { X = 1, Y = 2 };
_immutable = new ImmutablePoint(1, 2);
}
public float GetMutableSum() // 일반 struct → 방어적 복사 발생
{
return _mutable.GetSum();
}
public float GetImmutableSum() // readonly struct → 복사 없음
{
return _immutable.GetSum();
}
}
// ── GetMutableSum: 일반 struct에서 방어적 복사 ──
.method public instance float32 GetMutableSum() cil managed
{
.locals init (
[0] valuetype MutablePoint // ← 로컬 변수 선언 (복사본 저장 공간)
)
ldarg.0
ldfld valuetype MutablePoint Holder::_mutable // 필드 값을 읽어서
stloc.0 // 로컬 변수에 복사! (방어적 복사)
ldloca.s 0 // 복사본의 주소를 가져와서
call instance float32 MutablePoint::GetSum() // 복사본에서 호출
ret
}
// ── GetImmutableSum: readonly struct는 복사 없음 ──
.method public instance float32 GetImmutableSum() cil managed
{
ldarg.0
ldflda valuetype ImmutablePoint Holder::_immutable // 필드의 주소를 직접 로드!
call instance float32 ImmutablePoint::GetSum() // 원본에서 바로 호출
ret
}
차이가 명확하다:
- 일반 struct:
ldfld(값 복사) →stloc.0(로컬에 저장) →ldloca.s(복사본 주소) →call. 로컬 변수에 구조체 전체를 복사한 뒤 그 복사본에서 메서드를 호출한다. 컴파일러는GetSum()이 내부 필드를 수정할 수도 있다고 가정하기 때문에, readonly 필드를 보호하기 위해 복사본을 만드는 것이다. - readonly struct:
ldflda(주소 직접 로드) →call. 복사 없이 원본 필드의 주소에서 직접 메서드를 호출한다. 컴파일러가 "이 구조체는 어떤 메서드도 상태를 바꾸지 않는다"는 것을 보장할 수 있기 때문이다.
구조체가 12바이트(Vector3)나 64바이트(Matrix4x4)처럼 크면, 프레임당 수만 번 호출되는 핫패스(hot path, 자주 실행되는 코드 경로)에서 이 복사 비용이 누적된다.
in 매개변수에서도 같은 문제가 발생한다:
public struct NormalVector
{
public float X;
public float Y;
public float Length() => X * X + Y * Y;
}
public readonly struct ReadonlyVector
{
public float X { get; }
public float Y { get; }
public ReadonlyVector(float x, float y) { X = x; Y = y; }
public float Length() => X * X + Y * Y;
}
public class Calculator
{
public static float NormalIn(in NormalVector v) // 일반 struct + in
{
return v.Length();
}
public static float ReadonlyIn(in ReadonlyVector v) // readonly struct + in
{
return v.Length();
}
}
// ── NormalIn: 일반 struct + in → 방어적 복사 ──
.method public static float32 NormalIn([in] valuetype NormalVector& v) cil managed
{
.locals init (
[0] valuetype NormalVector // ← 방어적 복사를 위한 로컬 변수
)
ldarg.0
ldobj NormalVector // 참조에서 값을 꺼내서
stloc.0 // 로컬에 복사!
ldloca.s 0
call instance float32 NormalVector::Length()
ret
}
// ── ReadonlyIn: readonly struct + in → 복사 없음 ──
.method public static float32 ReadonlyIn([in] valuetype ReadonlyVector& v) cil managed
{
ldarg.0 // 참조를 그대로 사용
call instance float32 ReadonlyVector::Length() // 직접 호출!
ret
}
in으로 참조 전달하여 복사를 피하려 했지만, 일반 struct에서는 ldobj + stloc.0으로 결국 복사가 발생한다. readonly struct에서만 진짜 복사 없이 참조를 사용한다.
실전 적용
게임 데이터 구조체:
// ❌ Before: 일반 struct — readonly 필드나 in 매개변수와 함께 쓰면 방어적 복사
public struct DamageInfo
{
public float BaseDamage;
public float Multiplier;
public int AttackerId;
public float GetFinalDamage() => BaseDamage * Multiplier;
}
// ✅ After: readonly struct — 방어적 복사 제거
public readonly struct DamageInfo
{
public float BaseDamage { get; }
public float Multiplier { get; }
public int AttackerId { get; }
public DamageInfo(float baseDamage, float multiplier, int attackerId)
{
BaseDamage = baseDamage;
Multiplier = multiplier;
AttackerId = attackerId;
}
public float GetFinalDamage() => BaseDamage * Multiplier;
}
public class DamageSystem
{
// in으로 전달해도 방어적 복사 없음
public void ApplyDamage(in DamageInfo info)
{
float damage = info.GetFinalDamage();
// ...
}
}
Unity Jobs System과 readonly:
Unity의 Job System에서 [ReadOnly] 어트리뷰트는 C#의 readonly와는 다른 개념이지만, 같은 철학 — "이 데이터는 읽기 전용"이라는 의도를 시스템에 알려 병렬 처리를 안전하게 만든다.
using Unity.Jobs;
using Unity.Collections;
using Unity.Burst;
[BurstCompile]
public struct MoveJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float> Speeds; // 읽기 전용 데이터
public NativeArray<float> Positions; // 쓰기 가능 데이터
public float DeltaTime;
public void Execute(int index)
{
Positions[index] += Speeds[index] * DeltaTime;
}
}
함정과 주의사항
readonly struct를 써야 할 때 쓰지 않으면:
성능에 민감한 코드에서 in 매개변수와 일반 struct를 함께 쓰면, 개발자의 의도(복사 비용 절감)와 실제 동작(방어적 복사)이 정반대가 된다. in을 붙였으니 성능이 나아졌을 거라고 착각하지만, IL을 확인하면 오히려 복사가 발생하고 있다.
// ❌ 잘못된 기대: in을 붙였으니 복사가 없을 거야
public static float Process(in MutablePoint p)
{
return p.GetSum(); // 실제로는 방어적 복사 발생!
}
// ✅ 올바른 방법: readonly struct로 선언해야 in의 효과를 얻는다
public readonly struct SafePoint
{
public float X { get; }
public float Y { get; }
public SafePoint(float x, float y) { X = x; Y = y; }
public float GetSum() => X + Y;
}
public static float Process(in SafePoint p)
{
return p.GetSum(); // 진짜 복사 없음
}
readonly struct의 제약:
- 모든 필드가
readonly여야 한다 — 자동 프로퍼티는get만 허용. - 메서드 내에서
this에 값을 할당할 수 없다. - 완전한 불변 설계를 강제하므로, 상태가 변해야 하는 구조체에는 적합하지 않다.
C# 버전별 변화
C# 1.0 — const와 readonly의 시작
C# 최초 버전부터 const와 readonly 필드가 존재했다.
// C# 1.0부터 사용 가능
public class Config
{
public const int MaxSize = 100; // 컴파일 타임 상수
public readonly int BufferSize; // 런타임 상수
public static readonly int DefaultPort = 8080;
public Config(int bufferSize)
{
BufferSize = bufferSize;
}
}
C# 7.2 — readonly struct와 in 매개변수
가장 큰 변화. 구조체 전체를 불변으로 선언하고, 값 타입을 읽기 전용 참조로 전달하는 기능이 추가되었다. 방어적 복사 문제를 해결하는 핵심 도구다.
// ❌ Before (C# 7.1 이하): 구조체의 불변성을 보장할 방법이 없었다
public struct Point
{
public readonly float X; // 필드 하나하나에 readonly를 붙여야 했고
public readonly float Y; // 메서드가 상태를 안 바꾼다는 보장이 없었다
public float GetSum() => X + Y; // 이 메서드가 안전한지 컴파일러가 모름
}
// ✅ After (C# 7.2): readonly struct + in 매개변수
public readonly struct Point
{
public float X { get; }
public float Y { get; }
public Point(float x, float y) { X = x; Y = y; }
public float GetSum() => X + Y; // 컴파일러가 안전함을 보장
}
public float Calculate(in Point p) // 복사 없이 참조 전달
{
return p.GetSum(); // 방어적 복사 없음
}
C# 8.0 — readonly 인스턴스 멤버
구조체 전체를 readonly로 만들지 않고, 개별 메서드나 프로퍼티만 readonly로 선언할 수 있게 되었다. 일부 필드는 변경 가능하되 특정 멤버는 상태를 바꾸지 않음을 명시한다.
// ✅ C# 8.0: 부분적 readonly
public struct GameTimer
{
private float _elapsed;
private bool _running;
// readonly 프로퍼티: 이 멤버는 상태를 바꾸지 않는다
public readonly float Elapsed => _elapsed;
public readonly bool IsRunning => _running;
// 일반 메서드: 상태를 바꿀 수 있다
public void Start() { _running = true; }
public void Update(float delta) { if (_running) _elapsed += delta; }
// readonly 메서드: ToString도 안전하다고 명시
public readonly override string ToString()
{
return $"Timer: {_elapsed:F2}s (running: {_running})";
}
}
// readonly 멤버에는 IsReadOnlyAttribute가 붙는다
.method public hidebysig specialname
instance float32 get_Elapsed() cil managed
{
.custom instance void IsReadOnlyAttribute::.ctor() = (01 00 00 00) // ← readonly 표시
ldarg.0
ldfld float32 GameTimer::_elapsed
ret
}
IL에서 IsReadOnlyAttribute가 메서드에 부착된다. 컴파일러는 이 어트리뷰트가 있는 멤버를 호출할 때 방어적 복사를 생략할 수 있다.
C# 9.0 — init 접근자
init— 초기화 전용 setter (C# 9.0) 객체 초기화 구문({ })에서만 값을 설정할 수 있고, 이후에는 변경 불가.readonly와 유사하지만 객체 초기화 구문의 편의성을 유지한다.
예시:public string Name { get; init; }→new Player { Name = "Alice" }가능, 이후 변경 불가
// ❌ Before: readonly 필드는 생성자에서만 설정 가능
public class PlayerProfile
{
public readonly string Name;
public readonly int Level;
public PlayerProfile(string name, int level)
{
Name = name;
Level = level;
}
}
var p = new PlayerProfile("Alice", 10);
// ✅ After: init으로 객체 초기화 구문 사용 가능
public class PlayerProfile
{
public string Name { get; init; }
public int Level { get; init; }
}
var p = new PlayerProfile { Name = "Alice", Level = 10 };
// p.Name = "Bob"; // ❌ 컴파일 오류 — 초기화 이후 변경 불가
정리
const vs readonly vs static readonly 선택 기준
| 상황 | 선택 | 이유 |
|---|---|---|
| 수학 상수 (π, e) | const |
절대 불변, 인라인 최적화 |
| 비트마스크, 레이어 번호 | const |
컴파일 타임 계산, switch case 필요 |
| 외부 노출(public) 설정값 | static readonly |
어셈블리 경계 문제 방지 |
| 런타임에 결정되는 값 | readonly / static readonly |
const 불가 |
| Animator 해시, Shader ID | static readonly |
런타임 메서드 결과 |
| 데이터 전달용 구조체 | readonly struct |
방어적 복사 방지 |
핵심 체크리스트
- [ ]
const를public으로 노출할 때, 절대 변하지 않을 값인지 확인했는가? - [ ]
static readonly가 필요한 곳에const를 쓰고 있지 않은가? (어셈블리 경계 문제) - [ ]
in매개변수로 전달하는 구조체가readonly struct인가? (방어적 복사 확인) - [ ]
readonly참조 타입 필드를 "완전히 불변"이라고 착각하고 있지 않은가? - [ ] Unity MonoBehaviour에서
readonly대신private+Awake캐싱 패턴을 쓰고 있는가? - [ ] 핫패스에서 사용하는 구조체의 방어적 복사 여부를 IL로 확인했는가?
'C# 심화' 카테고리의 다른 글
| string은 왜 불변인가 — intern pool의 실체 / == 이 값 비교인 이유 / 변경할 때마다 새 객체가 생기는 원리 (0) | 2026.03.31 |
|---|---|
| 형변환 완전 정리 — 암시적·명시적·as·is (0) | 2026.03.31 |
| var — 타입 추론의 원리와 한계 (0) | 2026.03.30 |
| null 처리 연산자 — ??, ?., ??= (0) | 2026.03.30 |
| null이란 무엇인가 — null의 두 얼굴 (0) | 2026.03.30 |
