프로그래밍에서 흔히 마주치는 딜레마, 효율적인 문자열 처리

C#으로 프로그래밍을 하다 보면 수많은 문자열을 다루게 된다.
간단한 문자열 출력부터 복잡한 텍스트 처리까지, 문자열은 프로그래밍의 기본 요소이다.
하지만 문자열을 어떻게 다루느냐에 따라 프로그램의 성능이 크게 좌우될 수 있다.
특히, 문자열을 반복적으로 변경해야 하는 경우,
String StringBuilder 중 어떤 클래스를 선택하느냐가 매우 중요해진다.

String 이란?

String 클래스는 C#에서 가장 기본적인 문자열 자료형이다.
사용하기 쉽고 직관적이라는 장점이 있다.
하지만 불변(immutable) 객체라는 특징 때문에 반복적인 문자열 변경 작업에는 적합하지 않다.

가장 대표적인 예시로,
String 객체는 새로운 문자열을 추가할 때마다 기존 문자열을 복사한 새로운 String 객체가 생성된다.
이는 메모리 할당 및 해제 작업을 반복하게 만들어 프로그램의 성능 저하를 초래할 수 있다.

StringBuilder란?

StringBuilder 는 .NET 프레임워크에 내장된 강력한 클래스로
문자열을 효율적으로 변경하고 조작하기 위해 설계되었다.
String 은 기존 문자열은 변경할 때마다 새로운 문자열 객체를 생성하기 때문에 메모리 낭비가 심하지만,
StringBuilder 는 내부 버퍼를 사용하여 문자열을 저장하므로
필요에 따라 버퍼 크기를 조절하기 때문에 메모리를 절약하고 성능을 향상시킬 수 있다.

특히 많은 문자열 연산이 필요한 경우,
기존 String 객체는 불변(immutable)이어서 변경 시마다 새 객체를 생성하지만,
StringBuilder 는 가변(mutable)이어서 효율적이다.

StringBuilder를 왜 쓸까? 스트링 빌더 사용 이유

StringBuilder를 사용하는 주요 이유는 다음과 같다.

  1. 메모리 효율성 증가 및 성능 향상
    • 문자열을 자주 수정하거나 연결할 때, StringBuilder 는 일반 String 연산보다 훨씬 효율적이다.
    • 일반 String 은 불변(immutable)이어서 수정 시 매번 새 객체를 생성한다.
    • 반면 StringBuilder 는 가변(mutable)이어서 동일한 객체 내에서 문자열을 수정한다.
    • 이는 StringBuilder 가 내부 버퍼를 사용하기 때문에 메모리 재할당을 최소화한다.
  2. 대량 문자열 조작
    • 1번과 같은 이유로 반복문 내에서 문자열을 구성하거나 대량의 텍스트를 처리할 때 유용하다.
  3. 가변성
    • 문자열을 수정할 수 있어 런타임에 문자열 내용을 동적으로 생성 및 처리할 때 적합하다.
  4. 다양한 조작 메서드
    • Append, Insert, Remove, Replace 등 다양한 문자열 조작 메서드를 제공하기 때문에 편리하게 사용할 수 있다.
      (물론 String 클래스도 문자열을 다루는 다양한 메서드를 제공한다.)

그럼 String을 쓰지 않고 StringBuilder만 써도 될까?

단순히 답하자면, 항상 StringBuilder 만 사용하는 것은 권장하지 않는다.
상황에 따라 적절한 방식을 선택하여 코드의 성능과 가독성을 최적화할 수 있고,
많은 .NET 메서드들이 String 을 입력이나 반환 값으로 사용하기도 한다.
또한, 오히려 작은 문자열에 대해서는 String 이 더 효율적일 수 있다.

String StringBuilder 는 각각 장단점이 있어 상황에 따라 적절히 선택하는 것이 좋다.

