프로그래밍 언어/java

[Java] Java의 String은 왜 불변(Immutable)할까?

하루이2222 2024. 8. 18. 19:37


(feat. 메모리, 스레드, 해시코드)

Java를 공부하면서 가장 먼저 마주한 흥미로운 사실이 있었다. 바로 String 클래스가 불변(immutable) 이라는 점이다.

분명히 나는 str = str + " World"; 와 같은 코드로 문자열을 변경했다고 생각했는데, Java는 이를 "변경"이 아니라 "새로 생성"이라고 말한다.


1. String은 변경되는 것이 아니라, 새로 만들어진다

먼저 의문이 들었다. "str = str + 'World' 했는데, 왜 불변이라고 하지?"
그래서 실제로 참조가 바뀌는지 직접 확인해봤다.

import java.util.Arrays;

public class Main {
    public static void main(String[] args) {
        String str = "Hello";
        System.out.println("str 초기 객체 해시코드: " + System.identityHashCode(str));

        str = str + " World";
        System.out.println("str 변경 후 객체 해시코드: " + System.identityHashCode(str));

        System.out.println("--------------------------------");

        String[] strArray = {"Hello"};
        System.out.println("배열 초기 객체 해시코드: " + System.identityHashCode(strArray));

        strArray = Arrays.copyOf(strArray, strArray.length + 1);
        System.out.println("배열 변경 후 객체 해시코드: " + System.identityHashCode(strArray));
    }
}

결과:

str 초기 객체 해시코드: 1163157884
str 변경 후 객체 해시코드: 1956725890
--------------------------------
배열 초기 객체 해시코드: 356573597
배열 변경 후 객체 해시코드: 1735600054

해시코드가 바뀌었다.
즉, String은 내부 값을 수정하는 것이 아니라, 새로운 객체를 생성하고 참조를 바꾸는 방식으로 동작하고 있었다.
이는 배열의 참조가 바뀌는 방식과도 유사했다.


2. 왜 불변이어야 했을까? (설계의 핵심 이유 3가지)

단순히 "새 객체를 만든다"는 사실보다 더 궁금했던 건 이것이다.
왜 불변으로 만들어야 했을까?
이를 세 가지 관점에서 추적해봤다.


2-1. 스레드 안전성(Thread Safety)을 보장해야 했다

String은 다양한 곳에서 공유된다. 특히 URL, SQL, JSON, Key 값 등… 여러 스레드가 같은 문자열을 동시에 사용할 수밖에 없다.

그런데 만약 String이 가변이었다면, 하나의 스레드가 내용을 바꾸는 순간 다른 스레드는 예기치 못한 값을 읽게 됐을 것이다.
이를 막기 위해 synchronized 같은 락을 걸어야 했겠지만, 성능 저하와 복잡성이 문제였을 것이다.

그래서 아예 내용을 못 바꾸게 만들었다.

읽기만 가능한 객체라면, 여러 스레드에서 공유해도 아무 문제가 없기 때문이다.

public class StringThreadSafeExample {
    public static void main(String[] args) {
        String shared = "https://example.com";

        Runnable task = () -> {
            System.out.println(Thread.currentThread().getName() + " reads: " + shared);
        };

        new Thread(task).start();
        new Thread(task).start();
    }
}

이 코드는 어떤 환경에서도 항상 안전하게 작동한다.
String이 불변이라서 가능한 일이다.


2-2. 메모리 절약을 위해 캐싱(String Pool)이 필요했다

Java에서는 문자열 리터럴이 중복으로 생성되지 않는다.
동일한 리터럴은 String Constant Pool이라는 곳에 한번만 저장되고, 나중에 다시 쓰면 기존 객체를 참조한다.

String str1 = "Hello";
String str2 = "Hello";

System.out.println(str1 == str2); // true

이 캐싱 구조가 동작하려면 전제가 필요했다.
그 객체가 절대로 바뀌지 않아야 한다.

만약 str1이 "Hello"였는데 내부 값을 변경할 수 있다면, str2도 영향을 받게 되고, 의도치 않은 버그가 생긴다.
그래서 아예 변경을 못 하게 만들었다.
불변성이 있어야 String Pool이 성립한다.


2-3. 해시코드가 변하지 않아야 컬렉션에서 안전했다

HashMap, HashSet 등의 해시 기반 자료구조에서는 hashCode() 값이 매우 중요하다.
키의 해시코드가 바뀌면 데이터를 찾을 수 없게 된다.

그런데 String은 불변이기 때문에 한 번 정해진 해시코드는 절대 바뀌지 않는다.
게다가 JVM은 이 값을 캐싱까지 해준다.

String str = "immutable";
System.out.println(str.hashCode() == str.hashCode()); // true

만약 String이 가변이었다면, 내용 변경 → 해시코드 변경 → HashMap에서 검색 실패 → 데이터 유실.
그야말로 대참사다.

그래서 해시 기반 자료구조에서 안전하게 쓰기 위해서도 String은 불변이어야 했다.


결론

처음에는 불변이 불편해 보였다.
매번 새로운 객체를 만든다고 하니 성능도 걱정됐다.

하지만 실제로는 그 반대였다.

  • 스레드 안정성 확보
  • 메모리 재사용 최적화(String Pool)
  • 해시 기반 자료구조에서 신뢰성 확보

이 모든 것이 가능했던 이유는 바로 String이 불변이라서였다.

불변성은 단점이 아니라, Java의 성능과 안정성을 지탱하는 기반이었다.
String의 철학을 이해하면, Java가 왜 그렇게 만들어졌는지 한층 깊이 이해할 수 있다.