JavaMedium#62
Java의 불변 객체(Immutable Object)란 무엇이며 어떻게 만드나요?
#Java#불변객체#스레드안전#설계
힌트
final 클래스, private final 필드, 방어적 복사를 생각해보세요.
정답 및 해설
Java의 불변 객체(Immutable Object)란 무엇이며 어떻게 만드나요?
불변 객체(Immutable Object)는 생성된 이후 내부 상태를 절대 변경할 수 없는 객체입니다. 대표적인 예로 Java의 String, Integer, LocalDate 등이 있으며, 함수형 프로그래밍과 멀티스레드 환경에서 특히 중요한 개념입니다. 상태 변경이 필요하면 기존 객체를 수정하는 대신 변경된 상태를 담은 새 객체를 반환합니다.
불변 객체를 만드는 규칙
불변 객체를 올바르게 만들기 위해서는 5가지 조건을 모두 충족해야 합니다.
1. 클래스를 final로 선언
하위 클래스에서 불변성을 깨는 메서드를 오버라이드하는 것을 방지합니다.
public final class Money {
// final 클래스이므로 상속 불가
}
2. 모든 필드를 private final로 선언
private: 외부에서 직접 접근 불가final: 한 번 할당된 후 재할당 불가
public final class Money {
private final long amount;
private final String currency;
}
3. setter 메서드 제공하지 않음
public final class Money {
private final long amount;
// setter 없음
// public void setAmount(long amount) {} // 절대 추가하면 안 됨
public long getAmount() {
return amount;
}
}
4. 생성자에서만 초기화
public final class Money {
private final long amount;
private final String currency;
public Money(long amount, String currency) {
if (amount < 0) throw new IllegalArgumentException("금액은 0 이상이어야 합니다.");
this.amount = amount;
this.currency = currency;
}
}
5. 가변 객체 필드는 방어적 복사(Defensive Copy)
이것이 가장 자주 실수하는 부분입니다. 필드가 가변 객체(List, Date, 배열 등)를 참조한다면, 외부에서 해당 객체를 통해 상태를 변경할 수 있습니다.
import java.util.Date;
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
public final class Period {
private final Date start;
private final Date end;
private final List<String> events;
// 생성자에서 방어적 복사 (입력값 복사)
public Period(Date start, Date end, List<String> events) {
// 외부에서 전달한 Date 객체를 그대로 저장하면 위험
this.start = new Date(start.getTime()); // 방어적 복사
this.end = new Date(end.getTime());
this.events = new ArrayList<>(events); // 방어적 복사
}
// getter에서도 방어적 복사 (반환값 복사)
public Date getStart() {
return new Date(start.getTime()); // 복사본 반환
}
public Date getEnd() {
return new Date(end.getTime());
}
public List<String> getEvents() {
return Collections.unmodifiableList(events); // 읽기 전용 뷰 반환
}
}
// 방어적 복사 없이 만든 경우의 문제점 시연
Date start = new Date();
Date end = new Date();
Period period = new Period(start, end, List.of());
// 방어적 복사가 없다면 이렇게 외부에서 상태 변경 가능
start.setTime(0); // period.start도 변경됨!
완전한 불변 클래스 예시
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class ImmutableOrder {
private final String orderId;
private final long totalPrice;
private final List<String> items; // 가변 필드
public ImmutableOrder(String orderId, long totalPrice, List<String> items) {
this.orderId = orderId;
this.totalPrice = totalPrice;
this.items = new ArrayList<>(items); // 방어적 복사
}
public String getOrderId() {
return orderId;
}
public long getTotalPrice() {
return totalPrice;
}
public List<String> getItems() {
return Collections.unmodifiableList(items); // 불변 뷰 반환
}
// 변경이 필요하면 새 객체 반환 (with 패턴)
public ImmutableOrder withTotalPrice(long newPrice) {
return new ImmutableOrder(this.orderId, newPrice, this.items);
}
public ImmutableOrder addItem(String item) {
List<String> newItems = new ArrayList<>(this.items);
newItems.add(item);
return new ImmutableOrder(this.orderId, this.totalPrice, newItems);
}
@Override
public String toString() {
return "ImmutableOrder{orderId='" + orderId + "', totalPrice=" + totalPrice + ", items=" + items + "}";
}
}
// 사용 예시
ImmutableOrder order = new ImmutableOrder("ORD-001", 50000, List.of("책", "펜"));
ImmutableOrder updated = order.addItem("노트"); // 새 객체 반환
System.out.println(order.getItems()); // [책, 펜]
System.out.println(updated.getItems()); // [책, 펜, 노트]
Java 16+ record를 이용한 불변 객체
Java 16에서 정식 도입된 record는 불변 데이터 클래스를 더 간결하게 만들 수 있습니다.
// record는 자동으로:
// - 모든 필드를 private final로 선언
// - 생성자, getter, equals, hashCode, toString 생성
// - 암묵적으로 final 클래스
public record Point(double x, double y) {
// 컴팩트 생성자로 유효성 검사
public Point {
if (Double.isNaN(x) || Double.isNaN(y)) {
throw new IllegalArgumentException("좌표는 NaN일 수 없습니다.");
}
}
// 추가 메서드 정의 가능
public double distanceTo(Point other) {
double dx = this.x - other.x;
double dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}
}
// 사용
Point p1 = new Point(3.0, 4.0);
Point p2 = new Point(0.0, 0.0);
System.out.println(p1.x()); // 3.0 (getter: 필드명())
System.out.println(p1.distanceTo(p2)); // 5.0
불변 객체의 장점
1. 스레드 안전성 (Thread Safety)
// 불변 객체는 동기화 없이 여러 스레드에서 안전하게 공유 가능
public final class Config {
private final String host;
private final int port;
public Config(String host, int port) {
this.host = host;
this.port = port;
}
// 동기화 없이도 여러 스레드에서 읽기 안전
}
// 멀티스레드 환경에서 별도 동기화 없이 공유
Config config = new Config("localhost", 8080);
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 10; i++) {
executor.submit(() -> System.out.println(config.host())); // 안전
}
2. 캐싱과 재사용 가능
// String 리터럴은 String Pool에 캐싱
String s1 = "Hello";
String s2 = "Hello";
System.out.println(s1 == s2); // true (같은 객체)
// Integer.valueOf는 -128~127 범위 캐싱
Integer a = Integer.valueOf(100);
Integer b = Integer.valueOf(100);
System.out.println(a == b); // true (캐시된 객체)
3. 부작용(Side Effect) 없음
메서드에 불변 객체를 전달하면 원본이 변경될 걱정이 없어 코드를 추론하기 쉽습니다.
public void process(ImmutableOrder order) {
// order를 어떻게 사용해도 호출자의 원본 데이터는 안전
ImmutableOrder modified = order.withTotalPrice(order.getTotalPrice() * 2);
// 원본 order는 그대로
}
4. 안전한 Map 키, Set 원소로 사용 가능
// 가변 객체를 키로 사용하면 해시코드 변경 위험
Map<String, String> map = new HashMap<>();
map.put("key", "value");
// String은 불변이므로 키가 변경되어 맵이 깨지는 일 없음
// 가변 객체를 키로 사용하는 위험한 예 (안티패턴)
// StringBuilder sb = new StringBuilder("key");
// map.put(sb, "value"); // sb 변경 시 조회 불가
불변 객체의 단점과 해결책
단점: 새 객체 생성 비용
// String 연산마다 새 객체 생성 (O(n²))
String result = "";
for (int i = 0; i < 1000; i++) {
result += i; // 매번 새 String 생성
}
// 해결: StringBuilder 사용 후 최종에 String 변환
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append(i);
}
String result2 = sb.toString();
단점: 복잡한 객체 생성 - Builder 패턴으로 해결
// Builder 패턴으로 불변 객체를 편리하게 생성
public final class User {
private final String name;
private final int age;
private final String email;
private final String phone;
private User(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.email = builder.email;
this.phone = builder.phone;
}
// getter들...
public static class Builder {
private final String name; // 필수
private final int age; // 필수
private String email = ""; // 선택
private String phone = ""; // 선택
public Builder(String name, int age) {
this.name = name;
this.age = age;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder phone(String phone) {
this.phone = phone;
return this;
}
public User build() {
return new User(this);
}
}
}
// 사용
User user = new User.Builder("홍길동", 30)
.email("hong@example.com")
.phone("010-1234-5678")
.build();
정리
| 구분 | 내용 |
|---|---|
| 정의 | 생성 후 상태를 변경할 수 없는 객체 |
| 조건 1 | 클래스를 final로 선언 (상속 방지) |
| 조건 2 | 모든 필드를 private final로 선언 |
| 조건 3 | setter 메서드 제공하지 않음 |
| 조건 4 | 생성자에서만 초기화 |
| 조건 5 | 가변 필드는 방어적 복사 (생성자 입력, getter 반환 모두) |
| 장점 | 스레드 안전, 캐싱 가능, 부작용 없음, 안전한 공유 |
| 단점 | 매번 새 객체 생성 비용 |
| 대표 예시 | String, Integer, LocalDate, BigDecimal |
| Java 16+ | record 키워드로 간결하게 구현 가능 |
| 변경 시 패턴 | withXxx() 메서드로 새 객체 반환 (wither 패턴) |