[☕JAVA] 불변 객체(Immutable Object)
멀티스레드를 공부하다 보면 Lock, synchronized, volatile 같은 키워드가 쏟아진다. 그런데 그 전에 한 가지 질문을 먼저 해야 한다. “왜 Lock이 필요한가?” 그 답이 바로 불변 객체를 이해하는 데서 시작된다. 멀티스레드의 전제 지식인 불변 객체를 제대로 익혀보자.
불변 객체란 무엇인가? 🤔
불변 객체(Immutable Object)란 한 번 생성된 이후, 내부 상태(필드 값)를 절대 변경할 수 없는 객체이다.
Java에서 가장 친숙한 예시는 String이다.
1
2
3
String s = "hello";
s.toUpperCase(); // "HELLO"를 반환하지만 s 자체는 변하지 않음
System.out.println(s); // 여전히 "hello"
s의 값을 바꾸려면 새 객체를 만들어서 재할당해야 한다.
1
2
s = s.toUpperCase(); // 새 String 객체를 만들어서 s가 가리키게 함
System.out.println(s); // "HELLO"
String 외에도 Integer, Long, LocalDate, BigDecimal 등 Java 기본 라이브러리의 핵심 클래스 대부분이 불변 객체로 설계되어 있다. 우연이 아니다. 다 이유가 있음!
왼쪽(가변 객체): Stack의 list_a와 list_b가 Heap의 동일한 주소(0x1A00)를 가리키고 있다. 따라서 list_a로 리스트를 수정하면 list_b로 접근했을 때도 동일한 변경사항이 보인다. 두 변수가 같은 객체를 공유하는 구조다.
오른쪽(불변 객체): 문자열(str_a)을 수정하려고 하면 기존 객체를 변경하는 것이 아니라, 새로운 객체("HELLO")를 Heap에 생성하고 변수가 그 새 주소를 가리키도록 rebinding한다. 기존 객체("hello")는 그대로 남아있고, 다른 변수(str_b)는 여전히 원래 객체를 가리킨다.
왜 멀티스레드의 전제 지식인가? 🧵
멀티스레드 환경에서는 여러 스레드가 하나의 객체를 동시에 읽고 쓸 수 있다.
가변 객체를 공유하면 생기는 문제
1
2
3
4
5
6
7
8
public class Counter {
private int count = 0;
public void increment() {
count++; // 원자적 연산처럼 보이지만 실제론 3단계
// 1) count 읽기 2) +1 계산 3) count에 쓰기
}
}
Thread A와 Thread B가 동시에 increment()를 실행하면 이런 일이 일어난다.
1
2
3
4
5
6
Thread A: count 읽기 → 5
Thread B: count 읽기 → 5 (A가 아직 안 썼으니까!)
Thread A: 5 + 1 = 6, count에 쓰기
Thread B: 5 + 1 = 6, count에 쓰기
최종 결과: 6 (기대값은 7이었는데!)
이게 경쟁 조건(Race Condition)이다. 이걸 막으려고 synchronized, Lock, AtomicInteger 같은 것들이 필요한 거다.
두 스레드가 동시에 count를 증가시킬 때 발생하는 Race Condition
위 다이어그램은 Lost Update 문제를 시간축으로 보여준다. 초기값 count = 0을 Thread A와 Thread B가 거의 동시에 READ한다. 두 스레드 모두 0을 읽은 뒤 각각 +1 연산을 수행하고, 결과값 1을 WRITE한다. 두 번 증가시켰으니 기대값은 2인데, 실제로는 1이 된다. 마지막에 쓴 스레드의 값으로 덮어씌워지면서 한 번의 업데이트가 소실된 것이다. 이것이 경쟁 조건(Race Condition)의 전형적인 예시다.
불변 객체는 왜 안전한가
불변 객체는 상태 변경 자체가 불가능하다. 쓰기가 아예 없으니 경쟁이 일어날 일도 없다.
1
2
3
4
5
final ImmutableValue VALUE = new ImmutableValue(42);
Thread t1 = new Thread(() -> System.out.println(VALUE.get())); // 42
Thread t2 = new Thread(() -> System.out.println(VALUE.get())); // 42
// 두 스레드 모두 항상 42를 읽음, 충돌 없음, Lock 없음
💡 핵심 포인트 Lock이 필요한 이유는 “여러 스레드가 공유 상태를 동시에 변경하기 때문”이다. 불변 객체는 애초에 상태 변경이 없으므로 Lock 자체가 필요 없다. 이 맥락을 알아야 Lock/volatile의 설명이 이해된다.
불변 객체를 만드는 5가지 규칙 📋
규칙을 하나라도 어기면 불변성이 깨질 수 있다. 하나씩 왜 필요한지와 함께 살펴보자.
규칙 1 — 클래스를 final로 선언
상속을 막아 하위 클래스가 불변성을 깨는 것을 방지한다.
1
2
3
4
5
6
7
8
public final class Money {
// ...
}
// 아래 코드는 컴파일 에러
class MutableMoney extends Money { // ❌ 상속 불가
public void setAmount(int v) { ... } // 이런 메서드를 추가하려 했겠지만
}
final이 없으면 누군가 상속해서 setter를 만들어버릴 수 있다.
규칙 2 — 모든 필드를 private final로 선언
private: 외부에서 직접 접근 차단final: 생성 이후 재할당 불가
1
2
3
4
5
6
7
8
9
public final class Money {
private final int amount;
private final String currency;
public Money(int amount, String currency) {
this.amount = amount;
this.currency = currency;
}
}
final 필드는 반드시 생성자 또는 선언 시점에 초기화해야 한다. 이후엔 절대 변경할 수 없다.
규칙 3 — setter 메서드를 제공하지 않음
값을 “변경”하고 싶다면 새 객체를 반환하는 방식으로 대체한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public final class Money {
private final int amount;
private final String currency;
// ❌ setter 없음
// public void setAmount(int amount) { this.amount = amount; }
// ✅ 새 객체를 반환
public Money add(int extra) {
return new Money(this.amount + extra, this.currency);
}
public Money withCurrency(String newCurrency) {
return new Money(this.amount, newCurrency);
}
}
// 사용
Money m1 = new Money(1000, "KRW");
Money m2 = m1.add(500); // m1은 그대로, m2가 1500원
String의 toUpperCase(), trim(), replace() 모두 이 방식으로 새 String을 반환한다.
규칙 4 — 생성자에서 방어적 복사 (가변 객체 필드)
여기서부터가 핵심이다. int, String 같은 불변 타입만 필드로 갖는다면 규칙 1~3으로 충분하다. 하지만 List, Date, Map 같은 가변 객체가 필드에 있다면 이야기가 달라진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 나쁜 예 — 방어적 복사 없음
public final class Period {
private final Date start; // Date는 가변 객체!
public Period(Date start) {
this.start = start; // 외부 참조를 그대로 저장 → 위험!
}
}
// 외부에서 불변성을 깨버릴 수 있음
Date d = new Date();
Period p = new Period(d);
d.setYear(2000); // p.start도 같이 바뀜! 불변성 파괴
1
2
3
4
5
6
7
8
9
10
11
12
// ✅ 올바른 예 — 방어적 복사
public final class Period {
private final Date start;
public Period(Date start) {
this.start = new Date(start.getTime()); // 복사본을 저장
}
}
Date d = new Date();
Period p = new Period(d);
d.setYear(2000); // p.start는 전혀 영향 없음 ✅
규칙 5 — getter에서도 방어적 복사
가변 필드를 그대로 반환하면 호출자가 반환받은 객체를 수정해서 내부 상태를 바꿀 수 있다.
1
2
3
4
5
6
7
8
// ❌ 나쁜 예
public Date getStart() {
return start; // 내부 start 객체를 직접 노출
}
// 호출자가 이렇게 하면?
Date d = p.getStart();
d.setYear(2000); // p의 내부 start도 같이 바뀜!
1
2
3
4
// ✅ 올바른 예
public Date getStart() {
return new Date(start.getTime()); // 복사본 반환
}
💡 한 줄 요약: “들어올 때도 복사, 나갈 때도 복사”
방어적 복사 전체 예시 🛡️
List를 필드로 갖는 불변 클래스를 제대로 만들어 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class Team {
private final String name;
private final List<String> members; // List는 가변 컬렉션!
public Team(String name, List<String> members) {
this.name = name;
// 규칙 4: 생성자에서 방어적 복사
this.members = new ArrayList<>(members);
}
public String getName() {
return name; // String은 불변이므로 그대로 반환해도 OK
}
public List<String> getMembers() {
// 규칙 5: 방어적 복사 또는 수정 불가 뷰 반환
return Collections.unmodifiableList(members);
}
}
테스트해 보자.
1
2
3
4
5
6
7
8
9
List<String> members = new ArrayList<>(List.of("Alice", "Bob"));
Team team = new Team("Dev", members);
// 외부에서 원본 리스트 수정 시도
members.add("Charlie");
System.out.println(team.getMembers()); // [Alice, Bob] — 영향 없음! ✅
// getter로 받은 리스트 수정 시도
team.getMembers().add("Eve"); // UnsupportedOperationException 발생! ✅
방어적 복사를 적용하지 않았을 때와 적용했을 때의 메모리 참조 관계
왼쪽(방어적 복사 없음): Period 객체의 생성자가 외부에서 전달받은 Date 객체의 참조를 그대로 start 필드에 저장한다. 외부 변수 d와 내부 필드 period.start가 동일한 주소(0x2A00)를 가리키므로, 외부에서 d.setYear(2000)으로 값을 변경하면 period 내부의 start 값도 함께 오염된다. 불변성이 깨진다!
오른쪽(방어적 복사 적용): 생성자에서 new Date(start.getTime())으로 복사본을 만들어 저장한다. 외부 변수 d는 0x2A00을 가리키지만, 내부 필드 period.start는 독립적인 새 주소(0x2B00)를 가리킨다. 이제 외부에서 d를 아무리 변경해도 period 내부에는 전혀 영향을 주지 않는다. 완벽한 불변성을 유지한다.
장단점 정리 ⚖️
| 구분 | 내용 |
|---|---|
| ✅ 스레드 안전 | Lock 없이 여러 스레드에서 공유 가능 |
| ✅ 캐싱 가능 | 값이 변하지 않으므로 HashMap key, Set 원소로 안전하게 사용 |
| ✅ 실패 원자성 | 예외 발생 시 객체 상태가 중간 상태로 망가질 일 없음 |
| ✅ 부수효과 없음 | 메서드가 외부 상태를 변경하지 않아 코드 추론이 쉬움 |
| ⚠️ 새 객체 생성 비용 | 값을 바꿀 때마다 새 객체 생성 → 객체가 많이 생기면 GC 부담 |
단점 보완 팁: String 연결을 루프에서 수백 번 반복하면 매번 새 String이 생성된다. 이 때문에 가변 버전인 StringBuilder가 존재하는 것이다. 불변 객체가 무조건 좋은 게 아니라, 적절한 상황에 쓰는 것이 중요!
Java 16+ record로 더 간단하게 🚀
Java 16부터는 record 키워드로 불변 클래스를 아주 간결하게 만들 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// record는 자동으로 생성해줌:
// - final 클래스
// - private final 필드
// - 생성자
// - getter (필드명과 동일한 메서드)
// - equals / hashCode / toString
public record Money(int amount, String currency) {
// compact 생성자 — 유효성 검사 추가 가능
public Money {
if (amount < 0) {
throw new IllegalArgumentException("금액은 0 이상이어야 합니다: " + amount);
}
if (currency == null || currency.isBlank()) {
throw new IllegalArgumentException("통화 코드는 필수입니다");
}
}
// 필요한 메서드 직접 추가 가능
public Money add(int extra) {
return new Money(this.amount + extra, this.currency);
}
}
사용 예시:
1
2
3
4
5
6
7
8
Money m = new Money(1000, "KRW");
System.out.println(m.amount()); // 1000
System.out.println(m.currency()); // KRW
System.out.println(m); // Money[amount=1000, currency=KRW]
Money m2 = m.add(500);
System.out.println(m2.amount()); // 1500
System.out.println(m.amount()); // 1000 — m은 그대로
주의:
record도 가변 객체 필드가 있으면 방어적 복사가 필요하다.record가 규칙 1~3을 자동으로 해줄 뿐, 규칙 4~5는 여전히 직접 처리해야 한다!
핵심 정리 📌
- 불변 객체 = 생성 이후 상태가 절대 변하지 않는 객체
- 5가지 규칙:
final클래스 →private final필드 → setter 없음 → 생성자 방어적 복사 → getter 방어적 복사 - 멀티스레드 안전 이유: 상태 변경이 없으므로 경쟁 조건 자체가 발생하지 않음 → Lock 불필요
- 대표 불변 클래스:
String,Integer,LocalDate,BigDecimal - 단점: 값 변경 시마다 새 객체 생성 → GC 부담 (→
StringBuilder가 존재하는 이유) - Java 16+:
record로 보일러플레이트 없이 불변 클래스 작성 가능

