[Java] String은 왜 불변(Immutable)할까? String Pool, intern(), 생성 방식까지 완벽 정리

2025. 4. 9. 17:52·BackEnd/JAVA

자바에서 문자열을 다룰 때 가장 많이 사용하는 타입은 단연 String입니다. 하지만 여러분은 다음과 같은 코드에서 의문을 가져본 적 없으신가요?

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

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

위 코드에서 s1 == s2는 true이고 s1 == s3는 false라는 결과가 나옵니다. 도대체 무슨 일이 일어난 걸까요?

이번 글에서는 Java에서 String이 어떻게 동작하고, 왜 불변(Immutable)하게 설계되었는지, 그리고 메모리 구조, 성능, 보안 측면에서 어떤 이점이 있는지 하나씩 정리해보겠습니다.

1. String 객체는 불변(Immutable)하다

Java에서 String은 불변 객체(Immutable Object)입니다. 즉, 한 번 생성된 문자열은 절대 변경될 수 없습니다.

1.1 내부 구조

내부 구조부터 살펴보면 Java 9 이상에서는 String 클래스의 핵심 멤버는 다음과 같습니다.

@Stable
private final byte[] value;
private final byte coder; // LATIN1 or UTF16
private int hash;
  • value: 문자열 데이터를 저장하는 byte 배열이며, final로 선언되어 있어 재할당 불가
  • coder: 인코딩 방식 (LATIN1 또는 UTF16)을 나타냄
  • hash: hashCode() 결과를 캐싱해두는 필드

참고로 Java 8까지는 char[] value 형태였으나, Java 9부터는 메모리 최적화를 위해 byte[] + coder 방식으로 변경되었습니다.

2. String이 불변이면 좋은 점이 뭐야?

단순히 "안 바뀌니까 안전해요!" 수준이 아닙니다. 불변성이 있기에 String은 Java에서 여러 측면에서 핵심적인 역할을 할 수 있습니다.

2.1 String Pool을 통한 메모리 절약

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

System.out.println(a == b); // true
  • "hello"는 컴파일 시점에 String Constant Pool에 저장되고, 동일한 리터럴 사용 시 같은 객체를 재사용합니다.
  • 메모리 낭비 없이 문자열을 공유할 수 있는 이유는 바로 String이 불변이기 때문입니다.
  • 만약 String이 변경 가능했다면, 공유된 객체가 예상치 못하게 변경될 위험이 생겼을 겁니다.

2.2 멀티스레드 환경에서 안전 (Thread-safe)

  • 불변 객체는 상태가 변하지 않기 때문에 동기화 없이도 안전하게 사용할 수 있습니다.
  • String은 Java에서 매우 자주 쓰이므로, 성능적으로도 큰 이점입니다.

2.3 해시코드 캐싱

private int hash;
  • String의 hashCode()는 한 번 계산되면 내부 필드(hash)에 캐싱됩니다.
  • 불변 객체이기에 이후에도 항상 동일한 해시값을 반환합니다.
  • HashMap, HashSet 등에서 키로 사용 시 매우 효율적입니다.

2.4 보안 측면에서도 안전

  • String을 수정할 수 없다면, 비밀번호나 토큰 같은 민감 정보가 실수로 수정되거나 변조될 위험이 없습니다.
  • 보안 라이브러리에서 String을 자주 사용하는 이유이기도 합니다.

3. 리터럴과 new String의 차이

3.1 리터럴로 생성한 String

String first = "hello";
String second = "hello";

System.out.println(first == second); // true
  • 두 객체는 같은 String Pool 내 주소를 참조
  • 메모리를 절약하며, 내부적으로 하나의 객체만 존재

3.2 new String으로 생성한 String

String third = new String("hello");

System.out.println(first == third); // false
  • third는 Heap 영역에 별도로 생성된 새로운 객체
  • Pool에 존재하는 "hello"와는 주소가 다름

3.3 intern() 메서드로 Pool 강제 참조

String pooled = third.intern();

System.out.println(first == pooled); // true
  • intern() 메서드는 Pool에 해당 문자열이 존재하면 그 참조값을 반환하고
  • 없으면 Pool에 추가하고 참조를 반환합니다

📌 예제: 실제 해시코드 비교

String first = "hello";                     // 리터럴
String second = new String("hello");        // 생성자
String third = second.intern();             // intern() 사용

System.out.println(System.identityHashCode(first));  // ex) 998325
System.out.println(System.identityHashCode(second)); // ex) 1423451
System.out.println(System.identityHashCode(third));  // ex) 998325
  • first와 third는 같은 Pool 객체를 참조 → 같은 주소
  • second는 새로운 Heap 객체 → 주소 다름

4. 이미지를 통해 직관적으로 이해하기

다음 이미지는 String 객체가 생성되는 방식과 메모리 내 구조, 객체 비교 결과를 아주 잘 보여줍니다.

