본문 바로가기

Java

Immutable Object(불변 객체)

평소에 불변 객체와 관련된 내용을 여러번 들었다.
그래서 머릿속에서 맴도는 불변 객체와 관련된 지식들은 꽤 많은 상태였다.

하지만 정리되지 않은 듯한 느낌이 들었다.

이번 기회에 불변 객체에 대해 제대로 학습해서
글로 정리하고 머릿속에도 딱 정리해놓기 위해 글을 작성한다.


Immutable Object(불변 객체)란?

이름 그대로 변하지 않는 객체를 뜻한다.

자세히 말하면 불변 객체는
가리키고 있는 주소 값과 불변 객체를 통해 얻을 수 있는 값들의
힙 영역에 있는 데이터 그 자체 가 변경되지 않는 것을 의미한다.

대표적으로 String 클래스가 있다.

String 클래스의 대부분의 메소드들은 (+ 연산 포함)
String에 저장되어 있는 문자열의 값을 바꿔주는 것처럼 보이지만
새로운 String객체를 다시 만들 뿐이다.

물론 전부가 그런것은 아니고 몇가지는 this를 리턴한다.
대표적으로 replace가 있다.

String 클래스의 replace 메소드는
replace 할게 없다면 this로 자기 자신을 리턴한다.
하지만 이 또한 변하지 않은 자기 자신을 리턴한 것이기 때문에 값이 변한건 아니다.


그렇다면 불변 객체는 왜 강조되고 중요한걸까.

  • 객체의 자율성이 보장된다.
  • 프로그램의 안정도를 높일 수 있다. (사이드 이펙트가 발생할 확률이 적다.)
  • 멀티 쓰레드 환경에서 안전하다. (thread safe하다.)
  • 방어적 복사본을 만들 필요가 없다.

위 장점들은 객체가 변하지 않는다는 보장에서 오는 장점들이다.

변하면 안되는 객체가 가변 객체라면 여러 사람이 협업하는 환경에서
객체 내부의 값이 변경될 가능성이 있고
멀티 쓰레드 환경에서 값이 의도치 않게 변할 우려가 있다.

그러면 프로그램에서 사이드 이펙트가 발생될 확률이 올라가는 것이다.

하지만 불변 객체로 인해 성능이 떨어지는 경우도 있다.
String 클래스만 해도 잘못 사용하면 프로그램의 성능이 떨어진다.

항상 새로운 객체를 생성하는 비용이 들기 때문이다.

값이, 상태가 가변한다는게 무조건 나쁜건 아니다.
바뀔건 바뀌어야한다. 다만 바뀌지 말아야 할 것이 바뀌거나
바뀔 수도 있다는 위험성을 가진게 문제일 뿐이다.

결론적으로, 그 객체가 성능이 떨어져도 되는 부분인지 아닌지를 잘 구분하고
값이 변하면 안되는 객체인지, 변해도 되는 객체인지를 잘 구분해서
값이 변하면 안되고 성능에 문제가 없다고 판단되는 객체는
확실하게 불변 객체로 만드는 좋은 습관을 가져야 한다.


fianl 키워드

불변하면 떠오르는 것이 final 키워드이다.

하지만 final은 조심스럽게 잘 알고 써야한다.

우선 final은 재할당만 금지한다.

final List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
System.out.println(list);

List를 final로 선언했음에도 불구하고
List는 1과 2가 add되어
List를 출력해보면 1과 2가 찍혀서 나올 것이다.

final은 불변을 만들어주는 키워드가 아니라
재할당만 금지시켜주는 키워드인 것이다.

위의 경우에도 List는 변하지 않지만 List의 데이터는 변하고 있다.

이처럼 final은
기능적으로는 재할당만을 금지하지만 다른 프로그래머가 보면 암묵적으로
해당 변수가 변하지 않을 것이라는 기대를 하게 한다.

따라서, final 키워드를 썼을 땐
확실하게 변수가 변하지 않게 만들어야
다른 프로그래머들과의 의사소통에 오류를 예방할 수 있다.

그래서 final 키워드는 조심스럽게 잘 알고 써야한다는 것이다.

fianl 키워드에 대해 클린코드 책에서는 이런말을 하기도 했다.

또한 인수와 변수 선언에서 final 키워드를 모두 없앴다. 실질적인 가치는 없으면서 코드만 복잡하게 만든다고 판단했기 때문이다. final을 제거하겠다는 결정은 일부 기존 관례에 어긋난다. 예를 들어, 로버트 시몬스는 "코드 전체에 final을 사용하라....."고 강력히 권장한다. 확실히 나와 다른 생각이다. 내가 보기에 final 키워드는 final 상수 등 몇 군데를 제하면 별다른 가치가 없으며 코드만 복잡하게 만든다. 어쩌면 내가 이렇게 느끼는 이유는 내가 짠 단위 테스트가 final 키워드로 잡아낼 오류를 모두 잡아내기 때문인지도 모르겠다.


불변 객체 만들기

