프로그래밍/C#, .NET

[.NET] "" == string.Empty 의 답은? - .NET에서 문자열(string)이 살아가는 방법

당근천국 2022. 9. 1. 15:30

당연히 참(true)입니다만.....

왜 이 당연한 질문을 하는 걸까요?

 

인터넷을 돌아다니다 보면 빈문자열("")대신 'string.Empty'라는 녀석을 쓰라는 말을 많이 볼 수 있습니다.

이 질문의 답을 이해하려면 .NET에서 문자열이 어떻게 살아가고 있는지 알아야 합니다.

 

 

1. 문자열의 구현 

대부분의 언어가 'string'형을 구현할 때 

내부에서는 'char'배열로 구현합니다.

 

그래서 아래와 같이 배열로 접근 가능하죠.

(언어마다 접근방법이 다를 수 있습니다.)

 

문자열은 참조 형식 변수? 값 형식 변수?

변수의 형식에는 크게 2가지 가 있습니다.

- 참조 형식 (참고 : MS Docs - 값 형식(C# 참조) )

그때그때 메모리에 할당되고 값을 전달할 때는 복사되어 원본이 보존됩니다.

 

- 값 형식 (참고 : MS Docs - 참조 형식(C# 참조) )

처음에만 메모리에 할당되고 값을 전달할 때는 주소(포인터)만 전달되어 원복을 계속 같이 쓰는 형식입니다.

 

그런데 문자열은 값 형식인데 구현은 참조 형식인 배열로 되있다굽쇼??????

 

아래 코드를 실행해 봅시다.

var s1 = "가나다";
var s2 = "가나다";
var n1 = 0;
var n2 = 0;

Console.WriteLine("string s1 == s2 : " + Object.ReferenceEquals(s1, s2));
Console.WriteLine("int n1 == n2 : " + Object.ReferenceEquals(n1, n2));

//Hello, World!
//string s1 == s2 : True
//int n1 == n2 : False

 

'Object.ReferenceEquals'는 지정된 개체가 동일한지 판단하는 함수입니다.

메모리 주소가 다르면 다르다고 판단합니다.

 

'Object.ReferenceEquals'는 참조 변수만 사용할 수 있는데 문자열은 에러가 나지 않습니다??

(참고 : MS Docs - Object.ReferenceEquals(Object, Object) 메서드 )

분명 s1과 s2는 다른 변수인데 주소가 같습니다???

 

 

2. '.NET'에서 문자열이 동작하는 방법

.NET에서 문자열은 값 형식입니다.

정확하게는 참조형식인 문자열을 필요에 따라서 다시 메모리에 할당하는 방식으로 사용됩니다.

 

2-1. 문자열 재설정

즉, s1이라는 변수에 "가나다"를 넣고,

다시 "마바사"를 넣는다면 아래 그림처럼 작동합니다.

 

이것을 코드로 보면 아래와 같습니다.

string s1 = "가나다";
string s1_2 = s1;
string s2 = s1;

Console.WriteLine("string s1 == s1_2 : " + Object.ReferenceEquals(s1, s1_2));
Console.WriteLine("string s1 == s2 : " + Object.ReferenceEquals(s1, s2));
Console.WriteLine("string s1_2 == s2 : " + Object.ReferenceEquals(s1_2, s2));
Console.WriteLine("----------------------------------------------");

s1 = "마바사";
Console.WriteLine("s1, s2 = \"마바사\"");
Console.WriteLine("string s1 == s1_2 : " + Object.ReferenceEquals(s1, s1_2));
Console.WriteLine("string s1 == s2 : " + Object.ReferenceEquals(s1, s2));
Console.WriteLine("string s1_2 == s2 : " + Object.ReferenceEquals(s1_2, s2));

//string s1 == s1_2 : True
//string s1 == s2 : True
//string s1_2 == s2 : True
//----------------------------------------------
//s1, s2 = "마바사"
//string s1 == s1_2 : False
//string s1 == s2 : False
//string s1_2 == s2 : True

기존 s1과 같은 주소를 쓰는 s1_2, s2는 그대로 있지만

s1에 "마바사"를 넣으니 다른 주소가 되었습니다.

 

 

2-2. 메모리 공유

여기서 재미있는 것은 완전히 동일한 문자열이면 새로 메모리에 할당되는 게 아니라

기존에 할당된 메모리의 주소만 가져와서 '참조' 한다는 것입니다.

 

s2에 "마바사"를 넣으면 아래와 같이 동작합니다.

 

이것을 코드로 보면 다음과 같습니다.

string s1 = "가나다";
string s1_2 = s1;
string s2 = s1;

Console.WriteLine("string s1 == s1_2 : " + Object.ReferenceEquals(s1, s1_2));
Console.WriteLine("string s1 == s2 : " + Object.ReferenceEquals(s1, s2));
Console.WriteLine("string s1_2 == s2 : " + Object.ReferenceEquals(s1_2, s2));
Console.WriteLine("----------------------------------------------");

s1 = "마바사";
s2 = "마바사";
Console.WriteLine("s1, s2 = \"마바사\"");
Console.WriteLine("string s1 == s1_2 : " + Object.ReferenceEquals(s1, s1_2));
Console.WriteLine("string s1 == s2 : " + Object.ReferenceEquals(s1, s2));
Console.WriteLine("string s1_2 == s2 : " + Object.ReferenceEquals(s1_2, s2));

//string s1 == s1_2 : True
//string s1 == s2 : True
//string s1_2 == s2 : True
//----------------------------------------------
//s1, s2 = "마바사"
//string s1 == s1_2 : False
//string s1 == s2 : True
//string s1_2 == s2 : False

 

s2에 "마바사"를 넣자 기존에 "마바사"를 가지고 있던 s1과 같은 주소를 쓰게 됩니다.

 

2-3. 새 문자열 할당하기

기존 문자열을 검색하지 않고 바로 할당하려면 새 문자열로 할당해야 합니다.

새 문자열을 할당하려면 'new string("[문자열]")'을 하면 됩니다.

string s1 = "가나다";
string s1_2 = s1;
string s2 = s1;

Console.WriteLine("string s1 == s1_2 : " + Object.ReferenceEquals(s1, s1_2));
Console.WriteLine("string s1 == s2 : " + Object.ReferenceEquals(s1, s2));
Console.WriteLine("string s1_2 == s2 : " + Object.ReferenceEquals(s1_2, s2));
Console.WriteLine("----------------------------------------------");

s1 = "마바사";
s2 = new string("마바사");
Console.WriteLine("s1, s2 = \"마바사\"");
Console.WriteLine("string s1 == s1_2 : " + Object.ReferenceEquals(s1, s1_2));
Console.WriteLine("string s1 == s2 : " + Object.ReferenceEquals(s1, s2));
Console.WriteLine("string s1_2 == s2 : " + Object.ReferenceEquals(s1_2, s2));

//string s1 == s1_2 : True
//string s1 == s2 : True
//string s1_2 == s2 : True
//----------------------------------------------
//s1, s2 = "마바사"
//string s1 == s1_2 : False
//string s1 == s2 : False
//string s1_2 == s2 : False

 

문자열이 빈번하게 변경되는 경우

문자열이 빈번하게 변경된다면 'StringBuilder'를 쓰는 것이 좋습니다.

(참고 : MS Docs - StringBuilder 클래스)

'StringBuilder'는 메모리에 재생성하지 않고 문자열을 수정합니다.

 

 

2-4. 동적 문자열도 똑같다.

이 규칙은 동적 문자열도 똑같습니다.

즉, 위 샘플에서 s1_2와 "가나다"는 같은 주소를 사용합니다. 

string s1 = "가나다";
string s1_2 = s1;

Console.WriteLine("string s1_2 == \"가나다\" : " + Object.ReferenceEquals(s1_2, "가나다"));

//string s1_2 == "가나다" : True

 

 

3. 정리

이 동작을 이렇게 정리할 수 있습니다.

1) 겉으로는 값 형식으로 보이지만 내부적으로는 필요할 때마다 새로 할당된다.

2) 문자열을 추가하면 기존 문자열이 있는지 확인하기 위한 자원 소모가 있다.

3) 사용하지 않는 값은 가비지 컬렉션에 의해 정리된다.

    = 가비지 컬렉션에 압력이 가해진다.

4) 'string'형은 변수 재활용에 이득이 거의 없다.

    = 새로 선언하나 기존 변수를 쓰나 압력이 똑같다.

