C#에서는 문자열을 처리하기 위한 여러가지 방법이 있습니다.
흔히 수행속도측면의 성능과 관련해서 이야기를 할때는 문자열끼리 붙이는(Concat) 작업으로 비교를 많이합니다.
문자열끼리 붙이는 작업은 실제로 문자열 처리에서 굉장히 흔하게 일어나는 작업입니다.
1. 문자열을 이어붙일때는 string 보다는 StringBuilder 를 선호하세요.
많은 분들이 아시듯 단순 string 데이터에 직접적인 + 연산은 겉보기엔 가장 직관적이지만 성능적으로는 좋은편이 아닙니다.
string str = "hello my world";
for(int i = 0; i < 1000; ++i)
str += "ok"; //str = str + "ok"
string인 str변수에 문자열데이터를 최초에 집어넣은 이후 for문을 1000번 돌고있는 코드입니다.
c#의 string의 + 연산 (operator+)의 구현부를 보면 string타입을 매개변수로 받고 있습니다.
( += 연산은 실제론 자기자신과 피연산대상 과의 + 연산과 같습니다. 따라서 += 연산구현부는 + 연산을 보아야합니다.)
또한 짚고 넘어가야 할것은 string은 일반적으론 불변성을 띄고 있다는 점 입니다. (Reflection을 사용하지 않는이상...)
따라서 string클래스 내부에서는 직접적으로 자신이 데이터를 유동적으로 추가 삭제하며 관리할 문자열 버퍼를 가지고 있지는 않습니다. 다만 대상 문자열을 레퍼런스로 가리키고 있습니다. (참조만 하고있습니다.)
따라서 + 연산을 담당하는 string의 operator+(string) 함수 또한 string 자기 자신과 인자로 받은 문자열을 서로 더하여 새로운 string인스턴스를 생성해서 반환을 합니다.
즉, 위의 루프문은 매번 문자열 이어붙이기 연산을 위해서 새로운 string객체를 만들고 있습니다.
이는 string 인스턴스를 매번 생성하기 때문에 힙영역의 할당시 일어나는 속도측면의 부담도 있지만 더 무서운 점은
이를 통해 지속적으로 가비지(Garbage)가 발생한다는 점입니다.
string 클래스에 만약 내부적으로 자기자신이 문자열을 관리하기위한 문자열버퍼 char[] 가 있다면 + 연산이 일어날때
다음과 같이 처리될 수 있을 것입니다.
str.operator+("ok") 가 일어나더라도 내부적으로 "ok" 의 데이터를 자신 내부에서 관리하는 char[] 버퍼뒤에 붙여서 복사한다.
C#에서는 이러한 처리를 위해서 표준적으로 StringBuilder 클래스를 제공합니다.
StringBuilder클래스는 내부적으로 문자열을 관리하는 char[] 버퍼를 가지고 있으며 이는 문자열을 이어 붙이는 처리를 할때
매번 새로운 string인스턴스를 만들어서 반환하지 않고 자신 내부에서 관리하는 문자열 버퍼에 이어붙일 문자열 데이터를 복사가 가능하게 합니다.
//기본적인 사용법 (Append()를 통해 문자열 + 처리를 합니다.)
StringBuilder stringBuilder1 = new StringBuilder();
stringBuilder1.Append("hello");
stringBuilder1.Append("world");
stringBuilder1.Append("is");
stringBuilder1.Append("mine");
//모두 이어붙인 문자열을 한번에 string으로 만들어서 반환할 수도있으며
//이어붙이는 처리가 많을수록 훨씬 더 효율적입니다.
string str = stringBuilder1.ToString();
//미리 내부의 문자열 버퍼에 데이터를 생성과 동시에 담아둘 수 있습니다.
//미리 담아서 생성하는 편이 안담은 후 Append 하는 것보다 미세하게나마 빠릅니다.
StringBuilder stringBuilder2 = new StringBuilder("hello");
stringBuilder2.Append("world");
//미리 내부의 문자열 버퍼의 공간을 할당해놓을 수 있습니다. (바이트 단위)
StringBuilder stringBuilder3 = new StringBuilder(200);
//미리 여유롭게 공간을 할당했으므로 그 크기만큼의 데이터가 들어오기전엔 Append 할때마다
//추가 버퍼공간 확보가 일어나지 않아 빠릅니다.
stringBuilder3.Append("hello");
stringBuilder3.Append("world");
2. string클래스가 불변성(Immutable)을 가진 이유
string클래스는 c#의 기본 타입입니다. 굳이 StringBuilder를 만들 필요없이 string자체가 StringBuilder와 같이
유동성있게 동작했다면 직관적인 + 연산을 마음대로 사용하면서 편하게 문자열 이어붙이기 처리를 할 수 있어보이기도 합니다.
string클래스가 불변성을 가지는 이유는 여러가지가 있으나 2가지만 소개하겠습니다.
a. 멀티 스레드 환경에서 스레드 세이프 합니다.
멀티스레드 환경에서는 여러개의 스레드가 하나의 자원(공유메모리)에 접근할 수 있습니다.
멀티스레드 환경은 기본적으로 시간을 서로 분할하여 각 스레드마다 지정된 아주짧은 시간만큼 제어권을 받아 명령을 수행합니다. 이 과정에서 스레드A의 특정 처리가 모두 끝나지 않았는데도 불구하고 스레드B로 제어권이 넘겨가면서 스레드B는 스레드A가 조작하던 메모리를 조작하여 문제가 발생하는 경우가 생길 수 있습니다.
만약 string이 StringBuilder처럼 동작했다면 다음과 같은 문제가 생길 확률이 있습니다.
(1) 스레드A에서는 string a 에 "hello" 라는 데이터를 + 합니다.
(2) "ok" 라는 데이터를 받아서 자신 내부의 버퍼에 데이터를 복사합니다.
(3) 다만 이과정 끝나기도 전에 스레드B로 제어권이 넘어갔습니다. string a에는 현재 "hel" 까지만 들어갔습니다.
(4) 스레드B에서는 "world" 란 데이터를 string a 에 + 합니다.
(5) 이번엔 운이 좋아서 스레드B가 "world"를 모두 넣고나서 스레드A로 제어권이 넘어갔습니다.
(6) 스레드A에서는 자신이 하던 작업을 이어서 진행합니다. 아직 넣지 못한 "lo"를 이어서 붙여 넣습니다.
(7) a 에는 "helloworld" 란 데이터가 넣어지길 바랬으나 실제로 넣어진 데이터는 "helworldlo" 입니다.
스레드의 최소 작업단위는 기본적으로 하나의 어셈블리 명령단위 입니다.
(최소단위의 명령 하나가 시작되면 끝나기전까지 제어권이 다른스레드로 넘어가지 않습니다.)
단순 참조의 경우 기본적으로 레퍼런스(주소)의 대입연산 이므로 이는 어셈블리 명령단위상 하나의 명령입니다.
따라서 string 의 단순 대상 참조명령은 최소단위(Atomic)이므로 도중 스레드제어권이 넘어가지 않습니다.
(Atomic 합니다.)
기본 타입인 만큼 string은 이를 고려하여서 동작되도록 설계되었을 것이며 실제로 StringBuilder는 멀티스레드 환경에서 안전하지 않습니다. 따라서 상황에 맞게 사용되기 위하여 두가지를 제공한 것이라고 보여집니다.
-p.s -
여기서 "어? 그냥 StringBuilder를 스레드세이프하게 만들면 다 끝나는것 아닌가?" 라는 의문을 가질 수 있습니다.
이를 위해 Java로 이야기를 잠시만 새보면...Java또한 string과 StringBuilder가 동일하게 존재합니다. 추가로 Java에서는 멀티스레드 환경에서 안전하지 않게 동작하는 StringBuilder를 대신하여 내부적으로 스레드 세이프 하게 처리하는 StringBuffer 클래스도 있습니다. 다만 Atomic하지 않은 처리를 Atomic하게 처리하기위해 내부에서 스레드락을 이용합니다. 멀티스레드 환경이 아닌경우나 굳이 고려될 필요가 없는 경우에도 불필요하게 스레드락 처리를 하는것은 오히려 성능의 낭비를 야기할 수 있습니다. 직접 스레드 세이프를 위해 StringBuffer 클래스를 제작해서 사용할때 이 논리는 C#에서도 적용할 수 있습니다.
b. 중복적으로 사용되는 문자열의 경우 재사용성이 증가합니다.
문자열을 내부적으로 버퍼를 만들어 관리하게 되면 동일한 문자열을 사용하더라도 매번 그 문자열을 담을 버퍼 공간만큼 메모리를 할당을 해서 관리하고 있어야 할 것입니다. 하지만 이미 만들어진 문자열을 참조만 하고 있는 것이라면, 이문자열이 중복되서 사용되는 경우에는 각 string들이 버퍼 공간을 매번 만들 필요없이 만들어진 문자열 레퍼런스(주소)를 단순히 참조만 하면되기 때문에 재사용성에서 오히려 이득이 될 수 있습니다.
특히 상수 문자열(Literal String)의 경우 이 이점이 두드러지게 드러나게 됩니다.
프로그램이 시작할때 코드에 박힌 상수 문자열들은 이미 메모리 어딘가에 존재하는 상수 테이블이란 공간에 올라가게 됩니다. (PE에 대해 아시는 분들은 상수 문자열이 이미 PE내에 그대로 저장됨도 알고계실 것입니다.)
string 변수 여러개를 선언하고 이들에게 동일한 상수 문자열을 대입할경우, 프로그램 시작 시 해당 상수 문자열이 있는 공간의 주소(레퍼런스)를 string 변수가 가리키고 있게 됩니다. 만약 StringBuilder처럼 동작했다면 해당 상수 문자열을 가리키지 않고(참조하지 않고) 매번 자신의 내부 버퍼에다가 복사하였을 것입니다. 이런 경우에는 오히려 메모리 공간적으로나 시간적으로 낭비가 될 수 있습니다.
3. 결론
결론적으로 빈번한 문자열 이어붙이기 처리가 일어날경우 StringBuilder를 사용하는 것이 성능향상에 도움이 될 수 있습니다.
다만 그렇지 않은경우는 오히려 불리해지는 경우도 있으니 상황에 맞게 잘 고려해서 사용해야 합니다.