개요

C# - <1>에 struct와 class의 차이를 이렇게 적어둔 적이 있다.

구조체는 값 타입으로 스택 메모리에 생성되고, 클래스는 참조 타입으로 힙 메모리에 생성됨

struct를 인터페이스로 넘길 때 왜 박싱이 생기는지 파고들다가, 이 문장의 기준부터 틀렸다는 걸 알게 됐다.. 클래스 필드에 들어간 struct는 힙에 있다.

C# - <3>에서는 박싱이 “성능에 영향을 미칠 수 있다”고만 적고 넘어갔는데, 이번엔 그 뒤를 따라가 봤다.

  • 값형의 기준: 스택/힙이 아니라 값이 그 자리에 있는가
  • 박싱이 필요한 이유: 인터페이스 호출은 실행 중에 타입을 물어야 하는데 struct에는 물어볼 헤더가 없다
  • 시점과 대상: 상자에 담기는 순간 1회, 그리고 원소가 아니라 열거자가 박싱되는 경우
  • 제네릭 제약: 타입이 확정되는 시점이 박싱 유무를 가른다
  • 숨는 지점: 매개변수 타입이 object·인터페이스인지 본다
  • struct를 고르는 기준: 상태가 계속 바뀌는 객체를 struct로 두면 생기는 일

아래 코드는 설명용 예시다. 할당량 측정 실험은 이번에 건너뛰었기 때문에 바이트 수치는 확인한 값이 아니다.


값형은 “그 자리에” 있다

struct는 자기를 담는 곳에 inline으로 저장된다.

  • 지역 변수면 보통 스택
  • 클래스의 필드나 배열의 원소면 그 객체 안, 즉 힙
class Player
{
    // Player 객체가 힙에 있으니 position의 x, y, z도 그 객체 안( 힙 )에 들어간다
    public Vector3 position;
}

void Move()
{
    // 지역 변수라 보통 스택에 놓인다
    Vector3 delta = new Vector3(1f, 0f, 0f);
}

그래서 값형과 참조형을 가르는 기준은 스택이냐 힙이냐가 아니라, 값이 그 자리에 바로 있느냐, 주소를 거쳐 가리키느냐다. C# - <1>의 문장은 “지역 변수일 때 흔히 그렇다”를 정의처럼 적은 것이었다.


인터페이스로 다루면 왜 박싱이 필요한가

인과 순서대로 적으면 이렇다.

  1. IDamageable target으로 호출하는 지점에서는 실제 타입을 모른다
  2. 실행 중에 객체에게 “너 무슨 타입이야?”를 물어야 한다
  3. 물으려면 object header와 타입 정보 포인터가 필요하다
  4. struct에는 헤더가 없으니, 힙에 상자를 만들고 값을 복사해 넣는다 → 이게 박싱이다

C++로 치면 virtual 함수가 있는 객체의 vptr이다. 다형성이 필요한 객체만 vptr을 갖듯, C#의 struct도 평소에는 이런 정보를 갖고 다니지 않는다.

모든 struct에 헤더를 붙이지 않은 이유는 지금 추측하면 메모리 밀도다. 64비트 기준 헤더가 16바이트쯤 되는데, 12바이트짜리 Vector3 배열의 원소마다 이게 붙으면 배열이 두 배 넘게 커지고 캐시에 들어가는 원소 수도 줄어든다.

박싱된 값은 복사본이다

상자 안에 들어간 건 원본이 아니라 복사본이라, 상자를 통해 값을 바꿔도 원래 변수는 그대로다.

interface IDamageable { void TakeDamage(int amount); }

struct Barrel : IDamageable
{
    public int Hp;
    // 자기 Hp를 깎는 메서드
    public void TakeDamage(int amount) { Hp -= amount; }
}

Barrel barrel = new Barrel { Hp = 10 };
IDamageable target = barrel;   // 박싱: 힙에 상자를 만들고 barrel 값을 복사
target.TakeDamage(3);          // 상자 안의 Hp만 7이 된다
Debug.Log(barrel.Hp);          // 10, 원래 변수는 그대로

C++의 object slicing과 뿌리가 같다. 둘 다 “값으로 대입하면 새 객체가 생긴다”에서 나온다.


