반응형

[PART7.클래스와 객체 입문(14/21)] readonly 구조체 멤버 — 메서드·프로퍼티 단위 불변성 (C# 8)

구조체 전체를 불변으로 만들지 않고도 "이 메서드는 this를 건드리지 않는다"고 선언하는 법 / 방어적 복사를 IL 한 줄로 끊어내기 / in 매개변수의 성능 이점을 지키는 핵심 한정자


1. 문제 제기 — in 으로 넘겼는데 왜 빨라지지 않는가

Unity의 Update() 안에서 카메라 행렬을 계산하는 메서드를 떠올려 봅시다. 매 프레임마다 호출되는 코드라면 한 번이라도 불필요한 복사가 끼어들면 성능에 그대로 드러납니다.

C#
// 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

C#
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을 비교하는 게 가장 빠릅니다.

C#
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 (방어적 복사 발생)

IL
.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로 복사 제거)

IL
.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개. 핵심은 ldfldldflda(끝에 a) 한 글자 차이입니다. ldflda 는 "load field address" — 값을 복사하지 않고 필드의 주소를 그대로 로드합니다. 임시 변수도, stloc 도 없습니다.

왜 컴파일러는 이렇게 다르게 행동하는가

Point 구조체의 Sum()readonly가 없으므로 컴파일러 입장에서는 "내부적으로 X나 Y를 바꿀 수도 있는 메서드" 입니다. 그런데 _pointreadonly 필드라 변경되어선 안 됩니다. 컴파일러가 택할 수 있는 선택지는 둘 중 하나입니다.

  1. 원본 주소를 넘겨주고 메서드를 믿는다 → 메서드가 정말 필드를 바꾸면 readonly 필드 규약 위반.
  2. 복사본을 만들고 거기에 대고 호출한다 → 메서드가 뭘 바꾸든 원본은 안전.

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 안 붙인 경우

C#
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 결과는 가차없습니다.

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 멤버로 복사 제거

C#
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();
}
IL
.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 핫패스 — 카메라/물리 계산

C#
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 굳이 안 써도 됨 — 복사 비용 무시 가능
요약: inreadonly 멤버는 짝으로 쓸 때만 의미가 있다. 한쪽만 있으면 IL에 ldobj 가 끼어 들어와 의도가 무너진다.

5. 함정과 주의사항

함정 1 — ToString(), GetHashCode() 재정의 안 함

가장 흔한 함정입니다. 모든 구조체는 System.ValueType 을 상속하고, 그 위에 Object 가 있습니다. ValueType.ToString()readonly 가 아닙니다. 따라서 우리가 만든 구조체에서 ToString()재정의하지 않으면, 호출 때마다 방어적 복사가 발생합니다.

C#
// ❌ 잘못된 패턴 — 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 인스턴스라도 컴파일러는 방어적 복사를 삽입합니다.

C#
// ✅ 올바른 패턴 — 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, EqualsObjectvirtual 로 제공하므로 구조체에서도 override 가능하다.
예시: public override string ToString() => ...; 부모의 ToString 동작을 우리 구조체용으로 교체

함정 2 — 자동 구현 프로퍼티의 set이 있으면 get은 자동 readonly가 아니다

C# 9 부터 일부 자동 프로퍼티의 get 은 자동으로 readonly 로 추론됩니다. 하지만 set 이 함께 있는 자동 프로퍼티의 get 은 그렇지 않습니다.

C#
// ❌ 의도와 다른 패턴
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 한 번, 총 두 번 방어적 복사가 일어납니다. 해결책은 두 가지입니다.

C#
// ✅ 방법 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 메서드를 호출하면 그 자리에서 방어적 복사가 발생합니다. 컴파일러가 경고를 띄워주지만, 모르고 지나가기 쉽습니다.

C#
// ❌ 의도치 않은 복사
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() 를 호출하면 방어적 복사가 일어납니다 — 그것도 매 프레임.

C#
// ❌ 매 프레임 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#
// 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#
// 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#
// 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).
  • [ ] 약속을 지키면 컴파일러는 ldfldldflda, ldobjldarg 로 IL을 바꿔 방어적 복사를 제거한다.
  • [ ] in 매개변수와 짝으로 써야 의미가 있다. 한쪽만 있으면 ldobj 가 끼어 들어와 의도가 무너진다.
  • [ ] readonly struct 가 더 강력하지만 모든 필드를 readonly 로 만들어야 한다. 일부만 불변으로 두고 싶으면 멤버 단위 readonly 가 정답.
  • [ ] 가장 흔한 함정은 ToString(), GetHashCode() 재정의를 안 한 readonly structValueType 의 기본 구현이 비-readonly 라 호출 때마다 복사가 일어난다. 반드시 override readonly 로 재정의한다.
  • [ ] Unity의 Vector3, Quaternion, Bounds 는 mutable struct다. 핫패스에서 in 으로 받을 때 호출하는 메서드가 readonly 인지 확인한다.
  • [ ] CS8656 경고는 "여기서 방어적 복사 일어난다"는 신호다. 무시하지 말고 호출 대상 멤버에 readonly 를 붙여 해결한다.
  • [ ] 16 바이트 이상 구조체 + 핫패스 = readonly 도장 후보. Matrix4x4 (64B), Bounds (24B), Color (16B) 가 대표 사례.
한 줄 결론 "in 으로 받을 거면, 호출 대상 멤버에는 반드시 readonly 도장을 찍어라."
반응형

+ Recent posts