StringVsStringBuffer


Youngrok Pak at 5 years, 6 months ago.

String과 StringBuffer에는 상당히 오랫동안 유지되어온 일반적인 오해가 있다. String의 + 연산은 임시 중간 객체를 생성하기 때문에 메모리 소모가 많고 느리다는 것이다. 이것은 사실이 아니다. 어디서 이런 오해가 시작되었을까. 아마도 C++과 자바를 혼동한 초기 자바 프로그래머들에 의해 생겨난 것이 아닐까 싶다. 다음 코드를 보자.

   1 String str = "Hello" + " " + "World!!";

먼저 C++의 시각에서 보자. str은 String 타입의 객체이고 +는 연산자이므로 이건 오퍼레이터 오버로딩이다. 이 연산은 operator+(operator+("Hello", " "), "World!!")와 유사하게 변환된다. operator+의 결과 값 역시 String. 그렇다면 operator+("Hello", " ")에 의해 생겨난 String 객체는 임시 객체이고 다음 번 연산에 다시 인수로 넘겨져 활용된 이후 버려진다. 이런 시각에서 보면 자바에서도 웬지 String의 +연산은 C++의 오퍼레이터 오버로딩처럼 임시 중간 객체를 생성할 것처럼 보인다. 마침 String에는 이런 역할을 하는 concat이라는 메쏘드까지 있다. 여기서 이 오해가 시작된 것이 아닐까 추측한다.

그러나, 자바에는 오퍼레이터 오버로딩이 없다. 오히려 객체 간에 연산자가 직접 사용되는 이런 코드는 자바 문법에서 대단히 예외적인 상황이다.(extralinguistic) 그렇다면 오퍼레이터 오버로딩도 없이 이런 코드가 어떻게 처리가 되고 있는 것일까? 해답은 자바 스펙에 있다. 자바는 다른 객체지향 언어와는 달리 문법적으로 String을 특별대우하고 있고 String의 +연산은 자바 객체의 직접적인 연산이 아니라 자바 컴파일러가 먼저 처리하여 일반적인 자바 객체의 메쏘드 호출로 변환시켜준다. 그리고 대부분의 JDK에서 이것은 String.concat이 아닌, StringBuffer.append로 변환된다. 따라서 String의 +연산은 StringBuffer.append를 사용하는 것에 비해 눈꼽만큼도 느리지 않은 것이다.

그럼에도 불구하고 많은 벤치마크에서 String이 StringBuffer보다 느리다는 결과가 나왔다. 이는 어찌된 것일까? 다음 테스크 코드 하나를 살펴보자. 아래 코드는 OKJSP 운영자이신 허광남님이 작성하신 코드입니다.

   1 /**
   2  * @author kenu
   3  * http://okjsp.pe.kr
   4  */
   5 public class StringTest {
   6 
   7     public static void main(String[] args) {
   8         long sTime = 0;
   9         String bbb = "abcde";
  10         for(int i = 0; i<200; i+=5) {
  11             bbb = bbb + "abcde";
  12             System.out.println("length:"+bbb.length());
  13 
  14             sTime = System.currentTimeMillis();
  15             doString(bbb);
  16             System.out.println(System.currentTimeMillis()-sTime);
  17 
  18             sTime = System.currentTimeMillis();
  19             doStringBuffer(bbb);
  20             System.out.println(System.currentTimeMillis()-sTime);
  21 
  22             System.out.println();
  23         }
  24     }
  25     
  26     public static void doString(String bbb){
  27         String str = null;
  28         String ccc = "ab12345abcab12345abcab12345abcab12345abc";
  29         for(int i=0;i<1000000;i++) {
  30             str = bbb+ccc;
  31         }
  32     }
  33     
  34     public static void doStringBuffer(String bbb){
  35         StringBuffer sb  = new StringBuffer();
  36         String ccc = "ab12345abcab12345abcab12345abcab12345abc";
  37         for(int i=0;i<1000000;i++) {
  38             sb.setLength(0);
  39             sb.append(bbb).append(ccc);
  40         }
  41     }
  42 }

