생성된 뒤 상태가 절대 바뀌지 않는 객체. 자바에서 가장 값싼 안전장치다.
왜 강력한가
- ① 그 자체로 스레드 안전하다
- 아무도 못 바꾸니 동기화가 필요 없다 — 락도 volatile 도 불필요
- ② 해시 컬렉션의 키로 안전하다
- 해시가 바뀌지 않으니 잃어버릴 일이 없다
- ③ 캐시·공유가 안전하다
- 여러 곳에서 같은 인스턴스를 써도 된다 (Integer 캐시가 이 원리)
- ④ 사이드 이펙트가 없어 추론이 쉽다
만드는 규칙
public final class Money { // ① 상속 금지 (하위가 가변으로 만들 수 있다)
private final long amount; // ② 모든 필드 final + private
private final List<String> tags;
public Money(long amount, List<String> tags) {
this.amount = amount;
this.tags = List.copyOf(tags); // ③ 방어적 복사 (들어올 때)
}
public List<String> getTags() {
return tags; // ④ 이미 불변 컬렉션이라 그대로 반환해도 안전
}
public Money plus(long v) { // ⑤ 변경 대신 새 객체를 반환
return new Money(amount + v, tags);
}
}
방어적 복사를 빠뜨리면
List<String> input = new ArrayList<>(List.of("a"));
Money m = new Money(100, input); // 복사하지 않았다면
input.add("b"); // 바깥에서 m 의 내부를 바꿔 버린다
final은 참조를 고정할 뿐 가리키는 객체의 내용까지 막지 않는다 — 불변의 가장 흔한 오해다.
record는 얕은 불변이다
record Order(Long id, List<String> items) { }
var list = new ArrayList<String>();
var o = new Order(1L, list);
list.add("해킹"); // o.items() 가 바뀐다
// 컴팩트 생성자에서 복사한다
record Order(Long id, List<String> items) {
Order { items = List.copyOf(items); }
}
성능이 걱정되면
-
"매번 새 객체를 만들면 느리지 않나?"
-
대부분의 객체는 짧게 살고 죽는다 → 젊은 세대 GC 는 매우 싸다
-
TLAB 덕분에 할당 자체가 포인터를 밀어내는 수준으로 빠르다
-
동기화를 없앤 이득이 대개 더 크다
-
정말 문제가 되는 구간(대량 반복 · 큰 객체)만 측정해서 예외를 둔다
자바 표준에서의 예
String · Integer 등 래퍼 · LocalDate/LocalDateTime · BigDecimal List.of() · Map.of() · List.copyOf() 로 만든 컬렉션
면접 함정
- ❌ "final을 붙이면 불변" → 참조만 고정된다. 내부 컬렉션은 여전히 바뀔 수 있다.
- ❌ "record면 불변" → 얕은 불변이다. 가변 필드를 담으면 깨진다.