Java String은 왜 불변인가
String 불변성의 설계 근거부터 String Pool의 메모리 구조, StringBuilder의 성능 원리, 그리고 실전 패턴의 공통 철학까지, Java 문자열의 모든 결정을 추적한다.
- 01 Java String은 왜 불변인가
- 02 Java 수 타입의 설계 철학 — 왜 세 가지가 필요한가
- 03 Java 배열과 Arrays 클래스, 무엇을 알아야 하는가
- 04 Java Generics는 왜 타입을 지운가
- 05 Java Enum은 단순한 상수 묶음이 아니다
- 06 Java 예외는 어떻게 설계해야 하는가
- 07 Java Collections Framework, 하나의 철학으로 읽는다
- 08 Java 함수형 인터페이스는 왜 이렇게 설계됐을까
- 09 Modern Java의 불변 설계 — Record, Switch, Sealed Class
- 10 Java Util API의 공통 철학: 비교, 흐름, 부재, 패턴
- 11 Java 8 Time API는 왜 기존 Date를 버렸는가
- 12 Java IO는 왜 이렇게 복잡한가
- 13 Java Reflection은 어떻게 프레임워크를 만드는가
- 14 Java 동시성은 왜 이렇게 설계됐을까
Java에서 String은 단순한 문자 배열이 아니다. 불변 객체이고, 전용 메모리 풀을 갖고, + 연산자 오버로딩까지 허용받는 유일한 클래스다. 왜 이렇게 설계됐을까? 그리고 이 설계가 StringBuilder의 존재 이유와, 수많은 실전 패턴의 선택 기준을 어떻게 결정하는가?
불변성: 모든 것의 출발점
String이 불변(immutable)이라는 뜻은, 한 번 생성된 객체의 내용을 절대 변경할 수 없다는 것이다. str.replace("old", "new")는 원본을 바꾸지 않는다. 새로운 String 객체를 반환할 뿐이다.
이 설계에는 네 가지 구체적인 이유가 있다.
첫째, 스레드 안전성이다. 불변 객체는 락 없이 여러 스레드에서 공유할 수 있다. 아무도 내용을 바꿀 수 없으니 동기화가 불필요하다. 둘째, 보안이다. 파일 경로, 패스워드, URL을 검증하는 도중 다른 스레드가 값을 교체하는 공격이 불가능하다. 셋째, HashMap 키로 안전하다. 키가 변경되면 해시 버킷 위치가 달라져 맵이 망가진다. 불변성이 이를 원천 차단한다. 넷째, String Pool이 가능하다 — 이것이 가장 큰 귀결이다.
String Pool: 불변성의 수익
JVM은 Heap 내부에 String Pool이라는 특별한 영역을 유지한다. 리터럴로 생성된 문자열은 여기에 저장되고, 같은 내용이면 객체를 하나만 만든다.
String s1 = "Hello";
String s2 = "Hello";
System.out.println(s1 == s2); // true — 같은 객체
new String("Hello")는 Pool을 우회해 Heap에 별도 객체를 만든다. 이 두 경로가 다르기 때문에 ==가 아닌 equals()로 내용을 비교해야 한다는 규칙이 생긴다.
[String Pool] [Heap]
"Hello" (0x100) ← s1, s2
"Hello" (0x200) ← new String("Hello")
Pool이 가능한 이유는 오직 불변성 덕분이다. 여러 참조가 같은 객체를 가리켜도, 어느 쪽도 내용을 바꿀 수 없으니 공유가 안전하다. 불변성이 없으면 Pool은 존재할 수 없다.
new String("Hello").intern()은 Pool에서 동일 내용을 찾아 반환한다. 컴파일러는 "Hello" + "World" 같은 리터럴 연결을 컴파일 타임에 "HelloWorld" 하나로 최적화한다. 변수가 개입되는 순간 런타임 연결이 되어 새 객체가 생성된다.
불변성의 대가: 왜 StringBuilder가 필요한가
불변성은 비용이 있다. 문자열을 연결할 때마다 새 객체가 생성된다.
String result = "";
for (int i = 0; i < 10000; i++) {
result += i; // 매 반복마다 새 String 객체 생성
}
// 약 800~1500ms
10,000번 반복이면 10,000개 객체가 생성되고 버려진다. GC 부담과 시간 낭비가 선형으로 쌓인다. StringBuilder는 이 문제를 내부 가변 버퍼로 해결한다.
StringBuilder sb = new StringBuilder(10000); // 초기 용량 지정
for (int i = 0; i < 10000; i++) {
sb.append(i); // 버퍼 내부 수정, 새 객체 없음
}
String result = sb.toString(); // 마지막에 한 번만 String 생성
// 약 2~5ms — 300배 이상 빠름
초기 용량을 예측해 지정하면 버퍼 재할당도 줄어든다. 재할당 공식은 (현재 용량 × 2) + 2다. 용량을 예상할 수 있다면 처음부터 충분히 잡아라.
StringBuffer는 StringBuilder와 API가 동일하지만 모든 메서드에 synchronized가 붙는다. 멀티스레드에서 공유하는 경우에만 쓰고, 단일 스레드라면 항상 StringBuilder를 선택한다.
비교, 검색, 변환의 공통 규칙
불변성에서 파생되는 패턴 규칙은 일관된다.
비교: ==는 참조(메모리 주소)를 비교한다. equals()는 내용을 비교한다. Scanner로 받은 입력은 항상 Heap에 새 객체로 생성되므로, input == "exit"는 거의 항상 false다. null 안전 비교가 필요하면 "exit".equals(input) 패턴이나 Objects.equals()를 쓴다.
변환: toUpperCase(), replace(), trim() 같은 모든 메서드는 원본을 변경하지 않고 새 String을 반환한다. 결과를 받지 않으면 의미가 없다.
검색: indexOf()는 위치를 반환하고 못 찾으면 -1을 돌려준다. contains()는 존재 여부만 필요할 때 더 직관적이다. split()에서 .은 정규식의 “모든 문자”를 의미하므로, IP 주소를 쪼갤 때는 반드시 \\.로 이스케이프해야 한다.
성능: 반복 연결은 StringBuilder, 단순 결합은 String.join(), 정규식을 반복 사용한다면 Pattern.compile()로 컴파일 결과를 상수로 캐싱하라.
// 정규식 매번 컴파일 — 비효율
email.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$");
// 캐싱 — 약 10배 이상 빠름
private static final Pattern EMAIL = Pattern.compile("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$");
EMAIL.matcher(email).matches();
트레이드오프
불변성은 안전성과 Pool을 얻는 대신 연결 비용을 지불한다. StringBuilder는 속도를 얻는 대신 스레드 안전성을 포기한다. StringBuffer는 스레드 안전성을 얻는 대신 성능 오버헤드를 감수한다. Java 9+의 Compact Strings는 순수 ASCII 문자열을 byte[]로 저장해 메모리를 절반으로 줄이지만, 한글 같은 멀티바이트 문자는 여전히 2바이트로 처리된다.
정리
- String이 불변인 이유는 스레드 안전성, 보안, HashMap 키 신뢰성, 그리고 String Pool 구현 가능성이다.
- String Pool은 불변성 덕분에 존재하며, 같은 내용의 리터럴을 하나의 객체로 공유한다.
- 반복 연결에는 반드시
StringBuilder를 쓴다. 초기 용량 예측이 성능을 추가로 개선한다. equals()vs==, 정규식 이스케이프,Pattern캐싱 — 모두 불변성과 내부 구조를 이해하면 자연스럽게 따라오는 규칙이다.
다음 글에서는 int, long, char 같은 기본 타입의 박싱/언박싱과 Integer 캐시 범위가 어떤 성능 함정을 만드는지 추적한다.