전체 목록
JavaMedium#64

Java의 String, StringBuilder, StringBuffer의 차이를 설명해주세요.

#Java#String#성능#스레드
힌트

불변성, 스레드 안전성, 성능의 차이를 생각해보세요.

정답 및 해설

Java의 String, StringBuilder, StringBuffer의 차이를 설명해주세요.

Java에서 문자열을 다루는 세 가지 핵심 클래스는 불변성(immutability)과 동기화(synchronization) 여부에서 근본적인 차이가 있습니다. 올바른 클래스를 선택하는 것은 코드의 정확성과 성능 모두에 영향을 미칩니다. 반복적인 문자열 연산 시 String의 잘못된 사용은 O(n²) 시간 복잡도로 심각한 성능 저하를 유발할 수 있습니다.

String - 불변(Immutable)

String은 한 번 생성되면 그 내용이 변경되지 않는 불변 객체입니다. 모든 문자열 연산은 새로운 String 객체를 반환합니다.

String Pool (문자열 상수 풀)

// 리터럴 방식: String Pool 사용 (JVM의 Heap 내 특별 영역)
String s1 = "Hello";
String s2 = "Hello";
System.out.println(s1 == s2);      // true (같은 Pool 객체 참조)
System.out.println(s1.equals(s2)); // true

// new 방식: Heap에 별도 객체 생성
String s3 = new String("Hello");
String s4 = new String("Hello");
System.out.println(s3 == s4);      // false (다른 객체)
System.out.println(s3.equals(s4)); // true

// intern(): String Pool로 이동
String s5 = s3.intern();
System.out.println(s1 == s5); // true (같은 Pool 객체)

String 불변성과 새 객체 생성

String str = "Hello";
str = str + " World"; // "Hello"는 그대로, 새 "Hello World" 객체 생성

// 컴파일러가 자동으로 StringBuilder로 최적화 (단일 연산)
String a = "Hello" + " " + "World"; // 컴파일 타임에 "Hello World"로 합쳐짐

// 하지만 반복문에서는 최적화 안 됨 (매 반복마다 새 객체)
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i; // 매번 새 String 생성 → O(n²) 위험!
}

String의 주요 메서드

String s = "  Hello, World!  ";

s.length();            // 17
s.trim();              // "Hello, World!" (양쪽 공백 제거)
s.strip();             // Java 11+ 유니코드 공백도 제거
s.toUpperCase();       // "  HELLO, WORLD!  "
s.toLowerCase();       // "  hello, world!  "
s.contains("World");   // true
s.startsWith("  He");  // true
s.indexOf("o");        // 5 (첫 번째 'o' 위치)
s.substring(7, 12);    // "World"
s.replace("World", "Java"); // "  Hello, Java!  "
s.split(", ");         // ["  Hello", "World!  "]
s.charAt(7);           // 'W'
String.valueOf(42);    // "42"
"42".equals(42 + ""); // false (참조 비교 주의)

StringBuilder - 가변(Mutable), 비동기

StringBuilder는 내부 버퍼를 직접 수정하는 가변 객체입니다. 단일 스레드 환경에서의 문자열 조작에 최적화되어 있습니다.

기본 사용법

StringBuilder sb = new StringBuilder(); // 기본 용량 16
StringBuilder sb2 = new StringBuilder(100); // 초기 용량 지정
StringBuilder sb3 = new StringBuilder("Hello"); // 초기값

// append: 문자열 추가
sb.append("Hello")
  .append(", ")
  .append("World")
  .append("!")
  .append(42);
System.out.println(sb.toString()); // "Hello, World!42"

// insert: 특정 위치에 삽입
sb.insert(7, "Beautiful "); // "Hello, Beautiful World!42"

// delete: 범위 삭제
sb.delete(7, 17); // "Hello, World!42"

// replace: 범위 교체
sb.replace(7, 12, "Java");  // "Hello, Java!42"

// reverse: 뒤집기
new StringBuilder("abcde").reverse().toString(); // "edcba"

// deleteCharAt: 특정 인덱스 문자 삭제
sb.deleteCharAt(0); // 첫 글자 제거

// length, capacity
System.out.println(sb.length());   // 현재 문자열 길이
System.out.println(sb.capacity()); // 현재 버퍼 용량 (자동 확장)

반복문에서의 성능

// StringBuilder 사용: O(n) 복잡도
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100_000; i++) {
    sb.append(i);
}
String result = sb.toString();

// String + 연산: O(n²) 복잡도 (절대 비권장)
String result2 = "";
for (int i = 0; i < 100_000; i++) {
    result2 += i; // 매번 전체 문자열 복사 → 심각한 성능 저하
}

내부 동작 원리

// StringBuilder는 내부적으로 char[] 배열을 유지
// 기본 용량 16, 부족하면 (현재용량 * 2 + 2)로 확장
StringBuilder sb = new StringBuilder(); // char[16]
sb.append("Hello World!!!!"); // 16자: char[16]
sb.append("!"); // 17자: char[34]로 확장

StringBuffer - 가변(Mutable), 동기화(Synchronized)

