C#에서 복합 형식을 만드는데엔 2가지 방법 - struct, class가 있다.
(tuple이나 record등은 모두 struct, class의 파생 형태)

C# 프로그래밍을 오래 해오면서 항상 드는 고민중에 하나가 바로

struct? class? 어떤 걸로 선언해야 할까?

이다.

각자 만의 답이 있을 것이다. 이 글에선 내가 했던 고민과 생각을 정리해본다.
일반적으로 class가 기본 선택지이기 때문에 반대로 struct를 선택하면 좋을 때에 대해 다룬다.

  • 아주 기초적인 내용들은 다루지 않는다. C#에 대한 기본적인 이해도가 있다고 가정한다.
  • struct/class로 정의하는 것을 구조체라고 하겠다. struct를 지칭할 시엔 struct라고 하겠다.

구조체의 크기

struct는 ValueType이고, class는 ReferenceType이다. ValueType은 메서드 사이로 전달될 때마다, 값을 할당할 때마다 복사된다. 아래의 구조체가 있다고 해보자. 아래 코드를 실행하면 구조체의 크기로 72가 나온다.

public struct SomeStruct
{
    bool Boolean1; bool Boolean2;
    double Double1; double Double2;

    long Long1; long Long2; long Long3; long Long4;
    string String1; string String2;
}

int size = System.Runtime.InteropServices.Marshal.SizeOf(typeof(SomeStruct));
System.Console.WriteLine(size); // 72

SomeStruct 타입 파라미터, 리턴 타입, 값 할당을 할 때마다 72바이트의 복사가 일어난다.
복사가 자주 일어난다면 class를 사용하는게 나을 수도 있다.

struct -> class 전환의 구조체 크기 마지노선이 얼마인지는 아직까지도 잘 모르겠다.
환경에 따라 다르기도 하고, 정답이 없을 것 같긴 한데, MSDN(링크)에서는 16 bytes 를 초과하는 경우에는 struct를 사용하지 말라고 권한다.

구조체 크기가 크더라도 in, out, ref를 적절히 사용하는 경우엔 struct의 복사 이슈를 피할 수 있어서 검토해볼만 하다.

객체의 Lifecycle

프로그램속 객체는 언젠가 생성되고, 소멸된다. 이 라이프 사이클이 struct/class 결정에 필요한 요인중 하나라고 생각한다.

.net에서는 ReferenceType인 class의 인스턴스의 생성과 소멸을 GC가 관리하고, 그에 따른 오버헤드가 있다. 반면 ValueType인 struct는 Boxing 되거나 class의 멤버로 사용되지 않는 이상 스택에서 생성되고 소멸되기 때문에 GC의 관리를 받지 않는다.

만약 어떠한 구조체의 인스턴스가 ‘아주 많이’ 생성되고 소멸된다면, struct를 사용하는게 더 효율적일 수 있다.

스택상에서만 잠깐 생성되어 사용되고, 어딘가에서 참조되지 않는 객체는 ReferenceType으로 동작할 필요가 없으니 struct를 사용해도 좋겠다.

Immutability(불변성)

불변 타입은 의도치 않은 멤버 변경을 막아 프로그램의 안정성을 높이고 코드 디자인적 강제성을 부여해 일관된 디자인을 돕는 등 여러 이점이 있다. struct와 class 모두 불변 객체로 만들 수는 있지만, struct가 더 쉬운 2가지 이유가 있다.

  • readonly struct keyword

    • readonly struct는 모든 필드를 readonly로 강제한다. 그 자체로 완전한 Immutable 구조체가 된다.
    • 반면 readonly class 라는 것은 없다.
  • struct is ValueType

    • struct는 ValueType 이기 때문에 하나의 객체 인스턴스를 여러 곳에서 참조해 쓰는 것이 아니고 값이 복사되어 사용된다. 그렇기에 애초에 엄한 곳에서 객체의 값을 바꾸더라도 다른 곳의 객체들은 애초에 다 다른 메모리상의 객체들이다.
    • 이 메커니즘이 Immutable로 만들어주진 않지만, Mutable하기 때문에 발생할 수 있는 문제 소지를 어느정도 줄여준다.

