0. 들어가기에 앞서
TIL도 꾸준히 하면서 틈틈히 코딩테스트도 진행하고 있는데, 백준 문제도 풀면서 유독 애를 먹었던 문제 하나가 생각이 났다.
단계별로 풀기에서 생각보다 초반에 있는 반복문 문제이다.
뭔가 문제를 채 읽어보기도 전에 아주 긴 설명이 있는데, 처음 문제를 풀 당시에 무슨 말인지 바로 이해할 수 없었다. 일단은 이 부분을 넘어가고 실제 문제를 확인해보기로 했다.
문제 자체의 내용은 처음 봤을 땐 쉬운 편이었다. 이전에 풀어본 A + B 문제와 거의 다를 바가 없는 문제였다.
일반적인 방법으로 풀어보자면, 처음 입력받은 Console.ReadLine()으로 반복문의 반복횟수로 넣어주고, 다음으로 입력받는 숫자들을 String.Split으로 나누어 int형으로 변환한 후 더해준 값을 출력해주면 된다.
...라고 생각하고 푼 순간 채점 결과가 이렇게 나온다.
비주얼 스튜디오에서 테스트 케이스를 넣으면 전부 정상 출력되는데, 계속 시간을 초과한다는 결과가 출력되었다. 왜 안 되는 거지? 위에서 설명한 문제에 대한 내용을 살펴 보고, 해당 자료를 살펴 보기로 했다.
이 당시에는 여기서 하는 말이 무슨 말인지 정확히 이해하지는 못했다. 일단, 내용 자체는 연산 속도를 단축하기 위해서 매 입력마다 연산하지 말고, 한 번에 출력하도록 하라는 말인 것 같다.
나는 여기서 1번 방법을 사용했었고, StringBuilder가 뭔지를 모르겠으니 구글링을 열심히 해서 아래와 같이 정답을 제출했다.
using System;
using System.Text;
namespace baekjoon
{
class Program
{
static void Main(string[] args)
{
StringBuilder sb = new StringBuilder();
int N = int.Parse(Console.ReadLine());
for (int i = 0; i < N; i++)
{
string[] ss = Console.ReadLine().Split();
sb.Append(int.Parse(ss[0]) + int.Parse(ss[1]) +"\n");
}
Console.WriteLine(sb.ToString());
}
}
}
일단은 어떻게든 풀고 넘어가자는 생각에 그 당시에는 StringBuilder가 뭔지에 대해서 잘 생각해 보지는 않았던 것 같다.
그냥 입력을 저장해놨다가 한 번에 결과 출력하는 용도이고, 이걸 사용하는 이유는 속도를 빠르게 하기 위해서인가 보다. 이 정도로만 생각했었다.
하지만 디자인 패턴에 대해서까지 배운 지금은 이 StringBuilder가 뭔지 설명할 수 있다. String'Builder' 이기 때문에, 이것 또한 Build 패턴으로 만든 함수라는 뜻이기 때문이다.
오늘의 주말 공부로는 이런 StringBuilder에 대해 자세히 알아보고자 한다.
StringBuilder MSDN을 살펴 보면 정의에 대해서 이렇게 적혀 있다.
StringBuilder 클래스
변경 가능한 문자 문자열을 나타냅니다. 이 클래스는 상속할 수 없습니다.
정의만 보면 딱 이해가 가지 않기도 하다. 실제로 많은 자료를 참고해 봤는데도 아무래도 정의를 다루는 내용은 잘 없었다. (다들 기능 비교랑 왜 이 기능을 쓰는 지에만 집중하는 편이었다)
하지만 정의를 유심히 보면서 생각해 볼 필요가 있다고 필자는 생각했다.
'변경 가능한 문자 문자열을 나타냅니다.'
이 말을 잘 생각해보면 원래 문자, 문자열은 변경이 불가능하다는 뜻이다. 이 부분을 이해하기 위해선 기존에 쓰던 String의 특성을 이해해야 할 필요성이 있다.
우리가 기존까지 쓰던 String의 경우에는 불변성이며 참조 타입이란 것을 배웠다.
하지만 StringBuilder의 경우에는, String과는 달리 불변성이 아니라 가변성을 가지고 있다는 뜻이다.
이와 같은 내용을 이해하기 위해 String의 불변성에 대한 내용과, String과 StringBuilder의 차이점에 대해 다뤄보고자 한다.
String의 MSDN을 검색해보면 '문자열 불변성'에 대해 길게 다루고 있는 것을 확인할 수 있다.
문자열 개체는 변경할 수 없는 : 만든 후에는 변경할 수 없습니다. 문자열을 수정하는 것처럼 보이는 모든 String 메서드 및 C# 연산자는 실제로 새 문자열 개체의 결과를 반환합니다. 문자열 "수정"은 실제로 새 문자열 만들기이므로 문자열에 대한 참조를 만들 때 주의해야 합니다.
이와 같이 쓰여 있고 아래 코드를 예시로 들고 있다.
string s1 = "A string is more ";
string s2 = "than the sum of its chars.";
// Concatenate s1 and s2. This actually creates a new
// string object and stores it in s1, releasing the
// reference to the original object.
s1 += s2;
System.Console.WriteLine(s1);
// Output: A string is more than the sum of its chars.
문자열을 더하기 연산으로 잇는 행위는 사실 두 문자열을 이은 새로운 문자열을 만든 후 참조 주소를 해당 데이터가 생성된 주소로 변경하는 것이다. 그러면 기존에 남아 있는 "A string is more " 이라는 문자열은, 사라진 게 아니라 참조할 수 없는 데이터로 남아버리게 되는 것이다.
이는 후에 가비지 컬렉터로 지워지는 데이터의 대상이 된다. 이런 과정이 한 두 번 정도 일어나는 거에 그치면 크게 문제는 없겠지만, 반복적으로 문자열을 더할 경우 메모리에는 가비지가 많이 쌓이게 되는 꼴이 된다.
구체적으로 이 과정이 어떻게 일어나는지 String과 StringBuilder의 차이를 다뤄보면서 얘기해보러고 한다.
우선 String에 문자열을 만들고, 추가할 때 벌어지는 일은 다음과 같다.
이와 같은 과정으로 문자열 추가가 이루어지기 때문에 문자열을 추가하는 과정에서 데이터 복사 등 연산 과정이 들어가고, 메모리를 낭비하는 비효율적인 일이 벌어지는 것이다. 이것이 String의 불변성으로 인해서 벌어지는 일이다.
반면에 StringBuilder를 쓰면 어떤 식으로 진행되는가? 아래와 같은 과정을 거친다.
이와 같은 과정을 보았을 때, 빌드 패턴을 구현해 보았을 때를 떠올려보자.
빌드 패턴에서는 먼저 데이터를 입력하는 과정이 있고, 그것을 실제로 Build할 때까지는 해당 객체가 생성되지 않았었다.
즉 입력한 데이터는 스택에 저장되어 있다가, 우리가 Build를 할 때가 되어서야 비로소 객체가 만들어지는 것처럼, StringBuilder 또한 비슷한 원리로 작동하고 있음을 알 수 있다.
이와 같은 방식으로 StringBuilder는 문자열을 합치고 출력하는 과정에서 그냥 string을 쓰는 것보다 훨씬 빠르고 효율적인 방식을 취한다는 사실을 알게 되었다.
StringBuilder의 작동 원리를 이해했으니, StringBuilder를 사용하는 방법에 대해 알아보고자 한다.
Visual Studio에서 StringBuilder 클래스를 불러오면 민 위에 using System.Text; 가 자동으로 추가되는 것을 알 수 있다.
StringBuilder 클래스는 네임스페이스 System.Text 에 있기 때문에 해당 네임 스페이스를 추가하고 사용한다.
StringBuilder는 클래스이므로 생성자를 선언하고 사용해야 한다.
// 1. 빈 StringBuilder로 선언
StringBuilder sb = new StringBuilder();
// 2. 문자열을 포함한 채로 선언
StringBuilder sb1 = new StringBuilder("안녕하세요.");
// 3. 크기를 정해놓고 선언 - 다만 정해놓은 크기보다 큰 인자를 넣어도 넣어짐
StringBuilder sb2 = new StringBuilder(1);
// 4. 크기와 최대 크기를 정해놓고 선언 - 최대치보다 큰 값이 들어올 시 오류
StringBuilder sb3 = new StringBuilder(1, 4);
// 5. 문자열을 넣고 최대 크기를 선언 - 최대치보다 큰 값이 들어올 시 오류
StringBuilder sb4 = new StringBuilder("Hello, World!), 25);
이 외에도 선언에 다른 방법들이 있는데 해당 내용은 MSDN에 적혀 있다.
Console.Write와 Console.WriteLine과 비슷한 느낌이다.
Append는 문자열 끝에 내용을 추가하고,
AppendLine는 문자열 끝에 내용을 추가하고 줄바꿈을 한다.
StringBuilder sb = new StringBuilder();
sb.Append("영희");
sb.Append("입니다");
Console.WriteLine(sb.ToString()); //출력 : 영희입니다
StringBuilder sb1 = new StringBuilder("안녕하세요.");
sb1.AppendLine(" 영희");
sb1.AppendLine("입니다");
Console.WriteLine(sb.ToString());
// 출력:
// 안녕하세요. 영희
// 입니다
문자열을 추가할 인덱스를 넣고, 추가할 문자열을 넣는다.
기본형
stringBuilder.Insert(시작 인덱스, 넣을 문자열)
StringBuilder sb = new StringBuilder("Hello, World!");
sb.Insert(6, " new");
Console.WriteLine(sb.ToString()); // 출력 : Hello, new World!
문자열의 인덱스와, 지울 문자열의 개수를 정할 수 있다.
기본형
stringBuilder.Remove(제거 시작 인덱스, 지울 문자열 개수)
StringBuilder sb = new StringBuilder("Hello, World!");
sb.Remove(2, 3);
Console.WriteLine(sb.ToString());
기존 문자열을 새로운 문자열로 모두 바꿔준다.
기본형
stringBuilder.Replace(바꾸기 전 문자열, 바꿀 문자열)
stringBuilder.Replace(바꾸기 전 문자열, 바꿀 문자열, 시작 인덱스, 해당 문자열을 찾을 인덱스 길이)
StringBuilder sb = new StringBuilder("Hello, Hello World!");
sb.Replace("Hello", "Bye");
Console.WriteLine(sb.ToString()); // 출력 : Bye, Bye World!
StringBuilder sb1 = new StringBuilder("Hello, Hello World!");
sb1.Replace("Hello", "Bye", 0, 5);
Console.WriteLine(sb1.ToString()); // 출력 : Bye, Hello World!
StringBuilder sb2 = new StringBuilder("Hello, Hello World!");
sb2.Replace("Hello", "Bye", 0, 12);
Console.WriteLine(sb2.ToString()); // 출력 : Bye, Bye World!
문자열을 추가할 때 형식을 정해줄 수 있는 방법이다.
기본형
stringBuilder.AppendFormat(문자열 형식, 추가할 문자열1, 추가할 문자열2...)
StringBuilder sb = new StringBuilder();
int a = 18;
sb.AppendFormat($"Score : {a:D4}", a);
Console.WriteLine(sb.ToString()); // 출력 : Score : 0018
- 문자열의 추가가 빈번하지 않은 경우 String과 큰 속도 차이는 없다.
- StringBuilder는 검색 관련 메소드가 부족하므로, 검색 관련 메소드는 String을 쓰는 것이 좋다.
-> 따라서 문자열의 빈번한 수정이 예상될 때 사용하는 것이 바람직하다.
참고자료
(String MSDN)
https://learn.microsoft.com/ko-kr/dotnet/csharp/programming-guide/strings/
(StringBuilder MSDN)
https://learn.microsoft.com/ko-kr/dotnet/api/system.text.stringbuilder?view=net-8.0