[PART7.클래스와 객체 입문(14/21)] readonly 구조체 멤버 — 메서드·프로퍼티 단위 불변성 (C# 8)
구조체 전체를 불변으로 만들지 않고도 "이 메서드는 this를 건드리지 않는다"고 선언하는 법 / 방어적 복사를 IL 한 줄로 끊어내기 / in 매개변수의 성능 이점을 지키는 핵심 한정자
목차
1. 문제 제기 — in 으로 넘겼는데 왜 빨라지지 않는가
Unity의 Update() 안에서 카메라 행렬을 계산하는 메서드를 떠올려 봅시다. 매 프레임마다 호출되는 코드라면 한 번이라도 불필요한 복사가 끼어들면 성능에 그대로 드러납니다.
// 64바이트짜리 행렬을 복사 없이 넘기고 싶다 — in 으로 받으면 된다
public float ComputeDet(in Matrix4x4 view)
{
return view.GetDeterminant(); // 정말 복사가 안 일어날까?
}
in 키워드는 "값 타입을 복사하지 말고 참조(주소)로만 넘겨라"는 지시입니다. Matrix4x4 처럼 64바이트나 되는 큰 구조체를 매 프레임 복사하면 GC에는 안 잡혀도 CPU 캐시에는 압박이 갑니다. 그래서 in 매개변수가 도입됐습니다.
그런데 함정이 있습니다. in 으로 받았더라도, 그 안에서 평범한 메서드를 호출하는 순간 컴파일러가 몰래 64바이트 복사본을 만들어 버리는 경우가 있습니다. 우리가 피하려고 했던 바로 그 복사가 그대로 일어납니다.
이 현상의 이름이 방어적 복사(defensive copy) 입니다. 그리고 C# 8 에 추가된 readonly 구조체 멤버 가 이 복사를 끊어내기 위해 만들어진 한정자입니다.
Unity 모바일 게임의 60fps 핫패스에서, 매 프레임 64바이트 × N개 구조체에 방어적 복사가 끼면 성능 그래프가 톱니처럼 튑니다. 이 글에서는 그 톱니가 어디서 오는지, 어떻게 한 줄로 없애는지를 IL 레벨까지 내려가서 봅니다.
2. 개념 정의 — readonly 멤버란
readonly(멤버에 붙는 경우) — 읽기 전용 멤버 (Readonly member, C# 8) 구조체의 메서드·프로퍼티·get 접근자 앞에 붙여 "이 멤버는this(자기 자신)의 필드를 절대 수정하지 않는다"고 컴파일러에게 약속하는 한정자다. 약속을 어기면 컴파일 에러가 나고, 약속을 지키면 컴파일러가 방어적 복사를 생략한다.
예시:public readonly int Sum() => X + Y;this.X = 0;같은 쓰기는 컴파일 에러
비유: 도서관 책에 붙은 "열람 전용" 스티커
도서관 사서에게 "이 책 좀 빌려줘"라고 했다고 합시다. 사서는 책이 훼손될까 봐 복사본을 한 권 떠서 그 복사본을 줍니다(원본은 안전하게 보관). 그런데 책에 "열람 전용 — 절대 표시하지 않음"이라는 스티커가 미리 붙어 있다면? 사서는 복사본을 만들 필요 없이 원본을 그대로 건네줍니다.
readonly 멤버는 메서드에 붙이는 그 "열람 전용 스티커"입니다. 컴파일러(=사서)가 이 스티커를 보면 복사본 없이 원본 주소를 그대로 사용합니다.
시각화 — 같은 코드, 다른 IL
기본 코드 — 메서드 단위 readonly
public struct Point
{
public int X;
public int Y;
// Sum은 X, Y를 읽기만 함 — readonly 약속
public readonly int Sum() => X + Y;
// Area도 마찬가지 — get 접근자에 readonly
public readonly int Area => X * Y;
// Translate는 필드를 바꿈 — readonly 안 붙임
public void Translate(int dx, int dy)
{
X += dx;
Y += dy;
}
}
readonly멤버 약속을 어기면?readonly int Sum() { X = 0; return X; }처럼 쓰면CS8341류의 컴파일 에러가 납니다.readonly메서드 안에서this의 필드 쓰기, 비-readonly 메서드 호출 모두 금지됩니다.
쉬운 설명
readonly 멤버는 메서드에 다는 "나는 안 건드림" 도장입니다. 도장이 찍힌 메서드는 컴파일러가 안심하고 원본을 그대로 쓰게 해 줍니다. 도장이 없으면? 컴파일러는 만에 하나 건드릴까 봐 매번 복사본을 떠서 건넵니다.
기술 정의
C# 8.0 부터 구조체의 인스턴스 멤버(메서드, 프로퍼티, 인덱서, 이벤트의 add/remove)에 readonly 한정자를 붙일 수 있다. 이 한정자는 다음을 보장한다.
- 해당 멤버 본문에서
this의 필드를 직접 수정할 수 없다. - 해당 멤버 본문에서
this의 비-readonly 인스턴스 멤버를 호출할 수 없다(호출하면 경고 + 자체적으로 방어적 복사 발생). - 호출 측에서 이 멤버를 호출할 때 컴파일러는 방어적 복사를 생략한다.
메타데이터에는 System.Runtime.CompilerServices.IsReadOnlyAttribute가 붙어 어셈블리 경계 너머에서도 동일한 보장이 유지된다.
3. 내부 동작 — IL 레벨에서 본 방어적 복사
방어적 복사가 일어나는 두 가지 트리거
컴파일러가 임시 복사본을 만드는 상황은 본질적으로 같은 패턴입니다 — 읽기 전용 위치에 있는 구조체에 대해, 변경 가능성이 있는 멤버를 호출할 때입니다.
Before/After 코드 — 같은 메서드, 다른 한정자
방어적 복사를 가장 명확하게 보려면 같은 시그니처의 메서드 두 개를 만들고 IL을 비교하는 게 가장 빠릅니다.
public struct Point
{
public int X;
public int Y;
public int Sum() => X + Y; // Before: readonly 없음
public readonly int SumReadonly() => X + Y; // After: readonly 있음
}
public class Caller
{
private readonly Point _point;
public int CallSum() => _point.Sum(); // 방어적 복사 발생
public int CallSumReadonly() => _point.SumReadonly(); // 복사 없음
}
C# 본문은 똑같이 _point.SomeMethod() 인데 컴파일러는 이 둘을 완전히 다른 IL로 컴파일합니다. 직접 컴파일해서 디컴파일하면 차이가 보입니다.
IL — Before (방어적 복사 발생)
.method public hidebysig
instance int32 CallSum () cil managed
{
.maxstack 1
.locals init (
[0] valuetype Point // ← 임시 복사본을 둘 지역 변수
)
IL_0000: ldarg.0 // this(Caller) 로드
IL_0001: ldfld valuetype Point Caller::_point // 필드 값을 통째로 스택에 올림
IL_0006: stloc.0 // ★ 지역 변수 V_0에 복사 저장
IL_0007: ldloca.s 0 // 복사본의 주소를 로드
IL_0009: call instance int32 Point::Sum() // 복사본 위에서 호출
IL_000e: ret
}
코드 크기 15바이트, 지역 변수 1개 사용. ldfld → stloc → ldloca 3 명령어가 바로 방어적 복사의 IL 지문입니다. 필드의 값을 통째로 스택에 올린 뒤(ldfld), 임시 변수에 저장하고(stloc), 그 임시 변수의 주소(ldloca)에 대해 메서드를 호출합니다. 원본 _point는 한 번도 메서드에 노출되지 않습니다.
IL — After (readonly로 복사 제거)
.method public hidebysig
instance int32 CallSumReadonly () cil managed
{
.maxstack 8
// 지역 변수 없음
IL_0000: ldarg.0 // this(Caller) 로드
IL_0001: ldflda valuetype Point Caller::_point // ★ 필드의 '주소' 직접 로드
IL_0006: call instance int32 Point::SumReadonly() // 원본 주소 위에서 호출
IL_000b: ret
}
코드 크기 12바이트, 지역 변수 0개. 핵심은 ldfld → ldflda(끝에 a) 한 글자 차이입니다. ldflda 는 "load field address" — 값을 복사하지 않고 필드의 주소를 그대로 로드합니다. 임시 변수도, stloc 도 없습니다.
왜 컴파일러는 이렇게 다르게 행동하는가
Point 구조체의 Sum()은 readonly가 없으므로 컴파일러 입장에서는 "내부적으로 X나 Y를 바꿀 수도 있는 메서드" 입니다. 그런데 _point 는 readonly 필드라 변경되어선 안 됩니다. 컴파일러가 택할 수 있는 선택지는 둘 중 하나입니다.
- 원본 주소를 넘겨주고 메서드를 믿는다 → 메서드가 정말 필드를 바꾸면
readonly필드 규약 위반. - 복사본을 만들고 거기에 대고 호출한다 → 메서드가 뭘 바꾸든 원본은 안전.
C# 컴파일러는 항상 2번 을 택합니다. 안전이 최우선이기 때문입니다. readonly 멤버는 이 둘 중 1번이 안전하다는 보증서입니다 — 메서드에 도장이 찍혀 있으니 원본을 그대로 줘도 된다고요.
이 섹션의 핵심: 방어적 복사는 "안 썼는데 생기는 복사"입니다. C# 코드만 보면 보이지 않고, IL의 stloc 한 줄로 드러납니다.
4. 실전 적용 — in 매개변수와의 조합
언제 효과가 가장 큰가
방어적 복사 한 번의 비용은 구조체 크기에 비례합니다. 8바이트짜리 Point(int X, int Y)라면 무시해도 되지만, Matrix4x4(64바이트), BoundingBox(24바이트), Unity의 Bounds(24바이트), Color(16바이트) 같은 구조체가 매 프레임 호출되는 핫패스에 들어가면 톱니가 보이기 시작합니다.
특히 in 매개변수는 처음부터 "복사하지 마라"는 의도로 만들어졌는데, 본문에서 비-readonly 메서드를 호출하면 도로 복사가 일어나서 in 의 의미가 사라집니다. readonly 멤버는 이 결합을 완성합니다.
Before — in 만 붙이고 readonly 안 붙인 경우
public struct PointMutable
{
public int X;
public int Y;
public int Sum() => X + Y; // readonly 없음
}
public static class Workload
{
// in 으로 받았으니 복사 안 일어날 거라 기대
public static int SumViaMutable(in PointMutable p) => p.Sum();
}
IL 결과는 가차없습니다.
.method public hidebysig static
int32 SumViaMutable ([in] valuetype PointMutable& p) cil managed
{
.maxstack 1
.locals init (
[0] valuetype PointMutable // ← 또 임시 복사본
)
IL_0000: ldarg.0 // p의 참조 로드
IL_0001: ldobj PointMutable // ★ 참조가 가리키는 값 통째로 복사
IL_0006: stloc.0 // 임시 변수에 저장
IL_0007: ldloca.s 0 // 복사본 주소
IL_0009: call instance int32 PointMutable::Sum() // 복사본 위에서 호출
IL_000e: ret
}
ldobj 가 새로 등장했습니다. ldobj 는 참조가 가리키는 값 타입을 통째로 스택에 올리는 명령어로, 본질적으로 8바이트든 64바이트든 구조체 전체를 복사합니다. in 으로 넘기면서 8바이트 참조만 전달했지만, 메서드 호출 한 번 때문에 다시 64바이트가 복사되는 셈입니다 — in 이 무력화됐습니다.
After — readonly 멤버로 복사 제거
public struct PointReadonlyMember
{
public int X;
public int Y;
public readonly int Sum() => X + Y; // ← 도장 추가
}
public static class Workload
{
public static int SumViaReadonlyMember(in PointReadonlyMember p) => p.Sum();
}
.method public hidebysig static
int32 SumViaReadonlyMember ([in] valuetype PointReadonlyMember& p) cil managed
{
.maxstack 8
IL_0000: ldarg.0 // 참조 그대로
IL_0001: call instance int32 PointReadonlyMember::Sum() // 원본 위에서 호출
IL_0006: ret
}
코드 크기가 15바이트 → 7바이트로 절반 이하가 됐습니다. ldobj, stloc.0, ldloca.s 0 세 줄이 통째로 사라졌습니다. 매 프레임 1만 번 호출되는 메서드라면 이 차이가 60fps 안정성으로 직결됩니다.
Unity 핫패스 — 카메라/물리 계산
using UnityEngine;
public readonly struct CameraView
{
public readonly Vector3 Position;
public readonly Quaternion Rotation;
public readonly float FieldOfView;
public CameraView(Vector3 pos, Quaternion rot, float fov)
{
Position = pos; Rotation = rot; FieldOfView = fov;
}
// readonly struct이므로 모든 메서드가 자동으로 readonly
public Vector3 Forward() => Rotation * Vector3.forward;
public bool IsLookingAt(in Vector3 target)
{
Vector3 dir = (target - Position).normalized;
return Vector3.Dot(Forward(), dir) > 0.95f;
}
}
public class FrustumCuller
{
public int CountVisible(in CameraView cam, Vector3[] points)
{
int count = 0;
for (int i = 0; i < points.Length; i++)
{
// cam은 in으로 받았고, IsLookingAt은 readonly struct의 메서드 → 복사 없음
if (cam.IsLookingAt(points[i])) count++;
}
return count;
}
}
points.Length 가 1만일 때 cam 이 일반 구조체였다면 매 반복마다 CameraView (28바이트) 가 복사되어 28만 바이트 카피가 발생합니다. readonly struct 또는 readonly 멤버가 붙어 있으면 이 카피가 0 이 됩니다.
실전 판단 기준
| 상황 | 권장 |
|---|---|
| 새 구조체 설계 + 절대 변하지 않는 값 | readonly struct 전체 적용 |
| 새 구조체 설계 + 일부 메서드는 mutate 필요 | 일반 struct + 읽기 전용 메서드에 readonly |
| 기존 mutable struct 수정 불가 (외부 라이브러리) | 사용 측에서 in 만 쓰고 방어적 복사 감수 (또는 ref 로 받기) |
Matrix4x4, Bounds 같은 16바이트↑ |
호출부에 in + 호출 대상은 readonly 멤버 (2 종 세트) |
8바이트 이하 작은 struct (Point 등) |
in 굳이 안 써도 됨 — 복사 비용 무시 가능 |
요약:in과readonly멤버는 짝으로 쓸 때만 의미가 있다. 한쪽만 있으면 IL에ldobj가 끼어 들어와 의도가 무너진다.
5. 함정과 주의사항
함정 1 — ToString(), GetHashCode() 재정의 안 함
가장 흔한 함정입니다. 모든 구조체는 System.ValueType 을 상속하고, 그 위에 Object 가 있습니다. ValueType.ToString() 은 readonly 가 아닙니다. 따라서 우리가 만든 구조체에서 ToString() 을 재정의하지 않으면, 호출 때마다 방어적 복사가 발생합니다.
// ❌ 잘못된 패턴 — readonly struct이지만 ToString 재정의 안 함
public readonly struct PointBad
{
public readonly int X, Y;
public PointBad(int x, int y) { X = x; Y = y; }
// ToString을 override하지 않음
}
void Log(in PointBad p)
{
System.Console.WriteLine(p.ToString()); // ★ 방어적 복사 발생
}
ToString() 이 ValueType 의 비-readonly 메서드이기 때문에, readonly struct 인스턴스라도 컴파일러는 방어적 복사를 삽입합니다.
// ✅ 올바른 패턴 — readonly로 재정의
public readonly struct PointGood
{
public readonly int X, Y;
public PointGood(int x, int y) { X = x; Y = y; }
public override string ToString() => $"({X}, {Y})";
public override int GetHashCode() => System.HashCode.Combine(X, Y);
public override bool Equals(object obj) => obj is PointGood o && X == o.X && Y == o.Y;
}
readonly struct 안에서 override 한 메서드는 자동으로 readonly 로 취급되므로, 위 코드는 방어적 복사 없이 호출됩니다.
override— 재정의 키워드 (Override) 부모 클래스/타입의virtual또는abstract메서드를 자식이 다시 구현할 때 사용한다.ToString,GetHashCode,Equals는Object가virtual로 제공하므로 구조체에서도override가능하다.
예시:public override string ToString() => ...;부모의 ToString 동작을 우리 구조체용으로 교체
함정 2 — 자동 구현 프로퍼티의 set이 있으면 get은 자동 readonly가 아니다
C# 9 부터 일부 자동 프로퍼티의 get 은 자동으로 readonly 로 추론됩니다. 하지만 set 이 함께 있는 자동 프로퍼티의 get 은 그렇지 않습니다.
// ❌ 의도와 다른 패턴
public struct DataMutable
{
public int X { get; set; } // get은 자동 readonly가 아님
public int Y { get; set; }
}
void Sum(in DataMutable d)
{
int s = d.X + d.Y; // ★ X, Y 각각의 get 호출 시 방어적 복사 2번 발생
}
d.X 한 번, d.Y 한 번, 총 두 번 방어적 복사가 일어납니다. 해결책은 두 가지입니다.
// ✅ 방법 1: get에 readonly 명시
public struct DataA
{
public int X { readonly get; set; }
public int Y { readonly get; set; }
}
// ✅ 방법 2: 구조체 전체를 readonly struct로
public readonly struct DataB
{
public int X { get; init; } // init만 있으면 자연스러운 불변
public int Y { get; init; }
}
함정 3 — readonly 멤버 안에서 비-readonly 멤버 호출
readonly 메서드 안에서 같은 구조체의 비-readonly 메서드를 호출하면 그 자리에서 방어적 복사가 발생합니다. 컴파일러가 경고를 띄워주지만, 모르고 지나가기 쉽습니다.
// ❌ 의도치 않은 복사
public struct Aggregate
{
public int X, Y;
public int RawSum() => X + Y; // readonly 없음
public readonly int SafeSum()
{
return RawSum(); // ★ 여기서 this의 복사본이 만들어져 RawSum 호출됨 (CS8656 류 경고)
}
}
// ✅ 같이 readonly로
public struct AggregateGood
{
public int X, Y;
public readonly int RawSum() => X + Y;
public readonly int SafeSum() => RawSum(); // 복사 없음
}
CS8656 — 방어적 복사 경고 Visual Studio / Rider 등에서 빨간 줄과 함께Call to non-readonly member from a 'readonly' member results in an implicit copy of 'this'메시지를 띄운다. 이 경고가 보이면 9 할은 호출 대상 멤버에readonly를 붙이면 해결된다.
함정 4 — Unity의 Vector3, Quaternion 은 mutable struct
Unity의 Vector3.Normalize() 같은 메서드는 this 를 수정하는 mutable 메서드입니다. 그래서 Unity 구조체를 in 으로 받아 Normalize() 를 호출하면 방어적 복사가 일어납니다 — 그것도 매 프레임.
// ❌ 매 프레임 12바이트 방어적 복사
public float DistAlongAxis(in Vector3 axis, in Vector3 target)
{
Vector3 a = axis; // 일단 복사로 받음
a.Normalize(); // 이건 a를 mutate하는 거라 OK
return Vector3.Dot(a, target);
}
// ✅ 정적 메서드 사용 — 복사 없음
public float DistAlongAxisGood(in Vector3 axis, in Vector3 target)
{
return Vector3.Dot(axis.normalized, target); // .normalized는 readonly get
}
Unity의 Vector3.normalized 프로퍼티는 새 벡터를 반환하는 readonly 동작이고, Normalize() 메서드는 this 를 바꾸는 mutating 동작입니다. 이름이 비슷해 보여도 시그니처가 완전히 다릅니다. 이 차이를 모르면 복사가 어디서 생기는지 끝까지 못 찾습니다.
6. C# 버전별 변화
C# 7.2 — readonly struct (전체 적용)
C# 7.2 에서 처음 도입된 readonly struct 는 구조체 전체를 불변으로 묶었습니다. 모든 필드가 readonly 여야 하고, 모든 메서드가 자동으로 readonly 취급 됩니다. 강력하지만 경직됩니다 — 일부 멤버만 읽기 전용으로 만들 수가 없습니다.
// C# 7.2 — 전부 또는 전무
public readonly struct OldStyle
{
public readonly int X; // 모든 필드가 readonly여야 함
public readonly int Y;
public OldStyle(int x, int y) { X = x; Y = y; }
// 이 메서드는 자동으로 readonly 취급
public int Sum() => X + Y;
}
C# 8 — 멤버 단위 readonly
C# 8 에서 도입된 멤버 단위 readonly 는 가변 구조체에 부분 도입할 수 있게 해 줍니다.
// C# 8 — 부분 도입 가능
public struct NewStyle
{
public int X; // 일반 필드 (mutable 가능)
public int Y;
public readonly int Sum() => X + Y; // 이건 안전
public void Translate(int dx, int dy) // 이건 변경
{
X += dx;
Y += dy;
}
}
같은 시그니처에서 IL 차이:
| 호출 케이스 | C# 7.1 이전 | C# 7.2 (readonly struct) |
C# 8 (readonly 멤버) |
|---|---|---|---|
| readonly 필드에서 호출 | 항상 방어적 복사 | 복사 없음 (전체 도장) | 멤버에 도장 있으면 복사 없음 |
in 매개변수에서 호출 |
항상 방어적 복사 | 복사 없음 | 멤버에 도장 있으면 복사 없음 |
| 가변 메서드 같이 두기 | 가능 (단, 복사 발생) | 불가능 (전체 readonly) | 가능 + 일부만 최적화 |
C# 9 — 자동 구현 프로퍼티의 get 자동 추론
{ get; } 또는 { get; init; } 형태의 자동 프로퍼티에서 get 접근자는 컴파일러가 자동으로 readonly 로 취급합니다. 명시적으로 쓰지 않아도 됩니다.
// C# 9+ — get-only / init-only 자동 프로퍼티
public struct V9Style
{
public int X { get; init; } // get은 자동 readonly
public int Y { get; init; }
public readonly int Sum() => X + Y;
}
다만 { get; set; } 처럼 set 이 있는 경우는 자동 추론이 안 되므로 readonly get 을 명시해야 합니다.
C# 12 / .NET 8 — IDE0251 자동 제안 강화
C# 자체 문법 변경은 없지만 .NET 8부터 IDE가 "이 메서드는 readonly 로 만들 수 있습니다(IDE0251)"라는 제안을 더 적극적으로 띄워줍니다. 코드 분석 규칙을 켜 두면 누락된 readonly 를 자동으로 찾아줍니다.
버전 변화 요약: C# 7.2 (전체) → C# 8 (멤버 단위) → C# 9 (자동 추론). 7.2에서 8로의 변화가 가장 의미가 큽니다 — "전부 readonly" 와 "전부 mutable" 사이에 끼인 회색 지대를 처리할 수 있게 됐습니다.
7. 정리
이번 글에서 다룬 핵심을 체크리스트로 정리합니다.
- [ ] 방어적 복사는 컴파일러가
readonly위치(필드·in·readonly struct)의 안전을 위해 몰래 만드는 임시 복사본이다. C# 코드에는 안 보이고 IL의stloc+ldloca패턴으로 드러난다. - [ ]
readonly멤버는 메서드·get 접근자에 붙여 "이 멤버는this를 안 바꾼다"고 컴파일러에 약속하는 한정자다 (C# 8). - [ ] 약속을 지키면 컴파일러는
ldfld→ldflda,ldobj→ldarg로 IL을 바꿔 방어적 복사를 제거한다. - [ ]
in매개변수와 짝으로 써야 의미가 있다. 한쪽만 있으면ldobj가 끼어 들어와 의도가 무너진다. - [ ]
readonly struct가 더 강력하지만 모든 필드를 readonly 로 만들어야 한다. 일부만 불변으로 두고 싶으면 멤버 단위readonly가 정답. - [ ] 가장 흔한 함정은
ToString(),GetHashCode()재정의를 안 한 readonly struct —ValueType의 기본 구현이 비-readonly 라 호출 때마다 복사가 일어난다. 반드시override readonly로 재정의한다. - [ ] Unity의
Vector3,Quaternion,Bounds는 mutable struct다. 핫패스에서in으로 받을 때 호출하는 메서드가readonly인지 확인한다. - [ ] CS8656 경고는 "여기서 방어적 복사 일어난다"는 신호다. 무시하지 말고 호출 대상 멤버에
readonly를 붙여 해결한다. - [ ] 16 바이트 이상 구조체 + 핫패스 =
readonly도장 후보. Matrix4x4 (64B), Bounds (24B), Color (16B) 가 대표 사례.
| 한 줄 결론 | "in 으로 받을 거면, 호출 대상 멤버에는 반드시 readonly 도장을 찍어라." |
|---|