외부활동/우아한테크코스 [프리코스]

불변성의 오해와 진실 톺아보기

softmoca__ 2025. 10. 30. 12:33
목차

3주차 로또 미션을 진행하며 일급 컬렉션, Value Object, 불변성에 대한 개념과 실제 적용 및 관계에 대해 혼동을 느꼈다.

 

그 기본인 불변성에 대해 final이 있으면 뭔가 아 ! 불변성을 얻었다 ! 싶은 생각이 충동적으로 들었다. 하지만 절대 절대 아니며 오히려 이런 잘못된 오해가 모든 혼란을 가져온다 ! 그로 인해 혼란 스러운 점과 실제 생성 방법 예시들을 정리해 보고자 한다 

불변성 (Immutability)

객체가 생성된 이후 그 상태를 변경할 수 없는 것을 의미

// ❌ 가변 객체
public class MutableLotto {
    private List<Integer> numbers;
    
    public void addNumber(int number) {
        numbers.add(number);  // 상태 변경!
    }
}

// ✅ 불변 객체
public class ImmutableLotto {
    private final List<Integer> numbers;
    
    public ImmutableLotto(List<Integer> numbers) {
        this.numbers = List.copyOf(numbers);  // 방어적 복사
    }
    
    public List<Integer> getNumbers() {
        return Collections.unmodifiableList(numbers);  // 불변 반환
    }
}

 

장점 1 스레드 안전 (Thread-Safe)

// 불변 객체는 자동으로 스레드 안전!
public class BonusNumber {
    private final int value;
    
    // 여러 스레드에서 동시에 접근해도 안전
}

 

장점 2 예측 가능한 동작

// 불변 객체
BonusNumber bonus = new BonusNumber(7);
// bonus는 항상 7을 유지
// 예측 가능!

// 가변 객체
MutableBonus bonus = new MutableBonus(7);
someMethod(bonus);  // 이 메서드가 bonus를 바꿀 수 있음
// bonus가 여전히 7인지 알 수 없음

불변성의 최대 사용 이유 !

사이드이펙트 가능성을 없애고 이를 통해 안전함을 보장받을 수 있다 !

 

장점3  방어적 복사 불필요

// 불변 객체는 그대로 공유 가능
public class WinningNumbers {
    private final Lotto winningLotto;  // 불변
    
    public Lotto getWinningLotto() {
        return winningLotto;  // 복사 불필요!
    }
}

// 가변 객체는 방어적 복사 필요
public class MutableWinningNumbers {
    private MutableLotto winningLotto;  // 가변
    
    public MutableLotto getWinningLotto() {
        return new MutableLotto(winningLotto);  // 복사 필수!
    }
}

 

 

 

불변성 vs 상수

final의 진실

final을 붙이면 불변 객체가 된다고 착각하기 쉽다. 하지만 이는 완전히 잘못된 생각이다 !

// ❌ 이것은 불변이 아니다 !
public class Lotto {
    private final List<Integer> numbers = new ArrayList<>();
    
    public Lotto(List<Integer> numbers) {
        this.numbers.addAll(numbers);
    }
    
    public void addNumber(int number) {
        this.numbers.add(number);  // 가능! final은 재할당만 막음
    }
}

// 사용
Lotto lotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
lotto.addNumber(7);  //  리스트가 변경됨!

 

final이 막는 것 vs 막지 못하는 것

public class Example {
    private final List<Integer> numbers = new ArrayList<>();
    
    public void wrong() {
        numbers = new ArrayList<>();  // ❌ 컴파일 에러!
        // Cannot assign a value to final variable 'numbers'
    }
}

final은 참조(reference)의 재할당만 막는다 !

public class Example {
    private final List<Integer> numbers = new ArrayList<>();
    private final StringBuilder sb = new StringBuilder();
    private final Map<String, Integer> map = new HashMap<>();
    
    public void canDoThis() {
        // ✅ 모두 가능! (객체 내부 상태 변경)
        numbers.add(1);
        numbers.remove(0);
        numbers.clear();
        
        sb.append("Hello");
        sb.delete(0, 5);
        
        map.put("key", 1);
        map.remove("key");
    }
}

