문자열 — String, StringBuilder, 그리고 String Pool
자바에서 가장 많이 쓰는 타입이 뭘까?
int?boolean? 아마 대부분의 프로그램에서 **String**이 압도적일 것이다. 그런데 이 문자열이라는 녀석, 생각보다 복잡하다. "왜String은 한번 만들면 바꿀 수 없지?" "리터럴이랑new String()이 뭐가 다른 거지?" — 이런 의문을 한 번이라도 가져봤다면 이 글이 도움이 될 것이다.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
String은 왜 불변(Immutable)인가
불변이라는 게 뭐냐면
String 객체는 한번 생성되면 내부 값을 절대 바꿀 수 없다. "바꾸는 것처럼 보이는" 모든 연산은 사실 새로운 String 객체를 만드는 것이다.
String name = "hello";
name = name + " world"; // "hello"가 바뀌는 게 아니라 "hello world"라는 새 객체가 생성됨
내부적으로 String 클래스는 final로 선언되어 있고, 문자 데이터를 담는 byte[] 배열도 private final이다.
// String 클래스의 내부 (간략화)
public final class String {
private final byte[] value; // 한번 할당되면 변경 불가
private int hash; // 해시코드 캐시
}
왜 이렇게 설계했을까?
공부하다 보니 "그냥 바꿀 수 있게 하면 편할 텐데?"라고 생각했는데, 불변으로 만든 이유가 꽤 많았다.
1. String Pool에서 공유하기 위해
Java는 같은 문자열 리터럴을 메모리에 하나만 두고 여러 변수가 공유한다. 만약 하나의 변수가 값을 바꿔버리면 같은 문자열을 참조하는 다른 변수에도 영향이 간다. 불변이니까 안전하게 공유할 수 있다.
2. 해시코드 캐싱
String의 hashCode()는 최초 호출 시 계산한 값을 hash 필드에 저장해둔다. 값이 절대 안 바뀌니까 한 번만 계산하면 된다. HashMap의 키로 String을 쓸 때 성능이 좋은 이유가 이것이다.
3. 보안
DB 연결 문자열, 파일 경로, 네트워크 URL 같은 민감한 정보가 String으로 전달된다. 불변이 아니면 전달 후에 값이 바뀔 수 있어서 보안 취약점이 된다.
4. 스레드 안전성
불변 객체는 여러 스레드가 동시에 읽어도 문제가 없다. 동기화 처리가 필요 없으니 멀티스레드 환경에서 자유롭게 쓸 수 있다.
▸ 기억 포인트: "String이 왜 불변인가요?"는 면접 단골 질문이다. Pool 공유, 해시 캐싱, 보안, 스레드 안전 — 이 네 가지를 떠올리자.
String Pool
리터럴 vs new String()
이 차이를 제대로 이해하는 게 문자열의 핵심이다.
// 리터럴 방식 — String Pool에 저장
String a = "hello";
String b = "hello";
// new 방식 — 힙(Heap)에 새 객체 생성
String c = new String("hello");
String d = new String("hello");
System.out.println(a == b); // true — 같은 Pool 객체 참조
System.out.println(a == c); // false — Pool과 힙은 다른 영역
System.out.println(c == d); // false — 각각 다른 힙 객체
그림으로 보면 이렇다.
┌─────────────────────────────────────┐
│ Heap 영역 │
│ │
│ ┌───────────────────────┐ │
│ │ String Pool │ │
│ │ ┌─────────────┐ │ │
│ │ │ "hello" │ ◄── a, b │
│ │ └─────────────┘ │ │
│ └───────────────────────┘ │
│ │
│ ┌─────────────┐ │
│ │ String 객체 │ ◄── c │
│ │ ("hello") │ │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ String 객체 │ ◄── d │
│ │ ("hello") │ │
│ └─────────────┘ │
└─────────────────────────────────────┘
- 리터럴(
"hello")은 컴파일 시점에 String Pool에 등록되고, 같은 값이면 같은 객체를 재사용한다. - **
new String()**은 무조건 힙에 새 객체를 만든다. Pool과 별개다.
intern() 메서드
intern()을 호출하면 해당 문자열이 Pool에 있는지 확인하고, 있으면 Pool의 참조를 반환한다.
String x = new String("world"); // 힙에 생성
String y = x.intern(); // Pool에 "world"가 있으면 그 참조 반환
String z = "world"; // Pool의 "world" 참조
System.out.println(x == z); // false — x는 힙 객체
System.out.println(y == z); // true — y는 Pool 참조
▸ 실무에서
intern()을 직접 쓸 일은 많지 않다. 하지만 "String Pool이 어떻게 동작하는지" 설명할 때 꼭 나오는 메서드이니 원리를 알아두자.
String vs StringBuilder vs StringBuffer
왜 StringBuilder가 필요할까?
String이 불변이라는 건 문자열을 이어 붙일 때마다 새 객체가 생긴다는 뜻이다.
// 비효율적인 문자열 연결
String result = "";
for (int i = 0; i < 10000; i++) {
result += i; // 매 반복마다 새 String 객체 생성!
}
이 코드는 반복할 때마다 새로운 String 객체를 만들고, 이전 객체는 가비지 컬렉션 대상이 된다. 10,000번이면 10,000개의 임시 객체가 생기는 셈이다.
StringBuilder — 가변(Mutable) 문자열
StringBuilder는 내부 버퍼를 가지고 있어서, 문자열을 추가해도 같은 객체 안에서 수정된다.
// 효율적인 문자열 연결
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i); // 같은 객체 내부에서 추가
}
String result = sb.toString(); // 최종 결과만 String으로 변환
StringBuilder vs StringBuffer
공부하다 보니 이 둘의 차이가 면접에서 정말 자주 나오더라고요. 핵심은 딱 하나다.
| 구분 | StringBuilder | StringBuffer |
|---|---|---|
| 가변(Mutable) | O | O |
| 동기화(synchronized) | X | O |
| 스레드 안전 | X | O |
| 성능 | 빠름 | 느림 |
| 도입 버전 | Java 5 | Java 1.0 |
// StringBuilder — 동기화 없음, 단일 스레드에서 사용
StringBuilder sb = new StringBuilder("hello");
sb.append(" world");
// StringBuffer — 동기화 있음, 멀티스레드에서 안전
StringBuffer sbf = new StringBuffer("hello");
sbf.append(" world"); // synchronized 메서드
주요 메서드
StringBuilder와 StringBuffer는 같은 메서드를 제공한다.
StringBuilder sb = new StringBuilder("Hello");
sb.append(" World"); // 뒤에 추가 → "Hello World"
sb.insert(5, ","); // 특정 위치에 삽입 → "Hello, World"
sb.delete(5, 6); // 범위 삭제 → "Hello World"
sb.replace(6, 11, "Java"); // 범위 치환 → "Hello Java"
sb.reverse(); // 뒤집기 → "avaJ olleH"
int len = sb.length(); // 길이
char ch = sb.charAt(0); // 특정 위치 문자
언제 뭘 써야 하나?
- 문자열 변경이 거의 없다 →
String - 반복문에서 문자열을 이어 붙인다 →
StringBuilder - 멀티스레드 환경에서 공유되는 문자열을 수정한다 →
StringBuffer
▸ 99%의 경우
StringBuilder면 충분하다.StringBuffer를 써야 하는 상황이라면 아마 다른 동기화 전략(예:ConcurrentHashMap)을 고민하는 게 나을 수도 있다.
문자열 비교
== vs equals()
이건 자바 문자열에서 가장 많이 실수하는 부분이다.
String a = "hello";
String b = "hello";
String c = new String("hello");
// == 는 참조(주소)를 비교한다
System.out.println(a == b); // true — 같은 Pool 객체
System.out.println(a == c); // false — Pool vs 힙, 다른 객체
// equals()는 내용(값)을 비교한다
System.out.println(a.equals(b)); // true
System.out.println(a.equals(c)); // true — 값이 같으니까
규칙은 단순하다: 문자열 비교는 항상 equals()를 쓴다. ==를 쓰면 Pool 여부에 따라 결과가 달라지므로 버그의 원인이 된다.
equalsIgnoreCase()
대소문자를 무시하고 비교할 때 쓴다.
String email1 = "User@Email.com";
String email2 = "user@email.com";
System.out.println(email1.equals(email2)); // false
System.out.println(email1.equalsIgnoreCase(email2)); // true
compareTo()
문자열을 사전순(lexicographic order)으로 비교한다. 정렬할 때 유용하다.
String a = "apple";
String b = "banana";
String c = "apple";
System.out.println(a.compareTo(b)); // 음수 — a가 b보다 앞
System.out.println(b.compareTo(a)); // 양수 — b가 a보다 뒤
System.out.println(a.compareTo(c)); // 0 — 같은 문자열
반환값 규칙:
- 음수: 호출 객체가 사전순으로 앞
- 0: 같음
- 양수: 호출 객체가 사전순으로 뒤
NullPointerException 방어
equals() 호출 시 null이 올 수 있다면, 리터럴을 앞에 두는 게 안전하다.
String input = null;
// input.equals("hello"); // NullPointerException 발생!
"hello".equals(input); // false — 안전하게 비교
또는 Java 7+에서는 Objects.equals()를 사용한다.
import java.util.Objects;
Objects.equals(input, "hello"); // false — null 안전
▸ 기억 포인트:
==는 "같은 객체냐",equals()는 "같은 값이냐"를 묻는 것이다. 문자열 비교는 반드시equals().
유용한 String 메서드
자주 쓰는 메서드를 한 번에 정리한다.
기본 조작
String s = " Hello, Java World! ";
// 공백 제거
s.trim(); // "Hello, Java World!" — 앞뒤 공백 제거
s.strip(); // "Hello, Java World!" — Java 11+, 유니코드 공백도 처리
// 대소문자 변환
s.trim().toUpperCase(); // "HELLO, JAVA WORLD!"
s.trim().toLowerCase(); // "hello, java world!"
// 길이와 빈 문자열 체크
s.length(); // 22 (공백 포함)
"".isEmpty(); // true — 길이가 0
" ".isBlank(); // true — Java 11+, 공백만 있으면 true
substring — 부분 문자열 추출
String str = "Hello, World!";
str.substring(7); // "World!" — 7번 인덱스부터 끝까지
str.substring(0, 5); // "Hello" — 0번부터 5번 전까지 (5 미포함)
공부하다 보니 substring()의 끝 인덱스가 미포함이라는 점에서 자주 헷갈렸다. [시작, 끝) 형태라고 기억하면 된다.
split — 문자열 분리
String csv = "apple,banana,cherry";
String[] fruits = csv.split(","); // ["apple", "banana", "cherry"]
// 정규식 특수문자는 이스케이프 필요
String path = "C:\\Users\\dev\\file.txt";
String[] parts = path.split("\\\\"); // ["C:", "Users", "dev", "file.txt"]
// 분리 개수 제한
String data = "a:b:c:d:e";
String[] limited = data.split(":", 3); // ["a", "b", "c:d:e"] — 최대 3개로 분리
join — 문자열 합치기
// 구분자로 합치기
String joined = String.join(", ", "apple", "banana", "cherry");
// "apple, banana, cherry"
// 배열도 가능
String[] arr = {"one", "two", "three"};
String result = String.join(" - ", arr);
// "one - two - three"
format과 formatted
// String.format() — C의 printf 스타일
String msg = String.format("%s님, %d개의 메시지가 있습니다.", "홍길동", 5);
// "홍길동님, 5개의 메시지가 있습니다."
// formatted() — Java 15+, 인스턴스 메서드 버전
String msg2 = "%s님, %d개의 메시지가 있습니다.".formatted("홍길동", 5);
// 같은 결과
자주 쓰는 포맷 지정자:
| 지정자 | 의미 | 예시 |
|---|---|---|
%s | 문자열 | "hello" |
%d | 정수 | 42 |
%f | 실수 | 3.14 |
%.2f | 소수점 2자리 | 3.14 |
%n | 줄바꿈 | (OS에 맞는 줄바꿈) |
그 외 유용한 메서드
String s = "Hello, World!";
// 포함 여부
s.contains("World"); // true
s.startsWith("Hello"); // true
s.endsWith("!"); // true
// 검색
s.indexOf("World"); // 7 — 처음 등장하는 위치
s.lastIndexOf("l"); // 10 — 마지막으로 등장하는 위치
s.indexOf("xyz"); // -1 — 못 찾으면 -1
// 치환
s.replace("World", "Java"); // "Hello, Java!"
s.replaceAll("\\w+", "*"); // "*, *!" — 정규식 사용
// 문자 → 문자열 변환
String.valueOf(42); // "42"
String.valueOf(true); // "true"
String.valueOf('A'); // "A"
▸
replace()는 단순 문자열 치환,replaceAll()은 정규식 기반 치환이다. 이 차이를 모르면 정규식 특수문자가 들어왔을 때 버그가 난다.
정규표현식 기초
Pattern과 Matcher
문자열에서 패턴을 찾고 싶을 때 정규표현식(Regex)을 사용한다. Java에서는 Pattern과 Matcher 클래스를 쓴다.
import java.util.regex.Pattern;
import java.util.regex.Matcher;
// 이메일 형식 검증 (간단 버전)
String email = "user@example.com";
String regex = "^[\\w.-]+@[\\w.-]+\\.\\w{2,}$";
// 방법 1: String의 matches()
boolean isValid = email.matches(regex); // true
// 방법 2: Pattern + Matcher (반복 사용 시 성능 유리)
Pattern pattern = Pattern.compile(regex); // 패턴 컴파일 (한 번만)
Matcher matcher = pattern.matcher(email);
boolean isValid2 = matcher.matches(); // true
자주 쓰는 정규식 패턴
| 패턴 | 의미 | 예시 |
|---|---|---|
. | 아무 문자 1개 | a.c → abc, a1c |
\d | 숫자 ([0-9]) | \d{3} → 123 |
\w | 단어 문자 ([a-zA-Z0-9_]) | \w+ → hello_123 |
\s | 공백 문자 | \s+ → 공백/탭/줄바꿈 |
* | 0회 이상 반복 | ab*c → ac, abbc |
+ | 1회 이상 반복 | ab+c → abc, abbc |
? | 0회 또는 1회 | ab?c → ac, abc |
{n,m} | n~m회 반복 | \d{2,4} → 12, 1234 |
^, $ | 시작, 끝 | ^Hello$ |
[] | 문자 클래스 | [aeiou] → 모음 |
그룹 추출
// 날짜에서 년, 월, 일 추출하기
String date = "2026-03-19";
Pattern p = Pattern.compile("(\\d{4})-(\\d{2})-(\\d{2})");
Matcher m = p.matcher(date);
if (m.matches()) {
String year = m.group(1); // "2026"
String month = m.group(2); // "03"
String day = m.group(3); // "19"
System.out.println(year + "년 " + month + "월 " + day + "일");
}
replaceAll()과 정규식
// HTML 태그 제거
String html = "<p>Hello <b>World</b></p>";
String text = html.replaceAll("<[^>]+>", ""); // "Hello World"
// 연속 공백을 하나로
String messy = "hello world java";
String clean = messy.replaceAll("\\s+", " "); // "hello world java"
// 전화번호 형식 변환
String phone = "01012345678";
String formatted = phone.replaceAll("(\\d{3})(\\d{4})(\\d{4})", "$1-$2-$3");
// "010-1234-5678"
▸
Pattern.compile()은 비용이 있는 연산이다. 같은 패턴을 반복 사용한다면 컴파일 결과를 상수로 저장해두는 게 좋다.
// 권장: 패턴을 상수로 선언
private static final Pattern EMAIL_PATTERN =
Pattern.compile("^[\\w.-]+@[\\w.-]+\\.\\w{2,}$");
public boolean isValidEmail(String email) {
return EMAIL_PATTERN.matcher(email).matches();
}
텍스트 블록 (Java 13+)
여러 줄 문자열의 고통
Java 13 이전에는 여러 줄 문자열을 쓰려면 이런 식이었다.
// 기존 방식 — 읽기도 쓰기도 고통스럽다
String json = "{\n" +
" \"name\": \"홍길동\",\n" +
" \"age\": 25,\n" +
" \"email\": \"hong@example.com\"\n" +
"}";
텍스트 블록으로 깔끔하게
텍스트 블록(""")을 사용하면 여러 줄 문자열을 그대로 쓸 수 있다.
// 텍스트 블록 — Java 13+ (정식: Java 15)
String json = """
{
"name": "홍길동",
"age": 25,
"email": "hong@example.com"
}
""";
핵심 규칙
// 1. 여는 """ 뒤에 바로 내용을 쓸 수 없다 (줄바꿈 필수)
String s = """
내용은 여기서부터
""";
// 2. 닫는 """의 위치가 들여쓰기 기준점이 된다
String a = """
Hello
World
""";
// 결과: "Hello\nWorld\n" — 닫는 """가 같은 들여쓰기
String b = """
Hello
World
""";
// 결과: "Hello\nWorld\n" — 들여쓰기 없음
// 3. formatted()와 조합 가능 (Java 15+)
String html = """
<html>
<body>
<p>안녕하세요, %s님!</p>
</body>
</html>
""".formatted("홍길동");
▸ 텍스트 블록은 JSON, HTML, SQL 같은 여러 줄 문자열을 다룰 때 가독성이 비약적으로 좋아진다. Java 15 이상을 쓰고 있다면 적극 활용하자.
실전 예제
지금까지 배운 내용을 종합해서 실전 문제를 풀어보자.
예제 1: CSV 파싱
// CSV 한 줄을 파싱해서 객체 리스트로 변환
String csvData = """
이름,나이,이메일
홍길동,25,hong@example.com
김영희,30,kim@example.com
박철수,28,park@example.com
""";
String[] lines = csvData.strip().split("\n"); // 줄 단위로 분리
String[] headers = lines[0].split(","); // 헤더 추출
for (int i = 1; i < lines.length; i++) {
String[] fields = lines[i].split(",");
// 각 필드를 이름, 나이, 이메일로 매핑
String name = fields[0];
int age = Integer.parseInt(fields[1]);
String email = fields[2];
System.out.println(name + " (" + age + "세) - " + email);
}
예제 2: 로그 분석
// 로그에서 ERROR 레벨만 추출하고, 타임스탬프와 메시지를 분리
String log = """
2026-03-19 10:00:01 [INFO] 서버 시작됨
2026-03-19 10:00:05 [ERROR] DB 연결 실패: timeout
2026-03-19 10:00:10 [INFO] 재시도 중...
2026-03-19 10:00:15 [ERROR] DB 연결 실패: connection refused
""";
Pattern logPattern = Pattern.compile(
"(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}) \\[ERROR\\] (.+)"
);
// 각 줄에서 ERROR 패턴을 찾아 추출
for (String line : log.strip().split("\n")) {
Matcher m = logPattern.matcher(line);
if (m.matches()) {
String timestamp = m.group(1); // 타임스탬프
String message = m.group(2); // 에러 메시지
System.out.println("[" + timestamp + "] " + message);
}
}
// 출력:
// [2026-03-19 10:00:05] DB 연결 실패: timeout
// [2026-03-19 10:00:15] DB 연결 실패: connection refused
예제 3: 문자열을 활용한 간단한 템플릿 엔진
// 간단한 템플릿 치환 엔진
String template = "안녕하세요, {{name}}님! {{count}}개의 새 알림이 있습니다.";
// 치환할 데이터
Map<String, String> data = Map.of(
"name", "홍길동",
"count", "3"
);
// {{key}}를 찾아서 값으로 치환
String result = template;
for (Map.Entry<String, String> entry : data.entrySet()) {
result = result.replace("{{" + entry.getKey() + "}}", entry.getValue());
}
System.out.println(result);
// "안녕하세요, 홍길동님! 3개의 새 알림이 있습니다."
정리 테이블
String vs StringBuilder vs StringBuffer
| 구분 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 가변 여부 | 불변(Immutable) | 가변(Mutable) | 가변(Mutable) |
| 스레드 안전 | O (불변이므로) | X | O (synchronized) |
| 성능 | 변경 시 느림 | 빠름 | StringBuilder보다 느림 |
| 사용 시점 | 변경 적을 때 | 반복 연결 시 | 멀티스레드 공유 시 |
문자열 비교 방법
| 방법 | 비교 대상 | 용도 |
|---|---|---|
== | 참조(주소) | 같은 객체인지 확인 (거의 안 씀) |
equals() | 내용(값) | 문자열 비교의 표준 |
equalsIgnoreCase() | 내용 (대소문자 무시) | 이메일, 사용자 입력 비교 |
compareTo() | 사전순 | 정렬, 대소 비교 |
주요 String 메서드 요약
| 메서드 | 설명 | 반환 |
|---|---|---|
length() | 문자열 길이 | int |
charAt(i) | i번째 문자 | char |
substring(s, e) | 부분 문자열 [s, e) | String |
split(regex) | 분리 | String[] |
join(delim, ...) | 합치기 | String |
trim() / strip() | 공백 제거 | String |
contains(s) | 포함 여부 | boolean |
indexOf(s) | 위치 검색 | int |
replace(a, b) | 단순 치환 | String |
replaceAll(regex, b) | 정규식 치환 | String |
format(fmt, ...) | 포맷팅 | String |
matches(regex) | 정규식 매칭 | boolean |
toUpperCase() / toLowerCase() | 대소문자 변환 | String |
다음 글에서는 Enum과 어노테이션을 다룬다. 상수를 더 안전하게 다루는 방법과, 어노테이션이 실제로 뭘 하는 건지 궁금하다면 이어서 보자.