Java String·StringBuilder·StringBuffer 차이: 문자열 연결 기준 정리

반응형

Java에서 문자열을 몇 번 붙이는 정도라면 String, StringBuilder, StringBuffer 가운데 무엇을 써도 눈에 띄는 문제가 없을 수 있다. 선택 기준은 이름이 아니라 문자열을 바꿀 필요가 있는지, 같은 객체를 여러 스레드가 공유하는지다.

기존 글은 다른 블로그 링크와 짧은 미리보기만 남아 있어 판단 기준을 확인하기 어려웠다. Java SE 25 API 문서를 기준으로 세 클래스의 계약부터 다시 정리했다.

가장 큰 차이는 불변과 가변이다

클래스 내용 변경 동기화 보장 먼저 고려할 상황
String 불변 불변 객체이므로 공유하기 쉬움 완성된 문자열, 비교·키·메서드 인자와 반환값
StringBuilder 가변 보장하지 않음 한 스레드 안에서 문자열을 반복 조립할 때
StringBuffer 가변 주요 메서드가 동기화됨 동일한 가변 버퍼를 여러 스레드가 공유해야 할 때

String이 불변이라는 말은 변수에 다른 문자열을 다시 대입할 수 없다는 뜻이 아니다.

String message = "hello";
message = message + " world";

변수 message는 새 결과를 가리킬 수 있지만, 처음 만들어진 "hello" 객체 자체의 내용은 바뀌지 않는다.

짧고 단순한 연결은 String으로 충분하다

한 표현식 안에서 몇 개의 값을 연결한다면 읽기 쉬운 코드를 먼저 고른다.

String label = userName + " (" + userId + ")";

Java 언어 명세는 문자열 + 연산을 정의하지만, 컴파일러가 내부에서 어떤 연결 전략을 사용할지는 구현에 맡긴다. Java API 문서도 JDK 버전에 따라 StringBuilder, StringBuffer, StringConcatFactory 등을 사용할 수 있다고 설명한다.

따라서 “+는 언제나 느리다”거나 “항상 중간 객체가 정확히 N개 생긴다”고 단정할 수는 없다. 코드가 짧고 연결 횟수가 고정돼 있다면 +가 의도를 가장 잘 드러내는 경우가 많다.

상수 표현식은 컴파일 시점에 합쳐질 수도 있다.

String protocol = "https" + "://";

반복문에서 누적할 때는 StringBuilder가 기본 선택이다

반복 횟수에 따라 문자열이 계속 커진다면 하나의 가변 버퍼에 붙이는 구조가 명확하다.

static String joinNames(List<String> names) {
    StringBuilder result = new StringBuilder();

    for (String name : names) {
        if (!result.isEmpty()) {
            result.append(", ");
        }
        result.append(name);
    }

    return result.toString();
}

StringBuilder는 내부 문자 저장 공간인 capacity를 가지고, 필요하면 더 큰 공간으로 확장한다. 최종 길이를 대략 알고 있다면 초기 capacity를 지정해 확장 횟수를 줄일 수 있다.

StringBuilder result = new StringBuilder(256);

다만 임의의 큰 값을 습관적으로 넣으면 메모리만 낭비한다. 실제 크기 분포를 알거나 병목을 측정했을 때 사용한다.

구분자만 넣어 컬렉션을 연결한다면 직접 루프를 쓰기 전에 표준 API도 확인한다.

String csv = String.join(",", values);

멀티스레드 프로그램이라고 StringBuffer가 필요한 것은 아니다

StringBuilder는 동기화를 보장하지 않는다. StringBuffer는 스레드 안전한 가변 문자 시퀀스로 설계됐고 주요 연산이 동기화된다.

하지만 웹 서버처럼 프로그램 전체가 멀티스레드라는 이유만으로 로컬 변수까지 StringBuffer로 바꿀 필요는 없다.

String render(User user) {
    StringBuilder html = new StringBuilder();
    html.append("<strong>")
        .append(user.name())
        .append("</strong>");
    return html.toString();
}

html 객체가 메서드 밖으로 공유되지 않고 호출마다 새로 만들어진다면 여러 요청이 동시에 실행돼도 각 스레드는 서로 다른 builder를 사용한다.

반대로 하나의 버퍼를 여러 스레드가 공유해야 한다면 두 가지를 먼저 묻는다.

  1. 정말 가변 버퍼 하나를 공유해야 하는가?
  2. 여러 단계의 연산 전체가 하나의 원자적 작업이어야 하는가?

StringBuffer의 개별 메서드가 동기화돼도 다음과 같은 복합 동작 전체가 자동으로 원자적이 되는 것은 아니다.

if (buffer.length() < limit) {
    buffer.append(value);
}

두 호출 사이에 다른 스레드가 버퍼를 바꿀 수 있다. 공유 설계를 피할 수 없다면 더 큰 임계 구역이나 별도의 동시성 구조가 필요한지 판단한다.

equals 비교에서는 세 클래스를 같게 다루지 않는다

String은 내용 기준으로 equals를 구현한다.

"abc".equals(new String("abc")); // true

반면 StringBuilderComparable을 구현하지만 equals를 내용 기준으로 재정의하지 않는다. 두 builder의 문자가 같아도 일반적인 equals 결과를 문자열 비교처럼 기대하면 안 된다.

StringBuilder a = new StringBuilder("abc");
StringBuilder b = new StringBuilder("abc");

boolean sameText = a.toString().equals(b.toString());

정렬과 compareTo의 기본 개념은 Java compareTo와 Comparator 정리에서 이어서 볼 수 있다.

선택 기준만 남기면

  • 완성된 값이나 짧은 연결은 String을 쓴다.
  • 반복문에서 하나의 문자열을 조립하면 StringBuilder를 먼저 본다.
  • 같은 가변 버퍼를 여러 스레드가 공유할 필요가 있을 때만 StringBuffer를 검토한다.
  • 성능이 중요하면 코드 모양만 보고 결론 내리지 말고 실제 JDK와 데이터 크기로 측정한다.
  • 문자열 결합만 필요하다면 String.join, Collectors.joining, 포맷 API 같은 더 직접적인 표준 기능도 확인한다.

이 글의 코드는 클래스 선택 기준을 설명하기 위한 예제다. 특정 애플리케이션에서 세 방식의 처리량을 측정한 벤치마크 결과는 아니다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글