구조체는 직접 정의하는 값 형식입니다. 필드, 속성, 메서드, 생성자를 가져서 클래스처럼 보이지만, 구조체 타입의 변수는 객체에 대한 참조 대신 데이터를 직접 담습니다. 이 하나의 차이가 대입, 메서드 호출, 같음 비교, null의 동작을 바꿉니다.
값 의미론: 대입하면 복사됩니다
기본 제공 숫자 타입, bool, char, DateTime, TimeSpan, Guid는 모두 구조체입니다. 직접 만든 구조체도 같은 방식으로 동작합니다:
출력:
struct: s1.X = 1, s2.X = 50
class: c1.X = 50, c2.X = 50
after Move: s1.X = 1, c1.X = 150
구조체에서 s2는 독립적인 복사본이고, Move도 자기 복사본으로 일합니다. 메서드가 호출하는 쪽의 구조체를 바꾸게 하려면 ref로 참조를 넘기세요(ref와 out 참고). 변경은 허용하지 않으면서 큰 구조체의 복사를 피하려면 C# 7.2가 추가한 in 매개변수를 쓰세요.
값 형식이라는 것의 다른 결과들:
- 구조체 변수는 절대
null이 될 수 없습니다.PointS p = null;은 컴파일되지 않습니다. "값 없음"이 의미가 있다면PointS?(nullable 값 형식)를 쓰세요. - 초기화되지 않은 구조체는 모두 0입니다. 숫자 필드는
0,bool필드는false, 참조 필드는null입니다. - 구조체는 다른 구조체나 클래스를 상속할 수 없고, 어떤 것도 구조체를 상속할 수 없습니다. 인터페이스는 구현할 수 있습니다.
- 지역 구조체 변수는 보통 스택이나 이를 담는 객체 안에 있으므로, 만들어도 힙에 할당되지 않습니다.
구조체 List의 함정: CS1612
구조체를 읽으면 복사본이 만들어지므로, 이 순진해 보이는 줄은 컴파일되지 않습니다:
var points = new List<PointS> { new PointS(1, 2) };
points[0].X = 10;
// error CS1612: Cannot modify the return value of 'List<PointS>.this[int]' because it is not a variable
List<T> 인덱서는 요소의 복사본을 반환하는 메서드입니다. 그 임시 복사본에 X를 설정해도 아무것도 바뀌지 않으므로 컴파일러가 막습니다. 속성이 구조체를 반환할 때도 같은 오류가 납니다: order.Location.X = 10. 복사하고, 수정하고, 다시 쓰세요:
출력:
35
7
배열은 예외입니다. array[0]은 복사본이 아니라 요소 자체입니다. 여기서 반복되는 불편함이 구조체를 불변으로 만들라는 표준 조언의 이유입니다. 구조체를 수정할 수 없다면 복사본을 수정해서 잃을 것도 없습니다.
생성자와 기본값
C# 7부터 9까지 구조체 생성자의 규칙은 엄격합니다:
- 매개변수 없는 생성자를 선언할 수 없습니다.
new PointS()는 항상 존재하며 모든 필드를 0으로 만듭니다. - 선언한 생성자는 반환하기 전에 모든 필드(와 자동 속성)를 대입해야 합니다.
- 인스턴스 필드에 필드 이니셜라이저(
public int X = 1;)를 쓸 수 없습니다.
출력:
19.90 EUR
0.00 (none)
0.00 (none)
0.00 (none)
Money는 기본값이 왜 중요한지도 보여 줍니다. 0으로 채워진 Money의 통화는 null이며, 배열, default(T), 초기화되지 않은 필드가 모두 이런 값을 만들므로 코드가 이를 처리해야 합니다.
새 버전은 이 규칙을 완화했습니다:
// C# 10: explicit parameterless constructors and field initializers
public struct Settings
{
public int Retries = 3;
public Settings() { }
}
// C# 11: fields you do not assign in a constructor are zeroed automatically,
// instead of being a compile error.
C# 10의 매개변수 없는 생성자는 new Settings()에서는 실행되지만 default(Settings)나 배열 요소에서는 실행되지 않으며, 이들은 여전히 모두 0입니다. 이 차이에 사람들이 놀라므로 조심해서 쓰세요.
readonly struct(C# 7.2)
구조체 자체를 readonly로 표시하면 컴파일러가 불변성을 강제합니다. 모든 필드는 readonly, 모든 자동 속성은 get 전용이어야 합니다.
public readonly struct Temperature
{
public double Celsius { get; }
public Temperature(double celsius) { Celsius = celsius; }
public Temperature WarmerBy(double delta) => new Temperature(Celsius + delta); // returns a new value
}
의도를 문서화하는 것 말고도 성능에 도움이 됩니다. readonly가 아닌 구조체를 readonly 필드에 저장하거나 in 매개변수로 넘기면, 컴파일러는 메서드 호출마다 먼저 복사합니다(메서드가 수정하지 않는다는 것을 알 수 없으니까요). readonly struct에는 이런 방어적 복사가 필요 없습니다. C# 7.0에서도 Money처럼 get 전용 속성으로 모든 필드를 readonly로 만들 수 있습니다. 그러면 불변성은 얻지만 복사 절약은 얻지 못합니다. 컴파일러는 readonly로 선언된 구조체만 믿기 때문입니다.
같음 비교
구조체의 Equals는 기본적으로 필드별로 비교하며, 원하는 값 동작입니다. 하지만 기본 구현(ValueType.Equals)은 리플렉션을 쓸 수 있어서 느리고, == 연산자는 아예 정의되어 있지 않습니다. 직접 만든 구조체에 a == b를 쓰면 CS0019 오류입니다. 구조체를 비교할 거라면 둘 다 구현하세요:
출력:
True
True
True
IEquatable<T> 구현은 컬렉션에서 중요합니다. HashSet<T>, Dictionary<TKey, TValue>, List<T>.Contains는 각 값을 박싱해 Equals(object)를 호출하는 대신 Equals(GridCell)을 직접 호출합니다. 레코드 구조체(아래)는 이 모두를 생성해 줍니다.
박싱
구조체를 object나 인터페이스 타입으로 변환하면 박싱됩니다. 런타임이 값을 새 힙 객체에 복사합니다. 그러면 박스와 원본은 서로 독립적입니다:
출력:
2
0
박싱은 매번 할당 비용이 들며, 그래서 ArrayList 같은 예전의 비제네릭 컬렉션이 값 형식에서 느렸고 제네릭이 이를 대체했습니다. 또 인터페이스를 통해 접근하는 변경 가능한 구조체는 원본이 아니라 박스 안에서 바뀐다는 뜻이기도 하며, 구조체를 불변으로 유지해야 할 또 하나의 이유입니다.
구조체와 클래스
| struct | class | |
|---|---|---|
| 종류 | 값 형식 | 참조 형식 |
| 대입과 매개변수 | 데이터 복사 | 참조 복사 |
null 가능 | 불가(T?는 가능) | 가능 |
| 기본값 | 모든 필드 0 | null |
| 상속 | 없음, 인터페이스 구현 가능 | 기본 클래스 하나, 인터페이스 |
== | 오버로딩하지 않으면 정의 안 됨 | 오버로딩하지 않으면 참조 같음 |
기본 Equals | 필드 비교 | 참조 비교 |
| 할당 | 인라인(스택 또는 담는 객체) | 힙, 가비지 컬렉션 |
| 어울리는 것 | 작은 불변 값 | 엔터티, 크거나 공유되는 상태 |
구조체를 언제 쓸까
다음이 모두 성립할 때 구조체를 고르세요. 타입이 하나의 논리적인 값(좌표, 금액, 색, 날짜 범위)이고, 작으며(Microsoft 지침은 대략 16바이트, int 네 개 정도), 불변이고, 상속이 필요 없을 때입니다. 구조체는 배열 안의 수백만 개의 점처럼 아주 많이 만들 때 이득입니다. 항목마다 힙 할당과 가비지 컬렉터 항목을 피하기 때문입니다.
정체성이 있는 것(고객, 주문), 큰 것, 여러 곳에서 변경되는 것, 그리고 확실하지 않을 때는 클래스를 고르세요. 크고 변경 가능한 구조체는 복사 비용에 복사본을 수정하는 혼란까지 줍니다.
record struct(C# 10)
C# 10은 구조체에 값 같음 비교, ==, ToString, with 지원을 한 줄로 생성해 주는 record struct를 추가했습니다:
public readonly record struct Coordinate(double Lat, double Lng);
var home = new Coordinate(38.72, -9.14);
var same = new Coordinate(38.72, -9.14);
Console.WriteLine(home == same); // True
Console.WriteLine(home); // Coordinate { Lat = 38.72, Lng = -9.14 }
var north = home with { Lat = 38.80 };
일반 record struct의 위치 속성은 변경 가능하고, readonly record struct는 이를 init 전용으로 만들며 보통 이쪽이 원하는 것입니다. 레코드 페이지에서 클래스 버전과 컴파일러가 작성하는 멤버를 다룹니다.
흔한 실수
- 복사본을 통해 구조체 수정하기.
list[0].X = 1(CS1612),foreach변수(CS1654), 속성 getter의 결과. 수정한 복사본을 다시 쓰거나 구조체를 불변으로 만드세요. - 큰 구조체. 모든 대입과 호출이 전체를 복사합니다. 필드가 몇 개를 넘으면 보통 클래스가 더 빠릅니다.
- 0으로 채워진 기본값 잊기. 배열과
default(T)는 생성자를 실행하지 않고 구조체 값을 만들므로, 생성자에서 검증하는 필드도 여전히 0이나null일 수 있습니다. - 정의하지 않고
==로 비교하기. CS0019입니다.IEquatable<T>와 연산자를 구현하거나 레코드 구조체를 쓰세요. - 인터페이스 뒤의 변경 가능한 구조체. 박싱 때문에 보고 있지 않은 복사본이 바뀝니다.
자주 묻는 질문
C#에서 구조체란 무엇인가요?
구조체는 사용자 정의 값 형식입니다: struct Point { public int X; public int Y; }. 구조체 타입의 변수는 객체에 대한 참조가 아니라 데이터 자체를 담으므로, 대입하거나 메서드에 넘기면 값 전체가 복사됩니다. int, double, DateTime, Guid가 모두 구조체입니다.
C#에서 구조체와 클래스의 차이는 무엇인가요?
클래스는 참조 형식입니다. 변수들이 하나의 객체를 공유하고 null이 허용됩니다. 구조체는 값 형식입니다. 변수마다 자기 복사본을 갖고, (Point?를 쓰지 않는 한) null이 될 수 없고, 상속하거나 상속될 수 없으며, 기본값은 모든 필드가 0인 상태입니다. 구조체는 작고 불변인 값에, 클래스는 정체성과 동작을 가진 엔터티에 어울립니다.
C#에서 언제 클래스 대신 구조체를 써야 하나요?
타입이 하나의 작은 값(좌표, 금액, 날짜 범위)을 나타내고, 되도록 불변이며, 내용으로 비교되고, 힙 할당을 피하는 것이 중요할 만큼 많이 만들어질 때입니다. Microsoft 지침은 크기가 대략 16바이트 이하일 것도 덧붙입니다. 타입에 정체성이 있거나, 필드가 많거나, 참조를 통해 변경된다면 클래스를 쓰세요.
"Cannot modify the return value because it is not a variable" 오류가 나는 이유는 무엇인가요?
CS1612 오류로, 보통 구조체의 List<Point>에서 list[0].X = 5를 할 때 납니다. 인덱서가 구조체의 복사본을 반환하므로 복사본을 바꿔도 사라지게 되어 컴파일러가 거부합니다. 요소를 변수에 복사하고, 바꾸고, 다시 대입하세요: var p = list[0]; p.X = 5; list[0] = p;. 배열은 array[0]이 요소 자체를 가리키므로 이 문제가 없습니다.
C#의 readonly struct란 무엇인가요?
readonly struct(C# 7.2)는 구조체의 어떤 멤버도 상태를 바꾸지 않는다고 선언합니다. 모든 필드는 readonly여야 하고 자동 속성은 get 전용이어야 합니다. 컴파일러가 이를 강제하며, 구조체를 in으로 넘기거나 readonly 필드에 저장할 때 방어적 복사를 피할 수 있게 해 줍니다. 대부분의 구조체는 readonly여야 합니다.