불변객체(Immutable Object)
불변 객체는 객체를 초기화 후 내부 상태(객체 내부의 값, 필드, 멤버 변수)를 변경할 수 없는 객체를 말한다. 읽기 전용(read-only) 객체로 불리며, 내부 상태는 외부에 노출되지 않거나 방어적 복사(Defensive Copy)를 통해 제공된다.
내부의 값이 변경되지 않음에 따라 객체 상태 값에 `final`로 선언한다. 그럼 내부에서 값을 변경할 수 있는 `setter`사용이 불가해진다.
그럼 이 클래스는 생성자를 통해서만 값을 설정할 수 있게 된다. 이후에는 값을 변경하는 것이 불가능하다.
public class ImmutableString {
private final String value; // 필드는 final로 선언하여 재할당 방지
// 생성자를 통해 초기 상태를 고정
public ImmutableString(String value) {
if (value == null) {
throw new IllegalArgumentException("Value cannot be null");
}
this.value = value;
}
// Getter 메서드로만 상태를 노출
public String getValue() {
return value;
}
@Override
public String toString() {
return "ImmutableString{" +
"value='" + value + '\'' +
'}';
}
}
물론 위 코드에 클래스에서 `setter`를 명시적으로 만들지 않는다면, 외부에서 변경할 수 있는 방법은 따로 존재하지 않는다. 하지만 멤버변수에 `final`선언함에 따라 명시적으로 불변 객체를 나타낼 수 있고, 개발자 실수를 통한 `setter`생성도 막을 수 있게 된다.
다음과 같이 `ImmutableString`을 이용하여 객체를 만든다면, 값을 변경할 수 있는 경우는 객체를 다시 생성할 수밖에 없다.
public class Main {
public static void main(String[] args) {
ImmutableString immutable = new ImmutableString("Initial Value");
System.out.println(immutable.getValue()); // "Initial Value"
// 상태 변경 시도 불가능
// immutable.value = "Modified"; // 컴파일 에러 (직접 접근 불가)
// immutable.setValue("New Value"); // 컴파일 에러 (setter 없음)
// 내부 상태는 항상 초기 값 유지
System.out.println(immutable); // ImmutableString{value='Initial Value'}
}
}
불변 객체의 값 변경
불변 객체는 내부 상태 값을 변경하지 않는 코드라고 했음에도 값 변경이 필요한 경우가 있을 수 있다. 위에서 방어적 복사를 통해 제공된다는 표현이 있는데 불변 객체의 값 변경에 있어 꼭 필요한 설명이다.
다음 예시 코드는 `int value` 멤버변수와 값을 더하는 합산 메서드인 `add()`가진 불변 객체에 대한 예시이다.
public class ImmutableInteger {
private final int value; // 상태를 변경할 수 없도록 final 선언
// 생성자를 통해 초기 상태를 설정
public ImmutableInteger(int value) {
this.value = value;
}
// 현재 값 반환 (읽기 전용)
public int getValue() {
return value;
}
// 값을 변경하는 대신 새로운 객체를 반환
public ImmutableInteger add(int data) {
return new ImmutableInteger(this.value + data);
}
}
`add()`메서드를 보면 새로운 객체를 생성하여 전달하는 것을 알 수 있다. 이게 방어적 복사에 딥 복사를 통해 이루어진다.
불변 객체의 값 변경은 기존 값 value는 유지하면서, 계산 결과를 바탕으로 새로운 객체를 만들어서 반환한다. 이렇게 하면 불변도 유지하면서 새로운 결과도 만들 수 있게 된다.
불변 객체의 장점
1. Thread-Safe (스레드 안전성)와 부수 효과 방지
- 불변 객체는 상태가 변경되지 않기 때문에, 여러 스레드에서 동시에 접근해도 안전하다.(Thread-Safe)
- 이는 부수 효과(Side Effect)를 방지하는 데 기여한다. 즉, 한 스레드의 작업이 다른 스레드에 영항을 미치지 않는다.
- 반면, 공유 자원(가변 객체)을 사용하는 경우에는 여러 스레드가 같은 자원을 변경하려 할 때 동기화(synchronization)가 필요하며, 잘못 처리하면 데이터 충돌등 예기치 않은 오류가 발생할 수 있다.
가변 객체를 사용한 예시이다.
package class4;
public class SharedMain {
public static void main(String[] args) throws InterruptedException {
SharedResource resource = new SharedResource(0);
// 2개의 스레드가 같은 자원을 변경
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
resource.add(1);
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
resource.add(1);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// 예상 결과: 2000, 실제 결과는 다를 수 있음 Final Value: 1880
System.out.println("Final Value: " + resource.getValue());
}
}
class SharedResource {
private int value;
public SharedResource(int value) {
this.value = value;
}
public void add(int data) {
this.value += data; // 상태 변경
}
public int getValue() {
return value;
}
}
`value`의 최종값은 2000이 되어야 하지만, 데이터 레이스(Data Race)로 인해 예기치 않은 값이 나올 수 있다.
다음은 불변 객체를 사용하는 경우이다.
package class4;
public class ImmutableMain {
public static void main(String[] args) throws InterruptedException {
ImmutableResource resource = new ImmutableResource(0);
// 두 스레드가 독립적으로 새로운 객체를 생성
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
resource.add(1); // 새로운 객체 생성
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
resource.add(1); // 새로운 객체 생성
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// 기존 객체는 변경되지 않음
System.out.println("Final Value: " + resource.getValue()); // 항상 0
}
}
class ImmutableResource {
private final int value;
public ImmutableResource(int value) {
this.value = value;
}
// 상태를 변경하지 않고 새로운 객체를 반환
public ImmutableResource add(int data) {
return new ImmutableResource(this.value + data);
}
public int getValue() {
return value;
}
}
기존 객체는 상태가 변하지 않으므로, 원래 값(`0`)을 그대로 유지한다. 스레드 간의 충돌 없이 객체의 불변성 보장한다.
단, 불변 객체도 값 변경이 이루어지니깐 공유 자원을 통한 변경이라고 생각할 수 있지만 동일한 공유 자원에 대해서 불변 객체는 말 그래도 읽기만 가능하다. 다시 말해서, 변경하기 위해 새로운 객체를 생성하여 반환하는 것은 다른 의미이다.
2. Failure Atomic (실패 원자성)
- 실패 원자적(Failure Atomic)인 메소드는 예외가 발생하더라도 객체의 상태를 변경하지 않는 메서드를 의미한다.
- 불변 객체는 본질적으로 실패 원자적 특성을 가지며, 가변 객체의 경우 이를 위해 특별히 설계해야 한다.
- 실패 원자성을 구현하면 메소드 실행 중 일부 작업이 실패해도 객체의 일관성이 유지된다.
public class ImmutableInteger {
private final int value;
public ImmutableInteger(int value) {
this.value = value;
}
// 실패 원자적 메서드: 상태 변경 없이 새로운 객체를 반환
public ImmutableInteger add(int data) {
if (data < 0) {
throw new IllegalArgumentException("Data must be non-negative");
}
return new ImmutableInteger(this.value + data);
}
public int getValue() {
return value;
}
}
public class Main {
public static void main(String[] args) {
ImmutableInteger number = new ImmutableInteger(10);
try {
// 성공적으로 새로운 객체 반환
ImmutableInteger newNumber = number.add(5);
System.out.println(newNumber.getValue()); // 15
// 실패해도 기존 객체는 변경되지 않음
number.add(-5);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // "Data must be non-negative"
}
// 기존 객체의 상태는 그대로 유지
System.out.println(number.getValue()); // 10
}
}
예외가 발생해도 `number`객체의 상태는 영향을 받지 않는다.
3. Cache/Map/Set 활용 적합성
- 불변 객체는 상태 변경이 없으므로, 키(key) 또는 값(value)으로 활용 시 해시코드나 동등성(equality) 비교가 일관되게 유지된다.
- 이러한 특성은 HashMap, HashSet, 또는 캐싱 메커니즘에서 적합하다.
다음 예시 코드를 보면 쉽게 이해 가능하다.
package class4;
import java.util.Objects;
public class MutableMember {
private String name;
private int age;
public MutableMember(String name, int age) {
this.name = name;
this.age = age;
}
public void changeName(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MutableMember that = (MutableMember) o;
return age == that.age && Objects.equals(name, that.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
package class4;
import java.util.HashMap;
public class CashMap {
public static void main(String[] args) {
MutableMember member = new MutableMember("A", 15);
HashMap<MutableMember, String> hashMap = new HashMap();
hashMap.put(member, "test");
System.out.println(member.hashCode());
System.out.println(hashMap.get(member)); // 검색이 된다.
member.changeName("AA");
System.out.println(member.hashCode());
System.out.println(hashMap.get(member)); // null 반환
}
}
위 코드는 불변객체가 아닌 가변객체로 `HashMap`으로 `key`로 활용 시 발생될 수 있는 문제이다. 해시 기반 컬렉션에 경우 검색 시 `Bucket`에 위치를 찾기 위해 해시코드를 이용한다.
하지만 가변 객체에 경우 필드의 값 변경에 따라 해시코드의 값이 변경됨에 따라 `key`로 선언한 `member`객체를 찾을 수 없게 된다.
다음과 같이 불편 객체로 설계한다면 값 변경 시 새로운 객체가 반환되므로 기존 객체에 대해서 그대로 사용되므로 검색이 가능하다.
package class4;
import java.util.Objects;
public class ImmutableMember {
private final String name;
private final int age;
public ImmutableMember(String name, int age) {
this.name = name;
this.age = age;
}
public ImmutableMember changeName(String name) {
return new ImmutableMember(name, this.age);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ImmutableMember that = (ImmutableMember) o;
return age == that.age && Objects.equals(name, that.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
package class4;
import java.util.HashMap;
public class CashMap1 {
public static void main(String[] args) {
ImmutableMember member = new ImmutableMember("A", 15);
HashMap<ImmutableMember, String> hashMap = new HashMap();
hashMap.put(member, "test");
System.out.println(member.hashCode());
System.out.println(hashMap.get(member)); // test
ImmutableMember memberAA = member.changeName("AA");
System.out.println(member.hashCode());
System.out.println(hashMap.get(member)); // test
}
}
4. GC의 성능을 높일 수 있다.
불변 객체를 단순히 생각해보면 동작이 발생될 때마다 새로운 객체가 반환되니 성능 저하에 대한 이슈가 있다고 생각할 수 있다.
하지만 불변 객체는 생성 이후 상태가 변하지 않는 객체이다. 이를 활용하면 GC(가비지 컬렉션)가 더 효율적으로 동작할 수 있다.
- 생명주기가 짧은 객체에 최적화된 GC 설계
- JVM의 GC는 Weak Generational Hypothesis라는 가설에 따라 설계되었다.
- 이 가설에 따르면, 대부분의 객체는 생명주기가 짧다는 특정을 가지고 있으며, GC는 짧은 생명주기를 가진 객체를 처리하는데 큰 부담을 느끼지 않는다.
- 불변 객체는 주로 짧은 생명주기를 가질 가능성이 높아 GC가 이를 쉽게 처리할 수 있다.
- 참조의 단순화
- 불변 객체는 상태가 변경되지 않기 때문에, 특정 컨터이너가 참조하는 객체들도 불필요한 변경이 발생하지 않는다.
- GC는 컨테이너가 제거될 때, 그와 연결된 모든 불변 객체들을 함께 정리할 수 있어 효율이 높아진다.
불변 객체와 가변 객체의 GC 차이
- 가변 객체(Mutable)
- 내부 필드의 상태를 변경할 수 있다.
- 참조가 끊어진 객체들은 컨테이너와는 별개로 GC 대상이 된다.
- 예를 들어, 컨테이너는 여전히 유효하지만 내부 객체는 더 이상 사용되지 않아도 별도로 추적해야 한다.
- 불변 객체(Immutable)
- 내부 필드의 상태를 변경할 수 없다.
- 컨테이너 객체가 더 이상 참조되지 않을 경우, 내부 객체까지 한 번에 GC 대상이 된다.
- 결과적으로 GC가 스캔해야 하는 메모리 영역과 빈도수가 줄어들어 성능이 향상된다.
// 불변 객체를 활용하는 컨테이너
public class ImmutableContainer {
private final Object value;
// 불변 필드 초기화
public ImmutableContainer(Object o) {
this.value = o;
}
// 값 반환 (읽기 전용)
public Object getValue() {
return value;
}
}
public class Main {
public void createHolder() {
// 1. 불변 객체 생성
final String value = "Hello world";
// 2. 불변 객체를 참조하는 컨테이너 생성
final ImmutableContainer holder = new ImmutableContainer(value);
// JVM 힙 메모리 구조:
// - "Hello world"는 String Pool 또는 Heap에 저장
// - ImmutableContainer는 Heap에 생성, value를 참조
}
}
JVM의 메모리 관리에서 불변 객체를 활용하면 GC 성능을 최적화할 수 있다.
ImmutableContainer가 불변 객체를 참조하는 경우, 상태가 변경되지 않아 참조 구조가 단순해지고 GC가 불필요한 참조 추적을 피할 수 있다. 컨테이너가 해제되면 내부 불변 객체도 함께 처리되어 메모리 관리가 간소화된다.
반면, MutableContainer는 내부 필드가 변경될 수 있어 GC가 추가 추적해야 하며, 이는 성능 부담으로 이어진다. 결과적으로, 불변 객체는 생성과 참조가 간결하고, GC 스캔 부담과 호출 빈도를 줄여 메모리 관리 효율성을 높아진다.
"불변 객체를 많이 생성하면 비용이 크다"는 오해는 객체 생성 비용을 과대평가한 결과이다.
Oracle의 발표에 따르면, 객체 생성 비용은 실제로 크지 않으며, GC 효율성 등으로 충분히 상쇄된다. 불변 객체를 설계 시 비용보다 성능과 안정성 향상에 초점을 맞추는 것이 더 중요하다.
'Language > Java' 카테고리의 다른 글
[Java] Wrapper 클래스와 성능(오토 박싱 & 오토 언박싱) (0) | 2025.01.23 |
---|---|
[Java] 불변객체 - String과 성능(StirngBuilder vs StringBuffer) (1) | 2025.01.22 |
[Java] 자바의 동등성과 동일성 (==, equals(), hashcode()) (0) | 2025.01.17 |
[Java] 자바의 Object 클래스는 왜 있는걸까? (0) | 2025.01.17 |
[Java] 자바의 메모리 구조와 Static (스택, 힙, 메서드 영역) (0) | 2025.01.10 |