Java에는 StringBuilder와 StringBuffer가 있습니다. 이들은 어떻게 구현이 되어 있길래 append 연산을 하는데, String보다 압도적으로 빠를까요? 사실, 이 두 클래스에는 메서드 2개가 공통적으로 있다는 것을 보실 수 있습니다.

 

 

 그 중에서, capacity는 눈에 띄는 함수입니다. 이것은 실제로 StringBuilder와 StringBuffer가 얼마만큼의 문자를 넣을 수 있는지 리턴해 주는 메서드인데요. 동적 배열로 따지면, 실제 배열에 할당된 크기를 의미합니다. 그러면 size와 String의 무엇과 대응이 될까요? 당연하게도 length일 거에요. 실제로 저거랑 size가 쌍으로 붙어 있다면 십중 팔구 동적 배열이다. 라고 생각하셔도 무난합니다.

 

 저는 이 두 메서드를 가지고, 빌더와 버퍼가 어떻게 작동하는지 간단히 설명해 보도록 하겠습니다. 이것을 보기 전에 Dynamic array에 대한 이해를 하시고 오셔야 하는데요. 이 용어가 생소하다면 아래 포스트를 보고 오시는 게 좋겠습니다.

 

 

[관련글]

동적 배열이 무엇일까?

 

 


 프로그램 1을 봅시다.

 

 

 저는 st에 chogahui05라는 문자열을 100회 append 했습니다. 그러면서 st의 길이와, 실제로 할당된 공간을 측정합니다. 이 결과가 어떻게 나올까요?

 

 

 보시면, 오른쪽에 있는 숫자가 늘어날 때에는 2배하고도 +2만큼 더해지는 것을 알 수 있어요. 즉 확장이 될 때, 이전 크기를 old라고 한다면 new_capacity는 2old에 2를 더한 값이 된다는 겁니다. 이 부분이 잘 이해가 안 가신다면 아래 포스팅을 보고 오시는 게 좋아보여요. 그러면, StringBuffer는 어떨까요?

 

 

 StringBuffer에도 capacity와 length가 있습니다. 이 두 메서드를 이용해 봅시다. 프로그램 2를 위와 같이 작성하고 실행을 해 봅시다. 그러면 growth rate를 구할 수 있습니다.

 

 

 그러면 capacity가 다음과 같이 변한다는 것을 알 수 있는데요. 어라? 확장 연산을 할 때, new_capacity가 old_capacity의 2배에다가 2를 더한 값이라는 건 builder와 똑같습니다. 일단, 이 두 녀석이 왜 생각보다 빠른 건지 이야기 해 보도록 하겠습니다.

 

 


 

 capacity가 3이고, length가 3입니다. 이 때, 't'라는 문자를 append를 한다고 합시다. 그러면 old가 3이였기 때문에 new는 3의 2배에다가 2를 더한 8이 될 겁니다.

 

 

 그 다음에 't'를 추가할 겁니다.

 

 

 그 다음에 4개의 문자를 append 할 때 까지, expand 연산이 일어날까요? 동적 배열 편을 보셨다면, 아니다. 라는 것을 알 수 있어요. 왜냐하면, 4개만큼의 공간이 아직 남아있기 때문입니다. x 배율로 확장한다면, expand 연산의 횟수가 상당히 많이 줄어드는데요.

 

 위 예제에서는 초기 size가 16이였습니다. 빈 문자열인 상태에서, 2359295번 append를 호출했을 때, 얼마만큼의 공간이 새로 만들어 져야 하는지 봅시다.

 

 

 prev는 이전 capacity를, cur는 append가 일어나고 난 후의 용량을 의미합니다. 이 두 값이 달랐다면, 확장 연산이 일어났다는 뜻입니다. 이 때, ans에다가 새로 할당된 Buffer, 혹은 Builder의 capacity를 더해주었습니다. 이렇게 프로그램을 작성하고 실행을 해 보겠습니다.

 

 

 9437128번. 이 때 버퍼의 용량은 4718590입니다. 4718591번 append를 시켰을 때에는 어떨까요? 18874310 만큼의 단위 메모리를 할당해야 해요. 4718590번 append를 시킨 경우, 9437128 만큼의 단위 메모리를 할당해야 할 거고요. 이게 무엇을 의미할까요? 18874310에 4718591을 나눈 값은 3.999입니다. 9437128에 2359295를 나눈 값 또한 3.999입니다. 이를 반올림하면 대략 4입니다. 즉, append 연산이 계속 일어났을 때, 새로 할당하는 비용은 4정도가 든다는 이야기입니다.

 

 

 1단위를 복사하는 비용과 1단위를 새로 할당하는 비용이 같다고 해 봅시다. 그러면 expand가 일어날 때, 새로운 공간을 2old + 2만큼 할당한다면, copy는 old개 만큼 하면 될 겁니다. 이 때, expand가 일어났을 때 새로운 공간을 할당하는 비용이 T라면, 복사 비용은 T/2라는 겁니다. 최종적으로 보면, 평균적으로 4의 1단위 할당 비용과 2의 1단위 복사 비용으로 해결할 수 있고요. 최악의 경우에도 평균적으로 O(1)이 보장되기 때문에 생각보다 상당히 빠릅니다.

 

 


 그러면 이 둘은 무엇이 다를까요?

 

 

 StringBuffer의 주요 메서드에는 앞에 synchoronized가 붙어 있는데요. 쉽게 말해서 이 함수를 호출했을 때, 객체를 잠그고, 이 함수를 빠져 나갈 때 잠금을 풀어버린다고 생각하시면 좋아요. Builder에는 붙어있지 않는데요. 객체를 잠그고 풀어버리는 비용은 공짜가 아닙니다.

 

 이는, 간단하게 실행 시간을 체크해 보면 알 수 있는데요.

 

 

 append를 100만번 호출했을 때, Buffer의 수행 시간을 측정해 보았습니다.

 

 

 54가 나왔습니다. 빌더는 어떨까요? 7번째 줄을 StringBuilder st = new StringBuilder("")로 바꾼 다음에 실행을 해 보았습니다. sync가 있고 없고의 차이가 생각보다 크게 나타날까요?

 

 

 32에서 37ms 정도가 나왔습니다. 생각보다 많이 차이나네요. Single 쓰레드를 쓰는 경우, 버퍼보다 빌더를 쓰는 게 좋겠네요. 물론, 멀티 쓰레드 환경에서는 Builder를 쓰면 안 되겠지만요.