본문 바로가기
C#

“C# readonly: 생성 후 불변을 보장해 버그를 줄이고 성능을 챙기는 설계 키워드”

by 공부봇 2025. 10. 10.
반응형

좋은 질문이에요! C#의 readonly는 “한 번 정해진 값(또는 상태)을 바꾸지 않겠다”는 불변(immutability) 의도를 코드에 담아 주는 키워드예요. 쓰임새가 몇 가지(필드, 구조체, 멤버 등)로 나뉘어 헷갈리기 쉬우니 교재식으로 차근히 정리해 드릴게요. (가정: C# 12 / .NET 8)


1) 왜 생겼는가 — “변하면 안 되는 건, 애초에 못 바꾸게”

  • 버그 예방: 실수로 값을 바꾸는 것을 컴파일 단계에서 차단합니다.
  • 가독성/의도 전달: “생성 후 불변”이라는 설계를 명확히 보여줍니다.
  • 최적화/성능: JIT가 읽기 전용임을 가정해 불필요한 방어적 복사(defensive copy)를 줄이거나 인라이닝에 유리합니다(특히 readonly struct, readonly members).

2) 무엇인가 — readonly의 네 가지 큰 얼굴

구분 적용 대상 효과 요약 변경 가능 시점

필드 readonly 인스턴스/정적 필드 생성자(또는 선언부) 이후 재할당 금지 생성자 실행 전까지
readonly struct 구조체 선언 모든 인스턴스 필드가 사실상 읽기 전용, 가상 아닌 멤버가 “읽기 전용 컨텍스트”로 동작 → 복사 최소화 생성 시점
readonly members 구조체의 특정 멤버(메서드/프로퍼티/getter) 해당 멤버 내에서 this가 읽기 전용 → 멤버 호출 시 구조체 복사 방지 N/A
ref readonly 반환 값/지역/매개변수 수식 참조로 전달/반환하되 수정 금지 N/A

혼동 주의: const는 컴파일 타임 상수이고 반드시 값 형식/문자열만 가능. readonly는 런타임에 한 번 결정되는 불변(예: DateTime.Now로 초기화, 컬렉션 인스턴스 참조 고정 등)이 가능합니다.


3) 언제/어떻게 쓰는가 — 상황별 패턴과 MWE

3-1. 필드에 붙이는 readonly

public sealed class Config
{
    public readonly string RootPath;         // 인스턴스 필드
    public static readonly TimeSpan Timeout; // 정적 필드

    static Config()
    {
        Timeout = TimeSpan.FromSeconds(30);  // 정적 생성자에서 1회 설정
    }

    public Config(string rootPath)
    {
        RootPath = rootPath ?? throw new ArgumentNullException(nameof(rootPath));
        // 여기 이후로 RootPath 재할당 불가
    }
}
  • 언제? 생성 시 한 번 정하고 절대 안 바꿀 값(경로, 연결 문자열, 아이디 등).
  • 장점: 생성자 이후 재할당 시 컴파일 에러로 잡아줌.

3-2. readonly struct — 값 타입의 “진짜 불변” 의도

public readonly struct Point
{
    public int X { get; }   // set 없음 → 값 변경 불가
    public int Y { get; }

    public Point(int x, int y) => (X, Y) = (x, y);

    // 멤버 내에서 this 수정 불가, 호출 시 복사 최소화에 도움
    public double Length() => Math.Sqrt(X * X + Y * Y);
}
  • 언제? 작은 값 타입(좌표, 범위, 단위 값 등)을 완전 불변으로 다루고 싶을 때.
  • 성능: 읽기 전용 멤버 호출 시 불필요한 복사 감소 → 캐시 친화.

3-3. 구조체의 readonly members

public struct BigVector
{
    private readonly double[] _data; // 참조는 고정(읽기 전용)

    public BigVector(double[] data) => _data = data;

    public readonly int Length => _data.Length; // 이 멤버 안에선 this가 readonly
    public readonly double Sum()
    {
        double s = 0;
        foreach (var v in _data) s += v;
        return s;
    }
}
  • 효과: 해당 멤버를 호출해도 구조체 인스턴스의 불필요한 복사를 막음.
  • 언제? 구조체가 크고, 주로 읽기 전용 연산을 제공할 때.

3-4. ref readonly (참조로 넘기되 수정 금지)

