불변 객체(Immutable Object)

Jihoon Oh·2022년 2월 27일
9


오빠는 변한다지만 객체는 변하지 말자
사진 출처

불변 객체(immutable object). 말 그대로 한 번 생성되면 값이 변하지 않는 객체를 말한다. 우리는 일반적으로 불변 객체로 만들기 위해 할당 이후에 변하지 않는 값에 final 키워드를 붙여서 사용하고자 한다. 하지만 과연 이렇게 사용하면 모두 불변 객체가 될까? 우선 위키백과의 정의를 먼저 찾아보자.

객체 지향 프로그래밍에 있어서 불변객체(immutable object)는 생성 후 그 상태를 바꿀 수 없는 객체를 말한다. 반대 개념으로는 가변(mutable) 객체로 생성 후에도 상태를 변경할 수 있다. 객체 전체가 불변인 것도 있고, C++에서 const 데이터 멤버를 사용하는 경우와 같이 일부 속성만 불변인 것도 있다. 또, 경우에 따라서는 내부에서 사용하는 속성이 변화해도 외부에서 그 객체의 상태가 변하지 않은 것 처럼 보인다면 불변 객체로 보기도 한다. 예를 들어, 비용이 큰 계산의 결과를 캐시하기 위해 메모이제이션(Memoization)을 이용하더라도 그 객체는 여전히 불변하다고 볼 수있다. 불변 객체의 초기 상태는 대개 생성 시에 결정되지만 객체가 실제로 사용되는 순간까지 늦추기도 한다.

불변 객체를 사용하면 복제나 비교를 위한 조작을 단순화 할 수 있고, 성능 개선에도 도움을 준다. 하지만 객체가 변경 가능한 데이터를 많이 가지고 있는 경우엔 불변이 오히려 부적절한 경우가 있다. 이 때문에 많은 프로그래밍 언어에서는 불변이나 가변 중 하나를 선택할 수 있도록 하고 있다.

출처 위키백과

결국 불변 객체의 핵심은 "생성 후 객체의 상태가 변하지 않는다." 라고 할 수 있다. 자바에는 대표적인 불변객체로 String 등이 있다. 오잉? String 변수에 새 문자열을 할당시킬 수 있는데 무슨 소린가요? 라고 할 수 있겠지만, String은 사실 불변 객체로, String은 따로 String constant pool에서 관리되며 String 변수가 문자열 리터럴을 직접 담고 있는 것이 아닌, String constant pool 내에 일치하는 문자열을 참조만 할 뿐이다. 할당하는 문자열 리터럴 값이 바뀐다면 String 변수는 새로운 객체를 참조한다. 따라서 String은 불변이다.

그런데 이런 불변 객체를 왜 사용해야 하는 걸까?

불변 객체를 사용해야 하는 이유

단순하다.

불변 객체의 상태는 생성된 시점으로부터 파괴되는 시점까지 그대로 유지된다. 즉, 프로덕션에서 해당 객체가 가진 값을 변하지 않게 하려는 추가적인 노력을 필요로 하지 않는다.

Thread-Safe하다.

기본적으로 멀티스레딩 환경에서의 문제는 여러 스레드가 같은 객체에 접근하여 데이터를 쓰는 작업을 할 때 발생한다. 여러 스레드에서 값을 수정하기 때문에 객체의 상태가 훼손되어 해당 객체를 공유하는 다른 스레드에도 영향을 끼치는 것이다. 하지만 불변 객체는 상태가 변하지 않으므로 그 어떤 스레드도 다른 스레드에 영향을 줄 수 없어 안심하고 공유할 수 있다.

cache, map의 key, set의 원소 등 변하지 않아야 하는 구성요소로 적합하다.

cache, map의 key set의 원소 등으로 사용되는 객체의 상태가 변한다면 로직을 깨지 않기 위해 추가적인 작업을 진행해주어야 한다. 불변 객체를 사용한다면 그런 작업을 고려하지 않아도 되어 해당 자료구조를 더 편하게 사용할 수 있다.

예측가능하다. side-effect의 가능성이 적다.

객체가 불변 객체가 아니어서 setter가 열려있는 등 다른 코드에서 해당 객체의 값을 수정 가능하다면, 의도하고자 하지 않은 방향으로 프로그램이 작동할 수 있다. 불변 객체를 사용하면 값을 예측할 수 있으므로 예상치 못한 값으로 인한 side-effect의 가능성이 적어진다.

