상세 컨텐츠

본문 제목

박싱(Boxing), 언박싱(UnBoxing)

유니티 학습/C#과 유니티

by 남민우_ 2026. 4. 3. 13:57

본문

시작하기에 앞서

Boxing 박싱 / UnBoxing 언박싱은 먼저 권장해서 사용하는 기능이 아니라, 언어의 하위 호환성과 타입 시스템의 일관성을 유지하기 위한 보루로 존재한다는 것을 알고 가야 한다.

C# 1.0 시대의 기준에 맞춰져 만들어진 기법이기에 현대에 Generics 이 도입이 되면서 박싱/언박싱을 사용하지 않는 방법들로 많이 개편이 되었지만, C#의 기본 철학과 연결된 것인 만큼 인터페이스-구조체 혼합 사용이나 런타임 내 복합 타입 처리에 대한 과정 등때문에 완벽하게 사용하지 않는 경우는 불가능하다.

따라서 이러한 박싱/언박싱에 대해 학습하는 것은 언제 발생하는지 알고, 이를 최소화 하는 설계를 하기 위함이다.

 

박싱 (Boxing)

박싱이란, 값 형식을 참조 형식으로 변환하는 것을 말한다.

 

C# 에서 모든 클래스는 참조 타입으로, System.Object를 상속한다.

이러한 클래스는 힙에 저장이 되며 GC가 관리하게 되고, 힙 메모리 주소를 가리키는 값은 스택에 저장이 된다.

 

따라서 값 형식을 참조 형식으로 변환한다는 것은, 값 형식의 데이터를 Object 라는 참조 형식으로 변환하는 동작을 말하며, 이 과정에서 간단한 참조 할당보다 최대 20배의 비용이 더 소모된다.

 

참조?

참조라고 해서 C++을 아는 사람이라면 떠올릴 수 있는 키워드가 하나 있는데, & 참조자이다.

본인도 같은 의미도 혼동이 왔던 만큼 내용을 작성해보자면, C++의 & 참조자와 같은 참조를 의미하냐면 그것은 아니다.

예시 코드를 하나 들어서,

void Method(int& Score) { ... }

같은 메서드가 하나 있다고 할 때, 이때의 score 참조는 '메모리 주소'를 넘겨주는 방식이다.

이와 같은 참조 키워드로 C#에서는 'ref' 를 사용하며, 다음과 같이 작성할 수 있다.

void Method(ref int score) { ... }

따라서 박싱과 참조자는 다른 개념이다.

 

박싱은 단순히 메모리 주소를 넘기는 것이 아니라, 값 타입을 object(참조 타입)라는 클래스 기반의 객체로 바꾸는 동작이다.

object 란, C#에서 모든 클래스의 조상으로 힙에 저장되는 객체로, 객체 타입인 만큼 값 형식으로 사용할 수 없다.

또한 값 타입은 객체가 가진 기능(메서드 호출 등)을 사용할 수 없기 때문에, 힙에 값을 담을 수 있는 객체를 새로 만들고, 그 안에 값을 복사해서 넣는 과정을 박싱이라고 말할 수 있다.

 

이러한 이유로 C#에서는 값 타입인 int 를 두고서도 int.Parse()와 같이 메서드를 사용할 수 있는 걸 볼 수 있다.

 

또한 박싱은 암시적으로 이루어진다.

이 암시적으로 이루어진다는 말은,

int i = 123;
object o = i;

다음과 같은 코드에서 볼 수 있듯이, 박싱을 한다 혹은 타입에 대한 명시를 하지 않고 이루어진다는 것이다.

int i 에 123이라는 값을 넣고, 참조 형식인 object 로 참조 할당을 넣으면서 박싱이 이루어진다.

이렇게 암시적으로 진행될 수 있는 이유는, object가 C#의 모든 클래스의 조상 객체이기 때문에 작은 그릇에서 큰 그릇으로 옮겨가는 과정인 만큼 형식에 대한 명시를 하지 않고도 이루어질 수 있다.

 

또한 위 코드에서 박싱이 이루어지면서 o 객체에 대한 새로운 정의가 이루어졌다.

