Java에서는 문자열을 다루는 객체로 String, StringBuilder, StringBuffer의 세 가지 클래스를 제공해줍니다.
이 글에서는 이 세 가지 클래스의 차이를 개념적으로 이해하고, 언제 어떤 클래스를 선택해야 할지에 대한 가이드를 작성해볼 것입니다.
String: 불변(immutable)한 문자열
String은 가장 기본적으로 사용되는 문자열 클래스입니다. 중요한 특징은 불변(immutable) 이라는 점입니다. 한 번 생성된 String 객체는 내용을 바꿀 수 없습니다.
String의 내부 구현을 보면 값을 저장하고 있고, 이 값은 final로 지정되어 있어 불변의 특성을 지니게 됩니다.
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
/* The value is used for character storage */
private final char value[];
// ...
}
String에 String을 더하는 + 연산자를 사용하면 기존의 String 객체를 변경하지 않고 새로운 String 객체를 만들어서 반환하게 됩니다.
val str1 = "str1"
val str2 = "str2"
// 문자열은 "str1str2"로 합쳐지지만, str1과 str2는 변하지 않고 새롭게 String을 만듭니다
val concatStr = str1 + str2
StringBuffer: 가변(mutable)의 쓰레드 안전한 객체
StringBuffer는 String과 다르게 내부 데이터가 변하는 가변 객체입니다. 즉, 문자열을 수정할 때마다 새로운 객체를 만들지 않고, 내부 버퍼를 수정합니다.
StringBuffer는 동기화 기능을 제공하므로 멀티 쓰레드 환경에서도 안전하게 사용할 수 있습니다.
// 가변 객체이므로 내부의 데이터가 변합니다
val sb = new StringBuffer("Hello")
sb.append(" World")
println(sb.toString()) // Hello World
StringBuilder에서 가장 자주 사용하는 append 내부 구현을 보면 다음처럼 작동합니다.
public final class StringBuffer extends AbstractStringBuilder implements Serializable, Comparable<StringBuffer>, CharSequence {
// 캐시로 문자열을 직접 가지고 있어서 toString()을 여러 번 호출해도 효율적이고 빠릅니다
private transient String toStringCache;
// 아무것도 하지 않으면 기본적으로 16 bytes 크기를 가집니다
public StringBuffer() {
super(16);
}
// ....
// primitive type과 Object 객체의 append를 각자 구현하고 있습니다
// synchronized를 통해 thread-safe하게 함수를 구현합니다
@Override
public synchronized StringBuffer append(Object obj) {
toStringCache = null; // 새롭게 조작할 때마다 캐시를 초기화합니다
super.append(String.valueOf(obj)); // String.valueOf를 통해 null 안전하게 변환합니다
return this;
}
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
// 이외에도 StringBuffer, CharSequence, boolean, char, int 등의 구현체가 있습니다
// ...
}
append 함수의 super는 AbstractStringBuilder를 가리키고, AbstractStringBuilder의 구현은 다음과 같습니다.
abstract class AbstractStringBuilder implements Appendable, CharSequence {
// 문자열 값을 바이트 자료형으로 가집니다
byte[] value;
// 이 값은 라틴 문자열인지, UTF 문자열인지 구분해주는 값입니다
// 라틴일경우 0, UTF일경우 1 값을 가집니다
byte coder;
// 사용된 character의 갯수를 의미합니다
int count;
// str 초기값을 건네주면 16 + (str size) 만큼의 capacity를 가지게 됩니다
AbstractStringBuilder(String str) {
int length = str.length();
int capacity = (length < Integer.MAX_VALUE - 16) ? length + 16 : Integer.MAX_VALUE;
final byte initCoder = str.coder();
coder = initCoder;
value = (initCoder == LATIN1) ? new byte[capacity] : StringUTF16.newBytesFor(capacity);
append(str);
}
// ...
// append 함수는 현재 문자열의 가장 마지막에 인자로 받은 문자열을 추가해줍니다
// 필요하다면 내부 배열의 크기를 확장합니다
public AbstractStringBuilder append(String str) {
if (str == null) return appendNull(); // 문자열 "null"을 돌려줍니다
int len = str.length();
ensureCapacityInternal(count + len); // 필요 시 내부 배열을 확장합니다
putStringAt(count, str); // 문자열을 복사합니다
count += len; // 문자열 길이를 재계산합니다
return this;
}
// ....
private void ensureCapacityInternal(int minimumCapacity) {
// 내부 char[] 배열이 실제로 몇 개의 문자 단위를 담고 있는지 계산해줍니다
// Latin1일경우 code값이 0이므로 value.length 값 그대로 사용됩니다
// UTF일경우 code값이 1이므로 value.length / 2 값으로 사용됩니다
int oldCapacity = value.length >> code;
// 공간이 부족하면 배열을 확장합니다
if (minimumCapacity - oldCapacity > 0) {
value = Arrays.copyOf(value, newCapacity(minimumCapacity) << coder);
}
}
// ...
// minCapacity만큼 늘었을 때의 필요한 공간을 계산합니다
private int newCapacity(int minCapacity) {
// 현재 문자열 길이입니다
int oldLength = value.length;
// 문자 수를 byte로 변환합니다. UTF일경우 2배입니다.
int newLength = minCapacity << coder;
// 현재보다 얼마나 더 커져야 하는지 계산합니다
int growth = newLength - oldLength;
// 실제 늘어야 할 크기가 growth, 선호하는 크기(prefLength)는 oldLength + (2 << coder)입니다
// 두 값 중 최대 크기만큼 oldLength에 더해서 length를 계산합니다
// 실제로는 문자가 하나라면 oldLength + (2 << coder)만큼 늘어나고, 아니라면 growth만큼 늘어납니다.
int length = ArraysSupport.newLength(oldLength, growth, oldLength + (2 << coder));
if (length == Integer.MAX_VALUE) {
throw new OutOfMemoryError("Required length exceeds implementation limit");
}
return length >> coder;
}
// ...
private void putStringAt(int index, String str, int off, int end) {
// 인코딩 기준이 다르면 내부 배열을 UTF로 바꿔서 호환되게 합니다
if (getCoder() != str.coder()) {
inflate();
}
// value 배열에 str의 off index부터 (end - off)의 크기만큼 value의 index 위치부터 복사해줍니다
// coder를 통해서 LATIN인지 UTF인지 구분해줍니다.
str.getBytes(value, off, index, coder, end - off);
}
// ...
}
내부 구현을 보면 알 수 있듯이 서로 다른 encoding에도 대응되게 개발되어 있습니다.
왜 newCapacity에서 선호 길이로 oldLength + (2 << coder)를 사용할까?
2는 자바 문자열 버퍼 정책에서 나오는 "최소 확장 단위"로, 이유는 다음과 같이 추측할 수 있습니다
1. 대부분의 문자열 추가는 한두 글자이므로 작은 문자열 확장에서는 배열 복사를 방지하려고 여유로운 확장을 진행합니다
2. 너무 작은 확장은 비효율적이므로 "2글자 정도는 여유로 보자"는 정책으로 이해할 수 있습니다
StringBuilder: 가변(mutable)의 쓰레드 안전하지 않은 객체
StringBuilder는 StringBuffer처럼 내부 데이터가 변하는 가변 객체입니다.
StringBuffer와의 차이점은 동기화 기능이 없어서 멀티 쓰레드 환경에서 안전하지 않지만 StringBuffer보다 훨씬 빠르게 동작한다는 것입니다.
val sb = StringBuilder("Hello")
sb.append(" World")
println(sb.toString()) // Hello World
StringBuilder의 내부 구현은 StringBuffer와 동일한데, 차이점은 단지 synchronized 키워드가 빠져있다는 것 뿐입니다.
언제 뭘 써야할까?
아래 표를 보고 적당히 골라서 사용해보면 됩니다.
클래스 | 불변여부 | 쓰레드 안전성 | 성능 | 용도 |
String | 불변 | 안전함 | 느림 (수정 많을 경우) | 일반적인 문자열 처리 |
StringBuilder | 가변 | 불안전함 | 빠름 | 단일쓰레드에서 문자열 자주 수정 시 |
StringBuffer | 가변 | 안전함 | 느림 (동기화 비용) | 멀티쓰레드 환경에서 문자열 자주 수정 시 |
추가: 문자열 조합이 있으면 항상 StringBuilder/StringBuffer를 사용해야할까?
표대로 보면 String 연산이 존재한다면 항상 StringBuilder나 StringBuffer를 사용하는게 합리적으로 보입니다. 정말 그럴까요?
실제로는 Java 컴파일러가 사용자 편의를 위해 단순한 String + 연산은 StringBuilder를 사용하게 코드를 조작해버려서 단순한 String 조작도 대부분 효율적으로 동작하게 됩니다.
val str = "hello"
val str2 = str + " world"
// 위 코드는 아래처럼 만들어집니다
val compiledStr2 = StringBuilder(str).append(" world").toString()
다만 항상 효율적으로 동작하지는 않는게 문자열을 조작하는 경우가 많을경우 계속해서 새롭게 StringBuilder를 생성하므로 비효율적으로 동작할 수 있습니다.
var str = ""
repeat(100) {
str = " test "
}
// 아래처럼 새롭게 StringBuilder를 루프 수만큼 할당해서 비효율적으로 동작한다
var compiledStr = ""
repeat(100) {
compiledStr = StringBuilder(compiledStr).append(" test ").toString()
}
따라서 문자열 조작이 많을경우에는 명시적으로 StringBuilder를 만들어서 조작하는게 낫습니다.
// 명시적으로 StringBuilder를 사용하는게 낫습니다
val builder = StringBuilder()
repeat(100) {
builder.append(" test ")
}