[Java] Immutable Object (불변 객체)

clean·2024년 1월 4일
0
post-thumbnail

Immutable Object란?

Immutable Object란 객체를 생성한 후 그 값(상태)를 바꿀 수 없는 객체를 의미한다. Imuutable Object는 read-only method만 제공하고, 객체의 내부 상태를 반환하는 메소드(getter)에서는 방어적 복사(defensive copy)등을 통해 제공하는 객체이다.

String 클래스가 대표적인 불변 객체에 해당한다.

Immutable Object 선언하기

불변 객체를 선언하는 규칙은 아래와 같다.

Immutable Object 선언 규칙

  • setter를 제공하지 않는다. (read-only method만 제공)
  • 모든 필드를 private final로 선언한다.
  • Class를 final로 선언하여 다른 클래스가 상속하는 것을 막는다.
  • 객체를 생성하기 위한 생성자 또는 정적 팩토리 메소드를 추가한다.
  • 불변 객체를 사용할 때는 방어적 복사를 고려하지 않아도 된다.

아래 클래스는 불변 객체의 예시이다.
final 클래스로 선언되어 있으며 모든 필드가 private final로 선언되어 있고 필드 값을 변경할 수 있는 setter가 제공되지 않는다. 또한 객체를 만들 때 초기화를 위한 생성자가 제공된다.

이렇게 하면, 한번 객체를 생성해 놓으면 내부 필드의 값을 변경할 수 없을 것이다.

final class ImmutablePerson {
	private final int age;
    private final int name;
    
    public ImmutablePerson(int age, int name) {
    	this.age = age;
        this.name = name;
    }
    
    public getAge() {
    	return age;
    }
    
    public getName() {
    	return name;
    }
}

하지만, 불변 객체를 생성할 때 주의해야하는 케이스들이 존재한다. 아래에는 그런 경우와 그럴 때는 어떻게 생성해야 하는지를 정리해보려고 한다.

필드에 Reference Type 변수가 존재

Amount.java

package immutable;

public class Amount {
    private int value;

    public Amount(int value) {
        this.value = value;
    }

    public void setValue(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    @Override
    public String toString() {
        return "amount=" + value;
    }
}

ImmutableReference.java

package immutable;

public final class ImmutableReference {
    private final int num;
    private final Amount amount; // 참조 자료형

    public ImmutableReference(int num, Amount amount) {
        this.num = num;
        this.amount = amount;
    }

    public int getNum() {
        return num;
    }

    public Amount getAmount() {
        return amount;
    }
}

위의 예시를 보면 불변 객체 생성 규칙을 모두 잘 지킨 것 처럼 보인다.
하지만 위 객체는 완전한 불변 객체가 아니고 내부 상태가 변할 가능성이 있다.

package immutable;

public class ImmutableReferenceTest {
    public static void main(String[] args) {
        ImmutableReference immutable_obj = new ImmutableReference(3, new Amount(2000)); // 불변 객체 생성

        Amount amount = immutable_obj.getAmount();
        System.out.println("바꾸기 전: " + immutable_obj.getAmount().getValue());

        amount.setValue(5000); // 참조 자료형 변수의 setter로 값 변경
        System.out.println("바꾼 후: " + immutable_obj.getAmount().getValue());
    }
}

이런 방식으로 내부 상태 변경이 가능하다. 이러한 문제는 ImmutableReference 안의 필드인 amount의 참조가 외부와 연결되어 있기 때문에 발생한다. 이런 문제를 막기 위해서는 (1) 필드에 존재하는 참조 자료형도 불변 객체이거나 (2) 방어적 복사로 참조 관계를 끊어주어야 한다.

+ 추가로 Amount.java에서 setter인 setNum() 메소드를 제거해주는 것도 방법이다.

ImmutableReference.java를 아래와 같이 변경시켜주자.

package immutable;

public final class ImmutableReference {
    private final int num;
    private final Amount amount; // 참조 자료형

    public ImmutableReference(int num, Amount amount) {
        this.num = num;
        this.amount = new Amount(amount.getValue()); // 방어적 복사
    }

    public int getNum() {
        return num;
    }

    public Amount getAmount() {
        return new Amount(amount.getValue()); // 방어적 복사
    }
}

변경 후 같은 테스트를 실행한 결과이다.

필드에 Collection이 존재

필드에 Collection이 존재하는 경우에도 불변 객체 생성시 주의해야한다.
ImmutableCollection이라는 불변 객체를 만들어보았다.

ImmutableCollection.java

package immutable;

import java.util.List;

public final class ImmutableCollection {
    private final String name;
    private final List<Integer> list;

