최근 구글 웹로그 분석에서 이 블로그의 방문자를 살펴보다가 아주 오래된 문서들이 여전히 참조되고 있다는 것을 알았습니다. 그래서 시간이 날 때마다 이전 글 중에서 업데이트가 필요한 글들을 갱신하고자 합니다. 이번 글은 2005년 이 블로그를 처음 만들면서 썼던 StringBuilder에 대한 글입니다. 최신 닷넷 프레임워크 4.x에서 변경된 부분도 다루어지므로 끝까지 읽어 보시길 권장드립니다.

문자열 이야기

String과 더불어 StringBuilder는 가장 많이 사용되는 닷넷 클래스 중 하나이다. 그런 의미에서 본좌가 2005년 이곳을 개설함과 동시에 몇 개의 글을 시리즈로 작성했었다. 이 글들은 String과 StringBuilder를 사용하면서 주의할 사항들 그리고 알아두어야 할 것을 다루고 있다.

위 글들에서 다룬 내용들은 모두 지금도 여전히 유효한 내용들이다. 위 링크들을 따라가기 귀찮아 하는 독자들을 위해 초 간단 요약을 해주도록 하겠다.

효율적인 문자열 사용

문자열은 변경이 불가능한(immutable) 객체이다. 문자열은 변경되지 않기 때문에 문자열에 대한 다양한 연산들(문자열 합치기, SubString, Replace, Trim 등)은 모두 연산 결과로 새로운 문자열 객체가 생성된다. 따라서 효율적으로 문자열에 대한 연산을 한다는 것은 임시로 생성되는 문자열들을 최소화 시키는 것이 되겠다. StringBuilder 클래스가 제공되는 이유도 바로 여기에 있다.

하지만 무조건 StringBuilder를 사용하는 것이 좋은 것만은 아니다. StringBuilder는 고유의 오버헤드를 갖기 때문이다. 그래서 문자열들을 합치는 연산을 수행할 때에는 단순히 StringBuilder.Append 메서드를 사용하는 것보다는 경우에 따라서 + 연산이나 String.Concat 메서드를 사용하는 것이 더 효율적이다.

문자열 상수(리터럴)들 만을 더할 때에는 + 연산자를 사용하는 것이 좋다. 문자열 상수들은 컴파일러에 의해 연결된 문자열을 생성하므로 StringBuilder.Append 메서드를 사용하는 것은 매우 비 효율적이다.

// 매우 좋음. 컴파일러는 다음과 같은 코드를 생성함.
// string query = "INSERT Table1(col1, col2) VALUES(1,2)";
string query = "INSERT Table1(col1, col2) " +
    "VALUES(1, 2)";

// 졸라 좋지 못함. 불필요한 메모리 할당 및 호출이 발생함.
StringBuilder sb = new StringBuilder();
sb.Append("INSERT Table1(col1, col2) ");
sb.Append("VALUES(1, 2)");
string query = sb.ToString();     // 여기서 또 문자열을 할당함. (졸라 삽질임)

 

또, 문자열 상수가 아닌 변수가 포함된 경우에도 4개 이하의 문자열을 합치고자 할 때는 + 연산자나 String.Concat이 더 효율적이다(+ 연산자는 String.Concat으로 변환됨). 여러 회 반복 되는 반복문 내에서 + 연산자를 사용하여 문자열을 합치는 것은 99% 비효율적이다. 이런 경우에는 StringBuilder를 사용하는 것이 더 효율적이다. 상세한 내용은 문자열 이야기 세 번째 글을 참고하기 바란다.

StringBuilder의 효율적인 사용

앞서 이야기 했지만 StringBuilder를 사용하는 것이 효율적일 때도 많다. 대표적으로 반복문 내에서 계속 해서 문자열을 만들어 나갈 때나 String.Format 대신 StringBuilder를 사용할 수도 있다. StringBuilder를 사용할 때에도 주의할 점이 있다.

가장 강조하고 싶은 것은 StringBuilder의 초기 버퍼 크기(capacity)를 명시하라는 것이다. StringBuilder의 디폴트 버퍼 크기는 16 글자이다(디폴트 생성자를 사용하는 경우임). 당연히 StringBuilder에 문자열이 추가됨에 따라 이 버퍼의 크기는 점점 커지기 때문에 대부분 capacity를 고려하지 않을 것이다. 하지만 StringBuilder는 버퍼의 크기가 부족하면 새로운 버퍼를 할당하기 때문에 메모리가 비 효율적으로 사용될 수도 있다. 따라서 개략적으로 StringBuilder의 초기 버퍼 크기를 주면 성능적으로 우수한 코드를 작성할 수 있다. 상세한 내용은 문자열 이야기 두 번째 글을 참조하기 바란다.

