본문 바로가기
Development/Java

자바에서 문자열 합칠 때 '+' 연산을 쓰지 마세요! (StringBuilder, StringJoiner, String.join, StringBuffer)

by Nahwasa 2023. 3. 7.

 

 

목차

     

      최근 String에 대한 '+' 연산을 사용해 timeout이 나고있는걸 봤습니다. 해당 코드를 디버깅해본 개발자는 timeout이라는 exception 자체를 해결하려 했겠지만, 실은 로직이 느린게 문제입니다. 일종의 XY problem 입니다. 개발자로써 대부분의 문제야 구글링해보면 해결할 수 있지만, 위의 경우 이 글의 내용에 대해 모르면 exception은 timeout으로 나오니 구글링으로 알 수 없는 내용입니다.

     

      이하 글에서 시간복잡도 표현을 위해 big-O 표기가 나오는데, O(N)과 같은 표기를 모르거나 시간복잡도에 대해 모른다면 '알고리즘 시간복잡도에 대해' 글을 참고해주세요.

     

      결론부터 말하자면 문자열을 합쳐서 만들 때 StringBuilder를 사용해야 합니다. 물론 언제나 팀의 합의와 토론으로 결정된 사항이 먼저이고, 크게 성능에 유의미 하지 않은 상황이라면 String에 대한 '+'를 써도 됩니다.

    StringBuilder sb = new StringBuilder();
    for (String cur : arr) {
        sb.append(cur);
    }

     

     

    String '+' 연산의 문제점

      아래와 같은 코드를 볼 수도 있고, 짜게될 수도 있습니다. 아래처럼 사용하게되면 메모리 및 시간 모두에서 엄청나게 낭비가 발생합니다.

    String result = "";
    for (String cur : arr) {
        result += cur;
    }

     

     

      이유를 알려면 우선 String의 '+' 연산에 대해 이해해야 합니다.

    자바에서 String은 불변입니다. 처음 생성된 문자열에 대해 수정을 할 수 없습니다.

    그렇다면 아래와 같이 a와 b를 더해 c를 만든다고하면, 어떤식으로 더해질까요?

    String a = "abc";
    String b = "def";
    String c = a + b;	// "abcdef"

     

      방법은 간단한데, 'a의 길이 + b의 길이'를 가지는 새로운 배열을 만들어서 a와 b를 모두 복사해넣고 그걸 c로 쓰는겁니다.

     

      자바 내부의 코드로는 아래와 같습니다.

     

      arr이 길이가 100인 String이 10만개 있는 배열이었다고 해봅시다.

    String result = "";
    for (String cur : arr) {
        result += cur;
    }


      result += cur은 result = result + cur 과 동일합니다. 위 a+b와 마찬가지로, result와 새로운 문자열을 더해 새로운 문자열을 만들어 result에 넣는걸 계속 반복하게 됩니다.

     

      따라서 시간복잡도는 $O(100)+O(100+100)+...+O(100000*100)$ 이 될 것이고, String의 수가 X, 각 문자열의 길이가 Y라고 한다면 $O((1+2+...+X)Y)\approx O(X^2Y)$가 됩니다. 컴퓨터의 연산은 물론 사양마다 다르겠지만, 대략적으로 1억번의 단순 연산에 1초정도로 잡으면 됩니다.

     

      실제로 동작해보니 78초가 걸리네요.

    long start = System.currentTimeMillis();
    for (String cur : arr) {
        result += cur;
    }
    System.out.println(System.currentTimeMillis() - start + " ms");

     

     

    StringBuilder를 사용해 개선

      자바에서 제공해주는 StringBuilder라는 클래스가 있습니다. 위 String '+' 연산에 대해 빠르고 효율적으로 가능하게 해주는클래스 입니다. StringBuilder는 불변이 아니고, 미리 일정한 크기의 배열을 잡아두고 거기에 붙여나가는 방식입니다. 그림으로 그려보면 다음과 같이 표현할 수 있습니다.

     

     

      배열이 가득찼을 경우엔 기존보다 2배 더 크게 새로운 배열을 만드는 형태로, 흔히 말하는 Vector 혹은 자바의 ArrayList와 동일한 방식이라고 보시면 됩니다(가변 배열처럼 사용할 수 있음).

    StringBuilder sb = new StringBuilder();
    for (String cur : arr) {
        sb.append(cur);
    }

     

      따라서 $O(XY)$로 문자열을 합치는 연산이 끝나게 됩니다.

     

     

    String '+'와 StringBuilder 시간 비교

      그렇다면 X가 10만, Y가 100일 때 한번 비교를 해보겠습니다.

    long start = System.currentTimeMillis();
    for (String cur : arr) {
        result += cur;
    }
    System.out.println(System.currentTimeMillis() - start + " ms");
    
    start = System.currentTimeMillis();
    StringBuilder sb = new StringBuilder();
    for (String cur : arr) {
        sb.append(cur);
    }
    System.out.println(System.currentTimeMillis() - start + " ms");

     

      결과는 78초 vs 8ms 입니다.

     

      만약 X가 1000만이었다면 2시간10분 vs 800ms 입니다. 이정도만 차이나도 가능하냐 불가능하냐의 차이 수준인데, String이 1억개였다면(X=1억), 9일 vs 8초의 차이 입니다. 물론 X, Y가 늘어남에 따라 격차는 더 벌어지게 됩니다.

     

     

    StringBuilder vs StringBuffer

      위에선 StringBuilder만 얘기했지만, 비슷한걸로 StringBuffer도 있습니다. 둘 다 AbstractStringBuilder를 상속받고 동작 로직은 이 클래스에 있습니다. StringBuilder와 StringBuffer의 차이는 후자는 synchronized를 붙여두었다는 점 입니다. 즉, 기본적으로는 StringBuilder를 쓰면 되지만, 멀티스레드로 동시에 StringBuilder에 여러 스레드에서 접근 가능해야 한다면 StringBuffer를 사용하시면 됩니다.

     

     

    다른 방법들

    Stream

    Arrays.stream(arr).collect(Collectors.joining());

      joining도 내부적으로 StringBuilder를 사용하므로 편하게 가능 합니다. (stream이다보니 아주 약간 느림. 자바 버전 올라갈수록 개선되서 빨라짐)

     

    StringJoiner

    StringJoiner stringJoiner = new StringJoiner("");
    for (String cur : arr) {
        stringJoiner.add(cur);
    }

     

      StringJoiner의 경우 아래처럼 순서대로 delimeter, prefix, suffix를 붙여서 join 가능합니다. 얘도 빨라요. (제일 빠른건 위에서 설명한 StringBuilder 사용하는건데 유의미하게 차이나지 않음)

    new StringJoiner(",", "[", "]");

     

    String.join

      아래처럼도 합칠 수 있습니다. 내부적으로 StringJoiner를 사용합니다.

    String.join("", arr);

     

     

    결론

      문자열을 합칠 때 메모리, 시간 모두 손해인 '+' 연산을 쓸 이유가 없습니다! 효율적인 다양한 방법들이 있으니 골라서 쓰시면 됩니다.

    댓글