4.1 String s1 = “Cat”

  • 이 코드는 "Cat"이라는 문자열 리터럴을 생성합니다.
  • Java는 컴파일 시점에 "Cat"을 String Constant Pool에 저장합니다.
  • 그리고 s1은 그 Pool에 있는 "Cat" 객체를 참조하게 됩니다.

4.2 String s2 = “Cat”

  • s2 역시 같은 리터럴 "Cat"을 사용하므로, String Pool에 같은 문자열이 이미 존재하는지 확인합니다.
  • "Cat"이 존재하므로 기존 객체를 재사용하며, s2도 동일한 "Cat" 객체를 참조합니다.

결과: s1 == s2는 true → 두 참조 변수는 동일한 메모리 주소의 같은 객체를 가리키고 있기 때문입니다.

4.3 String s3 = new String(”Cat”)

  • 이번엔 생성자 new를 사용하여 String 객체를 생성했습니다.
  • 이 방식은 항상 Heap 영역에 새로운 객체를 생성합니다.
  • 내부적으로는 여전히 String Pool에 있는 "Cat"을 참고하지만,
    → 그 내용을 기반으로 새로운 객체를 복사 생성합니다.

결과: s1 == s3는 false → s1은 String Pool의 "Cat" 객체를, s3는 Heap에 새로 만들어진 "Cat" 객체를 가리키므로 참조 주소가 다릅니다.

4.4 정리 요약

변수 생성 방식 메모리 위치 객체 공유 여부 == 비교
s1 리터럴 String Pool O (Pool 공유) —
s2 리터럴 String Pool O (Pool 공유) s1 == s2 → true
s3 new 생성자 Heap X (새 객체 생성) s1 == s3 → false

참고) .equals()는 값 비교!

==는 주소(참조) 비교이고, .equals()는 값(내용) 비교입니다.

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

따라서 문자열의 내용이 같은지를 확인하려면 무조건 .equals()를 사용해야 합니다.

5. 결론

Java의 String은 단순한 문자열이 아닙니다. 불변(Immutable)하게 설계된 이유는 다음과 같은 구조적 이점 때문입니다.

  • 메모리 최적화: String Pool을 활용해 동일 문자열을 공유
  • 스레드 안전성: 동기화 없이도 안정적으로 사용 가능
  • 성능 최적화: 해시코드 캐싱, 불필요한 객체 생성을 줄임
  • 보안 강화: 민감 정보 보호에 유리

또한 리터럴과 new 방식의 차이를 이해함으로써, 메모리 관리와 성능까지도 고려한 코드 작성이 가능합니다.

'BackEnd > JAVA' 카테고리의 다른 글

[JAVA] equals()와 hashCode() 완벽 정리  (1) 2025.02.24
[JAVA] 익명 클래스 (Anonymous Class) 정리  (1) 2025.02.20
[JAVA] Lombok의 @UtilityClass 어노테이션 정리  (1) 2025.02.13
[JAVA] 얕은 복사 (Shallow Copy) VS 깊은 복사 (Deep Copy)  (2) 2025.02.06
[JAVA] 와일드카드 (Generic Wildcards) 정리  (2) 2025.02.05
'BackEnd/JAVA' 카테고리의 다른 글
  • [JAVA] equals()와 hashCode() 완벽 정리
  • [JAVA] 익명 클래스 (Anonymous Class) 정리
  • [JAVA] Lombok의 @UtilityClass 어노테이션 정리
  • [JAVA] 얕은 복사 (Shallow Copy) VS 깊은 복사 (Deep Copy)
개발자 동긔
개발자 동긔
배우고 느낀점들을 기록합니다. 열정 넘치는 백엔드 개발자로 남고싶습니다.
  • 개발자 동긔
    Donker Dev
    개발자 동긔
  • 전체
    오늘
    어제
    • Category (39)
      • BackEnd (23)
        • JAVA (15)
        • Spring & JPA (7)
      • Database (4)
      • Computer Science (2)
        • Network (0)
        • Security (0)
        • Web (1)
      • DevOps (6)
        • Docker (1)
        • Jenkins (0)
        • Monitoring (2)
        • CICD (1)
      • 트러블 슈팅 (3)
      • 성능 개선 (1)
      • Project (0)
  • 인기 글

  • 태그

    CICD
    SSH
    java
    spring boot
    mysql master/slave replication
    인터페이스
    nginx
    restful api
    Database
    Jenkins
    @RequestBody
    Spring
    restful api 설계
    interface
    docker compose
    master/slave db 이중화 처리
    JPA
    spring cloud msa
    와일드카드
    docker
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
개발자 동긔
[Java] String은 왜 불변(Immutable)할까? String Pool, intern(), 생성 방식까지 완벽 정리
상단으로

티스토리툴바