본문 바로가기
DevelopmentTools/Java

[Java] Arrays.asList로 반환되는 리스트는 고정 길이인 이유 | feat. List.of()

by 수짱수짱 2025. 5. 18.

개요

Array와 Arrays를 비교 및 정리하면서 부끄럽지만 이제서야 Arrays의 asList 메소드가 고정 길이 이면서 배열과 동기화 되어 있는 리스트임을 알게 되었다.

 

어떻게 자바에서 list 데이터 구조를 가졌으면서 고정 길이를 가졌는지 이해가 안 됐다.

하지만 Arrays.asList의 인자로 들어온 배열과 ‘동기화’된 리스트라고 하니 원인이 대충 상상도 될법 했다 ... (?)

인자로 들어온 배열은 고정 길이이고 해당 배열과 동기화된 상태를 유지해야 하므로 asList로 반환하는 리스트도 고정 길이겠지

 

하지만 asList로 반환되는 리스트가 java.util의 List interface라면 가변 길이를 보장할탠데 어떻게 고정 길이를 유지하는건지 궁금했고 그 내용을 여기 정리해보고자 한다.

 

Oracle의 Arrays.asList 메소드에 대한 설명

먼저 공식문서의 Arrays.asList의 설명글을 읽어보자.

일단 Arrays.asList()는 인자로 들어온 배열을 리스트로 변환해주는 java의 메소드이다.

 

공식 문서의 설명을 읽다보면 눈에 띄는 단어들이 몇개 있다.

fixed-size list, unchanged and throw UnsupportedOperationException, The list returned by this method is modifiable...

asList 메소드를 설명하는 눈여겨 볼 단어들이다.

참고로 unmodifiableList는 Collections 클래스를 통해 생성 가능하다. 다만 이것도 원본 배열을 참조하기 때문에 주의해서 사용해야 한다. (=완벽한 불변이 아님)

 

고정 길이 리스트의 정체 -> Arrays.asList가 반환하는 리스트는 java.util의 List가 아니다.

Arrays.asList가 반환하는 리스트는 java.util.Arrays.ArrayList 클래스이기 때문이다.

 

Arrays에서 정의하고 있는 ArrayList 클래스 코드를 분석하면 왜 인자로 받은 배열과 동기화가 되는지, 고정 길이의 리스트가 되는지 알 수 있다.

 

asList 메소드는 인자로 받은 배열을 ArrayList의 생성자 인자 a로 그대로 넘겨준다.

ArrayList의 생성자는 인자로 받은 array가 null인지 확인 후 필드 a의 값으로 설정한다.

즉, ArrayList를 만들 때 새로운 List 컬렉션을 생성하는 것이 아니라 asList의 인자로 들어온 배열을 그대로 사용하는 것이다.

그렇기 때문에 asList로 반환 된 리스트도 인자 배열과 똑같이 동기화 되는 것이고 리스트임에도 불구하고 가변 길이가 될 수 없던 것이다.

 

size() 나 get(), set()과 같은 메소드를 보자.

size()는 배열의 length를 그대로 반환하고 있고 get()은 배열에 인덱스를 통해 접근한 값을 반환한다.

set()은 인덱스를 통해 변경 전의 값을 변수에 저장하고 똑같은 인덱스를 통해 배열에 새로운 값을 설정한다.

(참고로 AbstractList 클래스의 size, get, set 메소드를 오버라이딩하였기 때문에 값의 수정이 가능한 것이다. 아래에서 설명하겠지만 AbstractList 클래스를 상속받는 클래스들중 오버라이딩된 메소드가 없는 경우 값의 수정도 삭제도 불가능한 불변한 객체로 생성된다.)

그러니깐, 이름만 ArrayList인 것이고 실질적으로 행해지는 로직들은 전부 배열에서 행해지는 것이다.

 

그렇기에 ArrayList임에도 불구하고 가변길이가 아닌 고정길이, 인자로 받은 arr과 동기화가 되는 것이다.

마찬가지로 java의 배열은 고정 크기이므로 list여도 추가나 삭제가 불가능하게 하는 것이다.

 

다음으로 Arrays.asList의 반환값 리스트는 요소 추가 및 삭제가 불가능한 이유가 무엇일까?

 

Arrays.asList를 통해 반환받은 리스트에 add 혹은 remove 메소드를 수행하면 UnsupportedOperationException 예외가 발생한다.

이는, java.util.Arrays.ArraysList가 상속받고 있는 AbstractList 추상 클래스 때문이다.

AbstractList 추상클래스에서 add()와 remove()에 대해 항상 UnsupportedOperationException이 발생하도록 막아버리고 있다. 그래서 이를 상속받는Arryas.ArraysList 또한 add()와 remove() 수행이 불가능한 것이다.

 

Arrays.asList로 반환되는 리스트와 java.util의 리스트는 호환이 될까?

Arrays.asList
AbstractList

위는 Arrays.asList가 반환되는 데이터 타입이 List<T> 임을 확인할 수 있는 사진이다.

또한 Arrays.ArrayList가 상속받고 있는 AbstractList도 List를 구현하고 있음을 확인할 수 있다.

(이는 java.util의 List와 동일한 List이다.)

그렇기 때문에  Arrays.asList로 반환되는 리스트도 java.util 리스트와 어느정도 호환은 가능하지만 그 차이점(고정 길이)을 유의하자.

 

