흔한 덕후의 잡동사니

new 키워드, 그리고 boxing/unboxing에 대하여 본문

언어 관련/C#

new 키워드, 그리고 boxing/unboxing에 대하여

chinodaiski 2025. 1. 31. 23:29

c#의 객체는 크게 2가지, 참조형(reference type) / 값 형식(value type) 로 나뉜다. 이는 저장되는 메모리의 위치에 따라 달라진다. 이와 관련된 문제점 중 boxing에 관하여 알아보고자 한다.

 

new 키워드

C#에서 new 키워드는 주로 객체를 생성하거나 값 타입을 초기화하는 데 사용된다.
new를 사용할 때, 값 타입(구조체)일지라도 힙에 객체를 생성하지 않고, 스택에 값이 할당된다.

 

값 타입은 힙의 메모리 할당 없이 스택에 직접 할당되므로 가볍고 빠르다는 말이 많은데, 스택은 메모리 할당 과정이 없으니깐 당연히 빠르다.

 


Boxing이란?

Boxing은 값 타입(Value Type)을 참조 타입(Reference Type)으로 변환하는 과정을 의미한다. 이는 주로 object나 interface와 같은 참조 타입으로 값을 저장할 때 발생한다고 한다.

int x = 42;         // 값 타입, 스택에 저장
object obj = x;     // Boxing 발생, Heap에 할당

 

위의 코드에서 int는 값 타입이고, object는 참조 타입이다. int 값을 object 변수에 저장하려 할 때, Boxing을 통해 int 값을 Heap에 새로운 객체로 할당한다. object 변수에는 해당 객체에 대한 참조만 저장되는 식이다.

Boxing 과정
1. 값 타입 데이터가 Heap에 저장
2. Heap에 저장된 객체의 참조가 스택에 위치한 변수의 메모리에 저장

 

Boxing의 비용
성능 저하: 값 타입을 참조 타입으로 변환하는 과정에서 Heap에 메모리가 할당되므로 성능 저하를 유발한다.
GC 부담: Boxing으로 인해 Heap에 추가적인 객체가 생성되면, 이후 가비지 컬렉션(GC)에서 이 객체를 추적하고 제거해야 하므로, GC에 대한 부담도 증가한다.

 

 

Unboxing이란?

Unboxing은 Boxed 객체를 다시 원래의 값 타입으로 변환하는 과정을 의미한다.

object obj = 42;     // Boxing 발생 (Heap에 할당)
int x = (int)obj;    // Unboxing 발생 (Heap에서 값을 가져와 스택에 복사)


Unboxing은 Boxed 객체에서 값을 추출하여 원래의 값 타입 변수로 복사하는 과정으로, Heap에 있던 데이터는 다시 스택으로 복사되며, 원본 참조 타입 객체는 메모리에서 제거되지 않고 Heap에 계속 존재한다.

Unboxing의 비용
Unboxing은 단순히 값만 복사하기 때문에, Boxing에 비해 상대적으로 빠르다. 동적할당 부분은 boxing 과정에서 수행되었기에 문제 없다. 또한 형식이 올바른 형식인지 검사하는데 비용이 부과된다. 추가로 다음의 문제가 있다.

 

불연속적인 메모리 할당으로 인한 캐시 미스 증가
간접적인 메모리 참조로 인한 추가적인 연산. 참조로 접근할때 주소를 타고 가기에 어셈 명령어가 몇 줄 더 들어간다.  

 

 

Boxing과 Unboxing 성능에 미치는 영향 측정

Boxing과 Unboxing의 성능 오버헤드를 수치화하기 위해 간단한 벤치마킹 테스트를 해봤다.

 

테스트는 값 타입을 조작하는것과 이에 비해 boxing, unboxing 성능이 얼마나 차이나는지에 대한 여부를 10만번씩 1천번 반복하며 테스트 했고, 결과의 오염을 방지하기 위해 표준편차가 가장 큰 10개의 결과를 제외한 평균을 내었다.

더보기
using System;
using System.Diagnostics;
using System.Collections.Generic;
using System.Linq;

class Program
{
    const int Iterations = 100000;
    const int TestRuns = 1000;
    const int OutlierCount = 10;

