본문 바로가기
java

Java 의 불변 객체 (final, 방어적 복사, unmodifiable)

by CodingMasterLSW 2025. 3. 13.

우리는 불변 객체를 사용한다. 하지만 예상치 못하게 불변을 깨트리는 악당들이 있다.

 

예시를 통해 상황을 이해해 보자.

public class Name {
    private final String value;

    public Name(String value) {
        this.value = value;
    }

    @Override
    public String toString() {
        return "Name{" +
            "value='" + value + '\'' +
            '}';
    }
}

 

public class Names {

    private final List<Name> names;

    public Names(List<Name> names) {
        this.names = names;
    }

    public List<Name> getNames() {
        return names;
    }
}

 

Name을 가지고 있는 Names라는 일급 컬렉션이 있다. Names는 생성할 때 List <Name>를 필요로 하고, 이 값이 바뀌면 안 된다고 판단해 final을 할당해 줬다.

 

public class MainName {

    public static void main(String[] args) {
        List<Name> originalNames = new ArrayList<>();
        Name name1 = new Name("pobi");
        Name name2 = new Name("james");
        originalNames.add(name1);
        originalNames.add(name2);

        Names names = new Names(originalNames);

        originalNames.add(new Name("메롱메롱맨")); // 이 부분을 주의깊게 보자

        for (Name name : names.getNames()) {
            System.out.println(name);
        }
    }
}

 

해당 코드의 결괏값은 어떻게 될까? 

 

Names names = new Names(originalNames); 에서 names를 만들었으니까 name1, name2 만 가지고 있는 불변 객체 아닐까?

 

 

출력 결과를 보면 메롱메롱맨이 침투한 것을 확인할 수 있다. 이런 일이 왜 발생했을까??!

 

디버깅을 해보니 originalNames의 참조값과 names가 가지고 있는 names의 참조값이 같은 것을 확인할 수 있었다. originalNames에 값을 더해도 두 객체 사이의 참조값이 연결되어 있으니 메롱메롱맨이 names에도 추가된 것이었다...!!

 

이 문제를 어떻게 해결할 수 있을까?

 

public class Names {

    private final List<Name> names;

    public Names(List<Name> names) {
        this.names = new ArrayList<>(names); // 방어적 복사
    }

    public List<Name> getNames() {
        return names;
    }
}

 

위의 코드와 같이 Names를 생성할 때 새로운 ArrayList를 생성하고, 그곳에 names값을 저장하는 방식을 사용할 수 있다. 이러한  방법을 방어적 복사라고 한다.

 

디버깅을 통해 확인해 보니 originalNames의 참조값과 names가 가지고 있는 names의 참조값이 다른 것을 확인할 수 있다. 그 결과 originalNames와 names의 참조값이 끊어져 names를 불변으로 있는 것이다.


두 번째 상황을 보자.

public class Names {

    private final List<Name> names;

    public Names(List<Name> names) {
        this.names = new ArrayList<>(names);
    }

    public List<Name> getNames() {
        return names;
    }
}

 

public static void main(String[] args) {
        List<Name> originalNames = new ArrayList<>();
        Name name1 = new Name("pobi");
        Name name2 = new Name("james");
        originalNames.add(name1);
        originalNames.add(name2);

        Names names = new Names(originalNames);
        List<Name> getNames = names.getNames();

        getNames.add(new Name("메롱메롱맨"));

        List<Name> getNames2 = names.getNames();
        for (Name name : getNames2) {
            System.out.println(name);
        }
    }

 

음? 뭔가 이상하다.

 

우리는 Names의 값을 불변으로 선언하고 싶어 final을 할당해 불변으로 선언해 줬다. List <Name> names는 불변 객체인데 여기에 추가로 어떤 값을 넣을 수 있을까?

 

메롱메롱맨이 또 침투했다...!!!! 이게 무슨 상황일까? 우리는 분명 List<Name> names를 final을 통해 불변으로 선언해준거 아니였나? 왜 메롱메롱맨이 또 들어가 있을까?


final, Unmodifiable

java에서 final은 불변을 선언해 준다. 하지만 위와 같은 상황에서의 final은 단순히 객체의 재할당을 금지한다.

public class Names {

    private final List<Name> names;