StringBufferStringBuilder와 거의 동일하지만, 모든 메서드에 synchronized 키워드가 적용되어 있어 **멀티스레드 환경에서 스레드 안전(thread-safe)**합니다.

StringBuffer sbuf = new StringBuffer();

// StringBuilder와 동일한 API
sbuf.append("Hello");
sbuf.append(", World");
sbuf.insert(5, "!");
sbuf.delete(5, 6);
System.out.println(sbuf.toString()); // "Hello, World"

// 내부적으로 synchronized 메서드들
public synchronized StringBuffer append(String str) {
    // 락 획득 후 작업
    // 락 해제
    return this;
}

멀티스레드에서의 StringBuffer

StringBuffer sharedBuffer = new StringBuffer();

// 여러 스레드에서 동시에 접근해도 안전
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
    final int num = i;
    executor.submit(() -> sharedBuffer.append(num));
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.SECONDS);
// 결과의 순서는 보장되지 않지만 데이터 손상은 없음

성능 비교

벤치마크 코드

public class StringPerformanceTest {
    static final int ITERATIONS = 100_000;

    public static void main(String[] args) {
        // String + 연산
        long start1 = System.currentTimeMillis();
        String s = "";
        for (int i = 0; i < ITERATIONS; i++) {
            s += "a";
        }
        long time1 = System.currentTimeMillis() - start1;

        // StringBuilder
        long start2 = System.currentTimeMillis();
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < ITERATIONS; i++) {
            sb.append("a");
        }
        String result2 = sb.toString();
        long time2 = System.currentTimeMillis() - start2;

        // StringBuffer
        long start3 = System.currentTimeMillis();
        StringBuffer sbuf = new StringBuffer();
        for (int i = 0; i < ITERATIONS; i++) {
            sbuf.append("a");
        }
        String result3 = sbuf.toString();
        long time3 = System.currentTimeMillis() - start3;

        System.out.println("String:        " + time1 + "ms");  // ~수백ms
        System.out.println("StringBuilder: " + time2 + "ms");  // ~수ms
        System.out.println("StringBuffer:  " + time3 + "ms");  // ~수ms (약간 느림)
    }
}

알고리즘 복잡도

String + 반복 n회:   O(1 + 2 + 3 + ... + n) = O(n²)
StringBuilder 반복:  O(n) (분할 상환 분석)
StringBuffer 반복:   O(n) + 동기화 오버헤드

Java 컴파일러의 최적화

단순 문자열 연결은 컴파일러가 자동으로 StringBuilder로 최적화합니다.

// 소스 코드
String name = "World";
String greeting = "Hello, " + name + "!";

// 컴파일 후 (바이트코드 기준)
String name = "World";
String greeting = new StringBuilder("Hello, ")
    .append(name)
    .append("!")
    .toString();

// 단, 반복문 내부는 최적화 불가 (매 반복마다 새 StringBuilder 생성)
String result = "";
for (String item : list) {
    result += item; // 매번: new StringBuilder(result).append(item).toString()
}

실전 사용 가이드

// 1. 불변 문자열이 필요한 경우 → String
String name = "홍길동";
String title = "개발자";

// 2. 단일 스레드에서 반복 문자열 조작 → StringBuilder
public String buildReport(List<String> lines) {
    StringBuilder sb = new StringBuilder();
    for (String line : lines) {
        sb.append(line).append("\n");
    }
    return sb.toString();
}

// 3. JSON/XML 직접 구성 → StringBuilder
public String buildJson(Map<String, String> data) {
    StringBuilder sb = new StringBuilder("{");
    data.forEach((k, v) ->
        sb.append("\"").append(k).append("\":\"").append(v).append("\",")
    );
    if (sb.length() > 1) sb.deleteCharAt(sb.length() - 1); // 마지막 쉼표 제거
    sb.append("}");
    return sb.toString();
}

// 4. 멀티스레드 공유 버퍼 → StringBuffer (또는 별도 동기화 고려)
// 실제로는 각 스레드에 독립적인 StringBuilder를 주고
// 최종 결과만 합치는 방식이 더 좋은 경우가 많음

// 5. 단순 리스트를 구분자로 합치기 → String.join 또는 Collectors.joining
List<String> items = List.of("A", "B", "C");
String joined = String.join(", ", items); // "A, B, C"

String joined2 = items.stream()
    .collect(Collectors.joining(", ", "[", "]")); // "[A, B, C]"

세 클래스 비교 요약

구분StringStringBuilderStringBuffer
불변성불변 (Immutable)가변 (Mutable)가변 (Mutable)
동기화불필요 (불변)없음 (비동기)있음 (synchronized)
스레드 안전O (불변이므로)XO
성능 (반복 연산)가장 느림 O(n²)가장 빠름중간 (동기화 오버헤드)
String PoolOXX
주요 사용불변 문자열단일 스레드 문자열 조작멀티스레드 문자열 조작
Java 도입 버전초기Java 1.5Java 1.0
권장 상황일반 문자열 상수대부분의 동적 생성진짜 멀티스레드 공유만