public readonly struct Matrix3
{
    private readonly double[] _m; // 길이 9
    public Matrix3(double[] m) => _m = m;

    public ref readonly double At(int r, int c)
        => ref _m[3 * r + c]; // 요소를 복사 없이 “읽기 전용 참조”로 반환
}

var m = new Matrix3(new double[9]);
ref readonly double cell = ref m.At(0, 0);
// cell = 1.0; // ❌ 컴파일 에러(읽기 전용 참조)
  • 언제? 큰 값/배열 요소를 복사 없이 읽기만 하려 할 때.

3-5. const vs readonly vs init 비교 요약

키워드 시점 대상 변경 가능 대표 예

const 컴파일 타임 값 형식/문자열 전혀 불가 const double Pi = 3.14159;
readonly(필드) 런타임(생성자까지) 필드(참조/값) 생성자 이후 불가 readonly string Root;
init(setter) 객체 초기화 시 프로퍼티 초기화 식에서만 가능 public string Name { get; init; }

실무 팁: “불변 참조 + 가변 내부” 상황에선 readonly 필드에 불변 컬렉션(Immutable Collections) 사용을 고려하세요. readonly List<T>는 “참조”만 고정이지, 리스트 내용은 변할 수 있습니다.


4) 자주 하는 실수와 베스트 프랙티스

흔한 실수들

  1. readonly를 “내용까지 불변”으로 오해
    • readonly SomeClass obj;는 obj 참조만 고정할 뿐, obj 내부 상태는 바뀔 수 있어요. 불변 타입 또는 불변 컬렉션을 병행하세요.
  2. 구조체에서 복사 비용 간과
    • 큰 구조체에 readonly 멤버가 없으면 호출 때마다 방어적 복사가 발생할 수 있습니다. readonly struct 또는 readonly members를 적용해 보세요.
  3. 필드 초기화 타이밍 혼동
    • readonly 필드는 선언부 또는 (정적/인스턴스) 생성자에서만 설정 가능합니다. 일반 메서드에서 재할당하면 컴파일 에러.
  4. const로 해야 할 것을 readonly로
    • 진짜 상수 값(예: 수학 상수, 버전 문자열 등)은 const가 적합. 호출 사이트에 인라인되어 JIT 최적화에 유리.
  5. 스레드-세이프와의 혼동
    • readonly는 동기화를 보장하지 않습니다. 다만 공유 불변 데이터 패턴은 본질적으로 스레드-세이프에 가깝습니다.

베스트 프랙티스

  • 도메인 모델은 기본 불변: 생성자/팩토리에서 값을 모두 설정하고, 이후 수정 불가가 기본. 변경이 필요하면 새 인스턴스를 생성.
  • 성능 민감 구조체: 가능하면 readonly struct + 필요한 멤버에 readonly 지정, 또는 ref readonly로 반환해 복사 최소화.
  • 외부 노출 시 컬렉션 주의: IReadOnlyList<T>/ImmutableArray<T> 등을 쓰거나 AsReadOnly()로 감싸서 변경을 차단.
  • init과 조합: DTO/레코드에선 init 접근자를 활용해 초기화만 허용하고 이후 불변 유지.
  • API 계약 명확화: “읽기 전용” 의도를 타입/멤버에 꾸준히 부여하면 팀 합의와 코드 품질이 좋아집니다.

미니 종합 예제

public readonly struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
        => (Amount, Currency) = (amount, currency ?? throw new ArgumentNullException(nameof(currency)));

    // 읽기 전용 멤버: 구조체 복사 방지
    public readonly Money Add(Money other)
    {
        if (Currency != other.Currency) throw new InvalidOperationException("통화 불일치");
        return new Money(Amount + other.Amount, Currency);
    }
}

public sealed class Order
{
    public readonly Guid Id;               // 생성자 이후 변경 불가
    public Money Total { get; private set; } // 값 타입 자체가 불변
    public IReadOnlyList<string> Items { get; } // 읽기 전용 뷰

    public Order(Guid id, IEnumerable<string> items, Money total)
    {
        Id = id;
        Items = items.ToList().AsReadOnly(); // 내부 변경 차단
        Total = total;
    }

    public void AddFee(Money fee) => Total = Total.Add(fee); // 새 Money로 교체
}

 

 

반응형