CodeGym /행동 /C# SELF /참조형과 값형 타입

참조형과 값형 타입

C# SELF
레벨 12 , 레슨 3
사용 가능

1. 소개

상자 하나 있다고 상상해봐. 만약 그 상자가 실제 물건(예를 들어, 사과가 들어있는 상자)을 담고 있다면, 이건 값형 타입이랑 비슷해. 데이터 자체가 바로 이 "상자"(변수) 안에 들어있는 거지.

반대로, 명함에 주소가 적혀 있다고 생각해봐. 명함 자체가 집은 아니고, 어디에 집이 있는지 알려주는 것이야. 이건 참조형 타입이랑 비슷해. 변수에는 데이터 자체가 아니라, "명함"—즉, 메모리 어딘가에 있는 데이터의 주소가 들어있는 거지.

간단한 예시:

  • int x = 5; // 값형 타입: x 변수는 5라는 숫자를 직접 "저장"하고 있어.
  • string name = "Vasya"; // 참조형 타입: name 변수는 메모리 어딘가에 있는 "Vasya"라는 문자열의 주소를 "저장"하고 있어.

누가 누군지 한눈에 보기

헷갈리지 않게, 주요 데이터 타입들이 어떤 카테고리에 속하는지 정리해봤어:

값형 타입 (Value Types):
  • 기본 타입: int, double, float, bool, char, byte, short, long, decimal 등등.
  • 구조체 (struct): 네가 struct 키워드로 선언하는 모든 사용자 정의 구조체.
  • 열거형 (enum): 이름 붙은 상수 집합을 정의할 수 있는 타입.
참조형 타입 (Reference Types):
  • 문자열 (string): 문자열은 불변(immutable)이라는 특징이 있지만, 참조형 타입이야.
  • 모든 배열: 예를 들어, int[], string[], YourCustomClass[] 등.
  • 모든 클래스 (class): class 키워드로 선언하는 모든 사용자 정의 클래스.
  • 델리게이트 (delegate): 메소드에 대한 참조를 나타내는 타입.
  • 인터페이스 (interface): 인터페이스 자체는 객체가 아니지만, 인터페이스 타입 변수는 이 인터페이스를 구현한 객체의 참조를 저장할 수 있어.
  • 리스트, 딕셔너리 등 컬렉션: 예를 들어, List<T>, Dictionary<TKey, TValue> 등.
  • 그리고 structenum이 아닌 모든 것은 기본적으로 C#에서 참조형 타입이야.

2. 변수 복사

여기서 실제로 중요한 차이가 나와. 한 변수를 다른 변수에 할당할 때, 실제로 뭐가 복사될까?

A) 값형 타입(Value Type) 복사

값형 타입을 복사하면 완전히 독립적인 데이터 복사본이 만들어져. 마치 문서를 복사기에서 복사한 것처럼: 한 복사본을 바꿔도 원본이나 다른 복사본에는 아무 영향 없어.


    int a = 10;
int b = a; 					// b도 이제 10이지만, "자기만의 복사본"이야
Console.WriteLine($"초기값: a = {a}, b = {b}"); // 초기값: a = 10, b = 10

b = 15; 					// b를 변경
Console.WriteLine($"b 변경 후: a = {a}, b = {b}"); // b 변경 후: a = 10, b = 15

설명: b 변수는 10이라는 값을 자기만의 복사본으로 받았어. b를 15로 바꿔도 a는 여전히 10이야. 완전히 독립적이지.

B) 참조형 타입(Reference Type) 복사

참조형 타입을 복사하면 주소(참조)만 복사돼. 즉, 객체가 메모리 어디에 있는지 알려주는 "주소"만 복사되는 거야. 두 변수는 같은 객체를 가리키게 돼. 마치 두 사람이 같은 명함을 갖고 있는 것처럼: 한 사람이 집에 가서 벽을 칠하면, 다른 사람도 그 집에 가면 칠해진 벽을 보게 되는 거지.

배열로 예를 들어보자. 배열은 참조의 "마법"을 아주 잘 보여줘(문자열은 조금 다르니까 일단 패스).


