본문 바로가기

C#

C# 구조체(Struct) 심층 분석

구조체는 C#의 대표적인 값 형식입니다. 값语의 복사语, 힙/스택 오해, 불변 설계, boxing, ref struct, 생성자 규칙까지 실무에서 꼭 알아야 할 포인트를 정리합니다.

1. 값 형식의 본질

구조체는 값 형식입니다. 변수에 대입하거나 메서드 인자로 전달하면 값이 통째로 복사됩니다. 참조가 아닌 데이터 자체가 이동한다는 점이 핵심입니다.

struct Point
{
    public int X;
    public int Y;
    public Point(int x, int y) { X = x; Y = y; }
    public void Move(int dx, int dy) { X += dx; Y += dy; }
}

static void MoveRight(Point p)
{
    // 값 복사본을 수정합니다. 호출자 p에는 반영되지 않습니다.
    p.Move(1, 0);
}

var p = new Point(1, 1);
MoveRight(p);
Console.WriteLine(p.X); // 1 (원본 불변)

값 형식은 작은 데이터 집합을 표현할 때 최적이며, 논리적으로 "값"으로 비교되는 타입(좌표, 범위, 색상 등)에 적합합니다.

2. 스택 vs 힙: 흔한 오해

값 형식이 항상 스택에, 참조 형식이 항상 힙에 할당된다는 말은 사실이 아닙니다. 값 형식도 배열/클래스 필드로 존재하면 힙에 배치될 수 있습니다. 중요한 것은 메모리 위치가 아니라 복사语(값 복사)와 수명 관리(가비지 컬렉션 여부)입니다.

3. 복사와 불변(immutable) 설계

구조체는 복사 비용과 복사 시의 오류 가능성을 고려해야 합니다. 가변 구조체를 프로퍼티나 컬렉션에서 꺼내 수정하려다 "복사본"을 수정하는 실수를 자주 합니다. 따라서 구조체는 되도록 작고 불변으로 설계하는 것이 안전합니다.

readonly struct Size
{
    public int Width { get; }
    public int Height { get; }
    public Size(int w, int h) => (Width, Height) = (w, h);
    public Size Grow(int dw, int dh) => new Size(Width + dw, Height + dh);
}

var s = new Size(10, 20);
var s2 = s.Grow(5, 0); // 새로운 값 반환, 원본 불변

4. 매개변수 전달: in/ref/out로 복사 줄이기

큰 구조체는 전달 시 복사 비용이 큽니다. in으로 읽기 전용 참조 전달을 사용하면 복사 없이 접근합니다. ref/out은 쓰기 가능한 참조입니다.

struct Big
{
    public long A, B, C, D;
    public readonly long Sum() => A + B + C + D; // readonly로 방어적 복사 방지
}

static long SumFast(in Big b)
{
    // in: 호출자 복사 없이 읽기
    return b.Sum();
}

var big = new Big { A = 1, B = 2, C = 3, D = 4 };
Console.WriteLine(SumFast(in big));

readonly 한정자를 구조체의 인스턴스 메서드에 붙이면 읽기 전용 컨텍스트에서 불필요한 방어적 복사를 줄일 수 있습니다. 전체 타입을 readonly struct로 선언해도 좋습니다.

5. Boxing/Unboxing과 인터페이스

구조체를 object 또는 인터페이스로 변환하면 boxing이 발생하여 힙 할당과 복사가 이루어집니다. 성능에 민감한 경로에서는 피해야 합니다.

struct Temperature : IFormattable
{
    public double Value { get; }
    public Temperature(double v) => Value = v;
    public string ToString(string? format, IFormatProvider? fp) => Value.ToString(format, fp);
}

var t = new Temperature(36.5);
object boxed = t; // boxing 발생
Console.WriteLine(((IFormattable)t).ToString("F1", null)); // 인터페이스로 캐스트 시 boxing

// 제네릭의 constrained call은 boxing을 피할 수 있습니다.
static string FormatInvariant<T>(T value) where T : struct, IFormattable
{
    // 값 형식인 T에 대해 제네릭 제약을 통해 boxing 없는 호출을 유도
    return value.ToString(null, System.Globalization.CultureInfo.InvariantCulture);
}

Console.WriteLine(FormatInvariant(t));

6. readonly struct와 readonly 멤버

readonly struct는 모든 인스턴스 필드가 읽기 전용이며, 메서드가 암묵적으로 읽기 컨텍스트에서 호출됩니다. 가변 상태를 방지하고 방어적 복사 가능성을 낮춥니다.