    public Names(List<Name> names) {
        this.names = new ArrayList<>(names);
        this.names = new ArrayList<>(); // 컴파일 에러
    }

 

다시 말해서, 새로운 참조값을 만들어주는 것을 금지하는 것이지 List <Name> names의 내부 값들까지도 불변으로 보장해주는 것이 아니다. 그 결과 getNames()를 통해 외부로 List<Name> names가 유출되었고, 누구든지 이 names에 새로운 이름을 추가할 수 있는 것이다.

 

우리는 이런 상황을 원하지 않는다. List<Name> names가 처음 만들어지면, 외부에서도 이 값을 수정하지 못하게 불변으로 사용하고 싶다. 어떻게 해야 할까?

public List<Name> getNames() {
    return Collections.unmodifiableList(names);
}

 

이를 위해 자바에서는 불변 객체를 제공해 준다. 불변 객체를 담아서 반환해 준다면 외부에서 해당 값을 조작할 수 없다.

unmodifiable을 사용해 불변 객체를 반환한다면 메롱메롱맨은 침투할 수 없다.

 

public static void main(String[] args) {
    List<Name> originalNames = new ArrayList<>();
    Name name1 = new Name("pobi");
    Name name2 = new Name("james");
    originalNames.add(name1);
    originalNames.add(name2);

    Names names = new Names(originalNames);
    List<Name> getNames = names.getNames();

    getNames.add(new Name("메롱메롱맨")); // 런타임 에러
}

 

getNames에 add 하는 시점에서 런타임 에러가 발생한다! 이를 통해 불변을 유지할 수 있게 되었다.

 


추가로 생각해 볼 부분

 

unmodifiable을 통한 불변 객체를 가져온다는 것은 단순히 List <Name> names를 불변으로 선언한다는 것이다.

즉 names가 아닌, name이라는 내부 값은 변경이 가능하다.

 

다음과 같은 예시를 살펴보자. Crew 객체를 생성하고 이 안에 name, age라는 필드값이 있다.

 

public class Crew {
    private final String name;
    private int age;

    public Crew(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public void increaseAge() {
        this.age++;
    }

    @Override
    public String toString() {
        return "Name{" +
            "value='" + name + '\'' +
            ", age=" + age +
            '}';
    }
}

 

Name과 비슷하게 Crew의 List를 관리하는 Crews라는 일급 컬렉션이 있다.

 

public class Crews {

    private final List<Crew> crews;

    public Crews(List<Crew> crews) {
        this.crews = new ArrayList<>(crews);
    }

    public List<Crew> getCrews() {
        return Collections.unmodifiableList(crews);
    }
}

 

 

public static void main(String[] args) {
    List<Crew> originalCrews = new ArrayList<>();
    Crew crew1 = new Crew("pobi", 20);
    Crew crew2 = new Crew("james", 25);
    originalCrews.add(crew1);
    originalCrews.add(crew2);

    Crews crews = new Crews(originalCrews);
    List<Crew> getCrews = crews.getCrews();

    for (Crew crew : getCrews) {
        crew.increaseAge(); // 해당 메서드 호출을 통해 값을 변경할 수 있다.
    }

    for (Crew crew : getCrews) {
        System.out.println(crew);
    }
}

 

unmodifiable로 가져온 getCrews 자체는 불변이 맞다. 해당 리스트의 값을 변경하려면 아까와 같이 UnsupportedOperationException을 마주할 수 있다.

 

하지만 해당 객체가 불변인거지 내부 요소들은 변할 수 있다. 즉 crews는 불변이지만, 내부값은 crew는 불변이 아니기에 변경이 가능하다는 거다. 위와 비슷하게 setAge를 통해 불변객체의 내부값을 변경할 수도 있다.

나이가 늘어난 것을 확인할 수 있다.

 

즉 우리는 객체 내부에 있는 값들 또한 불변으로 선언해 줄 필요가 있다. 이를 리팩토링 하자면

public class Crew {
    private final String name;
    private final Age age;

    public Crew(String name, Age age) {
        this.name = name;
        this.age = age;
    }

    public Age increaseAge() {
        return new Age(age.getValue()+1);
    }

 

다음과 같이 age 값을 포장해서 새로운 객체를 반환해 준다면 내부의 값을 안전하게 보호해 줄 수 있다. 이 방법은 새로운 객체를 반환해 준다. 즉, 과도하게 사용한다면 쓸모없는 메모리 낭비를 유발할 수 있다. 

 

기본적으로 불변을 지향하되 어떤 데이터까지 불변으로 관리할지 잘 판단해 보자.

 

https://tecoble.techcourse.co.kr/post/2021-04-26-defensive-copy-vs-unmodifiable/

'java' 카테고리의 다른 글

상속과 합성  (0) 2025.03.17
Java Custom Exception  (0) 2025.03.04
Stream  (0) 2025.02.25
함수형 인터페이스  (0) 2025.02.25
Thread(3) - volatile, synchronize, lock  (8) 2025.01.19