int[] arr1 = {1, 2, 3};
int[] arr2 = arr1; // arr2와 arr1은 같은 배열을 가리켜!

// 초기값: arr1[0] = 1, arr2[0] = 1
Console.WriteLine($"초기값: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}"); 

arr2[0] = 42; // arr2로 배열 요소를 변경

// arr2[0] 변경 후: arr1[0] = 42, arr2[0] = 42
Console.WriteLine($"arr2[0] 변경 후: arr1[0] = {arr1[0]}, arr2[0] = {arr2[0]}"); 

설명: arr1arr2는 같은 배열을 가리키고 있어. arr2[0]을 바꾸면, 실제로 그 배열 자체가 바뀌는 거라서 arr1[0]도 같이 바뀌는 거야.

문자열(string)의 미묘한 점

C#에서 문자열은 참조형 타입이지만, 불변(immutable)이라서 조금 다르게 동작해. 한 번 만들어진 문자열은 바꿀 수 없어. 문자열을 "바꾸는" 모든 연산(예: 연결, Replace())은 실제로 새로운 문자열을 만들어.


string str1 = "Hello";
string str2 = str1; // str2도 str1과 같은 "Hello" 객체를 가리켜

// str1 = "Hello", str2 = "Hello"
Console.WriteLine($"초기값: str1 = \"{str1}\", str2 = \"{str2}\""); 

str2 = "Bye"; // 여기서 "Bye"라는 새로운 객체가 만들어지고, str2가 그걸 가리킴

// str1 = "Hello", str2 = "Bye"
Console.WriteLine($"str2 변경 후: str1 = \"{str1}\", str2 = \"{str2}\""); 

설명: 처음엔 str1str2가 같은 "Hello" 객체를 가리켰어. str2에 "Bye"를 할당하면, C#은 기존 "Hello" 객체를 바꾸는 게 아니라, "Bye"라는 새로운 객체를 만들고 str2가 그걸 가리키게 해. str1은 여전히 "Hello"를 가리키고 있지. 이게 초보자들이 자주 헷갈리는 부분이야.

3. 주요 차이점 표

특징 값형 타입
(예: struct, int)
참조형 타입
(예: class, string, 배열)
복사되는 것 값 자체("복사기"로 복사한 데이터) 객체의 참조("메모리 주소")
복사본 간의 관계 없음, 복사본은 완전히 독립적임. 하나를 바꿔도 다른 건 영향 없음. 있음, 모든 참조가 같은 객체를 가리킴. 한 참조로 객체를 바꾸면 모두에게 보임.
null이 될 수 있나? 아니(Nullable 타입, 예: int? 제외). 항상 값이 있음. 가능. "아무것도 없음"(null)을 가리킬 수 있음. null 객체에 접근하면 NullReferenceException이 발생함.
선언 방법 struct, 그리고 모든 기본 타입(int, bool 등), enum class, interface, delegate, array, string, object
정리 메커니즘 스코프를 벗어나면 스택에서 자동으로 삭제됨. 더 이상 참조가 없을 때 Garbage Collector가 정리함.

4. 예제: 실제 앱에서

간단한 콘솔 사용자 설문 앱을 만든다고 해보자. 시험 점수를 위한 구조체와 사용자 프로필을 위한 클래스를 만들어보자.

// 값형 타입: 점수 저장용 구조체
struct Score
{
    public int Points;
    public string Grade; // 보기 쉽게 추가
}

// 참조형 타입: 사용자 프로필 클래스
class User
{
    public string Name;
    public Score ExamScore; // 구조체 포함
}

구조체(Score) 복사

Score score1 = new Score { Points = 100, Grade = "A" };
Score score2 = score1; // score1의 모든 내용이 score2로 복사됨
Console.WriteLine($"score1: Points={score1.Points}, Grade={score1.Grade}"); // score1: Points=100, Grade=A
Console.WriteLine($"score2: Points={score2.Points}, Grade={score2.Grade}"); // score2: Points=100, Grade=A

score2.Points = 88;
score2.Grade = "B";

