본문 바로가기
  • 개발하는 곰돌이
Programming Language/Kotlin & Java

[Java] UnmodifiableList는 진짜 불변 리스트가 아니다

by 개발하는 곰돌이 2023. 8. 15.

목차

    들어가기 전에

    불변성이 강조되는 객체지향 프로그래밍의 특성상 자바에서도 UnmodifiableList라는 클래스가 존재한다(물론 List 뿐만 아니라 Map과 Set도 존재한다). UnmodifiableList라는 이름에 걸맞게 add(), set(), remove() 등 리스트의 내부가 변경되는 메소드를 사용하면 바로 예외가 터져버린다.

    UnmodifiableList의 내부 메소드들. 변경과 관련된 메소드는 모두 예외를 터뜨린다.

    그런데 이 UnmodifiableList는 불변성을 보장하는 진짜 불변 리스트가 아니다. 이에 대해 알아보자.

     

    이하의 모든 내용은 Map과 Set에도 동일하게 적용된다.

    UnmodifiableList 객체 선언

    UnmodifiableList 객체는 다음과 같이 Collection.unmodifiableList()를 사용하여 선언할 수 있다.

    List<Integer> unmodifiableList = Collections.unmodifiableList(list);

     

    그런데 이 메소드를 파고들어가 보면 다음과 같이 구현된 것을 볼 수 있다.

    Collections.unmodifiableList()의 내부 구현.

    아래에 원본 리스트를 인자로 하여 UnmodifiableList의 생성자를 호출하는 것을 볼 수 있다.

     

    그리고 이 UnmodifiableList 클래스의 내부 구조와 생성자는 다음과 같이 되어 있다.

    UnmodifiableList의 내부 구조와 생성자.

    클래스 내부에는 단순히 리스트를 필드로 갖고, 생성자는 이 리스트에 인자로 받은 리스트를 대입하기만 할 뿐이다.

    왜 불변성이 보장되지 않을까?

    UnmodifiableList의 구조를 그림으로 보면 다음과 같다.

    여기서 Unmodifiable 객체의 메소드 중 변경과 관련된 메소드를 호출하면 다음과 같이 동작한다.

    얼핏 보면 "변경과 관련된 메소드는 모두 예외를 터뜨리니까 불변성이 보장된게 아닌가?" 싶은 생각이 든다. 하지만 생성자의 this.list = list에서 볼 수 있듯이, UnmodifiableList 내부의 리스트는 인자로 받은 원본 리스트와 완전히 동일한 객체이다.(그림에서도 두 리스트가 연결된 것으로 표현했다.)

    원본 리스트의 변경은 막을 수 없다!

    아래 그림처럼 원본 리스트에 변경을 시도하면 어떻게 될까?

    애석하게도 UnmodifiableList라는 이름이 무색하게 내부의 리스트까지 변경되어 버린다. 내부의 리스트와 원본 리스트가 동일한 객체를 바라보고 있기 때문이다.

     

    실제로 아래 코드를 실행해보면 다음과 같은 결과가 나타난다.

    public class Main {
        public static void main(String[] args) {
            List<Integer> list = new ArrayList<>();
            for (int i = 0; i < 10; i++) {
                list.add(i);
            }
            List<Integer> unmodifiableList = Collections.unmodifiableList(list);
            System.out.println("원본 list = " + list);
            System.out.println("unmodifiableList = " + unmodifiableList);
            System.out.println();
            
            list.subList(0, 5).clear();
    
            System.out.println("요소를 삭제한 원본 list = " + list);
            System.out.println("unmodifiableList = " + unmodifiableList);
        }
    }

    원본 리스트의 요소만 삭제했을 뿐인데 UnmodifiableList까지 영향을 받아버린 것을 볼 수 있다.

    대책

    물론 대책이 없지는 않다. 원본 리스트가 UnmodifiableList의 내부 리스트와 동일한 객체를 바라보는 것이 문제이므로, 새로운 리스트를 복제해서 사용하면 된다.

     

    Java 8 이상

    List<Integer> unmodifiableList = Collections.unmodifiableList(new ArrayList<>(list));

    Java 10 이상

    List<Integer> unmodifiableList = List.copyOf(list);

     

    이외에도 스트림을 사용하고 마지막에 collect(Collectors.toUnmodifiableList())를 호출하는 것은 새로운 리스트를 반환하기 때문에 이 문제에서 자유롭다.

    List<Integer> unmodifiableList = list.stream()
            // ...
            .collect(Collectors.toUnmodifiableList());

    자바 16 이상이라면 아래와 같이 더 간단하게 사용할 수 있다.

    List<Integer> unmodifiableList = list.stream()
            // ...
            .toList();

    마치며

    아무래도 자바가 오래된 언어라서 그런지 기존 스펙을 건드리지 않는 선에서 이것저것 시도한 흔적이 많이 보이는 것 같다. 이번 글에서 다룬 UnmodifiableList를 포함한 UnmodifiableCollection도 그 과정의 일환이 아닐까 싶다.

    댓글