오늘 AI에게 코드 리펙토링 업무를 시키다가 AI가 처음보는 .NET API를 적용해서 알게 되었습니다.
MS Docs: StrongBox 클래스 (System.Runtime.CompilerServices) | Microsoft Learn
코드 구조: StrongBox.cs
사용 용도: What’s the purpose of StrongBox? · dotnet/runtime · Discussion #47774
사실 무슨 말인지 잘 이해는 가지 않았지만 AI에게 질문한 결과,
ReferenceType의 경우 메서드에 parameter로 넘기면 참조가 넘어가지만, struct 타입의 ValueType은 기본적으로 값이 복사되어 넘어갑니다. 그래서 값만 같고 인스턴스를 공유하지 않기 때문에 내부적으로 조작을 아무리 해도 바깥의 넘겨진 변수에 반영이 되지 않습니다.
이런 경우 ValueType을 Method로 넘길때 ref 키워드를 사용해서 넘기면 참조를 넘겨서 사용할 수가 있습니다. 대표적으로 Interlocked.Increment가 있을 것 같네요. 이 경우 값을 내부에서 바꿔도 바깥에서 변경이 됩니다.
이것과 동일한 동작을 StrongBox도 할 수 있습니다. 값타입을 참조형으로 넘겨서 사용할 수 있는 것입니다.
값 타입(System.ValueType)의 메모리 구조 이해
.NET은 기본적으로 모든 타입이 System.Object를 타입의 조상으로 가지고 있으며, System.Object를 상속하는 타입들은 기본적으로 ReferenceType이기 때문에 기본적으로는 Heap에 할당됩니다.
그리고 System.ValueType도 System.Object를 상속합니다. 그렇지만 System.ValueType은 좀 다릅니다. System.ValueType과 System.ValueType을 상속하는 System.Int32, System.Int64 같은 타입들에 대해 기본적으로는 Thread Stack에 할당되도록 해줍니다.
저는 계속 ‘기본적으로는’ 이라는 말을 반복해서 썼습니다. 기본이 아닌 예외가 있다는 뜻입니다.
System.ValueType은 Heap에도 할당될 수 있는 개념입니다. 이런 경우입니다.
public class ABC
{
public static int abc = 42; // ABC Class가 코드 상으로 처음 접근하는 시점에 GC Heap이 아닌 High-Frequency Heap에 할당됨.
public int Test { get; set; } // int 지만 ABC 생성자 호출 시점에 GC Heap에 할당됨.
}
이제 ABC 인스턴스를 만들어서 Test에 접근하면 이것은 GC Heap에 할당된 값형식의 인스턴스입니다. 그래서 일반적인 상황에서 Test의 값을 변경하면 다른데서도 참조해서 쓸 경우 변경된 Test의 정수 값을 가져다 쓸 수 있습니다.
그렇지만 Test는 기본적으로는 System.Int32인 ValueType이기 때문에 아무리 Heap에 할당된 Test라고 해도 다른 Method에 parameter로 넘기면 값복사가 일어납니다. 이런 경우입니다.
public class MyClass
{
public int Number { get; set; } // Heap에 저장됨 (객체의 일부로)
}
public class Program
{
public static void Main()
{
var myObject = new MyClass { Number = 42 };
Console.WriteLine($"[Before] myObject.Number = {myObject.Number}");
ModifyValue(myObject.Number); // GC Heap 공간의 Number 값을 넘겨줌.
Console.WriteLine($"[After] myObject.Number = {myObject.Number}");
}
static void ModifyValue(int value) // 여기서부턴 Thread Stack의 Stack Frame에 있음
{
Console.WriteLine($"[Inside Method] value = {value}");
value = 100; // 복사된 값만 바뀜
Console.WriteLine($"[Inside Method after change] value = {value}");
}
}
이제 MyClass 객체를 넘기지 않으면서 Number 값을 수정하고 싶다면 아래와 같이 해야합니다. ValueType을 참조로 넘긴다는 행위를 보이기 위해 일부러 뇌절하고 있습니다.
using System;
public class MyClass
{
public int Number { get; set; } // Heap에 저장됨
}
public class Program
{
public static void Main()
{
var myObject = new MyClass { Number = 42 };
Console.WriteLine($"[Before] myObject.Number = {myObject.Number}");
// 임시 변수 사용
int temp = myObject.Number;
ModifyValue(ref temp);
myObject.Number = temp;
Console.WriteLine($"[After] myObject.Number = {myObject.Number}");
}
static void ModifyValue(ref int value)
{
Console.WriteLine($"[Inside Method] value = {value}");
value = 100;
Console.WriteLine($"[Inside Method after change] value = {value}");
}
}
문법적으로 ref는 Property를 받을 수 없고 field만 받을 수 있기 때문에 이렇게 해야만 합니다. Property는 참조 반환을 할 수 없기 때문이죠.
물론 우리 모두가 아는 정석적인 방법은 아래일 것입니다.
using System;
public class MyClass
{
public int Number { get; set; } // Heap에 저장됨
}
public class Program
{
public static void Main()
{
var myObject = new MyClass { Number = 42 };
Console.WriteLine($"[Before] myObject.Number = {myObject.Number}");
ModifyValue(myObject);
Console.WriteLine($"[After] myObject.Number = {myObject.Number}");
}
static void ModifyValue(MyClass value)
{
Console.WriteLine($"[Inside Method] value = {value.Number}");
value.Number = 100;
Console.WriteLine($"[Inside Method after change] value = {value. Number}");
}
}
Boxing의 한계
ref 키워드의 제약사항을 극복하는 또 다른 방법은 boxing입니다. 하지만 boxing은 여러 문제점이 있습니다
public static void Main()
{
int number = 42;
object boxed = number; // Boxing 발생
ModifyBoxedValue(boxed);
// boxed 내부의 값은 변경되지 않음
Console.WriteLine($"Boxed value: {boxed}"); // 여전히 42
}
static void ModifyBoxedValue(object value)
{
// Unboxing 후 복사본을 수정하는 것이므로 원본에 영향 없음
if (value is int intValue)
{
intValue = 100; // 이렇게 해도 boxed object는 변경되지 않음
}
}
Boxing의 문제점
- 성능 오버헤드 (Heap 할당, GC 압박)
- 타입 안전성 손실 (object로 변환) - 사실 Object라는 타입이 중요하다기 보단 Heap에 복사되는 개념 자체를 Boxing이라고 봐야겠죠.
- 값 변경의 어려움 (unboxing 시 복사 발생)
StrongBox 명시적으로 ValueType을 Heap 할당
이러한 ref와 boxing의 한계를 극복하기 위해 StrongBox가 등장했습니다. 사실 StrongBox는 특별한 마법이 아닙니다. 그저 값 타입을 감싸는 아주 단순한 참조 타입 클래스일 뿐입니다. 아까 위에 소스코드도 링크를 보면 정말 간단하죠.
우리도 만들 수 있습니다.
public class IntWrapper
{
public int Value { get; set; }
public IntWrapper(int value)
{
Value = value;
}
}
그러나 이것은 우리가 개발할 때 API를 이용하지 않을 때의 단점을 동일하게 볼 수 있습니다.
우선 .NET에서 지원하는 것들을 최대한 써야 GC 수준의 최적화라던가, 추후 .NET의 코드 구조가 바뀌었을 때의 혜택을 볼 수 있다던가, 공용화된 같은 클래스 이름으로 다른 개발자와 소통할 수 있기 때문일 것입니다. 그것이 우리가 프레임워크를 배우는 이유겠지요.
위에 언급된 GitHub를 통한 사용처를 AI를 통해 질문해서 코드를 만들어 봤습니다.
- Sharing an individual value type instance between multiple threads in a mutable fashion
- Enabling value types of arbitrary size to be atomically written as a reference
- Working around the lack of ref fields (by passing around the box with its mutable contents)
멀티스레드 환경에서 값 타입을 명시적으로 Heap 올려서 공유
// 단순히 int를 Heap에 저장하고 여러 스레드가 참조
var sharedCounter = new StrongBox<int>(0);
// 이것과 동일한 효과
public class Counter { public int Value; }
var sharedCounter = new Counter { Value = 0 };
값 타입의 필드를 정의할 때
public class DataProcessor
{
// ref int _currentIndex; // C# 11 이전엔 불가능
// 그냥 Heap에 int를 저장하고 싶을 뿐
private StrongBox<int> _currentIndex = new(0); // 이거 Property 아닙니다. Field 입니다.
public void Process()
{
_currentIndex.Value++; // Heap에 있으니까 증가 가능
}
}
마무리
값 타입을 참조처럼 다루는 것은 .NET 개발에서 자주 마주치는 과제입니다. ref 키워드는 가장 효율적이지만 제약이 많고, boxing은 간단하지만 성능과 타입 안전성 문제가 있습니다.
보시면 아시겠지만 StrongBox는 특별한 마법이 아닙니다. 그저 "값 타입을 Heap에 저장하고 싶은데, 매번 wrapper 클래스를 만들기는 귀찮으니 표준 라이브러리에서 제공하는 것을 쓰자"라는 실용적인 선택입니다.