Console.WriteLine("--- score2 변경 후 ---");
Console.WriteLine($"score1: Points={score1.Points}, Grade={score1.Grade}"); // score1: Points=100, Grade=A (안 바뀜!)
Console.WriteLine($"score2: Points={score2.Points}, Grade={score2.Grade}"); // score2: Points=88, Grade=B

결과: score1은 그대로야. Score score2 = score1;에서 score1의 모든 필드가 score2로 복사된 거야. 두 변수는 이제 서로 독립적인 데이터 세트를 갖고 있어.

클래스(User) 복사

User u1 = new User 
{ 
    Name = "Anna", 
    ExamScore = new Score { Points = 95, Grade = "A" } 
};
User u2 = u1; // 이제 u2와 u1은 메모리에서 같은 사용자를 가리켜!
Console.WriteLine($"u1: Name={u1.Name}, Score={u1.ExamScore.Points}"); // u1: Name=Anna, Score=95
Console.WriteLine($"u2: Name={u2.Name}, Score={u2.ExamScore.Points}"); // u2: Name=Anna, Score=95

u2.Name = "Ivan"; // u2로 이름 변경
u2.ExamScore.Points = 60; // u2로 점수 변경
u2.ExamScore.Grade = "C";

Console.WriteLine("--- u2 변경 후 ---");
Console.WriteLine($"u1: Name={u1.Name}, Score={u1.ExamScore.Points}, Grade={u1.ExamScore.Grade}"); // u1: Name=Ivan, Score=60, Grade=C
Console.WriteLine($"u2: Name={u2.Name}, Score={u2.ExamScore.Points}, Grade={u2.ExamScore.Grade}"); // u2: Name=Ivan, Score=60, Grade=C

결과: u1도 같이 바뀌었지! u1u2가 처음부터 같은 User 객체를 가리키고 있었기 때문이야. u2.Name = "Ivan";처럼 속성을 바꾸면, 실제 객체가 바뀌는 거라서 u1.Name도 같이 바뀌는 거지. ExamScore 구조체도 객체의 일부라서 같이 바뀌는 거야.

5. 메소드에 전달될 때 무슨 일이?

타입이 메소드에 어떻게 전달되는지 이해하는 건 프로그램 동작을 예측하는 데 엄청 중요해.

값형 타입(Value Type)을 메소드에 전달

값형 타입을 메소드에 전달하면 기본적으로 값으로 전달돼. 즉, 메소드는 복사본을 받는 거야. 메소드 안에서 이 복사본을 바꿔도, 원본에는 아무 영향 없어.

void AddTen(int x)
{
    Console.WriteLine($"메소드 내부(변경 전): x = {x}"); // 메소드 내부(변경 전): x = 5
    x = x + 10; // x는 이제 15지만, 이건 로컬 복사본임
    Console.WriteLine($"메소드 내부(변경 후): x = {x}"); // 메소드 내부(변경 후): x = 15
    // 이 로컬 복사본 'x'는 메소드가 끝나면 사라져.
}

int num = 5;
Console.WriteLine($"메소드 호출 전: num = {num}"); // 메소드 호출 전: num = 5
AddTen(num);
Console.WriteLine($"메소드 호출 후: num = {num}"); // 메소드 호출 후: num = 5 (안 바뀜!)

결과: num은 그대로(5)야. AddTen 안의 xnum의 값으로 초기화된 완전히 별개의 변수야.

참조형 타입(Reference Type)을 메소드에 전달

참조형 타입도 기본적으로 값으로 전달되지만, 참조(주소)의 값이 복사돼. 즉, 메소드 안에서는 "명함"의 복사본을 받는 거야. 원본과 메소드 안의 변수 둘 다 같은 객체를 가리켜.

void RenameUser(User u)
{
    // 메소드 내부(변경 전): u.Name = "Olga"
    Console.WriteLine($"메소드 내부(변경 전): u.Name = \"{u.Name}\""); 
    u.Name = "새 이름"; // 'u'가 가리키는 객체의 속성을 변경
    // 메소드 내부(변경 후): u.Name = "새 이름"
    Console.WriteLine($"메소드 내부(변경 후): u.Name = \"{u.Name}\""); 
}