불변 객체를 만들기 위해서는 당연히
setter는 무조건 사용하지 않아야 하고 getter는 조심히 써야한다.

getter도 아예 안쓰고 메세지를 보내는 방식이 바람직하지만
어쩔 수 없이 getter를 써야하는 경우가 있다.

public class Car {
    private final int speed;

    public Car(int speed) {
        this.speed = speed;
    }

    public int getSpeed() {
        return speed;
    }
}

위와 같은 Car 객체는 불변 객체이다.

getSpeed 메소드로 speed를 리턴한다고해도
speed의 int값만 리턴되는 것이기 때문에
Car 객체가 변경될 가능성은 없다.

결론적으로,
인스턴스 변수로 기본 자료형을 가지고 있는 경우에는
getter를 사용하더라도 setter가 없다면 불변 객체이다.

참조형 변수를 인스턴스 변수로 가지고있는 경우에는 얘기가 다르다.

public class Lotto {
    private final List<Integer> lottoNos;

    public Lotto(List<Integer> lottoNos) {
        this.lottoNos = lottoNos;
    }

    @Override
    public String toString() {
        return lottoNos.toString();
    }
}

위의 Lotto 객체는 getter/setter가 없으니 불변 객체일 것 같지만
불변 객체가 아니다.

public static void main(String[] args) {
        List<Integer> temp = new ArrayList<>();
        for (int i = 1; i <= 6; i++) {
            temp.add(i);
        }
        Lotto lotto = new Lotto(temp);
        System.out.println(lotto);

        temp.add(777);
        System.out.println(lotto);
}

1, 2, 3, 4, 5, 6이 들어있는 temp를 파라미터로
Lotto를 초기화하고 Lotto를 출력을 해보면
1, 2, 3, 4, 5, 6 이 출력된다.

여기서 Lotto를 건드리지 않고
Lotto를 초기화할 때 사용했던 temp에 777을 add하고
Lotto를 출력해보면
1, 2, 3, 4, 5, 6, 777 이 출력된다.

결론적으로 Lotto에는 getter도 setter도 없지만
외부에서 Lotto를 초기화할 때 사용했던 List의 영향을 받는다.
그래서 객체 내부의 값이 변경될 수 있기 때문에
Lotto는 불변 객체가 아니다.

Lotto 객체를 불변객체로 만들기 위해선
다음과 같은 방법이 있다.

public class Lotto {
    private final List<Integer> lottoNos;

    public Lotto(List<Integer> lottoNos) {
        this.lottoNos = new ArrayList<>(lottoNos);
    }

    @Override
    public String toString() {
        return lottoNos.toString();
    }
}
this.lottoNos = new ArrayList<>(lottoNos);

다음과 같이 파라미터로 받아온 List를 인스턴스 변수에 바로 초기화 시키는 것이 아니라
new로 새로운 ArrayList를 만들어서 인스턴스 변수에 초기화 시켜준다.

이렇게 하면 외부에서 파라미터로 넘겼던 List를 수정해도
Lotto 객체는 영향을 받지 않고 불변 객체가 된다.

public class Lotto {
    private final List<Integer> lottoNos;

    public Lotto(List<Integer> lottoNos) {
        this.lottoNos = new ArrayList<>(lottoNos);
    }

    public List<Integer> getLottoNos() {
        return lottoNos;
    }
}

위에서 봤던 Lotto 객체에 getter가 추가된 경우이다.
이 Lotto 객체는 불변 객체가 아니다.

Lotto 객체는 final로 선언된 List를 인스턴스 변수로 갖고있지만
외부에서 get한 List를 add나 remove 등으로 수정할 수 있기 때문이다.

lotto.getLottoNos().clear();

외부에서 다음과 같이 코드를 작성한다면
Lotto 객체의 인스턴스 변수인 List는 텅 빈 상태가 된다.

해결 방법으로는 다음과 같은 방법들이 있다.

public List<Integer> getLottoNos() {
        return new ArrayList<>(lottoNos);
}

생성자에서 썼던 방법처럼 새로운 ArrayList를 리턴해주면
외부의 수정에 영향을 받지 않을 수 있다.

public List<Integer> getLottoNos() {
        return Collections.unmodifiableList(lottoNos);
}

Collections.unmodifiableList 메소드를 사용해서 수정이 불가능한 List가 되어
리턴되기 때문에 외부에서 수정하려고 하면
UnsupportedOperationException 예외가 발생한다.

public class Car {
    private final String name;

    public Car(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

String처럼 참조형이지만 이미 불변 객체인 인스턴스 변수를 return하는
경우에는 자동으로 불변이 보장된다.


참고 자료

'Java' 카테고리의 다른 글

Command Query Separation 원칙  (2) 2020.03.13
Exception 정리  (0) 2020.03.04
Factory Pattern(팩토리 패턴)  (0) 2020.02.24
Iterator 인터페이스와 Iterable 인터페이스  (0) 2020.02.17
함수형 인터페이스 API 정리  (0) 2020.02.12