    public ImmutableCollection(String name, List<Integer> list) {
        this.name = name;
        this.list = list;
    }

    public String getName() {
        return name;
    }

    public List<Integer> getList() {
        return list;
    }
}

아래 테스트를 돌려보면

package immutable;

import java.util.ArrayList;
import java.util.List;

public class ImmutableClassTest {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<Integer>();
        list.add(1);
        ImmutableCollection immutable_obj = new ImmutableCollection("tmp", list);

        System.out.println("추가하기 전: " + immutable_obj.getList());

        List<Integer> tmp_list = immutable_obj.getList();
        tmp_list.add(3);

        System.out.println("추가 후: " + immutable_obj.getList());

    }


}

불변 객체 내부 필드에 새로운 값이 추가되는 것을 확인할 수 있다. 이 또한 Collection의 참조가 끊어지지 않았기 때문에 발생하는 문제이므로 방어적 복사를 통해서 해결할 수 있다.

package immutable;

import java.util.ArrayList;
import java.util.List;

public final class ImmutableCollection {
    private final String name;
    private final List<Integer> list;

    public ImmutableCollection(String name, List<Integer> list) {
        this.name = name;
        this.list = new ArrayList<>(list); // 방어적 복사
    }

    public String getName() {
        return name;
    }

    public List<Integer> getList() {
        return new ArrayList<>(list); // 방어적 복사
    }
}

변경 후 테스트 결과

+ 추가
List(또는 컬렉션) 자체를 immutable 하게 만들기
위와 같이 getter와 constructor에서 방어적 복사를 함으로써 참조를 끊어내어 리스트가 바뀔 가능성을 차단하는 방법도 있지만, 그냥 필드에 있는 Collection 자체를 immutable하게 만드는 방법도 있다.
immutable한 List 또는 Collection을 만드는 방법은 이 글에서 확인할 수 있다.

필드 Collection의 요소가 가변 객체 참조 자료형일때

위에서 보았던 ImmutableCollection.java를 다음과 같이 수정해보았다. 필드인 List의 요소가 참조 자료형인 Amount이다.

package immutable;

import java.util.ArrayList;
import java.util.List;

public final class ImmutableCollection {
    private final List<Amount> list;

    public ImmutableCollection(List<Amount> list) {
        this.list = new ArrayList<>(list); // 방어적 복사
    }

    public List<Amount> getList() {
        return new ArrayList<>(list); // 방어적 복사
    }
}

테스트 코드도 다음과 같이 수정하였다.

package immutable;

import java.util.ArrayList;
import java.util.List;

public class ImmutableClassTest {
    public static void main(String[] args) {
        List<Amount> list = new ArrayList<Amount>();
        list.add(new Amount(3000));
        list.add(new Amount(2000));
        ImmutableCollection immutable_obj = new ImmutableCollection(list);

        System.out.println("변경 전: " + immutable_obj.getList());

        List<Amount> tmp_list = immutable_obj.getList();
        tmp_list.get(0).setValue(6000);

        System.out.println("변경 후: " + immutable_obj.getList());

    }


}

Collection의 방어적 복사를 통해 Collection의 참조는 끊어졌지만, Collection 안에 있는 인스턴스들의 참조가 끊어지지 않아서 발생하는 문제이다.

Collection 인터페이스는 매개변수를 받지 않는 생성자, 매개변수로 같은 종류의 Collection 객체를 받아서 그 매개변수 안 요소들을 모두 복사하는 두 개의 standard constructor가 있다. 그런데 매개변수 Collection의 요소를 복사하는 생성자는 그 요소를 얕은 복사한다고 한다. 따라서 Collection의 요소의 참조가 끊어지지 않은 것이다.

이런 경우는 Collection의 요소도 불변 객체로 생성하거나, 가변 객체를 써야하는 경우에는 getter와 constructor에서 깊은 복사를 함으로써 해결할 수 있다.

아래는 Collection을 복사하는 부분을 ArrayList의 원소 하나하나 깊은 복사(새로운 인스턴스를 만들어서 기존 가변 객체 내의 필드를 하나하나 복사)를 하는 코드로 수정한 것이다.

package immutable;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public final class ImmutableCollection {
    private final List<Amount> list;

    public ImmutableCollection(List<Amount> list) {
        List<Amount> copy = new ArrayList<>(); // 새로운 Collection 생성

        Iterator<Amount> itr = list.iterator();
        while (itr.hasNext()) {
            copy.add(new Amount(itr.next().getValue())); // Collection의 요소를 깊은 복사하기
        }

        this.list = copy; // 초기화
    }

    public List<Amount> getList() {
        List<Amount> copy = new ArrayList<>(); // 새로운 Collection 생성

        Iterator<Amount> itr = list.iterator();
        while (itr.hasNext()) {
            copy.add(new Amount(itr.next().getValue())); // Collection의 요소를 깊은 복사하기
        }

        return copy;
    }
}