User user = new User { Name = "Olga" };
// 메소드 호출 전: user.Name = "Olga"
Console.WriteLine($"메소드 호출 전: user.Name = \"{user.Name}\""); 
RenameUser(user); 
// 메소드 호출 후: user.Name = "새 이름"
Console.WriteLine($"메소드 호출 후: user.Name = \"{user.Name}\""); 

결과: 사용자 이름이 "새 이름"으로 바뀌었지. RenameUseruser 객체의 참조 복사본을 받았고, 그걸로 실제 객체의 속성을 바꾼 거야.

중요한 추가 설명: 만약 메소드 안에서 전달받은 참조형 타입 변수에 새 객체를 할당하면 어떻게 될까?

void ReassignUser(User u)
{
    u = new User { Name = "완전히 새로운 사용자" }; // 'u'는 이제 새 객체를 가리킴
    Console.WriteLine($"메소드 내부(재할당 후): u.Name = \"{u.Name}\"");
}

User originalUser = new User { Name = "원래 사용자" };
Console.WriteLine($"ReassignUser 호출 전: originalUser.Name = \"{originalUser.Name}\"");
ReassignUser(originalUser); 
Console.WriteLine($"ReassignUser 호출 후: originalUser.Name = \"{originalUser.Name}\""); // "원래 사용자" - 안 바뀜!

결과: originalUser안 바뀜! ReassignUser는 참조의 복사본을 받았고, u = new User(...)로 로컬 변수 u만 새 객체를 가리키게 했을 뿐이야. 원래 originalUser는 여전히 예전 객체를 가리키고 있어. 이거 진짜 중요한 포인트야!

6. "초보자의 눈물": 흔한 실수와 그 이유

참조형과 값형 타입을 이해하는 건 초보자에겐 꽤 어려울 수 있어. 여기 자주 하는 실수와 오해들을 정리해봤어:

배열 복사에서의 혼란: 초보자들은 arr2 = arr1;로 배열을 할당하면 독립적인 복사본이 만들어질 거라 기대해. 실제로는 같은 배열을 가리키는 두 참조일 뿐이야. 마치 게임 컨트롤러 두 개가 같은 게임을 조종하는 것처럼: 한쪽에서 뭘 누르면 게임에 바로 반영되고, 다른 쪽에서도 그게 보여. 독립적인 배열 복사본을 만들려면 명시적으로 복제해야 해(예: int[] arr2 = (int[])arr1.Clone(); 또는 Copy 메소드 사용).

문자열이 "참조"로 바뀔 거라는 기대: string이 참조형 타입이라서 배열처럼 바뀔 거라 생각하는 경우가 있어. 하지만 불변성 때문에, 문자열을 "바꾸는" 모든 연산은 실제로 새로운 문자열 객체를 만들어. 그래서 반복문에서 문자열을 여러 번 바꾸면 비효율적일 수 있어(이럴 땐 StringBuilder를 쓰는 게 좋아).

null을 잊는 실수: 값형 타입(Nullable 타입 int?, bool? 등 제외)은 항상 값이 있고 null이 될 수 없어. 참조형 타입은 null이 될 수 있고, 아무 객체도 가리키지 않을 수 있어. null인 객체의 멤버에 접근하면 악명 높은 NullReferenceException이 터져. 참조형 변수는 항상 null 체크를 해주는 게 좋아.

작은 데이터에 클래스만 쓰는 실수: 습관적으로 모든 걸 클래스(class)로 선언하는 경우가 많아. 하지만 작고 단순한 데이터(예: Point { X, Y }, Color { R, G, B } 등)는 구조체가 더 효율적일 수 있어. 구조체는 스택에 저장되고 값으로 복사돼서 Garbage Collector 부담이 적거든. 단, 구조체는 불변이고, 작아야 하며, 내부에 참조형 타입이 들어가면 안 돼(참조형이 null이 될 수 있거나 상태가 바뀌면 곤란하니까).

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION