박싱과 언박싱을 이해하려면 참조 타입과 값 타입에 대한 정의가 필요하다.
값 타입(Value Type)
값 타입의 정의
값 타입은 변수 자체에 데이터 값을 직접 저장하는 타입이다.
이러한 변수는 스택(stack)메모리에 할당되며, 복사 시 실제 데이터 값이 복사된다.
값 형식의 변수는 해당 형식의 인스턴스를 포함한다.
이는 해당 형식의 인스턴스에 대한 참조를 포함하는 참조 형식의 변수와 다르다.
기본적으로 할당, 메서드에 인수 전달, 메서드 결과 반환 시 변수 값이 복사된다.
값 형식 변수의 경우 해당 형식 인스턴스가 복사된다.
Value types and reference types are the two main categories of C# types. A variable of a value type contains an instance of the type. This differs from a variable of a reference type, which contains a reference to an instance of the type. By default, on assignment, passing an argument to a method, and returning a method result, variable values are copied. In the case of value-type variables, the corresponding type instances are copied. The following example demonstrates
출처 : Value types - C# reference
예시
- 기본 숫자형: int, float, double, decimal, long, short, byte
- 논리형: bool
- 문자형: char
- 열거형: enum
- 구조체: struct
C# 값 타입 vs C++ 값 타입
C# C++
구분 | C# | C++ |
값 타입 정의 | 구조체(struct), 열거(enum), 기본 숫자형 등값이 직접 저장됨 | 기본 자료형(int, float 등), struct, enum값이 직접 저장됨 |
메모리 위치 | 스택(기본적으로), 일부 상황에서 힙(예: 박싱) | 스택(기본적으로), new 사용 시 힙 |
복사 방식 | 복사 시 값이 복사됨 (Deep Copy of value) | 복사 시 값이 복사됨 (Deep Copy of value) |
복사 동작 예시
int a = 10;
int b = a; // 값이 복사됨
b = 20;
Console.WriteLine(a); // 출력: 10
a와 b는 서로 독립적인 값을 가지므로, b를 변경해도 a는 영향을 받지 않는다.
“Operations on a value-type variable affect only that instance of the value type.”
→ 값 타입은 독립된 복사본을 가진다.
출처: MS Docs
using System;
public struct MutablePoint
{
public int X;
public int Y;
public MutablePoint(int x, int y) => (X, Y) = (x, y);
public override string ToString() => $"({X}, {Y})";
}
public class Program
{
public static void Main()
{
var p1 = new MutablePoint(1, 2);
var p2 = p1;
p2.Y = 200;
Console.WriteLine($"{nameof(p1)} after {nameof(p2)} is modified: {p1}");
Console.WriteLine($"{nameof(p2)}: {p2}");
MutateAndDisplay(p2);
Console.WriteLine($"{nameof(p2)} after passing to a method: {p2}");
}
private static void MutateAndDisplay(MutablePoint p)
{
p.X = 100;
Console.WriteLine($"Point mutated in a method: {p}");
}
}
// Expected output:
// p1 after p2 is modified: (1, 2)
// p2: (1, 200)
// Point mutated in a method: (100, 200)
// p2 after passing to a method: (1, 200)
위 예시에서 보이듯,
값 유형 변수에 대한 작업은 변수에 저장된 해당 값 유형의 인스턴스에만 영향을 미친다.
값 형식에 참조 형시그이 데이터 멤버가 포함된 경우,
값 형식 인스턴스를 복사할 때 참조 형식의 인스턴스에 대한 참조만 복사된다.
복사본과 원본 값 형식 인스턴스 모두 동일한 참조 형식 인스턴스에 엑서스할 수 있다.
( = 구조체 안에 참조 타입이 있을 경우 주의점 shallow copy 문제를 말한다.)
using System;
using System.Collections.Generic;
public struct TaggedInteger
{
public int Number;
private List<string> tags;
public TaggedInteger(int n)
{
Number = n;
tags = new List<string>();
}
public void AddTag(string tag) => tags.Add(tag);
public override string ToString() => $"{Number} [{string.Join(", ", tags)}]";
}
public class Program
{
public static void Main()
{
var n1 = new TaggedInteger(0);
n1.AddTag("A");
Console.WriteLine(n1); // output: 0 [A]
var n2 = n1;
n2.Number = 7;
n2.AddTag("B");
Console.WriteLine(n1); // output: 0 [A, B]
Console.WriteLine(n2); // output: 7 [A, B]
}
}
값 유형의 종류와 유형 제약 조건
값 유형은 다음 두 종류 중 하나일 수 있다.
원문
A value type can be one of the two following kinds:
a structure type, which encapsulates data and related functionality
an enumeration type, which is defined by a set of named constants and represents a choice or a combination of choices.
📎 출처: MS Docs - Value Types
1. 사용자 정의 구조체(struct)
struct는 값 타입이다.
함수에 인자로 전달되면 복사본이 전달된다.
즉 "데이터와 관련 기능을 캡슐화하는 구조 유형"에 속하는 것이다.
쉽게 말하자면,
- 데이터(변수들)와 그 데이터를 처리하는 기능(메서드)를 하나로 묶은 타입
- C#의 struct가 이에 해당
- 클래스처럼 생겼지만, 값 타입이라는 차이점
2. Enumeration type (열거형 타입)
명명된 상수 집합으로 정의되고 선택 사항 또는 선택 사항의 조합을 나타내는 열거형
쉽게 말하자면,
- 미리 정해진 이름 있는 값들 중 하나(또는 몇 개)를 선택하게 해주는 타입
- enum 키워드로 정의
- 옵션이나 상태 등을 명확하게 표현할 때 유용
“선택 사항의 조합”이란?
- enum에 [Flags] 특성을 붙이면 여러 값을 조합 가능
비트 연산으로 Read + Write를 동시에 표현할 수 있음
[Flags]
enum FileAccess
{
Read = 1,
Write = 2,
Execute = 4
}
// 여러 값 조합
FileAccess access = FileAccess.Read | FileAccess.Write;
구조체 (struct) | 데이터와 기능을 함께 가지는 값 타입 | Point, DateTime, Vector2 |
열거형 (enum) | 이름이 붙은 정수 상수들의 모음으로, 하나 또는 여러 상태를 표현 | Color, FileAccess, DayOfWeek |
참조 타입(Reference Type)
C#에는 두 가지 종류의 타입이 있다: 참조형(reference types)과 값형(value types)이다.
- 참조형 변수는 데이터(객체)에 대한 참조(주소) 를 저장합니다.
- 값형 변수는 데이터를 직접 포함합니다.
참조형의 경우, 두 개의 변수가 같은 객체를 참조할 수 있다.
따라서 한 변수에 대한 작업이 다른 변수가 참조하는 객체에도 영향을 줄 수 있다.
반면, 값형은 각 변수가 자기만의 데이터 복사본을 가지므로,
한 변수에 대한 작업이 다른 변수에 영향을 줄 수 없다.
다음 키워드는 참조형 타입을 선언할 때 사용
- class
- interface
- delegate
- record
C#에는 다음과 같은 내장 참조형있음
- dynamic
- object
- string
예외
(except in the case of in, ref, and out parameter variables; see in, ref, and out parameter modifier).
(단, in, ref, out 매개변수로 전달된 경우는 예외입니다. 자세한 내용은 in, ref, out 매개변수 한정자 참고).
값 타입 참조 타입 비교
값 타입 | 참조 타입 | |
저장 위치 | 스택(Stack) | 힙(Heap) |
변수에 담기는 것 | 실제 값 | 참조(주소) |
복사 시 | 값 복사 (완전한 복제) | 주소 복사 (같은 객체 가리킴) |
null 가능 여부 | 불가 (nullable 필요) | 가능 |
- 참조 형식 값 = new를 통해 만들어진 객체 그 자체
- 참조 형식 변수 = 그 객체의 주소를 가진 변수
즉, 참조 변수는 객체를 "가리키고" 있고, 객체는 힙에 존재하는 “참조 형식 값”이다.
참고 사이트
Reference types - C# reference
Types - C# language specification
박싱(Boxing)과 언박싱(Unboxing) 정리
박싱(Boxing)이란?
값 타입(Value Type)을 참조 타입(Reference Type)인 object 또는 해당 값 타입이 구현한 인터페이스 타입으로 변환하는 과정
“Boxing is the process of converting a value type to the type object or
to any interface type implemented by this value type.”
출저 : Boxing and Unboxing - Microsoft Docs
- C#에서 int, float, bool 등은 값 타입입니다. 이는 메모리의 스택(stack)에 저장되고, 직접 값을 담는다.
- object는 모든 타입의 부모인 참조 타입이며, 메모리의 힙(heap)에 저장되고, 값을 참조(주소)한다.
- 박싱을 하면, 값 타입의 데이터를 힙에 새로 생성된 object 인스턴스에 복사해서 감싸게 된다.
int i = 123;
object o = i; // 암시적 박싱
"값 타입을 참조 타입(object나 인터페이스)으로 변환한다"는 의미
- 예시: int i = 123; 는 값 타입
- object o = i; 는 값 타입 i를 힙에 복사하고, 그 참조를 o가 가지게 되는 것
- 즉, int → object 로 감싸짐
“Boxing a value type allocates an object instance on the heap and copies the value into the new object.”
출저 : Boxing - Microsoft Docs
언박싱이란?
object 또는 인터페이스 참조에서 원래의 값 타입으로 되돌리는 명시적 캐스팅 과정
“Unboxing extracts the value type from the object. Boxing is implicit; unboxing is explicit.”
출처: Boxing and Unboxing - Microsoft Docs
- 이 과정은 명시적 형변환(explicit cast)이 필요
- 참조 타입인 object에 박싱된 값 타입을 꺼낼 때 사용하는 과정
object o = 123;
int i = (int)o; // 명시적 언박싱
인터페이스 참조란?
인터페이스(interface)는 메서드나 속성의 "규약"만 정의하고,
실제 구현은 구현체가 갖는 추상적인 틀
interface IExample
{
void DoSomething();
}
struct MyValueType : IExample
{
public void DoSomething() { Console.WriteLine("Doing something"); }
}
여기서 MyValueType은 값 타입이지만, IExample 인터페이스를 구현하고 있다.
이제 박싱을 통해 IExample 타입 변수에 값을 담을 수 있다.
MyValueType value = new MyValueType();
IExample ex = value; // 박싱 발생, 힙에 object 생성
즉, 값 타입이지만 인터페이스를 구현하고 있다면, 해당 인터페이스 타입으로의 변환도 박싱
박싱과 언박싱의 성능 비용에 대한 Microsoft 공식 문서의 설명
1. 박싱(Boxing)
"박싱은 값 형식을 참조 형식으로 변환하는 과정으로,
새로운 객체를 힙에 할당하고 값을 복사해야 하므로 계산 비용이 많이 듭니다."
박싱은 값 형식을 object나 해당 값 형식이 구현한 인터페이스 타입으로 변환하는 과정
이 과정에서 새로운 객체가 힙에 할당되고, 값이 복사되므로 추가적인 메모리와 CPU 자원이 필요로 함
2. 언박싱(Unboxing)
"언박싱은 참조 형식에서 값 형식으로 변환하는 과정으로, 캐스팅이 필요하며 이 역시 계산 비용이 많이 듭니다."
언박싱은 object나 인터페이스 타입에서 원래의 값 형식으로 변환하는 과정
이 과정에서는 런타임에서 타입 검사가 수행되며, 값이 다시 복사되므로 추가적인 계산 비용이 발생
3. 성능 비교
"박싱은 단순한 참조 할당보다 최대 20배 더 오래 걸릴 수 있으며, 언박싱은 4배 더 오래 걸릴 수 있습니다."
박싱과 언박싱은 단순한 값 할당이나 참조 할당에 비해 상당한 성능 차이를 보일 수 있다.
특히 반복적인 작업이나 대량의 데이터를 처리할 때 이러한 차이는 더욱 두드러진다.
요약
- 박싱(Boxing): 값 형식을 참조 형식으로 변환하는 과정으로, 힙에 새로운 객체를 할당하고 값을 복사하므로 성능 비용이 크다.
- 언박싱(Unboxing): 참조 형식에서 값 형식으로 변환하는 과정으로, 타입 검사와 값 복사가 필요하므로 성능 비용이 발생한다.
- 성능 영향: 박싱은 단순한 참조 할당보다 최대 20배, 언박싱은 4배 더 오래 걸릴 수 있다.
성능 최적화 팁
- 제네릭(Generic) 컬렉션 사용: List<int>와 같은 제네릭 컬렉션을 사용하면 박싱과 언박싱을 피할 수 있다.
- 값 형식 사용 최소화: 박싱이 자주 발생하는 경우, 값 형식의 사용을 최소화하거나 참조 형식으로 대체하는 것을 고려
박싱(Boxing) vs 언박싱(Unboxing) 비교 표
박싱 (Boxing) | 언박싱 (Unboxing) | |
정의 | 값 타입을 참조 타입 (object 또는 인터페이스)으로 변환 | 참조 타입 (object)에서 원래의 값 타입으로 변환 |
코드 예시 | object o = i; | int j = (int)o; |
메모리 위치 | 힙 (Heap) | 스택 (Stack) |
암시성 | 암시적 변환 (implicit) | 명시적 변환 (explicit) |
성능 영향 | 힙 메모리 할당 + 값 복사 + GC 부담 → 비용 큼 | 타입 검사 + 값 복사 → 비용 발생 |
위험 | C#에서는 값 타입을 object나 인터페이스 타입으로 변환하는 박싱은 컴파일러가 자동으로 허용 힙에 객체를 새로 생성하고, GC 대상 → 데이터를 처리할 때 성능이 급격히 저하 암묵적 박싱 발생으로 인한 예측 불가한 동작 특히 struct가 인터페이스를 구현하면, 인터페이스 변수에 담을 때마다 박싱이 발생 박싱 그 자체는 안전하지만, 언박싱을 잘못하면 박싱으로 인해 런타임 예외가 발생할 수도 있음. |
잘못된 형변환 → InvalidCastException - null 언박싱 → NullReferenceException |
피해야 할 경우 | 성능이 민감한 루프, 대량 데이터 구조에선 박싱 발생 시 성능 저하 가능 | 타입이 확실하지 않거나 null 가능성이 있을 경우 언박싱은 위험함 |
- 박싱 자체는 안전하다. (컴파일 오류 없음, 예외 없음)
- 하지만 실무에서는 성능 저하 및 언박싱 시 오류로 이어질 수 있는 잠재적 위험이 존재
박싱이 발생하는 대표적인 코드 패턴
값 타입 → object, interface로 변환 | object o = 123;, IComparable c = 5; 등 |
object 기반 컬렉션에 값 타입 추가 | ArrayList.Add(10); |
인터페이스를 구현한 struct를 인터페이스 타입 변수에 할당 | IMyInterface i = new MyStruct(); |
params object[]에 값 타입 인수 전달 | string.Format("{0}", 123); |
object.Equals() 또는 ToString() 호출 | 내부에서 박싱이 발생할 수 있음 |
박싱 발생을 탐지하는 방법
방법 1: Visual Studio 진단 도구
- Visual Studio → 성능 프로파일러(Alt + F2) → 할당 추적(Allocation Tracking)
- object 타입이 힙에 얼마나 생성되는지 확인 가능
- System.Int32 → System.Object로 변환된 흔적이 보이면 박싱 발생한 것
방법 2: IL 코드 확인
박싱된 코드는 IL(중간 언어)에서 box 명령어로 확인 가능
interface IMyInterface { void DoSomething(); }
struct MyStruct : IMyInterface
{
public void DoSomething() { }
}
void Main()
{
IMyInterface m = new MyStruct(); // 박싱 발생!
}
위 코드를 IL로 보면 박싱이 발생했다는 명확한 증거를 남김
box MyStruct
3. 박싱을 회피하는 전략
제네릭(Generic) 사용
박싱의 대표적인 대안은 object 기반이 아닌 제네릭 기반 컬렉션 사용
박싱 발생 코드 | 박싱 회피 코드 |
ArrayList list = new ArrayList(); list.Add(10); | List<int> list = new List<int>(); |
- List<object>는 int 추가 시 박싱 발생
- List<int>는 박싱 없이 직접 저장됨
박싱 발생 예제
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
List<object> list = new List<object>();
for (int i = 0; i < 5; i++)
{
list.Add(i); // 여기서 박싱 발생 (int → object)
}
foreach (object item in list)
{
Console.WriteLine(item); // Unboxing 없음, 하지만 힙 객체 접근
}
}
}
문제점
- int는 값 타입
- object는 참조 타입
- int를 object로 넣으면서 박싱이 발생합니다 → 힙 메모리 사용, 성능 저하
리팩토링 버전: List<int> 사용 → 박싱 제거 예제
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
List<int> list = new List<int>();
for (int i = 0; i < 5; i++)
{
list.Add(i); // ✅ 박싱 없음
}
foreach (int item in list)
{
Console.WriteLine(item); // 스택 기반 값 타입 그대로 처리
}
}
}
이점
- int는 값 타입이므로 스택에서 직접 처리됨
- 박싱/언박싱 없음, 힙 메모리 할당 없음
- GC 부담 없음 → 성능 향상
struct 대신 class, 또는 반대로
상황 | 전략 |
struct가 인터페이스에 할당될 때 | class로 바꾸면 박싱 없음 |
불필요하게 class 사용 중이고 할당 비용이 문제 | struct로 바꾸고 제네릭 사용 |
문제 예제 예시 IComparable 인터페이스 사용 시 박싱 발생
struct MyNumber : IComparable
{
public int Value;
public int CompareTo(object obj)
{
return Value.CompareTo(((MyNumber)obj).Value);
}
}
class Program
{
static void Main()
{
MyNumber a = new MyNumber { Value = 5 };
IComparable comp = a; // struct → interface로 박싱 발생
Console.WriteLine(comp.CompareTo(new MyNumber { Value = 3 }));
}
}
리팩토링 버전: Class로 변경
class MyNumber : IComparable
{
public int Value;
public int CompareTo(object obj)
{
return Value.CompareTo(((MyNumber)obj).Value);
}
}
class Program
{
static void Main()
{
MyNumber a = new MyNumber { Value = 5 };
IComparable comp = a; // struct → interface로 박싱 발생
Console.WriteLine(comp.CompareTo(new MyNumber { Value = 3 }));
}
}
Span<T>, ref struct, stackalloc 등 활용
C#에서는 고성능 코드를 위해 다음과 같은 힙 할당을 회피하는 기능도 제공:
- Span<T>: 스택 기반 배열 슬라이스
- stackalloc: 스택에 직접 배열 생성
- ref struct: 힙에 올릴 수 없는 구조체 (박싱 불가)
Span<int> numbers = stackalloc int[100]; // 힙 할당 없음
활용 이유
- 힙 할당 없이 스택 기반 버퍼/데이터 처리
- 값 타입을 직접 메모리에 접근하며 사용
- GC 부담 줄이고 박싱 방지
예제: Span<T> + stackalloc 활용
using System;
class Program
{
static void Main()
{
Span<int> numbers = stackalloc int[5]; // 스택에 배열 생성
for (int i = 0; i < numbers.Length; i++)
{
numbers[i] = i * 10;
}
foreach (int n in numbers)
{
Console.WriteLine(n); // 힙 할당 없이 출력
}
}
}
설명
- stackalloc은 스택에 메모리를 직접 할당
- Span<T>는 박싱 없이 메모리에 직접 접근할 수 있는 ref struct
- Span<T>는 ref struct이기 때문에 힙에 할당 불가, 따라서 박싱 자체가 불가능
제한 사항
- Span<T>는 필드( 클래스 또는 구조체 안에 선언된 변수 )로 저장 불가
클래스 객체는 힙에 올라가기 때문에, Span<T>를 필드로 만들면 힙에 저장되므로 금지 - 비동기 메서드, Task 안에서 사용 불가
await, 현재 작업을 멈추고, 다른 일을 하다가 결과가 준비되면 다시 이어서 실행
await를 사용하면 메서드가 중단되고 재개되기 때문에, 원래 스택 메모리가 사라질 수 있 - 왜냐하면 스택 메모리 생명 주기가 제한되어 있어서
Span<T> 안전하게 쓰려면?
- 메서드 내부에서만 선언하고 즉시 사용
- async/await, lambda, iterator, class 필드 안에서 사용
default interface method + 제네릭 제한 활용
박싱 없이 인터페이스 메서드를 호출하려면
interface IProcessor<T> where T : struct
{
void Process(ref T value); // ref를 통해 박싱 없이 처리 가능
}
목적
- 값 타입에 대해 박싱 없이 인터페이스 메서드 제공
- 제네릭 + ref 전달로 성능 최적화
예제: IProcessor<T> 인터페이스에 ref T 사용
using System;
interface IProcessor<T> where T : struct
{
void Process(ref T value)
{
Console.WriteLine("Default implementation: " + value);
}
}
struct MyStruct : IProcessor<MyStruct>
{
public int X;
public override string ToString() => $"MyStruct: {X}";
}
class Program
{
static void Main()
{
MyStruct m = new MyStruct { X = 42 };
CallProcessor(ref m);
}
static void CallProcessor<T>(ref T value) where T : struct, IProcessor<T>
{
value.Process(ref value); // 박싱 없이 인터페이스 호출
}
}
설명
T : struct 제한 덕분에 값 타입만 허용됨 → 박싱 X
ref 전달 → 복사 X, 직접 전달
IProcessor<T> 인터페이스 메서드는 default interface implementation 사용 가능 (C# 8.0+)
호출 시 박싱 없이 ref T 타입으로 호출됨
요약 | ||
1 | object 기반 대신 제네릭 사용 | List<T>, Dictionary<TKey, TValue> |
2 | struct → class 전환 고려 | 인터페이스 할당 시 박싱 주의 |
3 | 고성능 구조 활용 | Span<T>, ref struct, stackalloc |
4 | 제네릭 + 인터페이스 최적화 | where T : struct, ref T 활용 |
'프로그래밍 언어 > C#' 카테고리의 다른 글
.NET 과 C#의 아키텍처 (4) | 2025.05.17 |
---|---|
IEnumerator<T> 인터페이스 (0) | 2025.05.16 |
C# out 키워드 (0) | 2024.07.27 |
프로퍼티, 델리게이트, 이벤트 간단 정리 (0) | 2024.01.08 |
C# (1) | 2023.12.17 |