코드 한 줄의 기록

자바 String 문자열의 불변성(immutable) 이해하기: 완벽 가이드 본문

JAVA

자바 String 문자열의 불변성(immutable) 이해하기: 완벽 가이드

CodeByJin 2025. 9. 7. 08:20
반응형

자바를 공부하다 보면 가장 먼저 마주하게 되는 흥미로운 개념 중 하나가 바로 String의 불변성(Immutable)입니다. 많은 개발자들이 "분명히 문자열을 변경했는데 왜 불변이라고 하지?"라는 의문을 가지게 되죠. 이 글에서는 자바의 String이 왜 불변으로 설계되었는지, 그리고 이것이 우리에게 어떤 장점을 가져다주는지 자세히 알아보겠습니다.

String 불변성이란 무엇인가?

불변 객체(Immutable Object)란 객체가 생성된 후 내부의 상태가 변하지 않고 계속 유지되는 객체를 말합니다. 즉, String 인스턴스는 한 번 생성되면 그 값을 읽기만 할 수 있고, 변경은 불가능합니다.
하지만 아래 코드를 보면 의문이 들 수 있습니다.

String str = "Hello";
str = str + " World";
System.out.println(str); // "Hello World"

 
분명히 str의 값이 변경된 것처럼 보이지만, 실제로는 다음과 같은 과정이 일어납니다.

  1. "Hello"라는 새로운 String 객체 생성
  2. str + " World" 연산 시 "Hello World"라는 새로운 String 객체 생성
  3. str 변수가 새로운 객체를 참조하도록 변경
  4. 기존의 "Hello" 객체는 그대로 유지

실제 참조가 바뀌는지 확인해보는 코드입니다.

public class StringImmutableTest {
    public static void main(String[] args) {
        String str1 = "Hello";
        System.out.println("str1 초기 해시코드: " + System.identityHashCode(str1));
        
        str1 = str1 + " World";
        System.out.println("str1 변경 후 해시코드: " + System.identityHashCode(str1));
        
        // 결과: 해시코드가 다르다! (새로운 객체가 생성됨)
    }
}

 
이처럼 String의 불변성은 객체 자체의 내용이 변하지 않는다는 것을 의미합니다.

String Pool과 불변성의 관계

String의 불변성을 이해하려면 String Pool에 대해 알아야 합니다. String Pool은 JVM의 힙 메모리 영역에 위치한 특별한 공간으로, 문자열 리터럴을 저장하고 관리합니다.

String Pool의 동작 방식

String s1 = "Java";
String s2 = "Java";
String s3 = new String("Java");

System.out.println(s1 == s2);      // true
System.out.println(s1 == s3);      // false
System.out.println(s1.equals(s3)); // true

 
위 코드에서 s1과 s2가 같은 참조를 가지는 이유는

  1. s1 = "Java" 실행 시 String Pool에 "Java" 저장
  2. s2 = "Java" 실행 시 String Pool에서 기존의 "Java" 발견
  3. 새로운 객체를 생성하지 않고 기존 객체의 참조를 s2에 할당

하지만 new String("Java")는 String Pool이 아닌 일반 힙 영역에 새로운 객체를 생성합니다.

만약 String이 변경 가능했다면? String Pool에서 객체를 공유할 수 없었을 것입니다. s1의 값을 변경하면 s2의 값까지 함께 변경되는 문제가 발생하기 때문이죠.

String이 불변인 5가지 핵심 이유

1. String Pool을 통한 메모리 최적화
String이 불변이기 때문에 같은 값을 가진 여러 String 변수가 하나의 객체를 안전하게 공유할 수 있습니다.

// 메모리에서 단 하나의 "Hello" 객체만 생성됨
String a = "Hello";
String b = "Hello";
String c = "Hello";

// 모두 같은 메모리 주소를 참조
System.out.println(System.identityHashCode(a));
System.out.println(System.identityHashCode(b));
System.out.println(System.identityHashCode(c));
// 결과: 모두 동일한 해시코드 출력

 
이는 Runtime에서 힙 영역의 많은 메모리를 절약할 수 있게 해줍니다.

2. 보안(Security)
String은 Java에서 매우 빈번하게 사용되는 타입입니다. 특히 보안이 중요한 정보들(사용자명, 패스워드, DB 연결 URL 등)을 String으로 다룰 때가 많죠.

public void authenticateUser(String username, String password) {
    // 보안 검사 수행
    if (!isValid(username)) {
        throw new SecurityException("Invalid username");
    }
    
    // 여기서 다른 작업들...
    performDatabaseOperation();
    
    // 실제 인증 로직
    if (authenticate(username, password)) {
        // 인증 성공
    }
}

 
만약 String이 변경 가능했다면, 메서드의 호출자가 보안 검사 이후에 username이나 password 값을 변경할 수 있었을 것입니다. 이는 심각한 보안 취약점을 만들어냅니다.

3. 스레드 안전성(Thread Safety)
String이 불변이기 때문에 여러 스레드가 동시에 접근해도 값이 변경될 위험이 없습니다. 따라서 별도의 동기화 처리 없이도 thread-safe한 특성을 가집니다.

public class ThreadSafeStringExample {
    private String message = "Hello World";
    
    // 여러 스레드가 동시에 이 메서드를 호출해도 안전
    public String getMessage() {
        return message;
    }
}

 
4. hashCode 캐싱
String 클래스는 hashCode 값을 계산한 후 이를 캐싱합니다. 불변이기 때문에 한 번 계산된 hashCode가 절대 변하지 않으므로 안전하게 캐싱할 수 있죠.

