“String은 왜 불변(immutable) 객체인가?“는 자바 면접에서 자주 나오는 질문입니다. 이 글에서는 불변 객체의 개념부터 자바의 String이 불변으로 설계된 이유, 그리고 가변 문자열인 StringBuilder와의 차이까지 예제와 함께 정리합니다.
이 글은 자바 기초를 다룬 [자바] 1. 자바란?, 2. 변수, 3. 연산자 시리즈에서 한 걸음 더 들어간 심화 주제입니다.
불변 객체(Immutable Object)란
불변 객체(Immutable Object)는 한 번 생성되면 내부 상태를 변경할 수 없는 객체를 말합니다. 즉, 객체가 생성된 후에는 그 상태가 절대로 변하지 않습니다.
불변 객체의 특징
- 상태 변경 불가: 객체가 생성된 후에는 그 상태를 변경할 수 없습니다.
- 안전한 멀티스레딩: 상태가 변하지 않으므로 여러 스레드가 동시에 접근해도 문제가 발생하지 않습니다.
- 예측 가능성: 값이 바뀌지 않으므로 어디에 전달하든 동일한 상태가 유지되어, 부작용 없이 안전하게 공유할 수 있습니다.
불변 객체의 간단한 예시 코드를 살펴보겠습니다.
![[Java]String은 왜 불변인가? final 필드로 선언한 불변 Person 클래스 예시 코드](https://nuschool.cc/wp-content/uploads/2024/11/image-36.png)
![[Java]String은 왜 불변인가? 불변 객체를 생성해 사용하는 예시 코드](https://nuschool.cc/wp-content/uploads/2024/11/image-37.png)
위 Person 객체는 불변 객체이므로 생성 후에는 name과 age를 변경할 수 없습니다. setter 메서드가 없어서 상태를 바꿀 수 없는데, 단순히 setter가 없다고 해서 불변이 되는 것은 아닙니다. 다만 필드를 final로 선언했기 때문에 애초에 setter를 만들 수 없습니다.
객체의 값을 마음대로 변경하면 사이드 이펙트(side effect)가 발생할 수 있습니다. 사이드 이펙트란 함수나 메서드가 실행될 때 예상하지 못한 외부 상태의 변경이 발생하는 상황을 말합니다. 불변 객체를 사용하면 이러한 사이드 이펙트를 차단할 수 있습니다.
그렇다면 불변 객체의 값은 어떻게 바꿀까요? 불변 객체는 값을 변경할 수 없으므로, 값을 바꾸고 싶다면 변경된 값으로 새로운 객체를 생성해야 합니다. 이렇게 하면 기존 변수들이 참조하는 값에는 영향을 주지 않습니다. 불변 객체에 대해 더 알고 싶다면 관련 영상을 참고해 보세요.
String이 불변으로 설계된 4가지 이유
자바를 만든 제임스 고슬링의 인터뷰 내용에 따르면, String이 불변인 이유는 크게 다음 네 가지로 정리할 수 있습니다.
1. 보안
문자열은 데이터베이스 접속 정보, 파일 경로, 네트워크 주소 등 민감한 값을 담는 경우가 많습니다. 만약 String이 가변(mutable)이라면 이런 정보가 외부에서 변경될 가능성이 생겨 보안상 좋지 않습니다(예: SQL 인젝션). 불변이기 때문에 한 번 만들어진 문자열 값은 신뢰하고 사용할 수 있습니다.
2. 해시코드 캐싱
불변 객체는 한 번 생성되면 내부 값이 변하지 않기 때문에, 한 번 계산한 해시코드를 재사용해도 안전합니다. String은 해시코드를 캐싱해 두고 이후에는 계산된 값을 그대로 사용하므로, HashMap·HashSet의 키로 쓸 때 성능상 이점이 큽니다.
3. 재사용성(String Constant Pool)
String은 String Constant Pool을 사용하여 동일한 문자열 값을 공유합니다. 같은 값의 문자열을 여러 번 사용할 때 매번 새 객체를 만드는 대신 풀에 있는 동일한 객체를 참조하므로 메모리를 절약하고 성능을 높일 수 있습니다. String이 불변이기에 이런 공유가 안전하게 성립합니다.
String s1 = "hello"; // String Constant Pool의 "hello"를 참조
String s2 = new String("hello"); // 힙에 새 String 객체 생성, 내부 값은 풀의 "hello" 참조
4. 스레드 안전성(동기화)
String의 불변성은 멀티스레드 환경에서 스레드 안전성(Thread-safety)을 제공합니다. String 객체는 한 번 생성되면 내부 값이 변경되지 않으므로, 여러 스레드가 동시에 접근하더라도 상태 변경이 일어나지 않아 별도의 동기화 없이 안전하게 사용할 수 있습니다. 가변 객체라면 여러 스레드가 동시에 값을 수정할 수 있어 동기화가 필요하지만, String은 불변이기에 이 문제를 자연스럽게 해결합니다.
참고로 String Constant Pool(문자열 상수 풀)은 자바가 문자열 리터럴을 효율적으로 관리하기 위한 메모리 관리 기법입니다. 동일한 문자열 리터럴이 여러 번 생성되는 것을 방지하여 메모리와 성능을 절약합니다.
![[Java]String은 왜 불변인가? String Constant Pool에서 동일한 문자열을 공유하는 구조](https://nuschool.cc/wp-content/uploads/2024/11/image-38.png)
![[Java]String은 왜 불변인가? 문자열 리터럴과 new String의 참조 차이 예시](https://nuschool.cc/wp-content/uploads/2024/11/image-35.png)
String과 StringBuilder의 차이
불변인 String의 단점은 문자를 더하거나 변경할 때마다 새로운 객체를 생성해야 한다는 점입니다. 문자열을 자주 변경하는 상황이라면 그만큼 많은 String 객체가 만들어지고 GC 대상이 되어, CPU와 메모리 자원을 더 많이 소모합니다. 문자열이 클수록, 변경이 잦을수록 자원 소모가 커집니다.
이 문제를 해결하기 위해 가변 문자열인 StringBuilder가 존재합니다. String은 내부의 byte 배열이 final이라 변경할 수 없는 반면,
![[Java]String은 왜 불변인가? String 클래스 내부의 final byte 배열 소스](https://nuschool.cc/wp-content/uploads/2024/11/image-33.png)
StringBuilder가 상속받는 AbstractStringBuilder는 변경 가능한 byte 배열을 사용하는 것을 볼 수 있습니다.
![[Java]String은 왜 불변인가? AbstractStringBuilder의 가변 byte 배열 소스](https://nuschool.cc/wp-content/uploads/2024/11/image2.png)
String은 한 번 생성되면 내용을 변경할 수 없어, 문자열에 변화를 줄 때마다 새 객체가 생성되고 기존 객체는 버려집니다. 반면 StringBuilder는 가변적이므로 하나의 객체 안에서 문자열을 추가·삭제·수정할 수 있고, 그때마다 새 객체를 생성하지 않습니다. 그래서 문자열을 변경하는 동안에는 StringBuilder를 사용하다가, 변경이 끝나면 안전한(불변) String으로 변환하는 것이 좋습니다.
성능 비교 예제
String과 StringBuilder의 성능 차이는 아래 코드를 실행해 보면 확실하게 드러납니다. 먼저 StringBuilder로 10만 번 문자열을 이어 붙이는 경우입니다.
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append("Hello Java ");
}
long endTime = System.currentTimeMillis();
String result = sb.toString();
System.out.println("result = " + result);
System.out.println("time = " + (endTime - startTime) + "ms"); // time = 4ms
}
다음은 같은 작업을 String의 += 연산으로 처리한 경우입니다.
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
String result = "";
for (int i = 0; i < 100000; i++) {
result += "Hello Java ";
}
long endTime = System.currentTimeMillis();
System.out.println("result = " + result);
System.out.println("time = " + (endTime - startTime) + "ms"); // time = 6089ms
}
두 코드의 실행 시간은 약 4ms와 6089ms로 큰 차이가 납니다. 사실 자바는 문자열에 + 연산을 사용하면 컴파일 과정에서 StringBuilder로 변환한 뒤 다시 문자열로 돌려줍니다. 하지만 위처럼 반복문 안에서 매 반복마다 +=를 사용하면 반복 횟수만큼 StringBuilder 객체가 새로 생성되기 때문에 훨씬 오래 걸리는 것입니다.
정리: String · StringBuilder · StringBuffer
- String: 불변. 문자열 연산이 적거나 값이 거의 변하지 않는 경우, 멀티스레드 환경에서 안전하게 공유해야 하는 경우에 적합합니다.
- StringBuilder: 가변. 문자열의 추가·수정·삭제가 빈번한 경우에 적합합니다. 동기화를 지원하지 않으므로 단일 스레드 환경에서
StringBuffer보다 빠릅니다. - StringBuffer: 가변. 동기화를 지원하여 멀티스레드 환경에서도 안전하게 동작합니다(이 글에서는 자세히 다루지 않으니 따로 찾아보면 좋습니다).
바뀌면 안 되는 값은 불변으로 설계해야 안전하지만, 불변은 값을 바꾸려면 새 객체를 만들어야 한다는 비용이 따릅니다. String이 불변인 이유와 StringBuilder가 필요한 이유를 함께 이해하면, 상황에 맞는 문자열 처리 방식을 고를 수 있습니다.
참고자료: String / StringBuffer / StringBuilder 차이와 성능 비교, 김영한의 자바 강의



💬 댓글 2
이 글이 많은 분들에게 알려지면 좋을 거 같습니다. 그런 의미에서 아래의 링크를 참고하여 수정하면 좋을 거 같습니다.
https://www.nuschool.co.kr/%eb%88%84%ec%8a%a4%ec%bf%a8-%ea%b3%b5%ec%a7%80%ec%82%ac%ed%95%ad/%ea%b3%b5%ec%a7%80%ed%95%84%eb%8f%85-%eb%88%84%ec%8a%a4%ec%bf%a8-%ec%bb%a4%eb%ae%a4%eb%8b%88%ed%8b%b0%ec%97%90-%ea%b8%80-%ec%9e%91%ec%84%b1%ed%95%98%eb%8a%94-%eb%b2%95-seo-%ec%b5%9c%ec%a0%81%ed%99%94/
자신의 글을 채점하는 기능을 가진 플러그인을 탑재했는데요. 글을 작성하는데 도움이 될 겁니다. ^^
커뮤니티가 확장할 수 있도록 도움을 주셔서 고맙습니다.
게시글을 작성했는데 게시글 리스트에 사진은 안나와서 당황하셨죠? ㅎㅎ
특성이미지 설정!! 꼭 해주세요 ㅎㅎ 제가 보내준 링크로 들어가셔서 참고하면 할 수 있을 겁니다.