String s = "ab"; s += "c";가 새 객체를 만드는 이유 — 불변 문자열과 배열의 세계
웹 서버 로그를 조합하려고 루프 안에서 String message += line + "\n";을 실행했다. 데이터가 적을 때는 잘 돌아간다. 그러다 처리량이 늘어나면 GC 로그에 java.lang.String 객체가 폭발적으로 쌓이고, 결국 OutOfMemoryError가 발생한다. "문자열 더하기"가 왜 이런 결과를 부르는가?
답은 단어 하나에 있다: String은 불변(immutable)이다. +=는 기존 문자열을 수정하지 않고 새 String 객체를 생성한다. (JLS §15.18.1) 이 글은 String의 불변성이 만드는 모든 결과 — String pool, StringBuilder, 성능, thread safety — 와 배열의 동작 방식을 풀어간다.
String은 불변이다 — 그 의미와 결과
Java의 String 클래스는 한 번 생성되면 내용을 변경할 수 없다. [(String API - "Strings are constant")]](https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/String.html)
// Java 25
String s = "Hello";
s.concat(" World"); // 새 String "Hello World"를 반환하지만 s는 변하지 않음
System.out.println(s); // Hello
s.concat(" World")는 s를 수정하지 않는다. "Hello World"라는 새로운 String 객체를 반환한다. s는 여전히 "Hello"를 가리킨다.
flowchart LR
S1["s → "Hello"<br/>(불변)"] -->|concat| S2["새 객체 "Hello World"<br/>(아무도 참조 안 함 → GC 대상)"]
이 설계가 초래하는 결과:
| 결과 | 의미 |
|---|---|
| thread safe | 여러 스레드가 같은 String을 동시에 읽어도 안전 (내용이 변하지 않으므로) |
| hash cache | hashCode()를 한 번 계산하면 다시 계산할 필요 없음 (내용이 안 바뀌므로) |
| security | 파일 경로, DB URL 등이 String으로 전달 중 변조되지 않음 |
| 성능 비용 | 문자열 수정마다 새 객체 생성 → GC 부하 |
String pool — 같은 리터럴은 재사용된다
JVM은 문자열 리터럴을 String pool(string intern pool)에서 관리한다. (JLS §3.10.5)
// Java 25
String a = "Hello";
String b = "Hello"; // 같은 리터럴 → pool의 같은 객체 재사용
System.out.println(a == b); // true (같은 객체)
String c = new String("Hello"); // 새 객체 생성 (pool을 거치지 않음)
System.out.println(a == c); // false (다른 객체)
System.out.println(a.equals(c)); // true (내용은 같음)
new String("Hello")는 String pool을 확인하지 않고 무조건 새 객체를 heap에 생성한다. 거의 쓸 이유가 없다 — new String()은 코드 리뷰에서 즉시 지적 대상이다.
String pool은 Java 7부터 heap 영역에 저장된다(이전은 PermGen).
-XX:StringTableSize로 pool 크기(버킷 수)를 조절할 수 있다.String.intern()으로 명시적으로 pool에 등록할 수 있으나, 성능 이득보다 메모리 부담이 클 수 있어 신중하게 사용한다.
StringBuilder — 불변 문자열의 성능 해결책
문자열을 반복적으로 수정해야 할 때 — 루프 안의 조합, 동적 쿼리 생성, 로그 메시지 조립 — StringBuilder를 쓴다. 이것은 가변(mutable) 문자열 시퀀스다.
// Java 25
// 잘못된 방식: 매 반복마다 새 String 객체 생성
String result = "";
for (String word : words) {
result += word + " "; // 기존 result 읽기 + 새 String 생성 + 기존 result는 GC 대상
}
// 올바른 방식: StringBuilder 사용
StringBuilder sb = new StringBuilder();
for (String word : words) {
sb.append(word).append(" "); // 내부 버퍼를 직접 수정, 새 객체 생성 없음
}
String result = sb.toString(); // 마지막에 한 번만 String으로 변환
flowchart TD
subgraph bad["String += (매 반복)"]
B1["기존 String 읽기"] --> B2["새 String 객체 생성"]
B2 --> B3["기존 String은 GC 대상"]
B3 --> B1
end
subgraph good["StringBuilder.append (매 반복)"]
G1["내부 char[] 버퍼에 직접 쓰기"] --> G1
end
StringBuilder vs StringBuffer
| 클래스 | 동기화 | 용도 |
|---|---|---|
StringBuilder |
없음 | 단일 스레드 — 대부분의 경우 |
StringBuffer |
synchronized |
다중 스레드 환경 (거의 사용 안 함) |
StringBuffer는 Java 1.0부터 있던 클래스고, StringBuilder는 Java 5에서 추가됐다. 차이는 동기화뿐이다. 단일 스레드에서 StringBuffer를 쓰면 불필요한 동기화 오버헤드만 발생한다. 단일 스레드에서는 무조건 StringBuilder를, 문자열 조합이 thread-safe해야 하는 드문 경우에만 StringBuffer를 쓴다.
컴파일러가 단일 표현식의
+연산을StringBuilder로 자동 변환해 주지만 —String s = a + b + c;는 효율적이다 — 루프 안의+=는 매번 새StringBuilder를 생성하므로 최적화되지 않는다. 루프에서는 명시적으로StringBuilder를 써야 한다.
Text Block (Java 15+, JEP-378)
여러 줄 문자열을 다룰 때, 이스케이프(\n)를 반복하는 대신 text block(""")을 쓴다. (JEP-378)
// Java 25 — text block
String json = """
{
"name": "Java",
"version": 21,
"features": ["virtual threads", "pattern matching"]
}
""";
// 이전 방식
String oldJson = "{\n" +
" \"name\": \"Java\",\n" +
" \"version\": 21\n" +
"}";
text block의 핵심 규칙:
"""로 시작하고, 같은 줄에 내용 없이 줄바꿈- 들여쓰기는 가장 적게 들여쓴 줄을 기준으로 자동 제거(incidental whitespace)
- 이스케이프 없이
"를 그대로 사용 가능 \s(trailing space 보존)와\(줄 연결) 특수 이스케이프 지원
// Java 25 — text block 들여쓰기 규칙
String text = """
첫째 줄
둘째 줄 (들여쓰기 4칸 유지)
셋째 줄
""";
// 가장 적게 들여쓴 줄(첫째/셋째)이 기준 → 그 들여쓰기가 제거됨
// 결과:
// 첫째 줄
// 둘째 줄 (들여쓰기 4칸 유지)
// 셋째 줄
문자열 포맷팅 — String.format vs formatted
// Java 25
String name = "Java";
int version = 21;
// String.format
String s1 = String.format("Hello, %s %d", name, version);
// String.formatted (Java 15+, 인스턴스 메서드)
String s2 = "Hello, %s %d".formatted(name, version);
%s(문자열), %d(정수), %f(실수), %n(플랫폼별 줄바꿈), %.2f(소수점 2자리) 등 C printf 스타일 형식 지정자를 사용한다.
// Java 25 — 포맷팅 예제
double price = 19500.0;
System.out.println("가격: %,.0f원".formatted(price)); // 가격: 19,500원
System.out.println("진행률: %.1f%%".formatted(73.5)); // 진행률: 73.5%
배열 — Java에서 객체인 이유
Java의 배열은 C/C++와 달리 객체다. (JLS §10 - Arrays) 길이를 알고(.length), bounds checking을 수행하며, heap에 할당된다.
// Java 25
int[] nums = { 10, 20, 30 };
System.out.println(nums.length); // 3 (필드, 메서드 아님 — 괄호 없음)
System.out.println(nums[0]); // 10
System.out.println(nums[3]); // ArrayIndexOutOfBoundsException
배열은 공변(covariant)이다 — 제네릭과의 차이
// Java 25
Object[] objects = new String[10]; // 컴파일됨! 배열은 공변
objects[0] = "Hello"; // OK
// objects[1] = 42; // ArrayStoreException at runtime!
배열은 String[]이 Object[]의 하위 타입이 된다(공변). 이것이 컴파일은 되지만 런타임에 ArrayStoreException을 발생시키는 원인이다. 제네릭은 이 문제를 피하기 위해 불공변(invariant)이다 — List<String>은 List<Object>의 하위 타입이 아니다. (자세한 건 11-generics.md에서.)
배열 vs ArrayList — 언제 무엇을 쓸까
| 항목 | int[] / String[] |
ArrayList<T> |
|---|---|---|
| 크기 | 고정 (생성 시 결정) | 가변 (자동 확장) |
| 타입 | 공변(런타임 타입 안전) | 불공변(컴파일 타임 타입 안전) |
| 성능 | 원시 타입 직접 저장(박싱 없음) | 원시 타입 불가(Integer 필요, 오토박싱) |
| 메모리 | 최소 (객체 헤더 + 요소) | 더 큼 (객체 헤더 + 용량 배열 + 크기) |
| 사용 시기 | 크기를 알고, 불변 원시 데이터 | 가변 크기, 추가/삭제 필요 |
크기가 정해진
int[],double[]등은 박싱 비용 없이 원시 값을 직접 저장하므로 성능이 좋다. 크기가 가변이거나 객체를 다루면ArrayList가 더 적합하다.
배열 복사 — clone, Arrays.copyOf, System.arraycopy
// Java 25
int[] original = { 1, 2, 3, 4, 5 };
// 얕은 복사 (새 배열, 같은 요소)
int[] copy1 = original.clone();
int[] copy2 = Arrays.copyOf(original, 3); // { 1, 2, 3 }
int[] copy3 = new int[5];
System.arraycopy(original, 0, copy3, 0, 5); // { 1, 2, 3, 4, 5 }
// 다차원 배열은 얕은 복사 주의
int[][] matrix = { { 1, 2 }, { 3, 4 } };
int[][] shallow = matrix.clone();
shallow[0][0] = 99;
System.out.println(matrix[0][0]); // 99 (내부 배열은 공유됨!)
다차원 배열에서
clone()은 외부 배열만 복사한다. 내부 배열은 동일한 참조를 공유한다 — 얕은 복사. 깊은 복사가 필요하면 각 내부 배열을 개별적으로 복사해야 한다.
Modern String 메서드 (Java 11+)
Java 버전이 올라가면서 String에 실용적인 메서드들이 추가됐다. 이 메서드들은 기존 코드에서 흔히 하던 수동 처리를 한 줄로 줄여준다.
// Java 25
// strip() vs trim(): strip은 Unicode 공백을 처리, trim은 ASCII 공백(<= U+0020)만
String s = "\u3000Hello\u3000"; // \u3000 = 전각 공백 (CJK)
System.out.println(s.trim()); // 전각 공백 제거 안 됨
System.out.println(s.strip()); // 전각 공백 제거됨
// lines() — 줄 단위 Stream (Java 11+)
String csv = "name,age\nJava,21\nKotlin,2";
csv.lines().forEach(System.out::println);
// name,age
// Java,21
// Kotlin,2
// repeat(n) — 문자열 반복 (Java 11+)
String divider = "-".repeat(30); // "------------------------------"
// isBlank() — 공백 또는 빈 문자열 확인 (Java 11+)
System.out.println(" ".isBlank()); // true (trim().isEmpty()와 다름: 공백도 blank)
System.out.println("".isEmpty()); // true
strip()과trim()의 차이는 Unicode 공백 처리다.trim()은 ASCII 공백(U+0020이하)만 제거하므로, 전각 공백(\u3000)이나 다른 Unicode 공백 문자가 포함된 문자열에서는 예상과 다른 결과가 나온다. 국제화 환경에서는strip()을 기본으로 쓴다. (String.strip API)
실습 — 문자열 성능 체감
// Java 25 — StringPerf.java
public class StringPerf {
public static void main(String[] args) {
int n = 100_000;
// String += 방식
long start = System.currentTimeMillis();
String result = "";
for (int i = 0; i < n; i++) {
result += "x";
}
long stringTime = System.currentTimeMillis() - start;
// StringBuilder 방식
start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append("x");
}
String result2 = sb.toString();
long builderTime = System.currentTimeMillis() - start;
System.out.printf("String += : %d ms (길이 %d)%n", stringTime, result.length());
System.out.printf("StringBuilder: %d ms (길이 %d)%n", builderTime, result2.length());
}
}
java StringPerf.java
String += : 1248 ms (길이 100000)
StringBuilder: 3 ms (길이 100000)
확인할 것: 같은 결과를 만드는 데 String +=는 수백 배 더 느리다. n이 클수록 차이가 극단적으로 벌어진다 — String +=는 O(n^2)에 가까운 반면 StringBuilder는 O(n)이다.
측정값은 환경에 따라 다르다. 정확한 벤치마크는 JMH(Java Microbenchmark Harness)를 써야 한다. 여기서는 상대적 차이를 체감하는 것이 목적이다.
요약 — 이 글의 결론
- String은 불변이다. 모든 수정 메서드(
concat,replace,substring)는 새 String을 반환한다. 이 설계는 thread safety, hash caching, security를 제공하지만, 루프 안의 수정마다 새 객체를 만든다. - 루프 안에서는
StringBuilder를 쓴다.+=는 매 반복마다 새 String(그리고 내부적으로 새 StringBuilder)을 생성한다.StringBuilder.append는 내부 버퍼를 직접 수정한다. new String()은 쓰지 않는다. String pool을 무시하고 불필요한 객체를 만든다. 리터럴이나String.valueOf()를 쓴다.- text block(
""")으로 여러 줄 문자열을 깔끔하게 작성한다. JSON, SQL, HTML 등에서 이스케이프 지옥을 피한다. - 배열은 객체고 공변이다.
String[] → Object[]는 컴파일되지만 런타임ArrayStoreException위험이 있다. 제네릭은 이를 불공변으로 해결한다.
생각해 볼 문제
String s = "a" + "b" + "c";를 컴파일한 뒤javap -c로 바이트코드를 확인해 보자.StringBuilder가 생성되는가? 아니면 컴파일 타임 상수 결합인가?String.intern()을 대량으로 호출했을 때 메모리와 성능에 어떤 영향이 있는가?-XX:StringTableSize를 변경하면 어떻게 달라지는가?record의toString()은 내부적으로StringBuilder를 쓰는가?String+ 연산인가? (Java 25 소스를 확인해 보자.)char[]vsbyte[]— Java 9의 Compact Strings(JEP-254)가 String 내부 표현을 어떻게 바꿨는가?Arrays.sort()와Arrays.parallelSort()의 성능 차이를 큰 배열(1,000,000개)에서 측정해 보자.
참고
- JLS §10 - Arrays - 접근 2026-07-10
- JLS §3.10.5 - String Literals - 접근 2026-07-10
- String API (Java 25) - 접근 2026-07-10
- StringBuilder API (Java 25) - 접근 2026-07-10
- JEP-378: Text Blocks - 접근 2026-07-10
- JEP-254: Compact Strings - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 06. inheritance and polymorphism (0) | 2026.07.12 |
|---|---|
| Java - 05. class and object (0) | 2026.07.12 |
| Java - 03. operator control (0) | 2026.07.12 |
| Java - 02. types (0) | 2026.07.10 |
| Java - 01. intro (0) | 2026.07.10 |