final은 객체 상태 변경은 막지 못한다 !

 

 

메모리 관점에서 이해

public class Example {
    private final List<Integer> numbers;
    
    public Example() {
        numbers = new ArrayList<>();  // numbers는 0x1234 주소를 가리킴
    }
    
    public void wrong() {
        numbers = new ArrayList<>();  // ❌ 다른 주소(0x5678)를 가리킬 수 없음!
    }
    
    public void canDo() {
        numbers.add(1);  // ✅ 0x1234 주소의 객체 내용 변경은 가능!
    }
}

 마치 집주소인 참조는 바꿀수 없지만(final) 집 안의 가구(객채 내용)은 바꿀 수 있는 것과 같다 !

 

원시 타입 vs 참조 타입

public class Counter {
    private final int count = 0;
    
    public void increment() {
        count++;  // ❌ 컴파일 에러!
        // Cannot assign a value to final variable 'count'
    }
}

원시 타입은 객체가 아니므로 final = 불변이 맞다 ! 하지만 아래와 같이 참조 타입의 경우에는 절대 불변 보장이 안된다 !

public class Container {
    private final List<String> items = new ArrayList<>();
    
    // final이지만 내용 변경 가능
    public void addItem(String item) {
        items.add(item);  // ✅ 가능!
    }
    
    public void clearItems() {
        items.clear();  // ✅ 가능!
    }
}

 

;

 

 

진짜 불변 vs 가짜 불변

가짜 불변 (final만 사용)

public class FakeImmutable {
    private final List<Integer> numbers;
    
    public FakeImmutable(List<Integer> numbers) {
        this.numbers = numbers;  // 참조만 복사
    }
    
    public List<Integer> getNumbers() {
        return numbers;  // 원본 노출!
    }
}

// 사용
List<Integer> original = new ArrayList<>(List.of(1, 2, 3));
FakeImmutable fake = new FakeImmutable(original);

// 😱 외부에서 변경 가능!
original.add(4);
fake.getNumbers().add(5);

System.out.println(fake.getNumbers());  // [1, 2, 3, 4, 5]

진짜 불변 (방어적 복사 + 불변 컬렉션)

public class RealImmutable {
    private final List<Integer> numbers;
    
    public RealImmutable(List<Integer> numbers) {
        this.numbers = List.copyOf(numbers);  // 불변 복사!
    }
    
    public List<Integer> getNumbers() {
        return numbers;  // 이미 불변이므로 안전
    }
}

// 사용
List<Integer> original = new ArrayList<>(List.of(1, 2, 3));
RealImmutable real = new RealImmutable(original);

original.add(4); // ✅ 외부 변경 차단 real에 영향 없음

real.getNumbers().add(5);  // ✅ 내부 변경 차단 UnsupportedOperationException!

 

 

 

진짜 불변 객체 만들기 예시

 불변 객체의 3가지 조건

1️⃣ 모든 필드가 final
2️⃣ setter 메서드 없음
3️⃣ 가변 객체 참조 시 방어적 복사

1 원시 타입만 사용

public class Money {
    private final long amount;  // 원시 타입
    
    public Money(long amount) {
        this.amount = amount;
    }
    
    public Money plus(Money other) {
        return new Money(this.amount + other.amount);  // 새 객체 반환
    }
    
    public long getAmount() {
        return amount;  // 원시 타입은 복사됨
    }
}

원시 타입은 자동으로 복사됨,  getter도 안전,  가장 단순한 불변 객체

 

2 불변 객체만 참조

public class Person {
    private final String name;        // String은 불변
    private final LocalDate birthDate;  // LocalDate는 불변
    
    public Person(String name, LocalDate birthDate) {
        this.name = name;
        this.birthDate = birthDate;
    }
    
    public String getName() {
        return name;  // String은 불변이므로 안전
    }
    
    public LocalDate getBirthDate() {
        return birthDate;  // LocalDate는 불변이므로 안전
    }
}

 불변 객체끼리 참조는 안전,  방어적 복사 불필요,  Java의 불변 클래스: String, LocalDate, BigDecimal 등등 !

 3 가변 객체 참조 (방어적 복사 필수)