String 사용이 적합한 경우

  • 간단하거나 단순한 문자열 연산을 할 경우
    • 간단한 문자열 연결이나 비교와 같은 연산에서는 성능 저하가 크지 않다.
    • 적은 횟수의 수정 작업에는 더 간단하고 직관적이다.
  • 불변성이 필요한 경우
    • 문자열의 값이 변경되지 않고 고정되어 있을 때는 더 간단하고 직관적이다.
  • 간단한 문자열을 사용할 때
    • 작은 크기의 문자열을 다룰 때는 오히려 메모리를 더 효율적으로 사용할 수 있다.
    • StringBuilder는 초기 생성 비용(메모리)이 String보다 높다. 이는 아래의 예제를 통해 확인해보자.
  • 인터페이스 또는 API 요구 사항에서 필요할 때
    • 특정 인터페이스나 API에서 String 타입을 요구하는 경우에는 String을 사용해야 한다.

StringBuilder 사용이 적합한 경우

  • 반복적인 대량의 문자열을 조작할 때
    • 문자열을 여러 번 연결하거나 수정해야 할 때 성능을 크게 향상시킬 수 있다.
    • 루프나 조건문 내에서 문자열을 구성할 때 유용하다.
  • 동적으로 문자열 생성
    • 문자열의 길이가 실행 중에 결정될 때 유연하게 문자열을 구성할 수 있다.
  • 큰 문자열 생성 및 처리
    • 대용량 텍스트를 생성하거나 다룰 때 메모리 사용을 최적화할 수 있다.

일반적인 활용 가이드라인

  • 자주 변경되는 문자열: StringBuilder
  • 불변의 문자열, 간단한 연산: String
  • 성능이 중요한 부분: StringBuilder
  • 가독성이 중요한 부분: 상황에 따라 선택 (너무 복잡한 StringBuilder 사용은 가독성을 저하시킬 수 있음)

어떻게 사용할까? StringBuilder 사용법

StringBuilder 객체 생성하기

StringBuilder 를 사용하려면 먼저 System.Text 네임스페이스를 사용해야 한다.
그런 다음, 아래와 같이 StringBuilder 객체를 생성할 수 있다.

// StringBuilder를 사용하기 위한 System.Text 네임스페이스 사용
using System.Text;

// 빈 StringBuilder 객체 생성
StringBuilder emptySb = new StringBuilder();

// 초기 문자열을 지정하여 StringBuilder 객체 생성
StringBuilder sb = new StringBuilder("Eunbyeol"); 
C#

문자열 조작하기

StringBuilder 는 문자열을 조작하는 다양한 메서드를 제공한다.

  • Append(): 문자열 끝에 새로운 문자열을 추가한다.
sb.Append(" World!"); // sb: "Eunbyeol World!"
C#
  • Insert(): 지정된 인덱스에 문자열을 삽입한다.
sb.Insert(0, "Hello "); // sb: "Hello Eunbyeol World!"
C#
  • Replace(): 특정 문자열을 다른 문자열로 바꾼다.