불변 객체를 만드는 방법

이펙티브 자바에서는 클래스를 불변으로 만들기 위해 다음의 다섯 가지 규칙을 따르라고 한다.

  • 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다.
    • 즉, setter 등의 메서드를 사용하지 않는다.
  • 클래스를 확장할 수 없도록 한다.
    • final 클래스로 선언 등의 방법으로 상속을 막는다.
  • 모든 필드를 final로 선언한다.
  • 모든 필드를 private으로 선언한다.
  • 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.
    • 클래스에 가변 객체를 참조하는 필드가 하나라도 있다면, 클라이언트가 그 객체의 참조를 그대로 반환받도록 하지 말고 방어적 복사를 수행해야 한다.

reference 타입이나 collection은 final이어도 불변이 아니다.

가장 많은 사람들이 헷갈리는 부분이 모든 필드가 final이면 불변 객체가 아니냐고 하는 것이다. primitive 타입인 필드의 경우는 final을 붙여주는 것 만으로도 불변성을 보장한다. 하지만, 모든 필드가 final이어서 재할당 가능성이 없다 하더라도 그 객체의 불변성을 보장할 수는 없다. 다음의 경우를 보자.

public class Person {
    private final String name;
    private final int age;
    private final Nation nationality;
    
    public Person(String name, int age, Nation nationality) {
        this.name = name;
        this.age = age;
        this.nationality = nationality
    }
    
    public String getName() {
        return name;
    }
    
    public int getAge() {
        return age;
    }
    
    public Nation getNationality() {
        return nationality;
    }
}

이 객체는 불변일까? 얼핏 보기에는 불변으로 보인다. 하지만 사실은 불변성을 보장할 수 없다. 필드로 reference 타입인 Nation을 가지고 있기 때문이다. 만약 Nation 클래스가

public class Nation {
    private final String name;
    private long population;
    
    public Nation(String name, long population) {
        this.name = name;
        this.population = population;
    }
    
    ... // getter
    
    public void updatePopulation(long population) { // setter
        this.population = population;
    }
}

위와 같이 population 필드가 final도 아니고 setter가 열려 있는 가변 객체라면,

Nation korea = new Nation("대한민국", 51_638_809);
Person ohzzi = new Person("Ohzzi", 26, korea);

Nation ohzzisNation = ohzzi.getNation();
ohzzisNation.updatePopulation(0);

System.out.println(ohzzi.getNation().getPopulation()) // -> 0 출력

korea.updatePopulation(51_638_809);

System.out.println(ohzzi.getNation().getPopulation()) // -> 51638809 출력

이렇게 getNationality로 꺼낸 Nation의 상태를 조작하거나 참조 원본 객체를 조작하여 불변성을 깨뜨릴 수 있다.

collection의 경우를 보자.

public class Lotto {
    private final List<Integer> lottoNumbers;
    
    public Lotto(List<Integer> lottoNumbers) {
        this.lottoNumbers = List.copyOf(lottoNumbers);
    }
    
    public List<Integer> getLottoNumbers() {
        return lottoNumbers;
    }
}

이 클래스는 로또 번호 리스트를 통해 생성할 때 방어적 복사를 해서 외부 참조도 끊겨있고, lottoNumbers 필드가 private final로 선언되어 있어 재할당이 불가능하며, 심지어 lottoNumbers의 상태를 변화시킬 수 있는 로직도 존재하지 않는다. 하지만 이 클래스는 불변하지 않다. "재할당이 불가능하다 == 불변하다" 가 아니기 때문이다.

Lotto lotto = new Lotto(Arrays.asList(1,2,3,4,5,6));
List<Integer> numbers = lotto.getLottoNumbers();

numbers.add(7);
// lotto의 현재 상태 : 1,2,3,4,5,6,7

외부에서 Lotto 클래스의 인스턴스에서 getLottoNumbers()를 호출하면 lottoNumbers는 private final이지만 외부로 노출된다. getter로 꺼낸 lottoNumbers는 final이지만 add, remove 등의 메서드를 사용하여 상태를 변화시킬 수 있다. List lottoNumbers가 불변 객체가 아니기 때문이다. 또한 새로운 List가 반환된 것이 아니라 lotto 인스턴스의 필드를 참조하기 때문에, getter로 꺼낸 lottoNumbers를 변화시키면 기존 객체의 상태가 변하게 된다.