박싱은 언제, 무엇이 되는가

여기서 두 번 잘못 짚었다.

언제: 담는 순간 1회

상태 이상을 struct로 만들고 List<IStatusEffect>에 담아 매 프레임 Tick을 부른다고 해 보자. 처음엔 이게 매 프레임 박싱된다고 봤다.

var effects = new List<IStatusEffect>();
effects.Add(new Burn());       // Burn(struct)이 인터페이스 자리에 들어가는 여기서 박싱 1회

void Update()
{
    foreach (var e in effects) // e는 이미 만들어진 상자를 가리키는 참조
        e.Tick(Time.deltaTime);// 새 할당 없음, 바뀐 남은 시간은 그 상자에 유지된다
}

박싱은 struct가 object·인터페이스 자리에 들어가는 순간( 대입, Add 등 ) 한 번 일어난다. 이미 상자가 된 객체의 인터페이스 메서드를 부르는 건 새 할당이 아니다. 대신 상자 안에서 바뀐 값은 원래 변수나, 언박싱해서 꺼낸 복사본에는 반영되지 않는다.

무엇이: 원소가 아니라 열거자

IEnumerable<Vector3>로 받은 경로를 순회할 때는 List 안의 Vector3가 박싱된다고 봤다.

void DrawPath(IEnumerable<Vector3> points)
{
    // GetEnumerator()가 IEnumerator<Vector3>를 돌려줘야 해서
    // struct인 List<Vector3>.Enumerator가 여기서 박싱된다 (foreach 1회당 1번)
    foreach (var p in points)
        Debug.DrawRay(p, Vector3.up);  // Current는 제네릭이라 Vector3 원소는 박싱되지 않는다
}

DrawPath(pathList);  // List<Vector3>는 class라 인터페이스로 넘겨도 참조 변환일 뿐

List<T>를 직접 foreach하면 컴파일러가 struct 열거자를 그대로 쓰지만, 인터페이스로 받으면 열거자가 인터페이스 자리에 들어가야 해서 상자가 하나 생긴다. 박싱되는 건 원소가 아니라 이런 보조 객체일 수도 있다.

두 번 다 “박싱이 있다”까지는 맞았는데, 시점과 대상을 잘못 짚었다. 그래서 이제 박싱을 볼 때는 언제( 담는 순간인가, 호출마다인가 )와 무엇이( 원소인가, 열거자 같은 보조 객체인가 )부터 따진다.


제네릭 제약으로 박싱 피하기

인터페이스를 변수에 걸지 않고 제약으로 건다.

// T는 IDamageable을 구현한 타입이면 무엇이든 된다
static void Hit<T>(ref T target, int amount) where T : IDamageable
{
    target.TakeDamage(amount);  // 값형 T면 Barrel.TakeDamage를 직접 호출, 박싱 없음
}

Barrel barrel = new Barrel { Hp = 10 };
Hit(ref barrel, 3);
Debug.Log(barrel.Hp);  // 7, ref로 넘겼으니 원본이 바뀐다

값형 T는 타입마다 기계어가 따로 만들어져서, Hit<Barrel> 안에서는 T가 Barrel로 확정돼 있다. 실행 중에 타입을 물을 필요가 없으니 헤더도 상자도 필요 없다( IL에서는 constrained. 접두사가 붙은 호출로 나온다 ).

이 차이를 나는 타입이 확정되는 시점으로 정리했다. 인터페이스 변수는 런타임에 확정되고, 제네릭 제약은 컴파일 시점에 확정된다. 이 시점 차이가 박싱 유무를 가른다. C++로 치면 virtual( 실행 중 확인 )과 template 인스턴스화( 코드 생성 시 확정 )의 차이다.

ref 없이 T target으로 받으면 박싱은 없지만 복사본을 받는 거라, TakeDamage가 깎은 Hp가 원본에 남지 않는다.


박싱이 숨는 지점

규칙은 하나다. 매개변수 타입이 object나 인터페이스인지 본다.

숨는 곳 왜 박싱되나
Debug.Log(object) 매개변수가 object라 int·Vector3를 넘기면 상자가 생긴다
string.Format(string, object...) 인자 배열이 object[]
SendMessage(string, object) 두 번째 인자가 object
오버라이드 안 한 Equals/GetHashCode/ToString 기본 구현이 class인 System.ValueType에 있다
IEquatable<T> 없는 struct를 Dictionary/HashSet 키로 Equals(object) 경로를 타서 조회마다 박싱

