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 &rarr; &quot;Hello&quot;<br/>(불변)"] -->|concat| S2["새 객체 &quot;Hello World&quot;<br/>(아무도 참조 안 함 &rarr; 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 위험이 있다. 제네릭은 이를 불공변으로 해결한다.

생각해 볼 문제

  1. String s = "a" + "b" + "c";를 컴파일한 뒤 javap -c로 바이트코드를 확인해 보자. StringBuilder가 생성되는가? 아니면 컴파일 타임 상수 결합인가?
  2. String.intern()을 대량으로 호출했을 때 메모리와 성능에 어떤 영향이 있는가? -XX:StringTableSize를 변경하면 어떻게 달라지는가?
  3. recordtoString()은 내부적으로 StringBuilder를 쓰는가? String + 연산인가? (Java 25 소스를 확인해 보자.)
  4. char[] vs byte[] — Java 9의 Compact Strings(JEP-254)가 String 내부 표현을 어떻게 바꿨는가?
  5. Arrays.sort()Arrays.parallelSort()의 성능 차이를 큰 배열(1,000,000개)에서 측정해 보자.

참고

'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