따라서 이런 경우에는 반환 시 List.copyOf 등의 방법으로 방어적 복사를 해서 반환해주거나, 단순히 참조를 끊는것 뿐만 아니라 List 자체가 불변이고 싶다면 해당 List를 Collections.unmodifiableList와 같은 불변 자로구조로 만들어주는 것이 좋다. getter 등으로 반환 시 외에도 생성자에서도 마찬가지로 방어적 복사를 통해 외부 참조를 끊어주는 것이 좋다.

final은 재할당만 막아줄 분 reference 타입 또는 collection 내부의 상태의 변화까지 막아주지는 못한다는 점에 주의하자.

방어적 복사와 unmodifiableList의 차이점?

(편의를 위해 List를 기준으로 설명한다. 다른 타입이나 컬렉션도 마찬가지로 작동한다.) List를 방어적 복사를 하게 될 경우 기존 객체와의 참조가 끊긴다. 즉 값이 같은 전혀 다른 객체를 생성한다. 따라서 이 경우 새로 반환된 객체를 수정할 수 있다. (단, 수정하더라도 복사 원본의 값이 수정되지는 않는다.)

unmodifiableList는 add, remove 등의 메서드로 객체 요소를 수정하려고 하면 예외를 던진다. 따라서, getter로 unmodifiableList를 던져주면 해당 객체를 받은 쪽에서 데이터를 수정할 수 없다.

하지만 불변을 위해 unmodifiableList를 사용 시 주의해야하는 부분이 있다.

public class Foo {
    private final List<Integer> ints;

    public Foo(List<Integer> ints) {
        this.ints = ints;
    }

    public List<Integer> getInts() {
        return Collections.unmodifiableList(ints);
    }
}

---

@Test
@DisplayName("unmodifiableList 생성자 불변 테스트")
void unmodifiableListTest() {
    List<Integer> ints = new ArrayList<>();
    Foo foo = new Foo(ints);
    List<Integer> intsOfFooInstance = foo.getInts();

    ints.add(1); // 생성자에 주입했던 기존 인스턴스에 1 추가

    assertThat(intsOfFooInstance.size()).isEqualTo(0);
}

인스턴스 foo는 생성 시점에 빈 리스트를 받아서 생성하고, 리스트의 상태를 변화시키는 어떤 로직도 수행하지 않은채로 unmodifiableList를 반환하기 때문에 intsOfFooInstance의 크기는 0이어야 한다고 생각할 수 있다. 하지만 위 테스트의 결과는 테스트 실패다. unmodifiableList는 원본 객체와의 참조를 끊지 않기 때문에 생성자에 넣어준 리스트가 수정되게 되면 인스턴스 foo의 필드 리스트도 변하게 된다.

따라서 불변성을 보장하기 위해서는, 생성자에서는 방어적 복사를 하고, getter에서는 방어적 복사 또는 unmodifiableList 반환 중 선택해서 진행하는 것을 권장한다.

그렇다면 방어적 복사는 불변성을 보장하는가?

그렇지 않다. 방어적 복사를 진행하는 값이 reference 타입의 collection이라면 불변성을 보장할 수 없다. 방어적 복사는 얕은 복사를 수행한다는 점을 기억하자. collection의 요소에 변화가 일어나면 방어적 복사본에서도 불변성이 깨지게 된다. 따라서 collection의 요소 자체가 불변 객체여야만 방어적 복사시에도 불변성을 유지할 수 있다.

따라서 불변 객체를 만들 때 주의해야 할 점은 다음과 같다.

  • primitive 타입의 필드는 final로 불변성 보장 가능
  • reference 타입이나 collection을 필드로 가질 경우 방어적 복사를 통해 원본 참조를 끊을 것
  • primitive 타입의 collection은 방어적 복사와 unmodifiable collection을 이용해 불변을 보장할 수 있지만, reference 타입의 collection은 collection 내의 요소들의 불변성도 보장되어야 함

참고 자료
이펙티브 자바 아이템 17: 변경 가능성을 최소화하라

profile
Backend Developeer

3개의 댓글

comment-user-thumbnail
2022년 2월 27일

오빠 보고 들어왔다가 객체 유익하게 읽고 나갑니다🙏

답글 달기
comment-user-thumbnail
2022년 2월 27일

오 이건 귀하군요...

답글 달기
comment-user-thumbnail
2022년 2월 28일

그래서 구아바를 쓰죠. 아니면 자바를 포기를...크크크

답글 달기