로그는 박싱이 아니어도 문자열 자체가 할당이다. Update 안에 둔 로그는 [Conditional] 래퍼로 감싸 릴리스 빌드에서 호출째 지우는 편이 낫다.

public static class Log
{
    // UNITY_EDITOR가 정의되지 않은 빌드에서는 호출문과 인자 계산이 통째로 빠진다
    [System.Diagnostics.Conditional("UNITY_EDITOR")]
    public static void Info(object message) => Debug.Log(message);
}

struct를 키로 쓸 거면 세 개를 같이 둔다.

struct Cell : IEquatable<Cell>
{
    public int X, Y;
    // Dictionary가 이 오버로드를 쓰면 박싱이 없다
    public bool Equals(Cell other) => X == other.X && Y == other.Y;
    // object로 비교될 때도 같은 결과가 나오게 맞춘다
    public override bool Equals(object obj) => obj is Cell c && Equals(c);
    // 같은 칸이면 같은 해시가 나와야 한다
    public override int GetHashCode() => (X * 397) ^ Y;
}

float 기반 struct는 오차 때문에 같은 좌표가 다른 키가 될 수 있어 키로 쓰지 않는다. 격자 좌표면 Unity에 Vector2Int/Vector3Int가 이미 있다.

GC Alloc이 찍혔다고 다 박싱은 아니다. LINQ, 변수를 캡처한 람다( closure ), 문자열 연결, class new도 할당이다. 원인을 먼저 나눈 다음에 박싱인지 본다.


struct를 고르는 기준

Microsoft 설계 지침은 아래를 모두 만족할 때만 struct를 쓰라고 한다( Choosing Between Class and Struct ). 2008년 판 지침이 바탕이라 지금 기준으로는 낡은 부분이 있을 수 있다.

  • 하나의 값을 표현한다
  • 16바이트 미만이다
  • 불변이다
  • 자주 박싱되지 않는다

Unity의 Vector3는 불변 조건을 어긴 struct다. 그래서 이런 코드가 막힌다.

transform.position.x = 1f;     // CS1612: position은 복사본을 돌려주는 프로퍼티라 고칠 수 없다

var pos = transform.position;  // 꺼내서
pos.x = 1f;                    // 고치고
transform.position = pos;      // 다시 대입한다

만약 상태 이상 타이머처럼 상태가 계속 바뀌는 객체를 struct로 둔다면, 어느 리스트에 담아도 문제가 생긴다.

  • List<IStatusEffect>에 담으면 위에서 본 대로 Add마다 박싱 할당
  • List<Burn>처럼 struct 리스트에 담으면 복사 함정
foreach (var b in burns)
    b.Tick(dt);         // b는 복사본이라 줄어든 남은 시간이 리스트에 남지 않는다

for (int i = 0; i < burns.Count; i++)
{
    var b = burns[i];   // 꺼내서
    b.Tick(dt);         // 고치고
    burns[i] = b;       // 다시 넣는다
}

class로 바꾸면 남는 할당은 풀링으로

class로 바꾸면 박싱은 사라지지만 상태 이상이 걸릴 때마다 new가 남는다. 이건 오브젝트 풀링으로 줄이면 된다고 생각했다.

풀링의 위험은 struct와 반대 방향이다. struct는 복사본이 생겨서 문제였다면, 풀링은 같은 인스턴스가 재사용돼서 문제가 된다.

  • 반납할 때 상태를 초기화하지 않으면 이전 상태 이상의 남은 시간이 다음 사용에 섞인다
  • 반납한 뒤에도 그 객체를 쥐고 있는 UI나 이벤트 구독이 엉뚱한 상태 이상을 보게 된다

그래서 버그를 쫓을 때 던질 질문도 하나로 묶인다. “지금 이 변수가 가리키는 건 복사본인가, 공유된 원본인가?”


소감

단순하게 C#에선 박싱을 한다라고 생각했던 것과 다르게 여러 우회 방안이나 일어나는 시점이 있다는 게 신기했다