이렇듯 값 형식이 박싱된다는 말은 완전히 새로운 객체를 만드는 동작으로, 간단한 참조 할당보다 최대 20배의 비용이 소모되는데, 그 이유로는 힙에 새로운 메모리 공간을 찾아서 할당하고, 데이터를 복사하는 과정이 포함되기 때문이다.

 

그럼 왜 쓰는가?

박싱이 비효율적인 동작이라는 것을 이해하면 자연스럽게 왜 사용하는지에 대해 의문이 생긴다.

이 이유는 크게 두가지로 설명이 가능한데, 첫번째는 범용성, 두번째는 구시대의 한계이다.

1. 다형성의 극대화

C# 언어의 설계 당시, 어떤 데이터가 들어올지 모르는 상황에서 System.Object 하나로 모든 데이터를 처리하기 위해서 박싱을 사용하였다.

이 원리는 현재도 동일하게 동작하여, 모든 타입을 object 로 처리하면 코드가 짧아지고 범용적인 함수를 만들기 쉽다는 장점이 있다.

void Method(object target) { ... }

이러한 메서드의 경우 매개변수를 object 형식으로 받기 때문에, 매개변수로 전달되는 데이터가 값 형식이든 참조 형식이든 상관 없이 모두 처리할 수 있을 것이다.

2. Generics 이전의 한계

C# 1.0 시기에는 List<int> 와 같은 제네릭이 존재하지 않았다.

따라서 데이터를 담으려면 ArrayList 나 HashTable 처럼 Object 를 담는 통을 사용해야 했고, 이때 int 등을 사용하려면 데이터 형식이 바뀌어야 하기에 박싱이 필수였다.

이 내용은 현재도 남아있어, 오래된 라이브러리나 .NET 프레임워크 초기 API들을 사용하면 여전히 object 매개변수를 사용하는 경우를 볼 수 있다.

 

대체가 가능하지 않나?

위의 두가지 이유를 설명하고 나면 다시 이어지는 의문으로, 제네릭도 만들어졌고 메서드는 오버로드를 사용하는 등으로 대체가 가능할텐데 왜 아직까지 박싱이 남아있는지 궁금해진다.

실제로 제네릭이 나온 이후 실무에서 박싱을 사용하는 경우는 99% 이상 사라졌지만, 그렇다고 아예 없으면 안되는 이유가 있다.

1. 인터페이스(Interface) 의 존재

인터페이스는 참조 형식으로 동작한다.

interface IDamageable { void TakeDamage(int amount); }
struct Player : IDamageable { ... } // 구조체(값 타입)가 인터페이스를 상속

void DealDamage(IDamageable target) { target.TakeDamage(10); }

이 경우를 보면, Player 는 구조체 타입으로 값 형식이지만 인터페이스를 상속한다.

외부에서 DealDamage 로 Player 를 넘겨주는 경우, 인터페이스는 참조 형식으로 동작하지만 구조체는 값 형식이기에 박싱이 일어날 수 밖에 없다.

 

물론 이 경우에는 Player를 참조 형식인 클래스로 바꾸면 문제가 되진 않는다.

또한 제네릭으로 바꾸는 경우,

void DealDamage<T>(T target) where T : IDamageable

해결이 되긴 하지만 완전하게 해결된다고 볼 수 없는 경우가 있는데, 동적 다형성 이 그 경우다.

 

이 동적 다형성이란, 모든 타입을 하나의 리스트로 담아서 관리해야 하는 경우를 말하는데, 예시 코드를 보면서 이해할 수 있다.

List<IDamageable> targets = new List<IDamageable>();

예를 들어서 게임 플레이 중, 데미지를 받을 수 있는 객체(몬스터, 플레이어, 파괴 가능한 오브젝트 등)에 대해 일괄적으로 데미지를 부여하는 경우가 있다고 해보자.

이런 경우에는 IDamageable 인터페이스를 상속하는 객체들을 하나의 리스트에 담아 일괄적으로 처리해야 할텐데, 리스트 List<T> 는 T에 대해 동일한 타입만 담을 수 있어 동일한 로직 처리에 대해 각 경우를 별도로 나누어 처리하는 번거로운 과정이 발생한다.

