Mutable & Immutable
🔍 Java에서 객체 생성
자바는 new 연산자를 통해 객체를 생성할 수 있고 이때 heap 영역에 할당되며 stack 영역에서 참조 타입 변수를 통해 데이터에 접근한다.
이 때 자바의 객체 타입은 2가지다.
Mutable (가변) 객체와 Immutable (불변) 객체이다.
👉🏻 Summary
일단 둘의 차이에 대해 간단히 알아보자.
Mutable | Immutable |
객체 생성 후 내부 값 변경 가능 | 객체 생성 후 내부 값 변경 불가능 |
값을 변경할 수 있는 메서드 O (setter 존재) | 값을 변경할 수 있는 메서드 X (setter 존재X) |
Thread safety 하지 않을 수 있음 (멀티스레드 환경에서 동기화 필요) | Thread safety(스레드 안정성) 보장 |
StringBuilder, StringBuffer, java.util.Date, List, ArrayList, HashMap 등 | String, Wrapper Class(Integer, Boolean, Float, Long 등) |
String vs (StringBuffer, StringBuilder) 비교
1️⃣ String은 불변
기본적으로 자바에서는 String 객체의 값은 변경할 수 없다.
대표적인 불변 객체로 읽을 수만 있고 변경은 할 수 없다. (read-only)
이 코드를 보면 str의 값이 변경된 것처럼 보인다. 하지만 실제로는 메모리에 새로 "Hello World" 값을 저장한 영역을 따로 만들고 변수 str을 다시 참조하는 식으로 작동한다.
String str = "hello";
str = str + " world";
System.out.println(str); // hello world
❓ 자바에서 String을 불변으로 설정한 이유
String 객체를 불변하게 설계한 이유는 캐싱, 보안, 동기화, 성능 측면 이점을 얻기 위해서다.
2️⃣ StringBuffer / StringBuilder는 가변
StringBuffer와 StringBuilder는 문자열 데이터를 다룬다는 점에서 String 객체와 같지만, 객체의 공간이 부족해지는 경우 버퍼의 크기를 유연하게 늘려주어 가변적이다.
String 객체는 한번 생성되면 불변적인 특성에 의해 값 업데이트 시 새로운 문자열 String 인스턴스가 생성되어 메모리 공간을 차지하지만 StringBuffer/StringBuilder는 가변성을 가지기 때문에 .append(), .delete() 등의 API를 사용하여 동일 객체내에서 문자열 크기를 변경하는 것이 가능하다.
❓StringBuilder vs StringBuffer
StringBuilder는 동기화를 지원하지 않아 단일 스레드 환경에서 성능이 뛰어나다.
StringBuffer는 모든 메서드에 synchronized 키워드가 적용되어 멀티 스레드 환경에서도 안전하게 사용 가능
❔불변 객체는 왜 사용할까?
- 단순하다.
- 불변 객체의 상태는 생성 시점으로부터 파괴되는 시점까지 그대로 유지된다.
- 일반적으로 스레드 안정성(Thread Safety) 보장
- 멀티스레드 환경에서 문제가 발생하는 주된 원인은 공유 자원에 대한 동시 쓰기(write) 연산 때문이다.
- 하지만 불변 객체는 상태가 변하지 않기 때문에 동기화 없이도 여러 스레드에서 공유 가능
- 값 변경 방지
- 객체의 값을 변경할 수 없기 때문에 의도치 않은 값 변경을 방지할 수 있다.
- 불변 객체는 필드로 사용할 때 안전
- 다른 객체의 필드로 사용할 때 방어적 복사를 할 필요가 없다.
- 이는 코드의 간결함과 성능 향상으로 이어진다.
✔️ 불변 객체 구현 조건
- final class로 선언하여 상속을 막는다.
- 모든 필드를 private final로 선언해서 외부 접근 막고 수정 불가능하도록 한다.
- setter 함수를 제거한다.
- 가변 객체를 참조하는 필드가 있다면 방어적 복사를 수행해야 한다.
✔️ 방어적 복사
외부에서 전달받거나 반환할 객체를 복사해서 사용하는 것
이렇게 하면 외부에서 객체 내부 상태를 직접 변경하지 못하게 막을 수 있다.
❓ 왜 필요할까?
- 어떤 객체의 필드로 참조 타입 (ex. List, Date 등)이 들어올 때, 그 참조가 외부에서 변경될 수 있다.
- 이걸 그대로 저장하거나 반환하면 내부 상태가 의도치 않게 바뀔 위험이 있다.
- 이를 막기 위해 복사본을 만들어 사용한다. 이것이 방어적 복사다.
🧨 1. 방어적 복사를 하지 않았을 때 (문제 발생)
import java.util.List;
public class Team {
private final List<String> members;
public Team(List<String> members) {
this.members = members; // 방어적 복사 ❌
}
public List<String> getMembers() {
return members; // 그대로 반환 ❌
}
}
---------------------------------------------------------------
List<String> originalList = new ArrayList<>();
originalList.add("Alice");
originalList.add("Bob");
Team team = new Team(originalList);
// 외부에서 원본을 수정
originalList.add("Charlie");
// 내부 상태가 바뀜!
System.out.println(team.getMembers()); // [Alice, Bob, Charlie] 😱
- 내부의 members 필드가 외부에서 직접 수정되어 버림
- 캡슐화가 깨지고 클래의 불변성이 무너지게 됨
✅ 2. 생성자에서 방어적 복사 적용 (외부 참조 끊기)
public class Team {
private final List<String> members;
public Team(List<String> members) {
this.members = new ArrayList<>(members); // 생성자 방어적 복사 ✅
}
public List<String> getMembers() {
return new ArrayList<>(members); // (getter) 반환 시 방어적 복사 ✅
}
}
---------------------------------------------------------------------
List<String> originalList = new ArrayList<>();
originalList.add("Alice");
originalList.add("Bob");
Team team = new Team(originalList);
originalList.add("Charlie");
System.out.println(team.getMembers()); // [Alice, Bob] ✅
- 생성자에서 복사본을 저장해서 이후 원본을 바꿔도 Team 내부는 안전함
- 참조 주소를 공유하지 않음
- getter에서도 방어적 복사
❓ 방어적 복사는 얕은 복사
방어적 복사는 깊은 복사가 아니다.
this.members = new ArrayList<>(members);
- List 자체는 새로 만들어진 객체다. (외부 리스트와 주소 분리됨)
- 하지만 그 안에 들어있는 String 객체들은 그대로 같은 참조가 복사된다.
- 즉, 리스트 안에 들어있는 요소들은 그대로 공유되고 있는 상태
- 즉, 원본의 내부 요소가 바뀌면 복사본도 바뀌게 된다.
✔️ Unmodifiable Collection
외부에서 변경 시 예외처리되기 때문에 안전하게 보장할 수 있다.
즉, getter로 값을 꺼내도 데이터를 수정할 수 없다.
public List<String> getMembers() {
return Collections.unmodifiableList(members);
}
참고 : https://velog.io/@dabeen-jung/Java-Mutable%EA%B3%BC-Immutable-%EC%B0%A8%EC%9D%B4
참고 : https://github.com/devSquad-study/2023-CS-Study/blob/main/java/java_mutable_immutable.md