만약 어떤 구조체를 불변 타입으로 만들고 싶다면 struct 사용을 검토해볼 수 있다.

Native와 Interop 하는 경우

Managed(C#)와 Native(C, C++)를 상호 운용(interop)을 하는 경우 구조체를 자주 마샬링해서 사용한다. 이 경우 struct를 사용하는 편이 더 수월한 경우가 많은 듯 하다. ReferenceType의 경우 .net에 많은 논리와 처리(GC 등)이 포함되어 있지만, struct를 비롯한 ValueType은 C, C++의 struct/class의 동작과 크게 다를 것이 없다. 특히 메모리 레이아웃을 맞추는 등 사전 작업을 해두면 Native 메모리의 데이터를 C# struct로 바로 변환해 사용할 수 있는 게 편리하다.

Boxing과 Unboxing

최근에는 C# 개발을 하면서 라이브러리나 프레임워크에 강요받은 경우를 제외하고 내가 직접 Boxing과 Unboxing이 발생하는 상황을 만든 경우는 거의 없었던 것 같다.

struct를 사용하는 경우 애초에 Boxing과 Unboxing 자체가 일어나지 않도록 코드 디자인을 하는게 맞다고 생각한다. 그게 힘들 경우에는 차라리 class를 사용하는게 나을 수도 있다.

기타

.net 공식 소스코드의 struct 사용 사례

https://github.com/dotnet/runtime repo의 /src/libraries 하위에 존재하는 c# 파일들을 roslyn을 사용해 class와 struct의 정의부를 분석해보았다.

Total Classes: 41103
Total Structs: 3810

약 전체의 8% 정도가 struct로 정의되어 있다.

Unity DOTS의 struct 사용 사례

DOTS의 많은 부분이 struct로 구현되어 있다. DOTS의 핵심 가치인 성능을 확보하기 위해, 또한 유니티의 고질적인 GC 문제를 회피하기 위해서라고 생각해볼 수 있다. Native Memory를 직접 다루는 경우도 많기 때문에 위의 Native와 Interop 하는 경우 섹션에서 언급한 것과 비슷한 맥락에서 struct가 용도에 적합할 것으로 보인다.

  • Unity.Collections
    • Native Collection, Unsafe Collection들은 모두 struct로 구성되어 있으며
      Native Memory에 대한 포인터만을 가지는 형태로 구성되어 있다.
  • Unity.Entities
    • ECS Component는 기본적으로 (당연히) struct로 구성된다.
    • 청크 단위로 Native Memory를 사용하며 그 메모리를 마샬링해서 사용하기 때문에 struct가 적합하다.
  • Unity.Mathematics
    • 수학적 계산에 사용되는 것들은 ‘객체’의 성격이 아니라 ‘데이터’의 성격이 강하기 때문에 struct가 더 적합하다.
    • 물론 이 패키지 뿐만 아니라 System.Numerics의 구조체들도 struct로 구성되어 있다.
  • Unity.Burst
    • 버스트의 제약 조건 중 unmanaged (struct)라는게 있다.
    • 버스트까지가 DOTS의 완성이기 때문에 이를 위해서 struct를 사용하는 것도 있다.

결론

C#에서 struct와 class는 많은 차이점을 가진다. 상황과 목적, 의도에 맞게 잘 선택해야 한다.

생각보다 두가지 중 잘 선택하는게 중요하다.
퍼포먼스 테스트 및 프로파일링을 하다보면 잘못된 선택으로 인한 효과를 심심치 않게 볼 수 있다.

  • struct? class? 간단 정리
    • 객체지향 프로그래밍 패러다임에서 확실히 “객체"인 친구들은 class로 선언하면 된다.
    • 그 외에 context 라던가, data, parameter 등 다른 성격의 구조체는
      struct 사용을 검토할 수 있다.