public int hashCode() {
    int h = hash;  // 캐싱된 해시코드
    if (h == 0 && !hashIsZero) {
        // 처음 호출시에만 계산
        h = isLatin1() ? StringLatin1.hashCode(value)
                       : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h;  // 캐싱
        }
    }
    return h;
}

 
이는 특히 HashMap, HashSet 등에서 String을 키로 사용할 때 성능 향상을 가져다줍니다.

5. 클래스 로딩 보안
JVM의 클래스 로더는 클래스를 로딩할 때 클래스 이름을 String으로 받습니다. 만약 String이 변경 가능했다면, 악의적인 코드가 클래스 로딩 과정에서 클래스 이름을 변경하여 다른 클래스를 로딩하도록 만들 수 있었을 것입니다.

String 연산의 성능 문제와 해결책

String의 불변성은 많은 장점을 제공하지만, 문자열을 자주 변경해야 하는 상황에서는 성능 문제를 야기할 수 있습니다.

문제 상황

String result = "";
for (int i = 0; i < 10000; i++) {
    result += "a";  // 매번 새로운 String 객체 생성!
}

 
위 코드는 10,000개의 String 객체를 생성하게 되어 매우 비효율적입니다.

해결책: StringBuilder와 StringBuffer

// StringBuilder 사용 (단일 스레드 환경)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append("a");  // 기존 버퍼에 추가
}
String result = sb.toString();

// StringBuffer 사용 (멀티 스레드 환경)
StringBuffer sbf = new StringBuffer();
for (int i = 0; i < 10000; i++) {
    sbf.append("a");  // 동기화된 추가 연산
}
String result2 = sbf.toString();

 
StringBuilder vs StringBuffer

  • StringBuilder: 동기화를 지원하지 않아 더 빠름 (단일 스레드 환경 권장)
  • StringBuffer: 동기화를 지원하여 thread-safe (멀티 스레드 환경 권장)

성능 차이를 실측한 결과, String '+' 연산과 StringBuilder의 차이는 극적입니다. 데이터량이 늘어날수록 격차는 더욱 벌어집니다.

실무에서의 String 활용 팁

1. 문자열 비교 시 주의사항

String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
String s4 = s3.intern();  // String Pool로 이동

System.out.println(s1 == s2);      // true (같은 Pool 객체)
System.out.println(s1 == s3);      // false (다른 메모리 위치)
System.out.println(s1 == s4);      // true (intern()으로 Pool로 이동)
System.out.println(s1.equals(s3)); // true (내용 비교)

 

권장사항: 문자열 내용 비교는 항상 equals() 메서드를 사용하세요.

2. 성능을 고려한 문자열 처리

// 좋지 않은 방법
String html = "<html>";
html += "<body>";
html += "<h1>Title</h1>";
html += "</body>";
html += "</html>";

// 좋은 방법
StringBuilder htmlBuilder = new StringBuilder();
htmlBuilder.append("<html>")
          .append("<body>")
          .append("<h1>Title</h1>")
          .append("</body>")
          .append("</html>");
String html = htmlBuilder.toString();

 
3. String.intern() 메서드 활용

intern() 메서드는 문자열을 String Pool에 저장하고, 동일한 내용의 문자열이 있으면 해당 참조를 반환합니다.

String a = "apple";
String b = new String("apple");
String c = b.intern();

System.out.println(a == b);  // false
System.out.println(a == c);  // true

String Pool의 변화: Java 버전별 개선사항

Java 6 이전: PermGen 영역의 한계

  • String Pool이 PermGen(Permanent Generation) 영역에 위치
  • 고정 크기 (32MB ~ 96MB)
  • GC 대상이 아니어서 OOM(Out of Memory) 위험성

Java 7: 힙 영역으로 이동

  • String Pool이 힙 메모리로 이동
  • GC 수행 가능해짐
  • 동적 크기 조정 가능

Java 8: Metaspace 도입

  • PermGen 완전 제거, Metaspace로 교체
  • String Pool은 여전히 힙 영역에 유지

Java 7부터는 String Pool에서도 GC가 수행되어 더 이상 사용되지 않는 String들이 자동으로 정리되므로 OOM 위험이 크게 줄어들었습니다.

String 불변성의 진정한 가치

String의 불변성은 단순히 "값이 변하지 않는다"는 특성을 넘어서, Java 생태계 전반에 걸친 안정성과 성능 최적화의 핵심 요소입니다.

핵심 포인트 요약

  1. 메모리 효율성: String Pool을 통한 객체 재사용으로 메모리 절약
  2. 보안성: 민감한 정보의 무결성 보장
  3. 동시성: 별도 동기화 없이 thread-safe
  4. 성능: hashCode 캐싱으로 빠른 해시 연산
  5. 안정성: 예측 가능한 동작과 부작용 최소화

하지만 문자열을 자주 변경해야 하는 경우에는 StringBuilder나 StringBuffer를 적절히 활용하여 성능 문제를 해결할 수 있습니다. 특히 반복문 내에서 문자열을 연결하는 경우에는 반드시 StringBuilder를 사용해야 합니다.

String의 불변성을 이해하고 적절히 활용한다면, 더욱 안전하고 효율적인 Java 애플리케이션을 개발할 수 있을 것입니다. 이는 단순한 문법적 특성이 아닌, Java 언어 설계의 철학이 담긴 중요한 개념임을 기억해 주세요.

Java 기본 자료형과 객체형 변수 완전 정복: 선언부터 초기화까지 실전 가이드

Java 프로그래밍에서 변수는 데이터를 저장하는 핵심 요소입니다. 하지만 많은 초보 개발자들이 기본 자료형(Primitive Type)과 객체형 변수(Object Type)의 차이점을 제대로 이해하지 못해 메모리 관리

byteandbit.tistory.com

반응형