구조체 타입

구조체 타입 (C# reference)

구조체 타입(struct type)은 데이터와 그와 관련된 기능을 묶어서 담을 수 있는 C#의 값 타입이에요. 값을 복사해서 쓰는 방식의 가벼운 타입을 설계하고 싶을 때 주로 활용하죠. 지금부터 구조체가 어떻게 동작하는지, 그리고 어떤 상황에서 클래스 대신 써야 하는지 하나씩 살펴볼게요.

출처: Microsoft Learn

본문

구조체 타입(struct type)은 데이터와 관련 기능을 캡슐화할 수 있는 값 타입이에요.

C# 1.0부터 존재해 온 타입이라서, 초기 버전부터 C#을 써 온 분이라면 낯설지 않을 거예요. 구조체 타입을 정의할 때는 struct 키워드를 사용합니다.

public struct Coords
{
    public Coords(double x, double y)
    {
        X = x;
        Y = y;
    }

    public double X { get; }
    public double Y { get; }

    public override string ToString() => $"({X}, {Y})";
}

ref structreadonly ref struct 타입에 대한 내용은 ref 구조체 타입 문서에서 확인할 수 있어요.

구조체 타입은 값 의미론(value semantics)을 가집니다. 쉽게 말해 구조체 타입의 변수는 그 타입의 인스턴스를 그대로 담고 있어요. 기본적으로 시스템은 할당할 때, 메서드에 인자로 넘길 때, 메서드 결과를 반환할 때 변수 값을 복사합니다. 이 규칙은 구조체 타입 변수에도 그대로 적용돼서, 인스턴스 전체가 통째로 복사돼요. 자세한 내용은 값 타입 문서를 참고하세요.

보통 구조체 타입은 행동(동작)이 거의 없고 데이터 중심인 작은 타입을 설계할 때 사용합니다. 예를 들어 .NET은 숫자(정수실수), 불리언 값, 유니코드 문자, 시간 인스턴스를 표현할 때 구조체 타입을 사용해요. 반대로 타입의 행동(동작)에 집중하고 있다면 클래스를 정의하는 걸 고려해 보세요. 클래스 타입은 참조 의미론(reference semantics)을 가져서, 변수는 타입의 인스턴스 자체가 아니라 그 인스턴스를 가리키는 참조를 담습니다.

구조체 타입이 값 의미론을 갖기 때문에, 되도록 불변(immutable) 구조체 타입으로 정의하는 걸 권장합니다.

readonly struct

구조체 타입을 불변으로 선언하고 싶다면 readonly 한정자를 사용하면 돼요. readonly struct의 모든 데이터 멤버는 다음과 같이 읽기 전용이어야 합니다.

  • 모든 필드 선언에는 readonly 한정자가 있어야 해요.
  • 자동 구현 프로퍼티를 포함한 모든 프로퍼티는 읽기 전용이거나 init 전용이어야 해요. init 전용 setter는 C# 9 버전부터 사용할 수 있습니다.

이 규칙 덕분에 readonly struct의 어떤 멤버도 구조체의 상태를 바꿀 수 없게 보장돼요. 생성자를 제외한 모든 인스턴스 멤버는 암시적으로 readonly가 됩니다.

[!NOTE] readonly struct 안에서도, 변경 가능한 참조 타입의 데이터 멤버는 자기 자신의 상태를 바꿀 수는 있어요. 예를 들어 xref:System.Collections.Generic.List`1 인스턴스 자체를 교체할 수는 없지만, 그 리스트에 새 요소를 추가하는 건 가능합니다.

다음 코드는 init 전용 프로퍼티 setter를 가진 readonly struct를 정의하는 예시예요.

public readonly struct Coords
{
    public Coords(double x, double y)
    {
        X = x;
        Y = y;
    }

    public double X { get; init; }
    public double Y { get; init; }

    public override string ToString() => $"({X}, {Y})";
}

readonly 인스턴스 멤버

readonly 한정자는 구조체의 상태를 바꾸지 않는 인스턴스 멤버에 붙일 수도 있어요. 구조체 타입 전체를 readonly로 선언할 수 없다면, 상태를 바꾸지 않는 인스턴스 멤버에만 readonly 한정자를 붙여서 표시해 주세요.

readonly 인스턴스 멤버 안에서는 구조체의 인스턴스 필드에 값을 할당할 수 없어요. 그런데 readonly 멤버가 readonly가 아닌 멤버를 호출하는 건 가능합니다. 이 경우 컴파일러가 구조체 인스턴스의 복사본을 만들어서, 그 복사본에 대해 readonly가 아닌 멤버를 호출해요. 그래서 원본 구조체 인스턴스는 수정되지 않습니다.

보통 readonly 한정자는 다음과 같은 종류의 인스턴스 멤버에 적용해요.

  • 메서드:

    public readonly double Sum()
    {
        return X + Y;
    }
    

    xref:System.Object?displayProperty=nameWithType에 선언된 메서드를 재정의(override)하는 메서드에도 readonly 한정자를 적용할 수 있어요.

    public readonly override string ToString() => $"({X}, {Y})";
    
  • 프로퍼티와 인덱서:

    private int counter;
    public int Counter
    {
        readonly get => counter;
        set => counter = value;
    }
    

    프로퍼티나 인덱서의 접근자(accessor) 두 개 모두에 readonly 한정자를 적용해야 한다면, 프로퍼티나 인덱서 선언 자체에 붙이면 돼요.

    [!NOTE] 자동 구현 프로퍼티get 접근자는 프로퍼티 선언에 readonly 한정자가 있든 없든 컴파일러가 알아서 readonly로 선언합니다.

    init 접근자를 가진 프로퍼티나 인덱서에도 readonly 한정자를 적용할 수 있어요.

    public readonly double X { get; init; }
    

구조체 타입의 정적 필드에는 readonly 한정자를 적용할 수 있지만, 프로퍼티나 메서드 같은 다른 정적 멤버에는 적용할 수 없어요.

컴파일러는 readonly 한정자를 활용해 성능 최적화를 하기도 해요. 자세한 내용은 할당 피하기 문서를 참고하세요.

비파괴적 변경 (Nondestructive mutation)

with을 사용하면 지정한 프로퍼티와 필드만 바꾼 구조체 타입 인스턴스의 복사본을 만들 수 있어요. 어떤 멤버를 어떻게 바꿀지는 개체 이니셜라이저 구문으로 지정합니다. 아래 예시를 볼게요.

public readonly struct Coords
{
    public Coords(double x, double y)
    {
        X = x;
        Y = y;
    }

    public double X { get; init; }
    public double Y { get; init; }

    public override string ToString() => $"({X}, {Y})";
}

public static void Main()
{
    var p1 = new Coords(0, 0);
    Console.WriteLine(p1);  // output: (0, 0)

    var p2 = p1 with { X = 3 };
    Console.WriteLine(p2);  // output: (3, 0)

    var p3 = p1 with { X = 1, Y = 4 };
    Console.WriteLine(p3);  // output: (1, 4)
}

with 식은 원본을 건드리지 않고 원하는 값만 바꾼 새 복사본을 만들어 줘요. 불변 구조체를 다룰 때 아주 유용하죠.

record struct

레코드 구조체 타입도 정의할 수 있어요. 레코드 타입은 데이터를 캡슐화하기 위한 기능이 기본 내장돼 있죠. record structreadonly record struct 타입을 모두 정의할 수 있는데, record structref struct가 될 수 없어요. 자세한 내용과 예시는 레코드 문서를 참고하세요.

인라인 배열 (Inline arrays)

C# 12부터 struct 타입으로 인라인 배열을 선언할 수 있게 됐어요.

[System.Runtime.CompilerServices.InlineArray(10)]
public struct CharBuffer
{
    private char _firstElement;
}

인라인 배열은 같은 타입의 요소 N개가 연속된 메모리 블록 하나를 담는 구조체예요. 이건 unsafe 코드에서만 쓸 수 있던 고정 버퍼 선언을 안전 코드로 쓸 수 있게 만든 거예요. 인라인 배열은 다음 특징을 가진 struct입니다.

  • 단일 필드를 하나만 가져요.
  • 명시적인 레이아웃을 지정하지 않아요.

덧붙여 컴파일러는 xref:System.Runtime.CompilerServices.InlineArrayAttribute?displayProperty=fullName 특성을 검증합니다.

  • 길이는 0보다 커야 해요 (> 0).
  • 대상 타입은 구조체여야 해요.

대부분의 경우 인라인 배열은 일반 배열처럼 값을 읽고 쓸 수 있어요. 범위 연산자와 인덱스 연산자도 사용할 수 있죠.

인라인 배열의 단일 필드 타입에는 제약이 거의 없어요. 다만 포인터 타입은 될 수 없습니다.

[System.Runtime.CompilerServices.InlineArray(10)]
public struct CharBufferWithPointer
{
    private unsafe char* _pointerElement;    // CS9184
}

대신 임의의 참조 타입이나 값 타입은 될 수 있어요.

[System.Runtime.CompilerServices.InlineArray(10)]
public struct CharBufferWithReferenceType
{
    private string _referenceElement;
}

인라인 배열은 거의 모든 C# 데이터 구조에서 사용할 수 있습니다.

인라인 배열은 고급 언어 기능이에요. 요소가 인라인으로 연속된 메모리 블록을 사용하는 게 다른 대체 데이터 구조보다 빠른 고성능 시나리오를 염두에 두고 만들어졌죠. 더 자세한 내용은 C# 표준의 §16.6 인라인 배열에서 볼 수 있어요.

구조체 초기화와 기본값

struct 타입의 변수는 해당 struct의 데이터를 직접 담고 있어요. 데이터를 직접 저장하는 방식이라서, 기본값을 가진 초기화되지 않은 struct와, 생성으로 설정된 값을 저장하는 초기화된 struct가 구분됩니다. 다음 코드를 한번 볼게요.

public readonly struct Measurement
{
    public Measurement()
    {
        Value = double.NaN;
        Description = "Undefined";
    }

    public Measurement(double value, string description)
    {
        Value = value;
        Description = description;
    }

    public double Value { get; init; }
    public string Description { get; init; }

    public override string ToString() => $"{Value} ({Description})";
}

public static void Main()
{
    var m1 = new Measurement();
    Console.WriteLine(m1);  // output: NaN (Undefined)

    var m2 = default(Measurement);
    Console.WriteLine(m2);  // output: 0 ()

    var ms = new Measurement[2];
    Console.WriteLine(string.Join(", ", ms));  // output: 0 (), 0 ()
}

위 예시에서 보듯 기본값 식은 매개변수 없는 생성자(parameterless constructor)를 무시하고 구조체 타입의 기본값을 만들어요. 구조체 타입 배열의 인스턴스화도 매개변수 없는 생성자를 무시하고, 구조체 타입의 기본값으로 채운 배열을 만들어 줍니다.

기본값을 가장 흔하게 마주치는 상황은 배열이나 내부 저장소에 변수 블록이 포함된 컬렉션에서예요. 아래 예시는 각각 기본값을 가진 TemperatureRange 구조체 30개로 배열을 만듭니다.

// All elements have default values of 0:
TemperatureRange[] lastMonth = new TemperatureRange[30];

struct 타입은 데이터를 직접 저장하기 때문에, 생성될 때 구조체의 모든 멤버 필드는 확실히 할당(definitely assigned)되어야 해요. 그런데 structdefault 값은 모든 필드를 0으로 확실히 할당해 버립니다. 생성자가 호출될 때도 모든 필드는 확실히 할당되어야 해요. 필드를 초기화하는 방법은 두 가지가 있습니다.

  • 어떤 필드나 자동 구현 프로퍼티에 필드 이니셜라이저(field initializer)를 추가한다.
  • 생성자 본문에서 필드나 자동 프로퍼티를 초기화한다.

struct의 모든 필드를 초기화하지 않으면, 컴파일러는 생성자에 해당 필드를 기본값으로 초기화하는 코드를 추가해요. default 값에 할당된 struct는 0비트 패턴으로 초기화됩니다. new로 초기화된 struct는 0비트 패턴으로 초기화된 다음, 필드 이니셜라이저와 생성자가 실행돼요.

public readonly struct Measurement
{
    public Measurement(double value)
    {
        Value = value;
    }

    public Measurement(double value, string description)
    {
        Value = value;
        Description = description;
    }

    public Measurement(string description)
    {
        Description = description;
    }

    public double Value { get; init; }
    public string Description { get; init; } = "Ordinary measurement";

    public override string ToString() => $"{Value} ({Description})";
}

public static void Main()
{
    var m1 = new Measurement(5);
    Console.WriteLine(m1);  // output: 5 (Ordinary measurement)

    var m2 = new Measurement();
    Console.WriteLine(m2);  // output: 0 ()

    var m3 = default(Measurement);
    Console.WriteLine(m3);  // output: 0 ()
}

모든 struct에는 public 매개변수 없는 생성자가 있어요. 매개변수 없는 생성자를 직접 작성한다면 public이어야 합니다. struct가 필드 이니셜라이저를 선언하면 반드시 생성자를 명시적으로 선언해야 해요. 그 생성자가 매개변수 없는 생성자일 필요는 없습니다. struct가 필드 이니셜라이저는 선언했는데 생성자를 선언하지 않으면 컴파일러가 오류를 보고해요. 명시적으로 선언된 생성자(매개변수가 있든 없든)는 해당 struct의 모든 필드 이니셜라이저를 실행합니다. 필드 이니셜라이저가 없거나 생성자에서 할당되지 않은 모든 필드는 기본값으로 설정돼요. 자세한 내용은 C# 언어 사양 - 생성자를 참고하세요.

C# 12부터 struct 타입은 선언의 일부로 주 생성자(primary constructor)를 정의할 수 있어요. 주 생성자는 struct 본문 전체, 즉 그 struct의 어떤 멤버 선언에서든 쓸 수 있는 생성자 매개변수를 간결하게 선언하는 구문을 제공합니다.

구조체 타입의 모든 인스턴스 필드에 접근할 수 있다면, new 연산자 없이도 인스턴스를 만들 수 있어요. 이 경우 인스턴스를 처음 사용하기 전에 모든 인스턴스 필드를 초기화해야 합니다. 아래 예시가 그 방법을 보여줘요.

public static class StructWithoutNew
{
    public struct Coords
    {
        public double x;
        public double y;
    }

    public static void Main()
    {
        Coords p;
        p.x = 3;
        p.y = 4;
        Console.WriteLine($"({p.x}, {p.y})");  // output: (3, 4)
    }
}

내장 값 타입의 경우에는 해당 리터럴을 사용해서 타입의 값을 지정하면 됩니다.

구조체 타입 설계의 제약

구조체는 클래스 타입의 기능을 대부분 갖고 있지만, 몇 가지 예외가 있어요.

  • 구조체 타입은 다른 클래스나 구조체 타입에서 상속받을 수 없고, 클래스의 기반이 될 수도 없어요. 다만 인터페이스를 구현하는 건 가능합니다.
  • 구조체 타입 안에는 종료자(finalizer)를 선언할 수 없어요.
  • 구조체 타입의 생성자는 해당 타입의 모든 인스턴스 필드를 초기화해야 해요.

구조체 타입 변수를 참조로 전달하기

구조체 타입 변수를 메서드에 인자로 전달하거나 메서드에서 구조체 타입 값을 반환하면, 구조체 타입의 인스턴스 전체가 복사돼요. 값으로 전달하는 방식은 큰 구조체 타입을 다루는 고성능 시나리오에서 코드 성능에 영향을 줄 수 있습니다. 구조체 타입 변수를 참조로 전달하면 값 복사를 피할 수 있어요. ref, out, in, ref readonly 메서드 매개변수 한정자를 사용해 인자를 참조로 전달하라고 지정하거나, ref 반환을 사용해 메서드 결과를 참조로 반환하면 됩니다. 자세한 내용은 할당 피하기 문서를 참고하세요.

struct 제약 조건

struct 제약 조건에서 struct 키워드를 사용하면, 타입 매개변수가 nullable이 아닌 값 타입임을 지정할 수 있어요. 구조체 타입과 열거형 타입 모두 struct 제약 조건을 만족합니다.

변환 (Conversions)

ref struct 타입을 제외한 모든 구조체 타입에는 xref:System.ValueType?displayProperty=nameWithTypexref:System.Object?displayProperty=nameWithType 타입과의 박싱/언박싱 변환이 존재해요. 구조체 타입과 그것이 구현하는 인터페이스 사이에도 박싱/언박싱 변환이 있습니다.

C# 언어 사양

더 자세한 내용은 C# 언어 사양구조체 섹션을 참고하세요. 특히 다음 절들이 도움이 됩니다.

C# 언어 사양의 자동 기본 구조체 내용도 읽어볼 만해요.

더 알아보기