불변 객체(Immutable Object)란

불변 객체란?
불변 객체(Immutable Object)는 생성된 이후 내부 상태(필드 값)가 절대 변하지 않는 객체입니다. 한번 만들어지면 소멸할 때까지 동일한 데이터를 유지합니다.
자바의 대표적인 예가 String입니다. replace를 호출해도 원본은 그대로 두고 새 인스턴스를 반환합니다.
String str = "hello";
String result = str.replace("l", "L");
System.out.println(str); // hello ← 원본은 그대로
System.out.println(result); // heLLo ← 새 객체가 반환됨
"수정"처럼 보이는 연산도 사실은 새 객체를 만드는 것입니다. Integer, Long 같은 래퍼 클래스와 LocalDate, LocalDateTime 등 java.time 패키지도 모두 불변입니다.
자바에서 불변 객체 만드는 법
불변 객체를 만드는 규칙은 네 가지입니다.
- 클래스를
final로 선언해 상속을 통한 불변성 훼손을 막는다 - 모든 필드를
private final로 선언한다 setter를 제공하지 않는다. 대신 객체 생성시 생성자를 통해 값을 초기화한다- 가변 객체를 필드로 가질 경우 방어적 복사(defensive copy)를 한다
public final class Money { // 1. 상속 차단
private final long amount; // 2. 모든 필드 final
private final String currency;
public Money(long amount, String currency) {
this.amount = amount;
this.currency = currency;
}
public long getAmount() { return amount; } // 3. setter 없음
public String getCurrency() { return currency; }
// 4. 상태 변경이 필요하면 새 객체를 반환
public Money plus(long extra) {
return new Money(this.amount + extra, this.currency);
}
}
방어적 복사가 필요한 이유
필드가 final이어도 그 객체 자체가 가변이면 불변성이 깨집니다. final은 "참조를 바꿀 수 없다"는 의미일 뿐, "참조하는 객체의 내부가 안 바뀐다"는 보장이 아니기 때문입니다.
// ❌ 잘못된 예 — 방어적 복사 없음
public final class Team {
private final List<String> members;
public Team(List<String> members) {
this.members = members; // 외부 리스트를 그대로 참조
}
public List<String> getMembers() {
return members; // 내부 리스트를 그대로 노출
}
}
Team 클래스를 봤을때 불변성을 보장하는것처럼 보입니다.
// 불변성이 깨지는 상황
List<String> input = new ArrayList<>(List.of("Kim", "Lee"));
Team team = new Team(input);
input.add("Park"); // 외부에서 수정했는데...
team.getMembers().add("Choi"); // 반환받은 리스트로도 수정 가능
System.out.println(team.getMembers()); // [Kim, Lee, Park, Choi] ← 깨짐!
그러나 Team 클래스의 생성자로 전달했던 List<String>을 외부에서 수정하면 앞서 생성했던 Team과 동일한 객체를 참조하는것이기에 불변성이 깨집니다.