Arrays.ArrayList와 java.util.List는 대체로 stream을 통해 둘의 호환이 가능하다. 아래는 그 예제이다.

List<String> list = Arrays.asList("a", "b", "c");
List<String> utilList = list.stream().collect(Collectors.toCollection(ArrayList::new);
String[] arr = {"a", "b", "c"};
List<String> list = Arrays.asList(arr);
List<String> utilList = list.stream().collect(Collectors.toList());

 

 

결론

Arrays.asList는 우리가 사용하던 java.util의 List 인터페이스가 아님을 유의하고 사용해야 한다.

List 인터페이스와 다르게 Arrays의 ArraysList는 배열과 같이 동작하며 값 추가와 삭제가 불가능하다.

또한, Collections의 UnmodifiableList도 원본 배열을 참조하고 그 배열에 영향을 받기 때문에 완전한 불변 리스트라고 볼 수 없다. 백퍼센트 신뢰하지 말 것!

만약, 우리가 사용하던 Arrays.asList메소드 결과로 부터 java.util의 List 인터페이스를 얻고싶다면 아래와 같이 사용하면 된다.

new ArrayList<>(Arrays.asList(arr));

 

Arrays.asList는 값의 수정은 가능한 반 불변의 리스트이지만 JDK 9부터 지원하는 (인자가 3개 이상인) List.of()의 경우는 완전한 불변이라고 볼 수 있다. (배열의 값을 순회하여 일일히 다 복사함, 아래에서 자세히 서술)

 

번외) JDK 9 이상에서 지원하는 List.of()는 완전한 불변 리스트일까?

List.of를 수행하면 인자 개수에 따라 List12를 생성하거나 ListN을 생성하고 있다.

여기서도 우리가 아는 java.util의 List가 아닌 ImmutableCollection의 class List12, ListN을 생성하고 있음을 유의해야 한다.

둘의 차이는 인자 개수의 차이이다. 인자가 3개 이상이면 ListN을 생성하도록 되어 있는 것이다.

 

ListN의 내부 로직을 확인해보자. (ImmutableCollection.java에서 확인할 수 있다)

ListN의 경우 생성자에서 깊은 복사를 통해 값을 복사하고 있다 (빨간박스)

또한, 리스트를 순회하며 리스트의 요소가 null 값인지 검사하고 있어 null 값이 들어갈 수 없다.

반면에 위에서 본 Arrays.asList의 경우 얕은 복사를 통해 값을 복사하고 있어 원본 객체의 참조값을 그대로 가져가는 것이다.

ListN은 깊은 복사를 통해 값을 복사하고 있어 원본 리스트가 변화하더라도 List.of으로 반환된 리스트에는 전혀 영향을 끼치지 않는 것이다.

 

또한, ListN은 AbstractImmutableCollection 클래스 상속받고 있다.

해당 클래스는 위 사진과 같이 add, addAll, remove, replaceAll, set, sort와 같이 값을 변경하는 모든 메소드에 대해 예외를 반환하도록 한다.

 

따라서 List.of(인자3개) 이상을 호출할 시 반환되는 ListN에 대해서는 값을 변경, 수정, 삭제할 수 없으며 원본 리스트와 별개의 리스트로 만들어지므로 완전 불변한 리스트라고 볼 수 있다.

 

 

반면에, 내부 로직을 살펴보며 처음 안 것인데 인자를 2개 이하로 List.of()를 호출할 시 생성되는 List12의 경우는 ListN과 같이 깊은 복사가 아니라 얕은 복사로 필드에 값을 복사하고 있다.

또한, 내부에 null이 들어갈 수 있다. 해당 객체의 참조 값이 null인지만(=참조값 존재유무) 확인하는 것이다.

이러한 경우 원본 객체의 변화가 List12에 영향을 끼칠 것 같다는 생각이 들어 직접 확인해 보았다.

 

class Main {
    static class Test {
        String name;
        int age;

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

        public void setName(String name) {
            this.name = name;
        }

        public void setAge(int age) {
            this.age = age;
        }
        
        public String toString() {
            return String.format("이름 : %s, 나이 : %d세", this.name, this.age);
        }
        
    }
    
    public static void main(String[] args) {
        Test t1 = new Test("KIM", 20);
        Test t2 = new Test("PARK", 30);
        List<Test> listOf = List.of(t1, t2);
        
        System.out.println("변경 전 test 인자 - " + t1.toString() + " | " + t2.toString());
        System.out.println("origin 변경 전 List.of 리스트 - " + listOf);

        System.out.println("===============");
        System.out.println("List12는 불변일까? 정답은 NO");
        System.out.println("===============");

        t1.setName("변경된 NAME");
        t1.setAge(0);
        t2.setName("변경된 NAME");
        t2.setAge(100);
        System.out.println("변경 후 test 인자 - " + t1.toString() + " | " + t2.toString());
        System.out.println("origin 변경 후 List.of 리스트 - " + listOf);
    }
}

 

t1, t2 객체의 name, age 값을 변경하니 해당 객체가 인자로 반환 된 List.of의 리스트의 값도 변경되었다.

역시나 원본 객체의 값이 변경되니 List12로 반환된 리스트에도 영향이 가는 것을 확인할 수 있었다.

즉, 인자가 3개 이상일때만 ListN class를 활용한 리스트가 반환되므로 인자가 2개 이하인 경우 List.of()로 반환되는 리스트가  반드시 불변이라고 믿어서는 안 된다! 주의!

 

 

 


Reference