이 코드를 수행해보면 StringBuffer가 String에 비해 압도적으로 빠르다는 결과가 나온다. 왜 그럴까? 코드의 문제는 doString의 for문 안에 있는 라인에 있다. 이 코드에서 비교하고 있는 것은 String의 +연산과 StringBuffer.append가 아니다. String의 +연산과 대입 연산(=)과 StringBuffer.append의 비교가 일어나고 있다. 따라서 = 부분을 제거하거나, 같은 코드를 StringBuffer 쪽의 코드에 삽입해줘야 정상적인 테스트가 되는 것이다. 그런데 일반적인 for 문을 사용한다면 대입 연산 부분을 제거하기가 힘들다. 따라서 이 부분을 정확하게 테스트하려면 실제로 코드에 +로 연결된 코드를 집어넣어야한다. Uploads:TestString.java 은 이런 방식으로 코딩되어 있으며, 이 코드를 수행해보면 성능은 비슷하거나 String이 약간 더 빠른 결과가 나온다.

그런데, String의 +연산은 다 StringBuffer.append로 변환되는데 어째서 String이 더 빠른 경우가 간혹 나오는 것인가? 이것은 컴파일러의 상수 최적화 때문이다.

   1 String str = "Hello" + " " + "World!!";

이와 같은 코드는 컴파일 시 다음과 같은 코드로 컴파일된다.

   1 String str = "Hello World!!";

반면 StringBuffer로 쓰는 코드는 다음과 같다.

   1 String str = new StringBuffer().append("Hello" ).append(" ").append("World!!").toString();

당연히 String이 빠르다. 상수만으로 연결된 경우는 String이 압도적으로 빠른 것이다. 그러나, 다음과 같은 코드는 좀 다르게 동작한다.

   1 String en = "abc";
   2 String ko = "가나다";
   3 String str = "Hello" + " World!!" + " " + en + "Hello" + " World!!" + " " + ko;

이 코드는 다음과 같이 변환된다.

   1 String en = "abc";
   2 String ko = "가나다";
   3 String str = new StringBuffer().append("Hello World!! ").apend(en).append("Hello").append(" World!!").append(" ").append(ko);

즉, 상수의 연결 중에 변수가 들어가면 그 뒷부분은 최적화되지 않고 그냥 StringBuffer.append로 연결된다.

아직 승부는 끝나지 않았다. StringBuffer에는 초기 버퍼를 설정할 수 있다는 장점이 있다. String이 StringBuffer로 변환되더라도 초기 버퍼값을 세팅할 수 없는 반면 StringBuffer를 이용할 경우는 초기 버퍼값을 세팅해줄 수 있다. 그렇다면 문자열의 길이를 어느 정도 예측할 수 있는 경우는 StringBuffer가 유리한 것이 아닐까?

실험 결과 버퍼 사이즈에 따라 결과값은 아주 달랐다. 다음은 버퍼 사이즈 지정 값에 따른 수행 시간의 차이를 테스트한 결과이다.

+ : 14661
A / 4 : 12768
A / 2 : 14341
A - 20 : 14671
A - 10 : 16063
A - 1 : 17004
A :  8943
A + 1 : 8853
A + 5 : 9074
A + 10 : 9453
A + 20 : 9764
A + 30 : 10114
A + 100 : 13580
A * 2 : 11176
A * 4 : 15072

초기 버퍼값이 최종 문자열의 길이보다 약간 크게 설정해준 경우에 한해 StringBuffer가 String보다 빨랐고 최종 문자열의 길이보다 많이 크거나 약간 작은 경우는 오히려 String이 더 빨랐다. 그 이유는 무엇일까?

이것은 StringBuffer의 array doubling 작업의 효율 때문이다. StringBuffer는 append 시 현재 가지고 있는 배열보다 큰 배열이 필요할 경우 array doubling을 한다. 그 과정은 다음과 같다.

  1. 현재의 char 배열 길이를 n이라 두면 2n + 1 길이의 char 배열을 새로 할당한다.
  2. System.arraycopy로 원래 배열에서 새 배열로 요소들을 카피한다.