sb.Replace("Eunbyeol", "Eunbyeol`s"); // sb: "Hello Eunbyeol`s World!"
C#
  • Remove(): 지정된 범위의 문자열을 제거한다.
    • 첫 번째 인자에는 문자열의 시작 인덱스를, 두 번째 인자에는 문자열이 포함된 길이를 입력한다.
    • 예) 시작 인덱스 값 확인 : “Hello Eunbyeol”의 경우 E의 인덱스는 ‘H:1, e:2, l:3, l:4, :5, E:6’으로 6이다.
    • 예) 문자 길이 확인 : “Eunbyeol`s”의 경우 문자의 총 길이는 글자 수 그대로 10이다. 하지만 공백도 문자로 인식 때문에 뒤의 공백과 함께 지우려면 11이다.
sb.Remove(6, 11); // sb: "Hello World!"
C#
  • Clear(): 모든 내용을 제거한다.
sb.Clear(); // sb: 
C#
  • AppendLine(): 문자열 끝에 새로운 문자열을 추가하고 줄바꿈을 한다.
StringBuilder sb2 = new StringBuilder();
sb2.AppendLine("1");
sb2.AppendLine("2");
sb2.AppendLine("3");
// 1
// 2
// 3
C#

문자열 완성하기

StringBuilder 를 사용하여 문자열을 모두 조작했다면
ToString() 메서드를 사용하여 최종 문자열을 얻을 수 있다.

// sb: "Hello World!"
string finalString = sb.ToString(); // finalString: "Hello World!"
C#

String VS StringBuilder 직접 성능 테스트해보기 (샘플 코드)

StringBuilder는 특히 반복문 내에서 문자열을 변경하거나, 대량의 문자열을 처리할 때 그 진가를 발휘한다.
하지만 간단한 문자열일 경우, 오히려 String을 사용하는게 더 메모리에 이득이라고 한 바 있다.
진짜 그런지 확인해보자.

테스트 조건

테스트 조건은 다음과 같다.

  • String 객체와 SringBuilder 객체를 생성한다.
  • “Hello Eunbyeol World!”라는 문자열을 각각의 String과 StringBuilder 객체에 만 번씩 내용을 추가한다.
  • 각 객체마다 소요되는 시간과 변수 생성에 사용된 메모리, 작업 수행을 하면서 사용된 메모리를 확인한다.

샘플 코드

/* ***** String ***** */
// 메모리 사용량 및 시간 측정 시작
long initialMemory = GC.GetTotalMemory(true);
Stopwatch stopwatch = Stopwatch.StartNew();

// string 객체 생성
string result = "";
long checkMemory = GC.GetTotalMemory(true);

// 10,000번의 반복으로 문자열에 내용을 추가
for (int i = 0; i < 10000; i++)
{
    result += "Hello Eunbyeol World!";  // 문자열 연결
}

// 메모리 사용량 및 시간 측정 종료
stopwatch.Stop();
long finalMemory = GC.GetTotalMemory(true);

Console.WriteLine(" - 소요 시간: " + stopwatch.ElapsedMilliseconds + "ms");
Console.WriteLine(" - 변수 생성에 사용된 메모리 : " + (checkMemory - initialMemory) + " bytes");
Console.WriteLine(" - 메모리 사용량: " + (finalMemory - initialMemory) + " bytes");


/* ***** StringBuilder ***** */
// 메모리 사용량 및 시간 측정 시작
long initialMemory = GC.GetTotalMemory(true);
Stopwatch stopwatch = Stopwatch.StartNew();

StringBuilder result = new StringBuilder();
long checkMemory = GC.GetTotalMemory(true);

// 10,000번의 반복으로 문자열에 내용을 추가
for (int i = 0; i < 10000; i++)
{
    result.Append("Hello");  // 문자열 추가
}

// 메모리 사용량 및 시간 측정 종료
stopwatch.Stop();
long finalMemory = GC.GetTotalMemory(true);

Console.WriteLine(" - 소요 시간: " + stopwatch.ElapsedMilliseconds + "ms");
Console.WriteLine(" - 변수 생성에 사용된 메모리 : " + (checkMemory - initialMemory) + " bytes");
Console.WriteLine(" - 메모리 사용량: " + (finalMemory - initialMemory) + " bytes");
C#

테스트 결과

/* *** 만 번(10,000) 수행 했을 때의 결과 *** */
// [String]
// - 소요 시간: 300ms
// - 변수 생성에 사용된 메모리 : 40 bytes
// - 메모리 사용량: 420064 bytes

// [StringBuilder]
// - 소요 시간: 0ms
// - 변수 생성에 사용된 메모리 : 144 bytes
// - 메모리 사용량: 113576 bytes
C#
  • 초기 변수 생성은 String이 더 적게 메모리를 사용하였다.
  • 반복되는 대량의 문자열 추가 작업이 진행될수록 String은 소요시간과 메모리 사용량이 증가하였다.
  • 반면, StringBuilder는 String에 비해 매우 작업을 효율적으로 처리한 것을 볼 수 있다.

이는 테스트의 횟수를 증가시키면 차이는 더 명확히 드러난다.

  • 10만 번씩 수행하면 어떻게 될까?
/* *** 백만 번(1,000,000) 수행 했을 때의 결과 *** */
// [String]
// - 소요 시간: 44971ms
// - 변수 생성에 사용된 메모리 : 40 bytes
// - 메모리 사용량: 4195832 bytes

// [StringBuilder]
// - 소요 시간: 0ms
// - 변수 생성에 사용된 메모리 : 144 bytes
// - 메모리 사용량: 1012072 bytes
C#

설명에서 사용된 샘플 코드는 여기에서!

포스팅에서 사용된 샘플 코드는 아래의 깃허브에서 확인할 수 있습니다.

> C# 예제 프로젝트가 있는 깃허브 바로 가기

Similar Posts

댓글 남기기