이러한 상황처럼 다른 타입에 대해 동적 처리가 가능한 박싱이 유일한 해결책이 된다는 것이다.

 

2. C#의 철학 : 모든 것은 객체다!

이는 이해하고 넘어가기보단, 받아들여야 하는 이론으로 C#에서 모든 데이터를 객체라는 점을 깔고 간다.

대표적인 값 형식인 int 조차도 ToString() 이나 GetHashCode() 같은 메서드를 가진다.

만약 박싱이 없다면 int 를 object 로 취급할 수 없고, 이는 C#의 통합 타입 시스템(Unified Type System)을 붕괴시키게 된다.

 

이렇듯 박싱은 C# 언어를 사용하는 이상 피할 수 없고 경우에 따라서는 사용하기 때문에 그 비효율에 대해 인지하고 최대한 피할 수 있도록 하는 것이 올바른 사용 방식일 것이다.

 

그럼 어떻게 피하나?

박싱이 비효율적인 동작이라는 것은 위에서 충분히 설명하였고, 어쩔 수 없이 사용하게 되는 경우가 발생한다는 것도 소개하였다.

그렇다고 해서 범용성이라는 장점 하나만으로 무작정 사용하기에는 개발자로서 비효율을 감안하지 않을 수 없기에 최대한 박싱 과정을 회피하려는 노력이 필요할 것이다.

 

그 방법으로 Generics 사용이 있다.

 

1. Generics(제네릭) 사용

ArrayList 대신 List<T> 를 사용하는 방식과 비슷하다.

List<int> 는 int 라는 타입을 직접적으로 명시하는 만큼, 내부적으로 int[ ] 를 생성하여 int 타입에 대해서만 처리하기에 박싱이 일어나지 않는다.

 

2. ToString() 미리 호출

"Score : " + score

이러한 코드의 경우, score 는 일반적으로 int 나 float 등으로 처리될 텐데, 이를 문자열로 변환하는 경우 박싱이 일어난다.

이 대신,

"Score : " + score.ToString()

이렇게 ToString()을 미리 호출하는 경우, 숫자 자체를 박싱하는 대신 문자열 전달로 가비지를 조금 더 예측 가능하게 관리할 수 있다.

 

3. 인터페이스 제약 조건

제네릭 함수를 만들 때 where T : struct 같은 타입에 대한 제약을 걸어, 값 타입임을 명시하여 처리하는 것이다.

이 경우에도 박싱이 일어나지 않고 연산이 가능하다.

 

 

언박싱(UnBoxing)

박싱이 값 형식을 참조 형식으로 바꾸는 것이었다면, 언박싱은 그 반대인 참조 형식을 값 형식으로 변환하는 동작을 말한다.

더 정확하게 말하자면 '박싱된 힙 객체'에서 원래의 값 타입 데이터를 추출하는 과정으로, 단순히 참조 타입을 값 타입으로 바꾼다고 해서 모두 언박싱인 것은 아니다.

따라서 언박싱은 이전에 박싱된 적이 있는 객체에 대해서 이루어지며 값 타입이 원래의 타입과 정확히 일치해야 한다는 제약이 존재한다.

 

언박싱은 내부적으로 두 개의 동작으로 이루어지는데,

1. 객체 인스턴스가 해당 값 타입을 박싱한 것인지 확인한다.

int i = 123; object o = i; >> long j = (long)o; //에러! 타입 불일치
object o2 = null; int k = (int)02; //에러! NULL 참조

 

이 경우에는 각 에러를 발생시키며 언박싱이 일어나지 않는다.

 

2. 확인 후 힙에 있는 값을 스택의 변수로 복사

하여 언박싱이 마무리된다.

 

언박싱이 아닌 예시를 들어보자면, 다음과 같은 두 경우가 있다.

int.Parse("123");
   
class A { public int value; }
int x = a.value;

첫번째 Parse 의 경우, 언박싱이 아니라 '데이터 파싱'으로, 데이터를 새로 계산해서 만드는 동작이다.

