문자열 — String, StringBuilder, 그리고 String Pool
Java에서 가장 많이 쓰는 타입은 아마
String일 것이다. 그런데 왜 String은 한번 만들면 바꿀 수 없을까? 리터럴과new String()은 뭐가 다른 걸까?
String은 한번 생성되면 내부 값을 변경할 수 없는 불변(Immutable) 객체 다. "바꾸는 것처럼 보이는" 모든 연산은 실제로는 새로운 String 객체를 만든다.
String이 불변인 이유
내부적으로 String 클래스는 final로 선언돼 있고, 문자 데이터를 담는 byte[]도 private final이다.
public final class String {
private final byte[] value; // 한번 할당되면 변경 불가
private int hash; // 해시코드 캐시
}
이렇게 설계한 이유는 네 가지다.
- **String Pool 공유 **: 같은 리터럴을 메모리에 하나만 두고 여러 변수가 공유한다. 불변이니까 한 변수가 값을 바꿔도 다른 변수에 영향이 없다.
- ** 해시코드 캐싱 **:
hashCode()를 최초 호출 시 계산한 뒤hash필드에 저장해둔다. 값이 안 바뀌니까 한 번만 계산하면 된다.HashMap의 키로String을 쓸 때 성능이 좋은 이유다. - ** 보안 **: DB 연결 문자열, 파일 경로 같은 민감한 정보가 전달 후에 바뀌지 않는다.
- ** 스레드 안전 **: 불변 객체는 여러 스레드가 동시에 읽어도 문제가 없다.
String Pool — 리터럴 vs new String()
이 차이를 이해하는 것이 문자열의 핵심이다.
String a = "hello"; // Pool에 저장
String b = "hello"; // Pool에서 같은 객체 재사용
String c = new String("hello"); // 힙에 새 객체 생성
┌─────────────────────────────┐
│ Heap 영역 │
│ ┌──────────────────┐ │
│ │ String Pool │ │
│ │ ┌──────────┐ │ │
│ │ │ "hello" │ ← a, b │
│ │ └──────────┘ │ │
│ └──────────────────┘ │
│ ┌──────────┐ │
│ │ "hello" │ ← c │
│ └──────────┘ │
└─────────────────────────────┘
** 리터럴 **("hello")은 컴파일 시점에 Pool에 등록되고, 같은 값이면 같은 객체를 재사용한다. new String()은 무조건 힙에 새 객체를 만든다. 그래서 a == b는 true지만, a == c는 false다.
intern()을 호출하면 해당 문자열의 Pool 참조를 얻을 수 있다. 다만 실무에서 직접 쓸 일은 많지 않다.
StringBuilder — 반복 연결의 성능 문제 해결
String이 불변이라는 건 문자열을 이어 붙일 때마다 ** 새 객체가 생긴다 **는 뜻이다.
String result = "";
for (int i = 0; i < 10000; i++) {
result += i; // 매 반복마다 새 String 객체 생성
}
10,000번 반복이면 10,000개의 임시 객체가 생긴다. StringBuilder는 내부 버퍼를 가지고 있어서 ** 같은 객체 안에서 수정 **된다.
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i); // 같은 객체 내부에서 추가
}
String result = sb.toString();
StringBuilder vs StringBuffer
핵심 차이는 ** 동기화(synchronized) 여부** 딱 하나다.
| 구분 | StringBuilder | StringBuffer |
|---|---|---|
| 동기화 | X | O (synchronized) |
| 스레드 안전 | X | O |
| 성능 | 빠름 | 느림 |
99%의 경우 StringBuilder면 충분하다. StringBuffer가 필요한 상황이라면 다른 동기화 전략(ConcurrentHashMap 등)을 고민하는 게 나을 수도 있다.
주요 메서드
StringBuilder sb = new StringBuilder("Hello");
sb.append(" World"); // 뒤에 추가
sb.insert(5, ","); // 특정 위치에 삽입
sb.delete(5, 6); // 범위 삭제
sb.replace(6, 11, "Java"); // 범위 치환
sb.reverse(); // 뒤집기
언제 뭘 쓸까
- 문자열 변경이 거의 없다 →
String - 반복문에서 이어 붙인다 →
StringBuilder - 멀티스레드에서 공유 수정한다 →
StringBuffer
문자열 비교 — ==와 equals()의 차이
Java 문자열에서 가장 흔한 실수다.
String a = "hello";
String c = new String("hello");
a == c; // false — 참조(주소) 비교
a.equals(c); // true — 내용(값) 비교
==는 "같은 객체냐"를 묻고, equals()는 "같은 값이냐"를 묻는다. Pool 여부에 따라 == 결과가 달라지므로, ** 문자열 비교는 항상 equals()를 써야 한다 **.
null이 올 수 있다면 리터럴을 앞에 두거나 Objects.equals()를 사용한다.
"hello".equals(input); // input이 null이어도 안전
Objects.equals(input, "hello"); // Java 7+
equalsIgnoreCase()는 대소문자를 무시하고 비교할 때, compareTo()는 사전순 비교(정렬)에 쓴다.
유용한 String 메서드
String s = " Hello, Java World! ";
s.trim(); // "Hello, Java World!" — 앞뒤 공백 제거
s.strip(); // Java 11+, 유니코드 공백도 처리
s.toUpperCase(); // 대문자 변환
s.substring(2, 7); // 부분 추출 [시작, 끝)
split()은 구분자로 분리하고, join()은 구분자로 합친다.
String[] fruits = "apple,banana,cherry".split(",");
String joined = String.join(" - ", fruits); // "apple - banana - cherry"
replace()는 단순 문자열 치환, replaceAll()은 정규식 기반 치환이다. 이 차이를 모르면 정규식 특수문자가 들어왔을 때 예상과 다른 결과가 나올 수 있다.
정규표현식
문자열에서 패턴을 찾을 때 Pattern과 Matcher를 쓴다.
Pattern pattern = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher m = pattern.matcher("2026-03-19");
if (m.matches()) {
String year = m.group(1); // "2026"
String month = m.group(2); // "03"
}
Pattern.compile()은 비용이 있는 연산이므로, 같은 패턴을 반복 사용한다면 ** 상수로 선언 **해두는 게 좋다.
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[\\w.-]+@[\\w.-]+\\.\\w{2,}$");
텍스트 블록 (Java 13+)
여러 줄 문자열을 """로 깔끔하게 쓸 수 있다.
String json = """
{
"name": "홍길동",
"age": 25
}
""";
닫는 """의 위치가 들여쓰기 기준점이 된다. JSON, HTML, SQL 같은 여러 줄 문자열의 가독성이 비약적으로 좋아진다.
주의할 점
반복문에서 + 연결은 성능 저하의 원인이다
단순한 a + b는 컴파일러가 최적화하지만, 반복문 안의 +=는 매번 새 객체를 만든다. 반복문에서 문자열을 이어 붙일 때는 반드시 StringBuilder를 쓰자.
== 비교가 "되는 것처럼 보이는" 함정
리터럴끼리 ==로 비교하면 Pool 덕분에 true가 나온다. 그래서 "==도 되네?"라고 착각하기 쉽다. 하지만 new String()이나 외부 입력으로 생성된 문자열은 Pool에 없으므로 false가 된다. 결국 equals()를 일관되게 쓰는 것이 유일한 안전 전략이다.
String.matches()는 매번 Pattern을 컴파일한다
"text".matches(regex)는 내부적으로 Pattern.compile(regex).matcher("text").matches()를 실행한다. 반복문 안에서 쓰면 매번 컴파일이 일어나 성능이 떨어진다. 반복 사용 시 Pattern을 상수로 미리 컴파일해두자.
정리
String vs StringBuilder vs StringBuffer
| 구분 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 가변 여부 | 불변 | 가변 | 가변 |
| 스레드 안전 | O (불변이므로) | X | O (synchronized) |
| 성능 | 변경 시 느림 | 빠름 | StringBuilder보다 느림 |
| 사용 시점 | 변경 적을 때 | 반복 연결 시 | 멀티스레드 공유 시 |
문자열 비교 방법
| 방법 | 비교 대상 | 용도 |
|---|---|---|
== | 참조(주소) | 같은 객체인지 (쓰지 말 것) |
equals() | 내용(값) | ** 문자열 비교의 표준** |
equalsIgnoreCase() | 내용 (대소문자 무시) | 이메일, 사용자 입력 비교 |
compareTo() | 사전순 | 정렬, 대소 비교 |