CS 스터디 (Java) 8 - String, StringBuffer, StringBuilder 차이와 성능 비교
Java에서 문자열을 다룰 때 주로 사용하는 클래스는 String, StringBuffer, StringBuilder입니다. 기능은 비슷해 보이지만, 내부 동작 방식이나 성능 측면에서 확실한 차이가 있어서, 상황에 맞게 골라서 써야 합니다.
String
String a = "";
for (int i = 0; i < 10000; i++) {
a = a + i; // 내부적으로 new StringBuilder(a).append(i).toString(); 호출
}
- String은 불변(immutable) 객체라서, 문자열을 바꿀 때마다 매번 새로운 인스턴스가 만들어집니다.
- 이 덕분에 thread-safe 하고, 해시코드 캐싱 같은 장점도 있지만, 연산이 많아질수록 메모리 낭비나 성능 저하가 생길 수 있습니다.
- 문자열을 계속 더하는 연산(+)을 자주 쓰면, 컴파일 시 내부적으로 new StringBuilder(...).append(...).toString() 식으로 변환되기 때문에, 반복적으로 쓰면 불필요한 객체가 많이 생기고 GC에도 부담을 줍니다.
String은 왜 상속할 수 없는가?
- Java에서 `String` 클래스는 `final`로 선언되어 있어 상속이 불가능합니다.
- 이는 보안성과 불변성(immutability)을 유지하기 위한 설계로, 문자열 공유와 캐싱, 해시코드 캐싱 등의 동작이 깨지지 않도록 보장하기 위함입니다.
- 또한
-
단일 상속의 제약과 인터페이스로의 보완
- Java는 단일 상속(Single Inheritance) 언어로, 한 클래스는 오직 하나의 부모 클래스만 상속할 수 있습니다.
- 복잡한 다중 상속 구조에서 발생할 수 있는 메서드 충돌, 상태 일관성 붕괴, 다이아몬드 문제 등을 방지하기 위한 언어적 제약입니다.
- 이를 보완하기 위해, Java는 인터페이스 다중 구현을 지원합니다.
void printText(CharSequence cs) {
System.out.println(cs);
}
printText("hello"); // String
printText(new StringBuilder("hi")); // StringBuilder
예: CharSequence는 String, StringBuilder, StringBuffer 모두가 구현하며, 다형적 참조를 통해 공통 API 사용 가능
StringBuffer, StringBuilder 공통
- StringBuffer나 StringBuilder는 문자열을 버퍼(char[] 또는 byte[])에 저장하고, 필요하면 크기를 자동으로 늘려줍니다.
- 새로운 객체를 매번 만들지 않기 때문에 String에 비해 메모리도 덜 쓰고 속도도 빠릅니다.
StringBuilder
- StringBuilder는 StringBuffer와 거의 똑같이 생겼지만, 동기화를 안 걸기 때문에 단일 스레드 환경에서 훨씬 빠릅니다.
- 멀티스레드가 아닌 경우에는 StringBuffer보다 StringBuilder를 쓰는 게 성능 면에서 더 낫습니다.
StringBuffer
- StringBuffer의 주요 메서드는 synchronized로 선언되어 있어 다중 스레드 환경에서도 안전하여, 문자열 변경이 많은 때, 메모리 및 CPU 리소스를 효율적으로 사용합니다.
- 반면, 동기화 오버헤드로 인해 단일 스레드 환경에서는 불필요한 성능 저하가 발생합니다.
JDK 9+에서 도입된 Compact String 내부 구조
JDK 9부터 String 클래스는 내부적으로 char[] 대신 byte[]를 사용하며, 문자열의 문자 집합에 따라 적절한 인코딩을 선택하는데, 이를 Compact String이라고 합니다.
Compact String (JDK 9+)
- JDK 9부터는 String 클래스 내부 구조가 바뀌어서, 더 이상 char[]를 쓰지 않고 byte[]를 사용하게 됐습니다.
- 문자열 안에 어떤 문자가 들어있는지에 따라 Latin-1이면 1바이트로, 그렇지 않으면 UTF-16(2바이트)로 저장하게 최적화됐고, 이걸 Compact String이라고 부릅니다.
- 덕분에 영어처럼 1바이트로 표현 가능한 문자열들은 예전보다 훨씬 메모리를 적게 쓰게 되었습니다.
package org.example;
import java.lang.reflect.Field;
public class Main {
public static void main(String[] args) throws Exception {
// ASCII 문자열
String asciiStr = "Hello";
// UTF-16 문자열
String utf16Str = "안녕하세요";
// 문자열 세부 정보 출력
printStringDetails(asciiStr);
printStringDetails(utf16Str);
}
private static void printStringDetails(String str) throws Exception {
// String 클래스의 value 필드와 coder 필드에 접근
Field valueField = String.class.getDeclaredField("value");
Field coderField = String.class.getDeclaredField("coder");
valueField.setAccessible(true);
coderField.setAccessible(true);
byte[] value = (byte[]) valueField.get(str);
byte coder = (byte) coderField.get(str);
System.out.println("String: " + str);
System.out.println("Value (byte[]): " + java.util.Arrays.toString(value));
System.out.println("Coder: " + coder + " (" + (coder == 0 ? "LATIN1" : "UTF-16") + ")");
System.out.println();
}
}
실행 결과
String: Hello
Value (byte[]): [72, 101, 108, 108, 111]
Coder: 0 (LATIN1) // coder 값이 0 → 문자당 1바이트로 저장됨
String: 안녕하세요
Value (byte[]): [72, -59, 85, -79, 88, -43, 56, -63, -108, -58]
Coder: 1 (UTF-16) // coder 값이 1 → 문자당 2바이트로 저장됨
결론
String은 불변(immutable) 객체라서 문자열을 수정할 때마다 새로운 객체가 생성되며, 연산이 많을 경우 성능과 메모리 측면에서 비효율적입니다.
반면 StringBuilder와 StringBuffer는 가변 객체로, 내부 버퍼를 사용해 문자열을 직접 수정하므로 더 효율적이고,
StringBuffer는 메서드에 동기화를 적용해 멀티스레드 환경에서 안전하지만, 그만큼 오버헤드가 있습니다.
단일 스레드에서는 StringBuilder가 가장 빠르고, 다중 스레드 환경에서는 StringBuffer가 적합합니다.
JDK 9 이후에는 String도 Compact String 구조로 최적화되어, ASCII 기반 문자열은 내부적으로 1바이트로 저장되어 메모리 효율이 개선되었습니다.