public class Lotto {
    private final List<Integer> numbers;
    
    // ❌ 잘못된 구현
    public Lotto(List<Integer> numbers) {
        this.numbers = numbers;  // 참조만 복사!
    }
    
    // ✅ 올바른 구현 방법 1: 방어적 복사
    public Lotto(List<Integer> numbers) {
        this.numbers = new ArrayList<>(numbers);  // 새 리스트 생성
    }
    
    // ✅ 올바른 구현 방법 2: 불변 컬렉션
    public Lotto(List<Integer> numbers) {
        this.numbers = List.copyOf(numbers);  // 불변 리스트
    }
}

방어적 복사 패턴

생성자에서 방어적 복사

public class Lotto {
    private final List<Integer> numbers;
    
    public Lotto(List<Integer> numbers) {
        // 방어적 복사
        this.numbers = new ArrayList<>(numbers);
    }
}

// 사용
List<Integer> original = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
Lotto lotto = new Lotto(original);

original.add(7);  // lotto에 영향 없음!

getter에서 방어적 복사

public class Lotto {
    private final List<Integer> numbers;
    
    public Lotto(List<Integer> numbers) {
        this.numbers = new ArrayList<>(numbers);
    }
    
    // ❌ 잘못된 getter
    public List<Integer> getNumbers() {
        return numbers;  // 내부 상태 노출!
    }
    
    // ✅ 올바른 getter 방법 1: 방어적 복사
    public List<Integer> getNumbers() {
        return new ArrayList<>(numbers);  // 복사본 반환
    }
    
    // ✅ 올바른 getter 방법 2: 불변 뷰 반환
    public List<Integer> getNumbers() {
        return Collections.unmodifiableList(numbers);  // 읽기 전용
    }
}

불변 컬렉션 사용

public class Lotto {
    private final List<Integer> numbers;
    
    public Lotto(List<Integer> numbers) {
        this.numbers = List.copyOf(numbers);  // 불변 리스트!
    }
    
    public List<Integer> getNumbers() {
        return numbers;  // 이미 불변이므로 그대로 반환
    }
}

// 시도
Lotto lotto = new Lotto(List.of(1, 2, 3, 4, 5, 6));
lotto.getNumbers().add(7);  // UnsupportedOperationException!

불변 컬렉션 비교

방법 생성방법 변경시도 성능 최종 권장
List.of() List.of(1, 2, 3) 예외 발생 빠름 ⭐⭐⭐⭐⭐
List.copyOf() List.copyOf(list) 예외 발생 빠름 ⭐⭐⭐⭐⭐
Collections.unmodifiableList() Collections.unmodifiableList(list) 예외 발생 보통 ⭐⭐⭐⭐
new ArrayList<>() new ArrayList<>(list) 변경 가능 느림
// 1. List.of() - 불변 리스트 직접 생성
List<Integer> list1 = List.of(1, 2, 3);
list1.add(4);  // UnsupportedOperationException

// 2. List.copyOf() - 다른 컬렉션으로부터 불변 리스트 생성
List<Integer> list2 = List.copyOf(Arrays.asList(1, 2, 3));
list2.add(4);  // UnsupportedOperationException

// 3. Collections.unmodifiableList() - 불변 뷰 생성
List<Integer> original = new ArrayList<>(Arrays.asList(1, 2, 3));
List<Integer> list3 = Collections.unmodifiableList(original);
list3.add(4);  // UnsupportedOperationException
original.add(4);  // 원본 변경 가능! (list3에도 영향)

// 4. new ArrayList<>() - 가변 리스트 (방어적 복사)
List<Integer> list4 = new ArrayList<>(Arrays.asList(1, 2, 3));
list4.add(4);  // 가능! (진짜 불변 아님)

 

 

불변 객체 생성 패턴

정적 팩토리 메서드 패턴

생성자의 한계를 보완해, 의도 명확성·재사용성·유연성을 높이는 객체 생성 방식

public class PurchaseAmount {
    private final int amount;
    
    private PurchaseAmount(int amount) {  // private 생성자
        validate(amount);
        this.amount = amount;
    }
    
