1. 개요

String a = "hi"; a.concat("!") 을 해도 a 는 여전히 "hi" 다. String 은 한 번 만들어지면 내용을 바꿀 수 없다. 이 글은 왜 자바가 String 을 불변으로 설계했는지, 그리고 그 때문에 생기는 == 실수와 StringBuilder 를 언제 써야 하는지 정리한다.


2. 핵심 내용

2-1. String Pool 과 ==

문자열 리터럴은 힙 안의 별도 영역인 String Pool 에 저장되고, 같은 내용의 리터럴은 재사용된다.

String a = "hello";
String b = "hello";
System.out.println(a == b); // true, 같은 풀 객체를 참조
 
String c = new String("hello");
System.out.println(a == c); // false, new 로 힙에 새로 만든 별개 객체
System.out.println(a.equals(c)); // true, 내용은 같음

이게 가능한 이유가 불변성이다. 내용이 바뀔 수 있다면 여러 변수가 같은 객체를 공유하는 순간 한쪽의 변경이 다른 쪽에도 영향을 줘서 위험하다. 불변이니까 안심하고 재사용할 수 있다. 실무에서 흔한 실수는 substring, concat, 외부에서 받은 문자열 등 런타임에 만들어진 String 을 == 로 비교하는 것이다. 겉보기엔 같은 값인데 false 가 나와서 당황하게 된다. String 비교는 항상 .equals() 다.

2-2. 왜 불변으로 설계했나

  • 스레드 안전성: 값이 안 바뀌니 여러 스레드가 동기화 없이 같은 String 을 공유해도 안전하다.
  • 해시 캐싱: String 은 hashCode() 값을 내부에 캐싱해둔다. 내용이 안 바뀌니 한 번만 계산하면 된다. HashMap 의 키로 String 을 많이 쓰는 이유이기도 하다. (hashCode 계약 얘기는 equals 와 hashCode 를 함께 재정의해야 하는 이유 참고)
  • 보안: 파일 경로, 클래스 이름, 네트워크 주소 같은 민감한 값이 String 으로 전달되는데, 검증한 뒤에 값이 몰래 바뀔 수 있다면 보안 구멍이 된다. 불변이면 검증한 값이 끝까지 유지된다.

2-3. 그럼 반복문에서 문자열을 이어붙일 땐?

String 은 + 연산마다 새 객체를 만든다. 반복문 안에서 += 를 쓰면 매번 새 객체가 생겨서 반복 횟수가 늘수록 성능이 급격히 나빠진다(대략 O(n²)).

// 나쁜 예: 반복마다 새 String 객체 생성
String result = "";
for (int i = 0; i < 10000; i++) {
    result += i; // 매번 새 객체
}
 
// 좋은 예: 내부 버퍼를 그대로 수정
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i);
}
String result = sb.toString();

StringBuilder 는 가변이라 내부 버퍼를 그대로 늘려가며 이어붙인다. 멀티스레드 환경에서 동기화까지 필요하면 StringBuffer 를 쓰면 되는데, 요즘은 대부분 지역 변수로 쓰기 때문에 동기화 오버헤드가 없는 StringBuilder 를 기본으로 쓴다.


3. 마무리

요약

  • String 은 불변이라 리터럴을 String Pool 에서 안전하게 재사용할 수 있다.
  • == 는 참조 비교, .equals() 는 내용 비교다. String 비교는 항상 .equals() 를 쓴다.
  • 불변성 덕분에 스레드 안전, 해시 캐싱, 보안 이점을 얻는다.
  • 반복적인 문자열 결합은 StringBuilder 로, 멀티스레드 공유 버퍼가 필요하면 StringBuffer 로.