C#의 Value Type과 Reference Type
C#에서 값 형식과 참조 형식이 어떻게 다르게 동작하는지 정리하고, class와 struct의 차이를 함께 살펴봅니다.
들어가기 전에
C#을 배우다 보면 int, bool, Vector3 같은 값은 복사된다고 말하고, class로 만든 객체는 참조된다고 말한다. 처음에는 이 표현이 조금 추상적으로 들릴 수 있다. 하지만 기준을 하나로 잡으면 훨씬 단순해진다.
핵심 질문은 이것이다.
변수 안에 실제 값이 들어 있는가?
아니면 실제 객체를 가리키는 참조가 들어 있는가?
이 질문에 따라 C#의 형식은 크게 value type과 reference type으로 나뉜다.
Value Type은 값 자체를 가진다
Value type은 변수가 값 자체를 가진다. 대표적인 예시는 int, float, bool, char, decimal, enum, 그리고 struct다.
예를 들어 다음 코드를 보자.
int a = 10;
int b = a;
b = 20;
Console.WriteLine(a); // 10
Console.WriteLine(b); // 20
b = a를 하는 순간 a의 값이 b로 복사된다. 이후 b를 바꿔도 a는 영향을 받지 않는다. 두 변수는 각각 자기 값을 따로 가지고 있기 때문이다.
struct도 value type이다.
public struct Point
{
public int X;
public int Y;
}
Point p1 = new Point { X = 1, Y = 2 };
Point p2 = p1;
p2.X = 10;
Console.WriteLine(p1.X); // 1
Console.WriteLine(p2.X); // 10
Point는 구조체이므로 p2 = p1에서 값이 복사된다. 그래서 p2.X를 바꿔도 p1.X는 그대로다.
Reference Type은 객체를 가리키는 참조를 가진다
Reference type은 변수 안에 객체 자체가 아니라 객체를 가리키는 참조가 들어 있다. 대표적인 예시는 class, string, 배열, delegate, interface로 다루는 객체다.
public class Player
{
public int Level;
}
Player p1 = new Player { Level = 1 };
Player p2 = p1;
p2.Level = 10;
Console.WriteLine(p1.Level); // 10
Console.WriteLine(p2.Level); // 10
여기서 p2 = p1은 객체를 새로 복사하는 코드가 아니다. p1이 가리키던 같은 Player 객체를 p2도 가리키게 만든다. 그래서 p2.Level을 바꾸면 같은 객체를 보고 있는 p1.Level도 바뀐 것처럼 보인다.
정확히 말하면 p1이 바뀐 것이 아니라, p1과 p2가 함께 가리키는 객체의 내부 상태가 바뀐 것이다.
p1 ----\
-> Player 객체 { Level = 10 }
p2 ----/
class와 struct의 가장 큰 차이
C#에서 class와 struct의 가장 중요한 차이는 class는 reference type이고, struct는 value type이라는 점이다.
class -> reference type
struct -> value type
이 차이 때문에 대입, 메서드 전달, 비교, 메모리 사용 방식에서 동작이 달라진다.
대입할 때의 차이
struct는 대입할 때 값이 복사된다.
public struct Stat
{
public int Hp;
}
Stat a = new Stat { Hp = 100 };
Stat b = a;
b.Hp = 50;
Console.WriteLine(a.Hp); // 100
Console.WriteLine(b.Hp); // 50
반면 class는 대입할 때 참조가 복사된다.
public class StatClass
{
public int Hp;
}
StatClass a = new StatClass { Hp = 100 };
StatClass b = a;
b.Hp = 50;
Console.WriteLine(a.Hp); // 50
Console.WriteLine(b.Hp); // 50
겉으로는 둘 다 b = a처럼 보이지만, 실제 의미는 다르다. struct에서는 값의 복사이고, class에서는 같은 객체를 가리키는 참조의 복사다.
메서드에 전달할 때의 차이
메서드에 인자를 넘길 때도 기본적으로 같은 원리가 적용된다.
public struct Counter
{
public int Value;
}
void Increase(Counter counter)
{
counter.Value++;
}
Counter counter = new Counter { Value = 0 };
Increase(counter);
Console.WriteLine(counter.Value); // 0
Counter는 value type이므로 메서드에 전달될 때 값이 복사된다. Increase 안에서 값을 바꿔도 바깥의 counter는 바뀌지 않는다.
반대로 class를 넘기면 참조가 복사된다.
public class CounterClass
{
public int Value;
}
void Increase(CounterClass counter)
{
counter.Value++;
}
CounterClass counter = new CounterClass { Value = 0 };
Increase(counter);
Console.WriteLine(counter.Value); // 1
여기서는 counter라는 참조가 복사되어 메서드 안으로 들어간다. 하지만 그 참조가 가리키는 객체는 같기 때문에, 객체의 내부 값을 바꾸면 바깥에서도 그 변화가 보인다.
null을 가질 수 있는가
Reference type 변수는 객체를 가리키지 않는 상태를 표현할 수 있다. 이것이 null이다.
Player player = null;
반면 일반적인 value type은 null이 될 수 없다.
int count = null; // 컴파일 오류
다만 nullable value type을 사용하면 값 형식에도 null을 허용할 수 있다.
int? count = null;
int?는 Nullable<int>의 간단한 표기다. 즉 값 형식 자체가 갑자기 참조 형식이 되는 것은 아니고, 값이 있는지 없는지를 함께 표현하는 별도의 구조를 사용하는 것이다.
struct는 언제 쓰면 좋을까
struct는 작은 값을 표현할 때 잘 어울린다. 예를 들어 좌표, 색상, 범위, 날짜처럼 값 자체가 의미를 가지는 타입이 그렇다.
public struct Damage
{
public int Amount;
public string Type;
}
하지만 구조체가 너무 커지거나, 자주 복사되거나, 내부 상태를 많이 바꾸는 방식으로 설계되면 오히려 다루기 어려워질 수 있다. 구조체는 대입과 전달에서 복사된다는 점을 계속 의식해야 한다. 이를 해결하기 위해 ref, out, in 같은 키워드를 통해 메모리 주소 전달 방법을 이용할 수 있다.
ref로 원본 값을 직접 수정하기
ref는 메서드 안에서 받은 값을 직접 수정해야 할 때 사용한다. 호출하는 쪽의 변수와 메서드 안의 매개변수가 같은 저장 위치를 바라보게 된다.
public struct Position
{
public int X;
public int Y;
}
void MoveRight(ref Position position)
{
position.X += 1;
}
Position playerPosition = new Position { X = 0, Y = 0 };
MoveRight(ref playerPosition);
Console.WriteLine(playerPosition.X); // 1
ref를 사용하지 않았다면 MoveRight 안에서 Position 값이 복사되기 때문에 바깥의 playerPosition은 바뀌지 않는다. ref를 사용하면 원본 구조체를 직접 수정할 수 있다.
out으로 결과 값을 채워서 돌려주기
out은 메서드가 값을 만들어서 호출한 쪽에 돌려줘야 할 때 사용한다. 대표적인 예시는 int.TryParse 같은 패턴이다.
bool TryCreateDamage(string text, out Damage damage)
{
if (int.TryParse(text, out int amount))
{
damage = new Damage
{
Amount = amount,
Type = "Normal"
};
return true;
}
damage = default;
return false;
}
if (TryCreateDamage("25", out Damage damage))
{
Console.WriteLine(damage.Amount); // 25
}
out 매개변수는 메서드 안에서 반드시 값을 할당해야 한다. 그래서 성공 여부는 bool로 반환하고, 실제 결과 값은 out으로 받는 방식에 자주 사용된다.
in으로 복사는 피하되 수정은 막기
in은 큰 구조체를 읽기 전용으로 전달하고 싶을 때 사용한다. 값을 복사하지 않고 참조처럼 전달하지만, 메서드 안에서 값을 수정할 수 없다.
public struct CharacterStats
{
public int Hp;
public int Attack;
public int Defense;
}
int CalculatePower(in CharacterStats stats)
{
return stats.Attack * 2 + stats.Defense;
}
CharacterStats stats = new CharacterStats
{
Hp = 100,
Attack = 20,
Defense = 5
};
int power = CalculatePower(in stats);
Console.WriteLine(power); // 45
in은 "복사는 줄이고 싶지만, 메서드가 원본 값을 바꾸지는 못하게 하고 싶다"는 의도를 표현한다. 따라서 읽기 전용 계산이나 검사 함수에 잘 어울린다.
그래서 보통 struct는 다음 조건에 잘 맞을 때 선택한다.
- 작은 데이터를 표현한다.
- 값 자체가 정체성이다.
- 복사되어도 자연스럽다.
- 가능하면 변경 불가능하게 만들 수 있다.
예를 들어 Point(1, 2)는 어느 객체인지보다 값이 무엇인지가 중요하다. 그래서 구조체와 잘 맞는다.
class는 언제 쓰면 좋을까
class는 정체성이 있는 객체를 표현할 때 잘 어울린다. 예를 들어 플레이어, 몬스터, 인벤토리, 서비스, 매니저처럼 상태가 바뀌고 여러 곳에서 같은 대상을 공유해야 하는 경우다.
public class Player
{
public string Name;
public int Level;
}
플레이어는 단순히 Name과 Level 값의 묶음만은 아니다. 게임 안에서 하나의 존재이고, 여러 시스템이 같은 플레이어 객체를 바라볼 수 있다. 이런 경우에는 참조 형식인 class가 더 자연스럽다.
string은 reference type이지만 조금 특별하다
string은 reference type이다. 하지만 문자열은 불변 객체처럼 동작한다. 한 번 만들어진 문자열의 내용은 바뀌지 않고, 문자열을 수정하는 것처럼 보이는 코드는 보통 새 문자열을 만든다.
string a = "Hello";
string b = a;
b = "World";
Console.WriteLine(a); // Hello
Console.WriteLine(b); // World
이 예시만 보면 value type처럼 보일 수 있다. 하지만 string 변수에는 여전히 문자열 객체를 가리키는 참조가 들어 있다. 다만 문자열 객체 자체가 변경되지 않기 때문에, 참조 형식의 공유로 인한 상태 변경 문제가 잘 드러나지 않을 뿐이다.
정리
Value type과 reference type의 차이는 변수 안에 무엇이 들어 있는지에서 시작한다. Value type 변수에는 값 자체가 들어 있고, reference type 변수에는 객체를 가리키는 참조가 들어 있다.
struct는 value type이므로 대입하거나 전달할 때 값이 복사된다. class는 reference type이므로 대입하거나 전달할 때 참조가 복사된다. 그래서 구조체는 값 자체가 의미 있는 작은 데이터에 어울리고, 클래스는 정체성과 공유 상태가 중요한 객체에 어울린다.
결국 선택 기준은 단순히 "성능이 좋아 보이는가"가 아니다. 이 타입이 값처럼 복사되어도 자연스러운지, 아니면 하나의 객체로 공유되어야 하는지가 더 중요한 기준이다.