수정 후 동일한 테스트를 실행한 결과이다.
Amount 객체의 내부 값이 바뀌지 않은 것을 확인할 수 있다.

위 예시에서는 Amount 클래스의 필드가 'value' 하나이기 때문에 저렇게 복사를 하였지만, 어떤 클래스는 2개 이상의 필드를 가지고 있을 수 있고, 굉장히 많은 필드를 가지고 있을 수 있다.
따라서 참조 자료형을 deep copy 때는 보통 저런 방식을 쓰지 않고, Cloneable 인터페이스를 구현하고, Object 클래스에 있는 clone() 메소드를 재정의하여 clone() 메소드를 통해 복사할 것이다.

Cloneable 인터페이스를 구현하고 clone() 메소드를 오버라이딩 하는 방법은 이 글에 정리해놓았다.

Immutable Object의 장점과 단점

불변 객체의 장점은 다음과 같다.

  • thread safe하다. 내부 값이 바뀔 일이 없기 때문이다. 멀티 스레드 상황에서 동기화를 고려하지 않아도 되어서 유리하다.
  • 외부에서 객체에 대해 변경을 할 수가 없어서 안정성이 있다.
  • Collection의 요소로 쓰기 유리하다.

단점은

  • 객체의 값을 변경할 수 없기에, 다른 값을 가지는 객체를 만들려면 새로운 객체를 생성해야 하기 때문에 계속 새로운 객체가 생성되며 메모리가 낭비되고 GC가 자주 작동되게 하여 성능에 악영향을 미칠 수 있다.
    예를 들어 String 클래스 문자열을 '+'로 이어 붙일 때가 그러하다. String도 Immutable Class이기 때문에 문자열의 변경이 불가능하다. 따라서 "안녕" + "하세요" + "!!" 이런 식으로 문자열을 + 연산으로 이어 붙일 때, 새로운 String 객체를 생성하여 "안녕"과 "하세요"라는 문자열을 이어붙인 "안녕하세요"를 저장한 다음, 또 새로운 String 객체를 생성하여 "안녕하세요"와 "!!"을 이어붙인 "안녕하세요!!"를 저장하는 것이다.
    이 예시에서는 의미 없는 중간 객체가 1개 생성되었지만, 아래 코드를 실행한다면 수많은 중간 객체가 생성되고 결국 GC 동작을 빈번하게 일으키며 성능을 하락시킬 가능성이 있다.
    String s = "*";
    for(int i=0; i<1000000; i++) {
       s += "*";
    }
    System.out.println(s);
    이렇게 문자열을 빈번하게 더해야하는 경우, String 대신 StringBuilder 또는 StringBuffer를 사용하면 중간 객체를 생성하지 않고 String을 이어 붙일 수 있다.
    String, StringBuilder, StringBuffer에 대한 내용은 추후에 정리하여 업로드할 예정이다.

결론

불변 객체는 객체의 값을 변경할 수 없기에, 다른 값을 가지는 객체를 만들려면 새로운 객체를 생성해야한다는 단점이 있지만, 변경될 가능성이 없어 안정적이고 병렬 프로그래밍에서 thread-safe하다는 장점이 있다.
따라서 빈번하게 변할 가능성이 있는 Class인지, 한번 선언해 놓으면 계속 같은 값을 사용할 Class인지 판단하여 Immutable Class로 선언할 것인지, 일반적인 Class로 선언할 것인지를 판단해야할 것 같다.

또한 Immutable Class를 선언할 때에는 클래스에 필드로 Reference Type의 필드가 존재하는지 잘 살피고, 이 필드를 생성자를 통해 값을 초기화 줄 때나 getter로 값을 반환할 때 깊은 복사를 한 객체를 리턴함으로써 참조를 잘 끊어주어야 완전한 Immutable Class를 만들 수 있다.

Reference

https://mangkyu.tistory.com/131

https://devoong2.tistory.com/entry/Java-%EB%B6%88%EB%B3%80-%EA%B0%9D%EC%B2%B4Immutable-Object-%EC%97%90-%EB%8C%80%ED%95%B4-%EC%95%8C%EC%95%84%EB%B3%B4%EC%9E%90

profile
블로그 이전하려고 합니다! 👉 https://onfonf.tistory.com 🍀

0개의 댓글