    public static PurchaseAmount from(String input) {  // 정적 팩토리
        int amount = Integer.parseInt(input);
        return new PurchaseAmount(amount);
    }
}

생성자보다 메서드 이름으로 객체 생성의 목적을 드러낼 수 있다.

매번 new로 새 객체를 만들 필요 없이 캐싱/싱글톤 재사용 가능하다.

반환타입을 유연하게 지정할 수 있어 다형성 활용에 유리하다.

검증이나 변환과 같은 내부 로직을 감추고, 클라이언트에 단순한 인터페이스를 제공해서 로직을 캡슐화한다.

 

 

Builder 패턴

가독성·유연성·안정성을 높여 복잡한 객체를 직관적이고 안전하게 생성할 수 있게 해주는 패턴

public class WinningNumbers {
    private final Lotto winningLotto;
    private final BonusNumber bonusNumber;
    
    private WinningNumbers(Builder builder) {
        this.winningLotto = builder.winningLotto;
        this.bonusNumber = builder.bonusNumber;
    }
    
    public static class Builder {
        private Lotto winningLotto;
        private BonusNumber bonusNumber;
        
        public Builder winningLotto(Lotto lotto) {
            this.winningLotto = lotto;
            return this;
        }
        
        public Builder bonusNumber(BonusNumber bonus) {
            this.bonusNumber = bonus;
            return this;
        }
        
        public WinningNumbers build() {
            return new WinningNumbers(this);
        }
    }
}
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class WinningNumbers {
    private final Lotto winningLotto;
    private final BonusNumber bonusNumber;
    
    // Lombok이 Builder를 자동 생성!
}

// 사용
WinningNumbers winning = WinningNumbers.builder()
    .winningLotto(lotto)
    .bonusNumber(bonus)
    .build();

 

생성자나 정적패곹리 메서드에 인지가 많을 때, 어떤 값이 어떤 필드인지 명확하게 표현가능.

모든 필드가 final인 불변 객체 쉽게 생성 가능.

일부 필드만 설정하고 나머지는 기본값으로 둘수 있다.

내부에서 값 검증로직을 수행해  잘못된 객체 생성을 방지하며 로직을 캡슐화한다.

메서드 체이닝으로 직관적인 코드를 작성할 수 있다.

불변 객체 만들기 체크리스트

진짜 불변 = final + 방어적 복사 + 불변 컬렉션

✅ 모든 필드 final 선언
✅ setter 메서드 제거
✅ 생성자에서 방어적 복사
✅ getter에서 방어적 복사 or 불변 컬렉션
✅ 가변 객체 대신 불변 객체 사용
✅ 상태 변경 시 새 객체 반환

생성 패턴 선택 가이드

정적 팩토리 메서드:
→ 파라미터 2-3개
→ 이름으로 의미 표현 필요
→ 인스턴스 캐싱 필요
→ 다형성 활용

Builder 패턴:
→ 파라미터 4개 이상
→ 선택적 매개변수 많음
→ 가독성 중요
→ 복잡한 검증 로직

생성자:
→ 파라미터 1-2개
→ 단순한 경우

항상 불변을 먼저 고려

// ✅ 기본: 불변
public class Money {
    private final long amount;
}

// ❌ 예외적: 가변 (정말 필요한 경우만)
public class Counter {
    private int count;  // 성능상 이유로 가변
}

Lombok 활용

@Value  // 모든 필드 final + getter + equals/hashCode + toString
public class Money {
    long amount;
}

@Builder  // Builder 패턴 자동 생성
@Getter   // getter만
public class Order {
    private final Long id;
    private final Money amount;
}

불변 객체는 방어적 복사 불필요

public class Order {
    private final Money totalAmount;  // Money는 불변
    
    public Money getTotalAmount() {
        return totalAmount;  // 복사 불필요! 안전하게 공유
    }
}

컬렉션은 항상 방어적 복사

public class Lottos {
    private final List<Lotto> lottos;
    
    public Lottos(List<Lotto> lottos) {
        this.lottos = List.copyOf(lottos);  // 불변 복사!
    }
    
    public List<Lotto> getLottos() {
        return lottos;  // 이미 불변이므로 안전
    }
}