    static void Main()
    {
        List<long> valueTypeTimes = new List<long>();
        List<long> boxingTimes = new List<long>();
        List<long> unboxingTimes = new List<long>();

        for (int test = 0; test < TestRuns; test++)
        {
            valueTypeTimes.Add(MeasureValueType());
            boxingTimes.Add(MeasureBoxing());
            unboxingTimes.Add(MeasureUnboxing());
        }

        // 상위 표준편차 10개 제거 후 평균 계산
        double avgValueType = GetFilteredAverage(valueTypeTimes);
        double avgBoxing = GetFilteredAverage(boxingTimes);
        double avgUnboxing = GetFilteredAverage(unboxingTimes);

        Console.WriteLine($"Filtered Value Type Operation Time: {avgValueType:F2} ms");
        Console.WriteLine($"Filtered Boxing Time: {avgBoxing:F2} ms");
        Console.WriteLine($"Filtered Unboxing Time: {avgUnboxing:F2} ms");
        Console.WriteLine($"Boxing Overhead vs Value Type: {(avgBoxing / avgValueType):F2}x");
        Console.WriteLine($"Unboxing Overhead vs Value Type: {(avgUnboxing / avgValueType):F2}x");
    }

    static long MeasureValueType()
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        for (int i = 0; i < Iterations; i++)
        {
            sum += i;
        }
        sw.Stop();
        return sw.ElapsedMilliseconds;
    }

    static long MeasureBoxing()
    {
        Stopwatch sw = Stopwatch.StartNew();
        List<object> boxedList = new List<object>();
        for (int i = 0; i < Iterations; i++)
        {
            boxedList.Add(i);
        }
        sw.Stop();
        return sw.ElapsedMilliseconds;
    }

    static long MeasureUnboxing()
    {
        List<object> boxedList = new List<object>();
        for (int i = 0; i < Iterations; i++)
        {
            boxedList.Add(i);
        }

        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        for (int i = 0; i < Iterations; i++)
        {
            sum += (int)boxedList[i];
        }
        sw.Stop();
        return sw.ElapsedMilliseconds;
    }

    static double GetFilteredAverage(List<long> times)
    {
        // 평균 및 표준편차 계산
        double mean = times.Average();
        double stdDev = Math.Sqrt(times.Average(v => Math.Pow(v - mean, 2)));

        // 상위 OutlierCount개의 높은 표준편차 값 제거
        List<long> filteredTimes = times.OrderBy(t => Math.Abs(t - mean)).Take(times.Count - OutlierCount).ToList();

        return filteredTimes.Average();
    }
}

 

 

첫 테스트 결과는 다음과 같이 나왔다.

Filtered Value Type Operation Time: 0.02 ms
Filtered Boxing Time: 44.56 ms
Filtered Unboxing Time: 2.16 ms
Boxing Overhead vs Value Type: 2005.05x
Unboxing Overhead vs Value Type: 97.23x

 

 

충격적이다. boxing이 2000배? unboxing이 100배? 이렇게나 느릴 줄은 몰랐다.

그런데 몇번의 테스트를 더 해보니 다른 결과가 나왔다.

 

Filtered Value Type Operation Time: 0.05 ms
Filtered Boxing Time: 47.12 ms
Filtered Unboxing Time: 2.27 ms
Boxing Overhead vs Value Type: 1014.04x
Unboxing Overhead vs Value Type: 48.76x

Filtered Value Type Operation Time: 0.05 ms
Filtered Boxing Time: 47.36 ms
Filtered Unboxing Time: 2.31 ms
Boxing Overhead vs Value Type: 901.63x
Unboxing Overhead vs Value Type: 43.90x

...

 

 

평균적으로 boxing이 1000배, unboxing이 45배 정도 차이나는 것으로 나왔다. 처음과 결과가 2배 정도 차이났다. 이유를 찾아보니 JIT 최적화 라는 것이 있어 처음 테스트 할 땐 올바른 결과가 나오지 않는다고 한다. 

올바른 결과를 위해선 BenchmarkDotNet 패키지에서 제공하는 기능을 사용해야한다고 하는데, 이 부분은 다음에 정리하려 한다.

 

현재까지 나온 결론은 boxing시 성능에 심각한 하자가 있으니 이를 최소화해야한다는 것이다.

 

 

Boxing과 Unboxing을 피하는 방법

제네릭 사용: List<T>와 같은 제네릭 타입을 사용하면, T가 값 타입일지라도 Boxing을 방지할 수 있다. 제네릭 타입을 사용한다는 것 자체가 이미 힙 영역을 사용한다는 것이니 값 타입일지라도 해당 타입으로 힙 메모리에 동적할당 되어 있으므로 boxing은 일어나지 않는다.
값 타입을 직접 사용: 당연하지만 값 타입을 직접 사용하면 된다.

// Boxing 발생
object obj = 42;

// Boxing을 피한 예시 (제네릭 사용)
List<int> numbers = new List<int>();
numbers.Add(42);