// 기본 버퍼 크기가 16인 StringBuilder. 좆지 않아요...
var sb = new StringBuilder();
for(int i = 0; i < dataSize; i++)
{
    sb.Append(myData[i]);
}

// 개략적인 초기 크기를 갖는 StringBuilder. 괜춘함...
var sb = new StringBuilder(dataSize * 8);
......

닷넷 프레임워크 4.0의 StringBuilder 클래스

문자열 이야기 두 번째 글에서는 StringBuilder가 내부 버퍼 크기를 초과하는 Append가 발생하면 기존 버퍼보다 2배 더 큰 새로운 버퍼를 할당하고 기존 버퍼의 내용을 새 버퍼로 복사한다고 되어 있다. 이러한 작동 방식이 닷넷 프레임워크 4.0 이후에서는 변경되었다.

닷넷 프레임워크 4.0 이전에는 StringBuilder의 버퍼가 항상 2배씩 커졌으며 기존 버퍼의 내용을 새 버퍼로 복사한 후 기존 버퍼는 쓰레기가 되어 가비지 컬렉션의 대상이 되었었다. 이 때문에 초기에 작은 버퍼를 가진 StringBuilder에 많은 양의 문자열을 Append 하게 되면 많은 가비지 버퍼들이 생성되게 된다. 또한 잦은 메모리 복사 역시 발생했었다. 게다가 아주 커다란 문자열을 StringBuilder를 통해 생성하고자 하면 커다란 연속된 메모리 할당을 시도하게 되고 이로 인해 메모리가 아직 남아 있음에도 불구하고 OutOfMemoryException을 뚜드러 맞게 된다.

닷넷 프레임워크 4.0 부터 StringBuilder는 여러 버퍼를 연결 리스트(linked list)로 관리한다. StringBuilder에 문자열이 추가됨에 따라 버퍼(내부적으로는 Char[]을 사용한다)가 부족하게 되면 새로운 버퍼를 생성하고 링크를 구성한다. 다음과 같은 코드를 고려해 보자. 실제 코드에선 아래 코드와 같은 문자열 리터럴에 StringBuilder를 사용해선 아니 되지만(앞에서 설명했음… 님하! 기억력 쫌!), 간단한 예를 들기 위한 것이므로 눈감아 주기 바란다.

StringBuilder sb = new StringBuilder();
sb.Append("1234567890123456");
sb.Append("abc");

첫 번째 Append 메서드 호출에 의해 16글자의 버퍼가 채워질 것이다. 문제의 두 번째 Append 메서드 호출이 일어나면 StringBuilder가 가진 버퍼가 부족하게 된다. 닷넷 프레임워크 4.0 이전이라면 추가로 32글자를 넣을 수 있는 새로운 버퍼를 만들고 기존 버퍼의 내용을 복사했을 것이다. 4.0에서는 새로운 버퍼 복사 대신 그림과 같이 새로운 버퍼를 할당하고 이전 버퍼에 대한 링크(previous chunk)를 구성한다는 것이다. 물론 StringBuilder의 전체 버퍼 크기는 버퍼들 크기의 합이다.

image

새로이 생성되는 버퍼의 크기는 약간 복잡한 규칙을 따른다. 물론, 이 규칙은 문서화 되어 있지 않았기 때문에 언제든 변경될 수 있다.

새 버퍼의 크기(글자 수) = Max(부족한 버퍼 크기, Min(전체 버퍼 크기, 8000))

StringBuilder의 디폴트 버퍼 크기가 16이므로 위 규칙에 의하면 StringBuilder의 크기는 16, 32, 64, 128 등이 되어 이전 버전의 StringBuilder와 비슷하게 된다. StringBuilder가 점점 커져서 8000 글자 이상이 되면 새로이 추가되는 버퍼는 8000 이상이 되진 않는다. 다만, Append 메서드를 통해 아주 커다란 문자열을 한방에 추가하는 경우라면 새 버퍼의 크기는 8000 글자가 넘어갈 수 있다. 반복적으로 자주 Append 메서드를 호출하는 경우는 있어도 한번에 커다란 문자열을 Append 하는 경우는 드물기 때문일 것이다.

그래서 좋아진거여?

