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)
StringBuffer는 StringBuilder와 거의 동일하지만, 모든 메서드에 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]"
세 클래스 비교 요약
| 구분 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 불변성 | 불변 (Immutable) | 가변 (Mutable) | 가변 (Mutable) |
| 동기화 | 불필요 (불변) | 없음 (비동기) | 있음 (synchronized) |
| 스레드 안전 | O (불변이므로) | X | O |
| 성능 (반복 연산) | 가장 느림 O(n²) | 가장 빠름 | 중간 (동기화 오버헤드) |
| String Pool | O | X | X |
| 주요 사용 | 불변 문자열 | 단일 스레드 문자열 조작 | 멀티스레드 문자열 조작 |
| Java 도입 버전 | 초기 | Java 1.5 | Java 1.0 |
| 권장 상황 | 일반 문자열 상수 | 대부분의 동적 생성 | 진짜 멀티스레드 공유만 |