Mutable은 객체가 만들어진 이후에도 내부 상태가 바뀔 수 있다는 뜻이다. StringBuilder가 대표적이다.
StringBuilder sb = new StringBuilder("hello");
sb.append(" world"); // 같은 객체의 내부값이 직접 수정됨
System.out.println(sb); // "hello world"
sb라는 변수가 가리키는 객체의 주소는 처음부터 끝까지 바뀌지 않는다. 바뀌는 건 그 객체 안에 저장된 값이다. append()를 호출하면 JVM은 sb가 가리키는 메모리 주소로 찾아가서 그 안의 데이터를 직접 수정한다. 같은 객체가 살아있는 채로 내용이 달라지는 것이다.
이 말은 sb를 참조하는 곳이 여러 군데라면, 한 곳에서 append()를 호출했을 때 다른 모든 곳에서도 바뀐 값을 보게 된다는 뜻이기도 하다.
Immutable은 객체가 한 번 만들어지면 그 내부 상태가 절대 바뀌지 않는다는 뜻이다. String이 대표적이다.
String a = "hello";
String b = a + " world";
System.out.println(a); // "hello" — 원본 그대로
System.out.println(b); // "hello world" — 새 객체
System.out.println(System.identityHashCode(a)); // 1234 (예시)
System.out.println(System.identityHashCode(b)); // 5678 (예시) — 다른 주소
a + " world"를 실행해도 a가 가리키는 "hello" 객체는 바뀌지 않는다. JVM은 "hello world"라는 내용을 담은 완전히 새로운 객체를 메모리에 만들고, b가 그 새 객체를 가리키도록 한다. a와 b의 identityHashCode가 다른 게 바로 두 변수가 서로 다른 객체를 가리키고 있다는 증거다.
"hello" 객체 자체는 아무도 건드리지 않았다. String에는 내부 값을 수정하는 메서드가 아예 없다. 어떤 연산을 하든 항상 새 객체를 만들어 반환할 뿐, 원본은 생성된 순간부터 GC에 수거될 때까지 같은 값을 유지한다.
Java에서 String은 가장 자주 쓰이는 타입이면서, 동시에 가장 많이 공유되는 타입이다. 클래스 로딩할 때 클래스 이름을 String으로 전달하고, DB 연결할 때 URL을 String으로 넘기고, HTTP 요청을 파싱할 때도 String이다.
이렇게 어디서든 공유되는 타입이 Mutable이라면 어떤 일이 생길까.
String dbUrl = "jdbc:mysql://localhost/prod";
connection.connect(dbUrl);
// connect() 내부에서 dbUrl을 조작할 수 있다면?
보안 검사를 통과한 값이 메서드 내부에서 몰래 바뀔 수 있다. String이 Immutable이기 때문에 이런 걱정 없이 참조를 넘길 수 있다.
두 번째 이유는 String Pool이다. JVM은 같은 리터럴을 만나면 새 객체를 만들지 않고 Pool에서 꺼내 쓴다.
String a = "hello";
String b = "hello";
System.out.println(a == b); // true — 같은 객체
이게 가능한 이유가 Immutable이기 때문이다. 값이 절대 안 바뀌니까 여러 변수가 같은 객체를 공유해도 안전하다. String이 Mutable이었다면 a를 바꾸는 순간 b도 같이 바뀌는 문제가 생겨서 Pool 자체가 불가능하다.
세 번째 이유는 Thread safety다. 여러 스레드가 동시에 같은 String을 읽을 때 값이 바뀔 일이 없으니 synchronized 같은 동기화가 필요 없다. Immutable이 자동으로 Thread safety를 보장한다.
그렇다면 다 Immutable로 만들면 되지 않을까. Immutable은 값이 바뀔 때마다 새 객체를 만들어야 한다.
String result = "";
for (int i = 0; i < 10000; i++) {
result += i; // 매번 새 String 객체 생성, 이전 객체는 버려짐
}
10000번 반복하면 10000개의 String 객체가 만들어지고 버려진다. GC 부담이 커지고 성능이 떨어진다. 이런 경우엔 Mutable인 StringBuilder를 써야 한다.
상태 변화 자체가 도메인의 의미를 가질 때도 있다.
Order order = new Order();
order.place(); // PENDING
order.confirm(); // CONFIRMED
order.ship(); // SHIPPED
주문이라는 개념은 단계를 거치며 바뀌는 게 자연스럽다. 매 단계마다 새 Order 객체를 만드는 건 도메인 모델로도 어색하고, 코드를 읽는 사람도 혼란스럽다.
Immutable을 선택하면 Thread safety와 안전한 공유를 자동으로 얻는 대신 변경 비용을 치른다. Mutable은 변경이 자유롭지만 Thread safety는 직접 보장해야 한다.
이 객체가 생성된 이후 상태가 바뀌어야 하는지를 기준으로 삼으면 된다.
값 자체가 정체성인 경우엔 Immutable이 맞다. 숫자로 생각하면 이해하기 쉽다. 5000 - 2000 = 3000을 계산할 때 5000이라는 숫자가 3000으로 바뀌는 게 아니다. 5000은 그대로 있고, 연산 결과로 3000이라는 새 숫자가 생긴다. Immutable Money도 같은 방식으로 동작한다.
// Mutable — 위험
Money price = new Money(5000);
price.setValue(3000); // price를 참조하던 다른 곳에서 값이 몰래 바뀜
// Immutable — 안전
Money price = new Money(5000);
Money discounted = price.discountBy(2000); // price는 여전히 5000, 새 객체 3000 반환
Mutable이면 price를 여러 곳에서 참조하고 있을 때 한 곳에서 setValue(3000)을 호출하는 순간 다른 모든 곳에서 값이 몰래 바뀐다. Immutable이면 원본은 절대 건드리지 않고 새 객체를 만들어 돌려주니까 그런 문제가 없다.
반면 Order처럼 상태 변화가 도메인 흐름의 일부인 경우엔 Mutable이 맞다.
기본은 Immutable로 설계하고, 상태 변화가 도메인 의미를 가질 때만 Mutable로 연다.
void process(final Order order) {
order = new Order(); // ❌ 컴파일 에러 — 재할당 불가
order.setStatus("CONFIRMED"); // ✅ 통과 — 내부 상태 변경은 막지 않음
}
final이 막는 건 order 변수가 다른 객체를 가리키는 재할당뿐이고, 그 객체 안의 값을 직접 수정하는 건 막지 못한다.
List<Order> readonly = Collections.unmodifiableList(orders);
readonly.add(new Order()); // ❌ UnsupportedOperationException
readonly.get(0).setStatus("DONE"); // ✅ Order가 Mutable이라면 통과
unmodifiableList는 리스트 구조 변경(add/remove/set)만 막는다. 리스트 안에 든 객체의 내부 상태는 막지 못한다. List<String>처럼 원소가 Immutable이어야 비로소 완전한 불변이 된다.
게다가 unmodifiableList는 원본 리스트를 감싼 뷰에 불과해서, 원본이 바뀌면 같이 바뀐다.
List<String> original = new ArrayList<>(List.of("a", "b"));
List<String> readonly = Collections.unmodifiableList(original);
original.add("c");
System.out.println(readonly); // [a, b, c] — 원본이 바뀌면 같이 바뀜
진짜 불변 리스트가 필요하면 List.of() 또는 List.copyOf()를 써야 한다.
Mutable 객체를 파라미터로 받는 상황에서 진짜로 보호하려면 복사본을 만들어야 한다. 그런데 단순한 복사(shallow copy)는 안에 든 객체의 참조를 공유하기 때문에 충분하지 않다.
// shallow copy — 위험
List<Order> copy = new ArrayList<>(original);
copy.get(0).setStatus("DELETED"); // original.get(0)도 바뀜
// deep copy — 안전
List<Order> copy = deepCopy(original); // 내부 객체까지 전부 새로 생성
copy.get(0).setStatus("DELETED"); // original에 영향 없음
다만 deep copy는 비용이 있다. 객체가 크거나 중첩이 깊을수록 메모리와 시간이 든다. 그래서 방어적 복사는 차선이고, 애초에 객체를 Immutable로 설계하는 게 우선이다. 객체가 Immutable이면 참조를 공유해도 안전하니 복사 자체가 필요 없다.
Immutable로 설계할 수 없는 상황이라면, 그때 deep copy로 방어한다.