Java StringBuilder · StringBuffer 완전 정복 — 가변 문자열의 모든 것

String 연결이 느린 이유, StringBuilder와 StringBuffer의 내부 구조·차이, 핵심 API 사용법, 성능 최적화 전략까지 가변 문자열을 완전 정복한다

· 10 min read · PALDYN Team

지난 글에서 String Pool의 구조와 intern() 동작 원리를 살펴봤다. String이 불변(Immutable)이라는 사실 덕분에 Pool 공유가 가능하지만, 그 불변성은 문자열을 반복해서 이어 붙이는 상황에서 심각한 성능 문제를 일으킨다. 이번 글에서는 이 문제를 해결하기 위해 등장한 StringBuilderStringBuffer의 내부 구조, API, 그리고 실전 사용 전략을 완전히 파고든다.

String 연결이 느린 이유

String은 불변이기 때문에 + 연산을 수행할 때마다 새로운 String 객체를 힙에 생성한다.

String s = "";
for (int i = 0; i < 10_000; i++) {
    s = s + i;  // 매번 새 String 객체 생성
}

이 루프는 10,000번의 반복마다 점점 길어지는 문자열을 통째로 복사해 새 객체를 만든다. 복사량은 0+1+2+…+9999 = 약 5천만 자(character)에 달한다. O(n²) 복잡도다.

Java 컴파일러는 단순한 리터럴 연결("Hello" + " " + "World")은 컴파일 타임에 하나의 문자열로 합쳐 주지만, 루프 안의 연결은 최적화하지 않는다. 이때 필요한 도구가 StringBuilder다.

StringBuilder — 가변 문자열의 기본

StringBuilder는 내부에 char[] 배열(Java 9+에서는 byte[])을 두고, 데이터를 추가할 때 새 객체를 만들지 않고 배열을 직접 수정한다. 참조 변수는 그대로이므로 불변성 없이 빠른 조작이 가능하다.

StringBuilder sb = new StringBuilder(32); // 초기 capacity 지정

sb.append("Hello")
  .append(", ")
  .append("World");   // Hello, World

sb.insert(5, "!");    // Hello!, World
sb.delete(6, 7);      // Hello!World
sb.replace(6, 11, "Java"); // Hello!Java

String result = sb.toString(); // 불변 String으로 변환

append()StringBuilder 자신을 반환하므로 체이닝(.append().append()…)이 가능하다. 위의 루프를 StringBuilder로 바꾸면:

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10_000; i++) {
    sb.append(i);
}
String s = sb.toString(); // O(n)

O(n²)이 O(n)으로 바뀐다.

String · StringBuilder · StringBuffer 비교

내부 버퍼 구조 — capacity와 length

StringBuilder 내부 배열의 크기를 capacity, 실제 저장된 문자 수를 length라고 부른다.

StringBuilder sb = new StringBuilder(16); // capacity=16, length=0
sb.append("Hello");                        // capacity=16, length=5

sb.ensureCapacity(100); // 최소 100 보장 (필요 시 확장)
System.out.println(sb.capacity()); // 실제 capacity 확인
System.out.println(sb.length());   // 5

capacity가 부족할 때 JVM은 내부 배열을 2 × old + 2 크기로 자동 확장하고 기존 내용을 복사한다. 자주 확장이 일어나면 복사 비용이 누적된다. 루프 횟수나 예상 데이터 크기를 알고 있다면 생성자에 capacity를 미리 지정하는 것이 좋다.

// 예상 크기가 ~1000자라면 미리 확보
StringBuilder sb = new StringBuilder(1024);

기본 capacity는 16이다. 초기값을 new StringBuilder("Hello")처럼 문자열로 주면 "Hello".length() + 16 = 21이 초기 capacity가 된다.

핵심 API 정리

StringBuilder 핵심 API

메서드설명
append(x)끝에 추가. 거의 모든 타입 오버로드 지원
insert(i, x)인덱스 i 위치에 삽입
delete(s, e)[s, e) 구간 삭제
deleteCharAt(i)인덱스 i의 문자 하나 삭제
replace(s, e, str)[s, e) 구간을 str로 교체
reverse()전체 문자열 역순 변환
charAt(i)인덱스 i의 문자 반환
setCharAt(i, c)인덱스 i의 문자를 c로 변경
indexOf(str)부분 문자열 검색
substring(s, e)[s, e) 구간 String으로 추출
length()실제 문자 수
capacity()내부 버퍼 크기
toString()불변 String으로 변환

append()에 넘길 수 있는 타입은 boolean, char, int, long, float, double, char[], CharSequence, Object 등 거의 모든 타입이다. null을 넘기면 문자열 "null"이 추가된다.

StringBuffer — thread-safe 버전

