[TIL] String은 왜 불변일까: 자바 문자열을 메모리 관점에서 이해하기

revo·2026년 2월 9일

자바

목록 보기
7/30
post-thumbnail

String은 객체지만 값처럼 사용된다

먼저 가장 중요한 사실부터 정리해야 한다.

String은 객체다.
하지만 한 번 만들어지면 내부 내용이 절대 바뀌지 않는다.

이게 바로 불변 객체(Immutable Object) 다.

String s = "hi";
s = s + "!";

이 코드를 보면 "hi""hi!"로 바뀐 것처럼 보이지만, 실제 동작은 다음과 같다.

1. String Pool에 "hi" 생성
2. s(스택의 참조 변수) → "hi" 를 가리킴

3. s + "!" 연산 발생
   → 변수 연산이므로 내부적으로 StringBuilder가 "hi!"를 만든다.
   → "hi!"는 일반 Heap 객체로 생성 (String Pool이 아님)

4. s → "hi!" 를 가리키도록 변경

5. "hi"는 String Pool에 그대로 남아있음

여기서 중요한 점 세 가지:

  • s는 객체가 아니라 스택에 있는 참조 변수다. 힙의 객체를 가리키는 주소값을 담고 있는 것이다.
  • "hi!"는 변수 연산의 결과물이므로 String Pool이 아닌 일반 Heap에 생성된다.
  • "hi"는 String Pool에서 제거되지 않는다. String Pool은 JVM이 관리하며 리터럴은 쉽게 제거되지 않는다.

즉, String에서 "변경"처럼 보이는 모든 연산은 객체 교체다.


String 리터럴과 String Pool

리터럴이란

코드에 직접 작성된 고정값이다.

String a = "hello"; // "hello"가 리터럴
int n = 42;         // 42가 리터럴
char c = 'A';       // 'A'가 리터럴

변수에 담기 전, 코드에 그대로 적힌 값 자체를 리터럴이라고 한다.

String Pool

String a = "hello";
String b = "hello";

이때 "hello" 객체는 몇 개일까?

정답은 1개다.

문자열 리터럴은 String Pool이라는 특별한 Heap 영역에 String 객체로 저장된다. String Pool에 저장되는 건 문자열 자체가 아니라 String 객체이고, 그 객체가 내부적으로 문자열 데이터를 가지고 있는 것이다.

같은 리터럴이 이미 존재하면 새 객체를 만들지 않고 재사용한다.

[ String Pool ]
  ┌─────────────────┐
  │  String 객체    │
  │  value: "hello" │
  └─────────────────┘
       ↑       ↑
       a       b   (스택의 참조 변수)

이 구조가 가능한 이유는 단 하나다.

String이 불변이기 때문

만약 String이 가변 객체였다면, 한 곳에서 내용을 바꾸는 순간 같은 객체를 공유하는 모든 참조가 영향을 받게 된다.
그래서 문자열은 공유 가능한 값 객체로 설계되었다.


리터럴과 연산 결과 문자열은 다르다

String s1 = "a" + "b";

이 경우 컴파일러가 미리 "ab"로 합쳐버린다. "ab"String Pool에 저장된 리터럴이다.

반면,

String a = "ab";
String b = "a";
String s = b + "b";

코드에 직접 작성된 "ab", "a", "b" 는 전부 리터럴이므로 String Pool에 저장된다.

[ String Pool ]          [ 일반 Heap ]
  "ab" 객체  ← a            "ab" 객체  ← s
  "a"  객체  ← b
  "b"  객체

b + "b" 연산 결과인 "ab"는 String Pool에 이미 같은 값이 있더라도 무조건 일반 Heap에 새 객체를 생성한다. 변수가 포함된 연산은 컴파일 타임에 값을 확정할 수 없기 때문이다.

System.out.println(a == s);      // false — 다른 객체
System.out.println(a.equals(s)); // true  — 내용은 같음

정리하면:

  • 리터럴 → String Pool (중복이면 재사용)
  • new 또는 변수 연산 결과 → 일반 Heap (무조건 새 객체)

intern() — String Pool 강제 등록

String a = new String("아구몬"); // 일반 Heap 객체
String b = a.intern();           // String Pool에 등록하고 그 참조 반환

String c = "아구몬";             // String Pool 객체
System.out.println(b == c);      // true

intern()은 일반 Heap에 있는 String을 String Pool로 강제 등록한다.


StringBuilder는 왜 필요한가

String이 불변이라는 사실은 안정성을 주지만, 대가도 따른다.

String s = "";
for (int i = 0; i < 1000; i++) {
    s += i; // 반복마다 새로운 String 객체 생성 — 기존 객체는 GC 대상
}

성능과 메모리 모두 비효율적이다. 그래서 나온 것이 StringBuilder다.

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);
}
String result = sb.toString();

핵심 차이

  • String → 불변, 값 객체
  • StringBuilder → 가변, 문자열 조작용 버퍼

문자열을 많이 붙이는 상황에서는 StringBuilder를 사용하는 것이 설계 의도에 맞다.

StringBuilder 주요 메서드

StringBuilder sb = new StringBuilder();

sb.append("아구몬");      // 뒤에 추가
sb.insert(0, "진화: ");  // 특정 위치에 삽입
sb.delete(0, 4);         // 범위 삭제
sb.reverse();            // 뒤집기
sb.toString();           // String으로 변환
sb.length();             // 현재 문자열 길이
sb.capacity();           // 내부 버퍼 용량 (기본 16)

코테에서 reverse()는 팰린드롬 문제에서 바로 활용 가능하다.


== 와 equals()가 다른 이유

String a = "hello";
String b = "hello";
String c = new String("hello");
  • a == b → true — 같은 String Pool 객체
  • a == c → false — c는 일반 Heap 객체, 참조값이 다름
  • a.equals(c) → true — 내용 비교

==참조 비교, equals()내용 비교다.

String이 불변이기 때문에 내용 비교 결과를 신뢰할 수 있고, 그래서 equals()가 의미를 가진다.


정리

개념핵심
String불변 객체 — 변경처럼 보이는 모든 연산은 새 객체 생성
String Pool리터럴을 공유하는 특별한 Heap 영역
리터럴 vs 연산 결과리터럴 → String Pool, 연산 결과 → 일반 Heap
intern()일반 Heap의 String을 String Pool에 강제 등록
StringBuilder가변 버퍼 — 반복 문자열 조작 시 사용
== vs equals()참조 비교 vs 내용 비교 — 문자열은 항상 equals()

0개의 댓글