5) 변수에 할당하지 않는 문자열(동적 문자열)도 가비지 컬렉션에 압력을 준다.

 

 

왜 이런 구조인가

이런 구조 장점은

1) 같은 문자열이 많은 경우 메모리 낭비가 적다.

 

2) 어떤 식으로 참조하든 불멸성이 유지된다.

여러 곳에서(특히 쓰레드)에서 참조하는 경우에도 안전하고 예측가능한 동작을 합니다.

 

3) 빠른 복사

복사할 때는 주소만 전달되므로 낭비 없이 빠르게 복사됩니다.

특히 반복문으로 할당할 때 강력합니다.

 

 

4. 문제 해결

다시 처음으로 돌아가서 

"" == string.Empty 

이 코드를 보면 뭔가 달라 보이지 않습니까?

 

'string.Empty'는 닷넷에서 미리 할당해놓은 상수입니다.

닷넷 안에서 빈 문자열("")을 사용하면 'string.Empty'의 주소를 가져다 쓰게 된다는 의미입니다.

string s3 = "";
string s4 = string.Empty;

Console.WriteLine("string s3 == s4 : " + Object.ReferenceEquals(s3, s4));

//string s3 == s4 : True

 

그래서 'string.Empty'는 언제나 빈 문자열("")과 같은 값과 같은 주소를 가집니다.

하지만 빈 문자열("")은 메모리를 검색하는 부하를 준다는 차이가 있습니다.

 

그러니 빈 문자열("")이 필요하다면 'string.Empty'를 사용하는 것이 성능상 이득이라는 것을 알 수 있습니다.

 

 

마무리

참고 : 

Medium - Ahmed Tarek님의 글 - Memory Management In .NET, How String In .NET C# Works

 

'string.Empty'를 이해하려면 문자열이 어떻게 구현되어 있는지 알아야 하니 글이 길어졌습니다 ㅎㅎㅎ

가비지 컬렉션 관련 글을 보면 거의 항상 있는 이야기인데 'Ahmed Tarek'의 글을 보고 저도 정리해야겠다 싶어서 정리합니다.