readonly struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }
    public Money(decimal amount, string currency) => (Amount, Currency) = (amount, currency);
    public Money Convert(decimal rate) => new Money(Amount * rate, Currency);
}

개별 메서드에 readonly를 붙여도 동일한 효과(해당 멤버에 한해 this가 읽기 전용)가 있습니다.

7. ref struct: 스택 한정 타입

ref struct는 스택에만 존재할 수 있는 타입입니다. Span<T>가 대표적입니다. 힙 할당을 피하고 안전하게 포인터 유사 연산을 제공합니다.

ref struct BufferWindow
{
    private Span _span;
    public BufferWindow(Span span) => _span = span;
    public void Clear() => _span.Clear();
}

var arr = new byte[16];
var win = new BufferWindow(arr.AsSpan(4, 8));
win.Clear();

ref struct 제약: boxing 불가, 인터페이스 구현 불가, 배열/필드로 보관 불가(일반 클래스/구조체의 필드로 가질 수 없음), async/iterator/lambda capture 불가, 제네릭 형식 인수로 사용 불가, Nullable 불가 등 수명 보장을 위해 많은 제약이 있습니다.

8. 생성자와 초기화: default vs parameterless

C# 10부터 구조체에 매개변수 없는 인스턴스 생성자를 정의할 수 있습니다. 단, default(S) 또는 기본값으로 초기화할 때는 여전히 모든 필드가 0으로 초기화되며 사용자 정의 생성자는 호출되지 않습니다.

struct Counter
{
    public int Value;
    public Counter() { Value = 42; } // C# 10+
}

var a = new Counter();   // Value == 42 (생성자 호출)
var b = default(Counter); // Value == 0  (생성자 미호출)
Console.WriteLine($"a={a.Value}, b={b.Value}");

필드 초기자도 C# 10부터 구조체에서 지원됩니다. 여전히 default는 필드를 0으로 채웁니다.

9. Equals/GetHashCode와 IEquatable

기본 Equals(object)는 필드 비교를 수행하지만 박싱과 반사 기반 경로로 인해 느릴 수 있습니다. IEquatable<T>를 구현해 성능과 정확도를 높이고, == 연산자를 필요 시 함께 정의합니다.

readonly struct Point2D : IEquatable
{
    public int X { get; }
    public int Y { get; }
    public Point2D(int x, int y) => (X, Y) = (x, y);
    public bool Equals(Point2D other) => X == other.X && Y == other.Y;
    public override bool Equals(object? obj) => obj is Point2D p && Equals(p);
    public override int GetHashCode() => HashCode.Combine(X, Y);
    public static bool operator ==(Point2D a, Point2D b) => a.Equals(b);
    public static bool operator !=(Point2D a, Point2D b) => !a.Equals(b);
}

10. Nullable 값 형식

모든 구조체는 Nullable<T>로 null 가능 형태를 만들 수 있습니다. 메모리상으로는 값과 HasValue 플래그를 가집니다.

int? n = null;
n = 5;
if (n.HasValue)
    Console.WriteLine(n.Value);

11. Interop과 레이아웃

P/Invoke/COM 연동 시 레이아웃을 제어해야 합니다. StructLayout과 FieldOffset으로 메모리 배치를 지정합니다. 마샬링 규칙(bool 크기, 패킹 등)을 명확히 하세요.

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Header
{
    public ushort Magic;
    public uint Size;
    public byte Flags;
}

[StructLayout(LayoutKind.Explicit)]
struct Union32
{
    [FieldOffset(0)] public int I32;
    [FieldOffset(0)] public float F32;
}

12. struct vs class 선택 가이드

구조체를 선택하세요: 데이터가 작고(권장: 16바이트 이하), 불변이며, 논리적으로 값 의미를 가지며, 대량 컬렉션에서 성능이 중요한 경우입니다. 그 외에는 클래스가 일반적으로 안전합니다.

13. 요약 체크리스트

작고 불변이면 struct, 아니면 class를 고려합니다. 큰 struct는 in/ref를 사용해 복사를 줄입니다. boxing을 유발하는 object/인터페이스 경로를 피하고, IEquatable 구현으로 성능을 확보합니다. 불필요한 방어적 복사를 막기 위해 readonly struct 또는 readonly 멤버를 활용합니다. ref struct는 수명 제약을 이해하고 Span<T> 같은 성능 경로에만 사용합니다. C# 10의 매개변수 없는 생성자 동작과 default 초기화 차이를 반드시 인지합니다.