두번째 a.value 의 경우, 언박싱이 아니라 필드 참조로 객체 A 의 멤버에 접근하는 동작이지 객체 자체를 값으로 바꾸는 것이 아니다.

 

또한 언박싱은 명시적으로 이루어진다.

o = 123;
i = (int)o; // 언박싱!

박싱이 작은 그릇에서 큰 그릇으로 옮기기에 형식에 대해 명시를 하지 않아도 됐다면, 언박싱은 큰 그릇에서 작은 그릇으로 옮기는 과정이다.

이 과정에서 타입에 대한 명시가 이루어져야 한다.

 

언박싱은 결국 값 형식을 명시적으로 바꿔주는 동작인 '캐스팅'으로, 일반적인 캐스팅 + 할당 동작보다 4배의 비용 소모가 발생한다.

 

얼핏 봐서 일반적인 캐스팅과 할당 동작인데 왜 오래 걸리나? 라는 의문을 가지게 되는데, 이는 언박싱이 단순한 비트 복사가 아닌 런타임의 검증 절차가 추가되기 때문이다.

개발자가 (int)와 같이 타입을 명시한다고 해도, 내부(CLR, Common Language Runtime)에서 추가로 일을 하게 된다.

 

1. 타입 체크 << 가장 큰 비용

(int)라고 명시를 했어도 CLR이 int 타입이 맞는지 객체의 타입 핸들을 검사한다.

이 과정에서 메모리 참조와 비교 연산이 들어가 가장 큰 비용을 소모하게 된다.

 

2. null 체크

Object 는 참조 타입이므로 null 값이 들어갈 수 있다.

null 일 경우에 int 등으로 꺼내려 하면 NRE(Null Reference Error)를 발생시켜야 하기 때문에 이 동작에서 유효성 검사가 들어간다.

 

3. 간접 참조

일반적인 할당은 스택에 있는 칸을 옆으로 옮기면 끝난다.

이와 다르게 언박싱은

1. 스택에 있는 참조 주소 확인

2. 주소를 타고 힙 메모리 접근

3. 객체 헤더 뒤에 있는 실제 데이터 시작 지점 탐색

4. 그 지점의 데이터를 스택으로 복사

하는 4가지 동작으로 진행되기에 비용 소모가 많을 수 밖에 없다.

 

정리

박싱/언박싱은 결과적으로 봐서 비효율적인 동작임을 반복적으로 설명하게 된다.

많은 횟수로 박싱이 일어나야 하는 상황은 제네릭을 사용하여 회피하는 것이 좋으며, 제네릭이 아닌 컬렉션 클래스와 같은 경우 값 형식을 사용하지 않는 것이 가장 좋을 것이다.

 

박싱에 대해서, 값 타입을 참조 타입으로 변환할 때 힙 메모리에 할당이 발생하여 GC 부하를 일으킨다.

스택의 데이터를 힙으로 복사하는 과정에서 가비지를 생성하게 되며, GC가 동작하는 경우 처리해야 하는 양이 많아져 런타임 프레임 드랍 등 성능 저하로 이어질 수 있다.

 

또한 박싱/언박싱은 런타임에 메모리를 쓰고 복사하는 동작이므로 컴파일 단계에서만 영향을 주는 것이 아니라 실시간 성능에 영향을 끼치게 된다.

 

최종 정리하여,

박싱 언박싱은 정말 구시대적인 방식이 맞아서 C# 발전에 따라서 사용하지 않는 방법들로 많이 개편이 되었고 그 대표적인 예시가 제네릭이다. 

다만 C#의 기본 철학과 연결된 것이 박싱/언박싱인 만큼 인터페이스 구조체 혼합 사용이나, 런타임 내 복합 타입 처리에 대한 과정 등 때문에 완벽하게 사용하지 않는 경우는 불가능하다.

 

이를 아예 사용하지 않는 것은 불가능하니, 언제 발생하는지 알고 이를 최소화하는 설계를 하는 것이 개발자의 실력일 것이다.

관련글 더보기