StringBufferStringBuilderAPI가 완전히 동일하다. 차이는 단 하나: 모든 공개 메서드에 synchronized 키워드가 붙어 있다.

// StringBuffer 선언 — API는 StringBuilder와 동일
StringBuffer sbuf = new StringBuffer(32);
sbuf.append("thread-safe");
sbuf.insert(6, "-");
String result = sbuf.toString();

synchronized 덕분에 여러 스레드가 동시에 같은 StringBuffer 인스턴스에 접근해도 데이터 경쟁(race condition)이 발생하지 않는다. 그러나 락 획득·해제 비용 때문에 단일 스레드 환경에서는 StringBuilder보다 느리다.

StringBuffer를 선택해야 하는 경우

// 공유 상태를 여러 스레드가 수정하는 예
class LogCollector {
    private final StringBuffer log = new StringBuffer();

    public void add(String msg) {
        log.append(msg).append('\n'); // 여러 스레드에서 동시 호출 가능
    }

    public String get() {
        return log.toString();
    }
}

이처럼 인스턴스를 여러 스레드가 공유하며 수정하는 경우에만 StringBuffer가 필요하다. 대부분의 현대 코드에서는 스레드 로컬 변수 또는 StringJoiner·Stream으로 대체하는 편이 더 자연스럽다.

세 클래스 선택 기준 정리

단순 상수 / 변경 없는 값       → String
단일 스레드 문자열 조작        → StringBuilder  (기본 선택)
여러 스레드가 공유하며 수정    → StringBuffer   (또는 외부 동기화)

실무에서 StringBuffer를 만날 일은 드물다. JDK 1.5(Java 5)에서 StringBuilder가 추가되면서 대부분의 사용처를 대체했기 때문이다. 레거시 코드 리뷰 시 불필요한 StringBufferStringBuilder로 교체하는 것은 안전하고 간단한 성능 개선이다.

컴파일러의 자동 최적화

Java 컴파일러는 같은 구문 안의 + 연결을 자동으로 StringBuilder로 변환한다.

// 소스
String s = "Hello" + name + "!";

// 컴파일 후 (javap -c 로 확인)
// new StringBuilder()
// .append("Hello")
// .append(name)
// .append("!")
// .toString()

하지만 루프를 걸쳐 이어 붙이는 경우는 최적화되지 않는다. javac가 루프마다 새 StringBuilder를 생성하기 때문이다. 루프 내 반복 연결은 반드시 수동으로 StringBuilder를 꺼내 써야 한다.

Java 9+에서는 **invokedynamic 기반의 StringConcatFactory**가 추가되어 더 유연한 최적화가 가능해졌다. 내부 전략이 바뀌더라도 루프 안에서는 여전히 직접 StringBuilder를 쓰는 것이 가장 확실하다.

성능 비교 예시

int N = 100_000;

// 방법 1: String + (매우 느림)
long t1 = System.nanoTime();
String s = "";
for (int i = 0; i < N; i++) s += i;
System.out.println("String +: " + (System.nanoTime() - t1) / 1_000_000 + "ms");

// 방법 2: StringBuilder (빠름)
long t2 = System.nanoTime();
StringBuilder sb = new StringBuilder(N * 5);
for (int i = 0; i < N; i++) sb.append(i);
String s2 = sb.toString();
System.out.println("StringBuilder: " + (System.nanoTime() - t2) / 1_000_000 + "ms");

일반적인 환경에서 N=100,000 기준으로 String +는 수백 ms, StringBuilder는 수 ms 수준의 차이가 난다.

실전 팁

capacity 미리 지정: 예상 크기를 알 때 new StringBuilder(expectedSize)로 재할당을 방지한다.

trimToSize(): 연산 완료 후 남는 버퍼를 줄여 메모리를 절약한다. 결과를 장기간 보관하는 경우에 유용하다.

delete(0, sb.length())로 초기화: 객체를 재사용할 때 new StringBuilder()를 다시 만들지 않고 내용만 비워 쓸 수 있다. 단, 객체 풀이나 루프 재사용 맥락에서만 가치 있고, 대부분의 경우 새 객체를 만드는 게 코드 명확성 면에서 낫다.

StringJoiner / String.join(): 구분자가 필요한 리스트 합치기라면 StringBuilder보다 StringJoiner 또는 String.join(delimiter, list)가 더 간결하다.

// StringJoiner 예시
StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.add("apple");
sj.add("banana");
sj.add("cherry");
System.out.println(sj); // [apple, banana, cherry]

지난 글: Java String Pool 완전 정복 — intern, 리터럴, 메모리 구조

다음 글: Java Text Block 완전 정복 — 여러 줄 문자열 처리


읽어주셔서 감사합니다. 😊