기존 버전의 닷넷 프레임워크의 StringBuilder의 행동은 Append 메서드가 호출됨에 따라서 쓰레기 버퍼가 상당수 발생할 수 있으며, 버퍼 복사에 의한 오버헤드도 상당부분 존재했었다. 더욱이 메모리 효율을 떨어뜨리는 이유는 버퍼를 키워나갈 때 항상 이전 버퍼 크기의 2배에 달하는 새로운 버퍼를 할당한다는 것이다. 예를 들어, 500MB 크기의 버퍼를 가진 StringBuilder에 단 한 문자만 Append 하더라도 StringBuilder는 1GB 크기의 버퍼, 그것도 “연속된” 주소 공간을 차지하는 버퍼를 할당하려고 시도한다. x64 환경이라면 어케 쑈부가 가능하겠지만 x86 환경이라면 1GB의 연속된 메모리 공간이 없을 가능성이 대단히 높으며 OutOfMemoryException이 발생될 것이다.

반면 4.0의 StringBuilder는 버퍼가 부족하면 부족한 만큼만(혹은 최대 8000 글자 크기 만큼) 버퍼를 추가 할당하기 때문에, 커다란 버퍼를 구성하기 위해 연속된 메모리 공간을 요구하지도 않는다. 게다가 버퍼 복사가 발생하지 않을 뿐더러 쓰레기 버퍼가 발생되지도 않아서 메모리 효율이 좋아지는 것이다.

다만, 단점은 ToString() 메서드가 호출될 때이다. ToString() 메서드는 StringBuilder의 버퍼들을 돌면서 각 버퍼들의 문자열들을 하나의 String 객체로 구성해야 하므로 기존 StringBuilder에 비해 성능적으로 약간 불리하다.

4.0 이상에서 StringBuilder 사용법

그렇다면 닷넷 프레임워크 4.0 이상에서는 앞서 언급했던 StringBuilder의 권장되는 코딩 패턴이 달라지는가? StringBuilder의 초기 버퍼 크기를 명시하는 것이 좋다라고 한 이유는 반복적으로 버퍼가 생성되고 복사 되는 것을 줄이기 위함이지 않았는가? 4.0에서는 버퍼 복사가 발생하지 않으며 쓰레기 버퍼도 발생되지 않으므로 초기 버퍼 크기를 주지 않아도 되지 않을까?

결론부터 말하자면, 여전히 StringBuilder를 사용할 때 가급적 초기 버퍼의 크기를 주는 것이 좋다. 초기 버퍼의 크기를 적당하게 주는 경우 StringBuilder의 버퍼들이 쪼개지는 것을 최소화 할 수 있기 때문이다. StringBuilder를 사용하는 최종 목표가 ToString()을 통해 문자열 객체를 얻어 내는 것이라고 보았을 때, 버퍼들의 조각이 적을 수록 ToString 메서드의 효율은 좋을 것이다. 따라서 초기 버퍼의 크기를 충분하게 주었다면 Append 메서드 호출에 의한 추가 버퍼 생성이 최소화 될 것이고, 버퍼들의 조각화 역시 줄어들어 ToString 메서드가 빠르게 버퍼들로부터 문자열을 모아 모아서 하나의 문자열로 만들어 낼 수 있는 것이다. 비록 4.0 이전 버전의 StringBuilder를 사용할 때보다는 중요성이 줄어들었다 할지라도 여전히 StringBuilder에게 초기 버퍼의 크기를 충분히 확보하도록 하는 것은 중요하다 할 수 있겠다.

모르는 것이 약? 아는 것이 힘?

그렇지 않아도 바빠 뒤지겠는데 굳이 이렇게 까지 코딩을 해야 하나?

100% 공감한다. 메모리 압박을 많이 받지 않는 데스크 톱 어플리케이션이라면 사실 이딴 건 지나가는 개 아드님한테 줘버리고 그냥 UI에 시간을 더 투자하거나 야근을 없애는 것이 좋을 수도 있다.

하지만 서버 측 코드라면 이야기가 초큼 달라진다. 하루에서 수십만, 수백만 번 호출되는 코드에서의 비효율성은 점점 누적되어 전체적인 어플리케이션 성능에 영향을 줄 수도 있기 때문이다. 비효율적으로 메모리를 사용하면 그만큼 자주 가비지 컬렉션이 작동된다는 말이며 전체적인 성능이나 처리량(throughput)이 저하될 수 있다는 점을 명심하자.

필자의 조언은 이렇다. 가급적 권장되는 코딩 패턴을 익히고 간단한 코드를 작성할 때에도 이 패턴들을 의도적으로 사용함으로써 “버릇”을 들이는 것이다. 물론 필자가 100 퍼 이렇다는 것은 결코 아니다. 필자 역시 졸라 귀차니즘에 쩔어살기 때문에……


경고 : 이 글을 무단으로 복제/스크랩하여 타 게시판, 블로그에 게시하는 것은 허용하지 않습니다.