C#는 타입 안전성과 자동 메모리 관리를 중시하는 언어로, 일반적으로 변수 간 메모리 공유를 금지하지만, 특정 상황에서는 고성능 요구에 따라 메모리 레이아웃을 직접 조작할 수 있는 기능을 제공한다. 이 기능을 활용하면 C++의 union처럼 여러 타입이 동일한 메모리 공간을 공유하는 효과를 얻을 수 있다.
핵심 기술: 구조체의 명시적 메모리 배치
기본적으로 C#의 구조체는 컴파일러가 자동으로 메모리 순서를 결정한다. 이를 변경하고 싶다면 [StructLayout(LayoutKind.Explicit)] 특성을 사용해 메모리 배치를 수동으로 제어해야 한다. 각 필드에 [FieldOffset]을 적용하면 해당 필드가 메모리에서 시작하는 위치를 정확히 지정할 수 있다.
예를 들어, 두 개의 필드에 모두 0의 오프셋을 설정하면, 그들은 동일한 메모리 주소에서 시작하게 되며, 실제로는 겹치는 메모리 영역을 공유하게 된다.
실습 예제: 32비트 정수를 바이트 단위로 분해하기
다음은 int 값을 구성하는 각 바이트를 별도로 접근하는 전형적인 사례이다. 이는 네트워크 프로토콜 처리나 색상 표현(예: ARGB) 시 매우 유용하다.
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Explicit)]
public struct BitSplitter
{
[FieldOffset(0)]
public int RawValue;
[FieldOffset(0)]
public byte Blue;
[FieldOffset(1)]
public byte Green;
[FieldOffset(2)]
public byte Red;
[FieldOffset(3)]
public byte Alpha;
}
// 사용 예시
var pixel = new BitSplitter { RawValue = 0xFF00AA55 };
Console.WriteLine($"Red: {pixel.Red:X2}, Green: {pixel.Green:X2}, Blue: {pixel.Blue:X2}");
이 방식은 리틀엔디언 아키텍처(대부분의 x86/x64 시스템)에서 작동하며, 바이트 순서에 따라 결과가 달라질 수 있다.
고성능 변환: 실수를 정수로 직접 해석하기
double 값을 long로 변환할 때, BitConverter 같은 라이브러리 함수는 성능이 저하될 수 있다. 하지만 메모리 레이아웃을 명시적으로 제어하면, 실제 메모리 내의 비트 패턴을 그대로 읽는 것이 가능하다.
[StructLayout(LayoutKind.Explicit)]
public struct DoubleBits
{
[FieldOffset(0)]
public double Value;
[FieldOffset(0)]
public long Bits;
}
// 메모리의 원시 비트 데이터 추출
public static long GetBinaryRepresentation(double d)
{
var instance = new DoubleBits { Value = d };
return instance.Bits;
}
이 기법은 과학 계산이나 수치 알고리즘에서 중요한 역할을 할 수 있으며, unsafe 코드 없이도 구현 가능하다.
주의사항 및 제약 조건
- 참조형과 값형의 혼용 금지:
string,object등 참조형은FieldOffset을 사용해 다른 값형과 겹치는 것을 허용하지 않는다. 이유는 가비지 컬렉션(GC)이 객체 포인터를 정확히 추적해야 하기 때문이다. 이를 시도하면TypeLoadException이 발생한다. - 타입 안전성 위반: 이러한 접근은 타입 시스템의 기본 원칙을 깨뜨리는 행위로, 의도치 않은 오류를 유발할 수 있다. 반드시 필요한 경우에만 사용해야 한다.
현대적 대안: MemoryMarshal와 Span
C# 7.0 이후, MemoryMarshal와 Span<T>는 메모리의 논리적 해석을 변경하는 더 안전하고 효율적인 방법을 제공한다. 메모리 복사를 피하면서도, 하나의 데이터를 다른 타입으로 해석할 수 있다.
double number = 3.14159;
var bytes = MemoryMarshal.AsBytes(MemoryMarshal.CreateReadOnlySpan(ref number, 1));
foreach (var b in bytes)
{
Console.Write($"{b:X2} ");
}
이 코드는 double의 실제 메모리 내용을 바이트 배열로 보여주며, 복사 없이 직접 참조한다.
대안 선택: OneOf 라이브러리 활용
OneOf 라이브러리를 사용하면 여러 타입을 하나의 변수에 저장할 수 있지만, 메모리 공유가 아니라 타입 안전한 변형(variant)을 제공한다. 따라서 union과는 본질적으로 다릅니다.
using OneOf;
using Variant = OneOf<int, float, string>;
Variant value = 42;
value.Switch(
i => Console.WriteLine($"정수: {i}"),
f => Console.WriteLine($"실수: {f}"),
s => Console.WriteLine($"문자열: {s}")
);
결론: 어떤 상황에 어떤 방식을 쓸까?
| 용도 | 권장 솔루션 |
|---|---|
| 네트워크/프로토콜 파싱 | StructLayout(LayoutKind.Explicit) |
| 색상 또는 비트 조작 | StructLayout(LayoutKind.Explicit) |
| 메모리 해석 변경 (변환 없이) | MemoryMarshal.AsBytes / Cast |
| 다양한 타입 저장 (안전한 변형) | record + pattern matching (C# 9.0+) |
C#의 Explicit Layout은 하드웨어 수준의 제어력을 제공하지만, 사용자는 메모리 정렬, 엔디언, 타입 크기 등을 숙지해야 한다. 이는 고성능 애플리케이션 개발자의 도구상자에 반드시 포함되어야 하는 기술이다.