여기서 속도 결정요인은 2번일 것 같지만 의외로 2번은 시스템 콜을 하기 때문에 아주 빠른 연산이고 1번에서 메모리 할당 작업이 일어나는 것이 훨씬 더 중요한 속도 결정요인이 된다. 만약 필요 사이즈가 100일 경우 버퍼 사이즈를 지정하지 않으면 초기값 16에서 시작하게 되고 100에 다다르기까지 array doubling은 4번 하게 되며 최종 확보하는 메모리 공간은 135이다. 이 경우 만약 초기 사이즈를 100으로 지정하면 array doubling은 한 번도 일어나지 않고 메모리 확보량도 100이기 때문에 효과를 본다. 그러나, 만약 99로 지정한다면 어떻게 될까? array doubling은 한 번 일어나지만 최종 메모리 확보량은 201이다. 그런데 2번의 과정보다 1번이 훨씬 느리기 때문에 버퍼 사이즈를 지정 안한 것보다 훨씬 느린 결과가 나온다. 만약 50으로 지정한다면 복제는 한 번 일어나고 메모리 확보는 101이므로 좋은 결과를 얻을 수 있다. 그리고 150 정도로 지정한다면 복제는 일어나지 않지만 메모리 확보에 시간이 많이 걸려 버퍼 사이즈를 지정하지 않은 것만 못하게 된다.

결론적으로 버퍼 사이즈 지정은 최종 결과값보다 아주 약간 크게 지정할 경우만 좋은 효과를 보고 만약 예측이 빗나간다면 훨씬 더 안 좋은 상황을 초래할 위험성이 있다. 따라서 버퍼 사이즈를 지정할 수 있다는 것은 StringBuffer에게 별로 힘을 실어주지 못한다. 여전히 String의 판정승.

이쯤되면 StringBuffer는 String의 헬퍼일 뿐인가하는 의문이 들 수 있겠다. 그러나, StringBuffer는 여전히 가치가 있다. 최초의 테스트 코드를 보라.

   1         for(int i=0;i<1000000;i++) {
   2             str = bbb+ccc;
   3         }

이런 코드는 반복적으로 중간 객체를 생성하고 str에 대입하게 된다. 사실 이보다 실제 상황에서 많이 나오는 것은 문자열을 파싱하고 처리하는 경우이다.

   1         while(!EOF) {
   2             str += parsedFragment;
   3         }

이런 코드는 분명히 StringBuffer를 사용하는 것이 좋다. StringBuffer는 mutable이기 때문에 반복적인 연산에서 추가 객체를 생성하지 않는다.

요약하면 이렇다.

  1. 일반적으로 코드 내에서 직접 +를 사용해서 긴 문자열을 생성하는 경우에는 String을 그대로 쓰는 것이 좋다. 단, 다음과 같이 써서는 안된다.
       1 String str = "Hello";
       2 str += "World";
       3 str += "String is good";
       4 str += "Hey Hey Hey~";
    
    위와 같은 코드는 다음과 같이 바꾸어 쓰는 것이 좋다.
       1 String str = "Hello";
       2            + "World";
       3            + "String is good";
       4            + "Hey Hey Hey~";
    
  2. 루프를 돌면서 반복적으로 변수에 String을 대입해야하는 경우는 StringBuffer를 쓰는 것이 좋다.

       1         while(!EOF) {
       2             sb.append(parsedFragment);
       3         }
    

  3. StringBuffer 사용 시 버퍼 사이즈를 실제값보다 약간 크게 예상하여 지정하면 좋은 효과를 볼 수 있으나 정확하게 예상할 수 없는 경우는 오히려 퍼포먼스를 악화시킬 수 있으므로 지정하지 않는 것이 좋다.

참조#

다음 두 링크는 [http://javaservice.net 자바서비스넷]에서 제가 직접 토론했던 내용입니다. 토론 내용도 볼만하지만 저의 드러운 성격도 엿볼 수 있죠-_-


[자바분류]


Comments




Wiki at WikiNamu