생성자에서 내부 값만 복사하여 생성하면 됩니다. getter를 호출한 클라이언트 코드에서도 변경이 불가능하도록 합니다.
// ✅ 올바른 예 — 입력과 출력 양쪽에서 복사
public final class Team {
private final List<String> members;
public Team(List<String> members) {
this.members = new ArrayList<>(members); // 입력 시 복사
}
public List<String> getMembers() {
return Collections.unmodifiableList(members); // 출력 시 보호
}
}
JAVA Record 사용
Java 16부터 정식 도입된 record는 불변클래스를 설계할때 반복되는 보일러플레이트를 크게 줄여줍니다.
public record Money(long amount, String currency) {
public Money plus(long extra) {
return new Money(amount + extra, currency);
}
}
하지만 record도 내부에 가변 객체를 담으면 방어적 복사를 직접 해줘야 합니다. record는 깊은 불변(deep immutability)을 보장하지 않습니다.
public record Team(List<String> members) {
// compact 생성자에서 직접 방어적 복사
public Team {
members = List.copyOf(members); // 불변 복사본으로 교체
}
}
불변 객체의 장점
1. 스레드 안전성이 공짜로 보장된다
가장 큰 이유입니다. 상태가 변하지 않으므로 여러 스레드가 동시에 읽어도 동기화가 필요 없습니다. race condition 자체가 성립하지 않습니다.
// 가변 객체 — 멀티스레드에서 위험
class MutableCounter {
private int count = 0;
public void increment() { count++; } // 동기화 없으면 race condition
public int get() { return count; }
}
// 불변 객체 — 동기화 없이도 안전
final class ImmutableCounter {
private final int count;
ImmutableCounter(int count) { this.count = count; }
public ImmutableCounter increment() {
return new ImmutableCounter(count + 1); // 새 객체 반환
}
public int get() { return count; }
}
// 여러 스레드가 동시에 읽어도 문제없음 — lock도, synchronized도 불필요
ImmutableCounter c = new ImmutableCounter(10);
Runnable reader = () -> System.out.println(c.get()); // 항상 10
2. HashMap의 key, Set의 원소로 안전하다
hashCode가 변하지 않으므로 컬렉션 내부에서 객체가 "사라지는" 문제가 없습니다.
// ❌ 가변 객체를 key로 쓰면...
class MutablePoint {
int x, y;
MutablePoint(int x, int y) { this.x = x; this.y = y; }
@Override public int hashCode() { return Objects.hash(x, y); }
@Override public boolean equals(Object o) { /* x, y 비교 */ ... }
}
Map<MutablePoint, String> map = new HashMap<>();
MutablePoint p = new MutablePoint(1, 2);
map.put(p, "여기 있음");
p.x = 99; // key의 필드를 바꾸면 hashCode가 달라짐
System.out.println(map.get(p)); // null ← 분명 넣었는데 못 찾음!
불변 객체는 hashCode가 절대 변하지 않으므로 이런 버그가 구조적으로 발생하지 않습니다.
3. 사이드이펙트가 없어 안심하고 전달할 수 있다
메서드에 객체를 넘겨도 그 안에서 변경될 걱정이 없습니다. 아래 예제에서는 salary가 변경되지 않았다는 확신을 갖을 수 있고 불필요하게 process()메서드 내부를 한땀한땀 디버깅 할 필요가 없어졌습니다.
void process(Money money) {
// money가 불변이므로 이 안에서 무슨 짓을 해도
// 호출자가 가진 원본은 절대 변하지 않는다 → 방어적 복사 불필요
}
Money salary = new Money(3000, "USD");
process(salary);
// salary는 100% 그대로다. 코드를 더 읽어볼 필요가 없다.
4. 캐싱과 공유가 자유롭다
자바의 Integer.valueOf 캐시(-128~127)나 String 리터럴 풀이 가능한 이유가 바로 불변이기 때문입니다.
Integer a = Integer.valueOf(100);
Integer b = Integer.valueOf(100);
System.out.println(a == b); // true ← 같은 캐시 인스턴스를 공유
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // true ← 리터럴 풀에서 공유
불변이라 공유해도 안전하기 때문에 메모리를 아낄 수 있습니다.
단점 (코드로 보기)
1. 객체 생성 비용 feat. String
상태를 바꿀 때마다 새 객체를 만들어야 하므로, 변경이 빈번하면 GC 부담과 메모리 사용이 늘어납니다.
// ❌ 불변 String을 반복문에서 누적 — 매 반복마다 새 객체 생성
String result = "";
for (int i = 0; i < 100_000; i++) {
result += i; // 매번 새 String 객체가 만들어짐 → O(n²)에 가까움
}
// ✅ 가변 StringBuilder 사용 — 내부 버퍼를 재사용
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100_000; i++) {
sb.append(i); // 같은 버퍼에 추가
}
String result2 = sb.toString();
반복 누적 작업에서 StringBuilder(가변)를 쓰라고 하는 이유가 바로 이것입니다.
2. 대량 상태 변경에 비효율적
큰 컬렉션의 일부만 바꾸려 해도 전체를 새로 만들어야 할 수 있습니다.
// 100만 개 원소 중 단 하나만 바꾸고 싶어도...
List<Integer> original = List.of(/* 1,000,000개 */);
// 불변 리스트는 set이 불가능하므로 전체를 복사해야 함
List<Integer> copy = new ArrayList<>(original); // 100만 개 전부 복사
copy.set(500_000, 999); // 그제서야 한 개 수정
List<Integer> updated = List.copyOf(copy); // 또 한 번 복사
이 문제는 구조 공유(structural sharing)를 쓰는 persistent data structure로 완화할 수 있지만, 자바 표준 라이브러리에는 기본 제공되지 않습니다. (Vavr 같은 외부 라이브러리 필요)
3. 본질적으로 가변인 도메인엔 부적합
실시간으로 계속 변하는 상태를 억지로 불변으로 만들면 오히려 코드가 복잡해지고 성능이 나빠집니다.
// 게임 캐릭터의 위치를 매 프레임(초당 60회) 갱신한다면?
// ❌ 불변 — 매 프레임 새 객체 → 초당 60개씩 GC 대상 발생
record Position(double x, double y) {
Position move(double dx, double dy) {
return new Position(x + dx, y + dy);
}
}
// ✅ 이런 경우는 가변이 자연스럽고 효율적
class Position {
double x, y;
void move(double dx, double dy) { x += dx; y += dy; }
}
정리
| 항목 | 불변 객체 | 가변 객체 |
|---|---|---|
| 스레드 안전성 | 자동 보장 | 직접 동기화 필요 |
| 디버깅/추적 | 쉬움 | 어려움 |
| Map key / Set 원소 | 안전 | 위험 |
| 메모리/생성 비용 | 변경 잦으면 불리 | 유리 |
| 잦은 상태 변경 | 비효율적 | 효율적 |
실무 권장 사항은 "기본은 불변으로, 가변은 필요할 때만"입니다. 『이펙티브 자바(Effective Java)』에서도 변경 가능성을 최소화하라(minimize mutability)고 권합니다.
값 객체(VO), DTO, 설정값, 도메인 식별자처럼 변하지 않아야 의미가 분명한 것들은 불변으로 두면 버그를 구조적으로 차단할 수 있습니다. 그리고 성능이 정말 병목이 되는 지점에서만 선택적으로 가변 객체를 쓰는 방향이 안전합니다.
'자바[Java]' 카테고리의 다른 글
| [Java] Record란? feat. Lombok (0) | 2026.06.04 |
|---|---|
| [JAVA] Lombok이란? Lombok 적용하는 방법 (3) | 2020.02.13 |
| [Java] Java의 문자열(String) 객체가 저장되는 String Pool에 대하여 (0) | 2019.07.18 |
| [Effective Java] 인스턴스화가 필요없는 Utility 클래스 등은 private 생성자를 사용하자 (0) | 2019.07.16 |
| [Effective Java] 문자열 String 영어 대소문자 무시하여 비교시에는 equalsIgnoreCase() 메서드를 사용하자. (0) | 2019.07.11 |
도로락
WriterIT, 프로그래밍, 컴퓨터 활용 정보 등을 위한 블로그