Effective Java : 아이템28. 배열보다는 리스트를 사용하라

    들어가기 전

    이 글은 인프런 백기선님의 이펙티브 자바 강의를 복습하며 작성한 글입니다. 


    이 글의 요약

    • 배열은 공변이므로 타입 불안정성이 존재한다. 제네릭은 불공변이므로, 타입 불안정성이 해결된다. 따라서 배열대신 제네릭을 사용한 리스트를 사용하라. 
    • 배열은 실체화 되지만, 제네릭은 소거된다. 배열은 컴파일 되어도 바이트 코드에 남아있으나, 제네릭은 컴파일되면 소거되어 바이트 코드에 남아있지 않는다.
    • 제네릭은 컴파일 단계에서 소거되어 Raw 타입으로만 남는다. 대신 컴파일러는 필요한 시점에 캐스팅 코드를 넣어준다.
    • 제네릭 타입의 배열은 생성할 수 없다. 그렇지만 형변환해서 사용할 수는 있다.
    • 배열보다는 리스트를 사용하는 것을 추천한다. 제네릭 타입을 지원하기 때문이다.
    • 배열을 사용할 경우, 특히 상위 타입의 배열을 사용할수록 타입 캐스팅 런타임 에러가 발생할 수 있다. 이 부분을 제네릭으로 리팩토링하면 타입 안정성이 확보되어 런타임 classCastException이 발생하지 않을 것이다. 

    핵심 정리: 배열과 제네릭은 잘 어울리지 않는다.

    • 배열은 공변 (covariant), 제네릭은 불공변
    • 배열은 실체화(reify) 되지만, 제네릭은 실체화 되지 않는다. 
      • 제네릭은 컴파일 후 소거되고, 캐스팅만 남기 대문이다.
    • new Generic<타입>[배열]은 컴파일 할 수 없다.
    • 제네릭 소거: 원소의 타입을 컴파일 타임에만 검사하며 런타임에는 알 수 없다.

    공변 / 불공변

    공변과 불공변의 의미부터 살펴보고자 한다.

    • 공변은 상속 관계에 따라 같이 변한다로 이해할 수 있다. 배열은 공변 특성을 가진다.
    • 불공변은 상속 관계에 따라 같이 변하지 않는다로 이해할 수 있다. 제네릭은 불공변 특성을 가진다. 

    잘 이해가 안될텐데 이 부분을 코드로 살펴보면 다음과 같다. 공변은 아래와 같다.

    • 배열은 공변한다. 따라서 배열 안에 들어가는 타입을 최상위 타입으로 변환하는게 가능하다. 
    // 공변
    // 배열은 공변이기 때문에 String[]이 상위 타입의 Object[]로 변할 수 있다.
    Object[] anything = new String[10];

    불공변은 아래와 같다.

    • 리스트는 제네릭을 쓸 수 있다. 그리고 제네릭은 불공변한다. 불공변한다는 것은 List<String>, List<Object>가 각자 다른 타입이라는 것을 의미한다.
    • 서로 다른 타입은 같이 사용할 수 없으므로 아래 코드는 컴파일 에러가 발생한다.
    • 만약 같이 사용하고 싶다면 List<? extends Object>를 이용해서 사용하도록 한다. 
    List<String> names = new ArrayList<>();
    List<Object> objects = names; // 제네릭은 불공변이기 때문에 이렇게 동작할 수 없음.

     

    공변인 배열의 문제점, 그리고 리스트를 사용해야 함. 

    배열은 공변이기 때문에 한 가지 문제가 발생할 수 있다. 바로 아래 코드에서 확인할 수 있다.

    • Object[] 배열은 String[] 배열의 부모 클래스이기 때문에 캐스팅되어서 참조할 수 있다.
    • 이 때 Object[] 배열의 0번 인덱스에 숫자 1을 넣는다. 하지만 Object 배열은 String 배열을 참조하고 있고, String 배열에 1을 넣으려고 하기 때문에 문제가 발생한다. 

    그런데 이처럼 문제가 발생하는데, 이 문제는 '컴파일 타임'이 아닌 '런타임'에 확인할 수 있게 된다. 이것은 컴파일러가 컴파일 시점에 알아내지 못하기 때문이다. 컴파일러는 코드를 한줄씩 확인하는데, 각각의 코드를 한줄씩 살펴봤을 때는 아무런 문제가 없기 때문이다. 

    • String 배열이 부모 클래스인 Object 배열에 배정될 수 있다. 
    • 그리고 Object 배열에는 당연히 1이 들어갈 수 있다.

    이런 문제가 발생할 수 있다. 배열이 성능적으로는 인덱스로 바로 접근할 수 있다는 장점이 있지만 안정성이 떨어진다. 따라서 공변인 배열보다는 불공변 제네릭을 이용한 List를 사용하는 것을 추천한다. 아래는 불공변인 제네릭을 선언한 List 코드이며, 컴파일 단계에서 에러가 발생하는 것을 볼 수 있다. 

    여기서 불공변 제네릭은 List<String>과 List<Object>를 서로 다른 타입으로 인지하기 때문에 List<Object> 타입의 변수에 List<String>을 참조시킬 수 없는 것이다. 

     

    배열은 실체화 되지만, 제네릭은 실체화 되지 않고 소거된다. 

    여기서 이야기 하는 실체화는 코딩할 때 작성한 '타입'이 런타임에도 유지된다는 것을 의미한다. 배열과 제네릭은 실체화 관점에서 전혀 다르게 동작한다.

    • String[]은 런타임에서도 스트링 배열로 유지된다. 
    • 제네릭은 런타임에서 사라진다. List<String>이 컴파일되면 String이 사라지고 List만 남는다.

    제네릭은 자바 5 이전 버전과의 하위 호환성을 위해서 컴파일 시점에 소거되고 Cast 명령어가 추가되는 형태로 구현된다. 예를 들어 제네릭으로 다음과 같이 코드가 작성되었다면, 아래에서 볼 수 있듯이 제네릭을 반영한 바이트 코드는 다른 형태로 구현되는 것으 볼 수 있다.

    • 바이트 코드 상에서는 제네릭이 사라지고 Raw 타입으로 동작한다.
    • 제네릭은 실제 데이터에 접근하는 순간, Casting 코드가 사용되도록 컴파일러를 통해 구현된다. 
    public static void main(String[] args) {
        // 제네릭으로 작성한 코드
        ArrayList<String> names = new ArrayList<>();
        names.add("keesun");
        String name = names.get(0);
        System.out.println(name);
        
        // 제네릭을 반영한 바이트 코드는 다음과 같이 작성된다.
        List names2 = new ArrayList();
        names2.add("keesun");
        Object o = names2.get(0);
        String name2 = (String) o; // 컴파일러가 작성해주는 코드 
    }

    아래는 위 코드가 컴파일되고, 컴파일된 코드를 바이트 코드로 살펴본 것이다. 인텔리제이에서 빌드 후, shift 2번 누르고 show bytecodes를 사용하면 볼 수 있다. 아래 바이트 코드에서 실제로 확인할 수 있다.

    • New ArrayList를 만든다. 이 때, Raw 타입으로 만들어진다. 
    • 'keesun'이라는 파라메터를 List.add()에 전달한다. 
    • get()으로 꺼낼 때 CHECKCAST를 이용해 Object → String으로 타입 캐스팅한다. 

     

    만약 제네릭 타입의 배열을 선언하는 것이 가능하다면..  → 이래서 자바는 제네릭 배열이 없다. 

    기본적으로 배열은 공변이기 때문에 제네릭 타입의 배열을 선언하는 것이 자바 문법으로 막혀있다. 하지만 제네릭 타입의 배열을 선언하는 것이 가능하다면 어떤 문제가 발생할 수 있을까? 아래 코드에서 살펴볼 수 있다. 

    // 제네릭과 배열을 같이 사용할 수 있다면
    List<String>[] stringLists = new ArrayList<String>[1];
    List<Integer> intList = List.of(42);
    Object[] objects = stringLists;
    objects[0] = intList;
    
    String s = stringLists[0].get(0);
    1. ArrayList를 선언한다. 이건 자바 문법상 불가능하지만, 일단은 가능하다고 하자. 여기서 List<String>[]은 컴파일을 하게 되면 Raw 타입인 List[]로 바뀌게 된다. 
    2. List<Integer>는 일반적으로 선언했다. 
    3. Object[]과 List[] 배열은 공변이기 때문에 최상이 클래스인 Object[]로 캐스팅 가능하다. 따라서 Object[] objects는 stringLists를 참조할 수 있다. 
    4. objects[0]는 Object 배열이기 때문에 어떠한 것도 들어올 수 있다. 따라서 Object 배열의 첫번째 인덱스에 intList를 넣을 수 있다. 
    5. stringLists[0].get(0)를 하게 되면 컴파일러는 자동으로 String으로 캐스팅한다. 왜냐하면 제네릭으로 List<String>을 사용했기 때문이다. 

    여기서 문제점은 무엇이 발생할 수 있을까? 오브젝트 배열은 사실은 List<String>[] 배열이다. 즉, List<String> 인스턴스만 배열로 가질 수 있어야 한다. 그런데 이 배열에 List<Integer> 타입의 인스턴스 intList가 objects[0] = intList;로 추가되었다. List<String> 인스턴스만 들어올 수 있는데, List<Integer>가 들어오면서 타입 캐스팅 에러가 런타임에 발생할 것이다.

    그렇지만 위 코드에서 볼 수 있듯이, objects[0] = intList를 할 때에는 어떠한 컴파일 에러도 발생하지 않았다. 이런 문제가 발생하기 때문에 자바에는 제네릭 배열을 문법으로 막아두었다. 


    핵심정리2 : 배열을 썻을 때의 문제점과 리스트로 고쳤을 때의 장점 살펴보기 → 배열 사용 상태

    먼저 배열을 직접 사용했을 때 발생할 수 있는 문제점을 살펴보자. Chooser_Array 코드에서 살펴볼 부분은 다음과 같다. 

    • Chooser_Array는 Collection을 받아서 랜덤한 값을 리턴해주는 기능을 한다. 
    • 생성자에서 콜렉션 타입(Raw Type)을 받아 배열로 바꿔서 멤버로 가진다. 
    • 클라이언트 코드에서 사용할 때 (Number)를 이용해서 형 변환한다. 

    배열을 썻을 때의 문제점은 클라이언트 코드에서 (Number)를 이용해서 형 변환할 때 발생한다. 만약 이 (Number) 캐스팅이 배열에서 제공하는 타입과 호환되지 않는다면 문제가 생길 수 있다. 예를 들어 배열에는 String, Number가 담겨져 있는데 String 타입이 반환되었다면 타입 캐스팅 에러가 발생한다. 이처럼 배열을 기반으로 코딩을 한다면, 포괄적인 타입의 배열을 사용했을 때 형변환 에러가 발생할 가능성이 크다.  

    배열을 그대로 사용하고자 한다면, 이럴 때는 Object[]이 아니라 좀 더 좁은 범위인 Integer[]로 변환해주면 해결할 수 있다. 왜냐하면 배열에서 선언하는 타입 자체는 실체화가 되어 런타임에도 해당 타입이 보존되기 때문이다. 그렇게 되면 choose() 메서드의 타입은 Integer가 되고, chooser.choose() 메서드를 사용했을 때 캐스팅을 하지 않아도 된다.  

    // 코드 28-6 배열 기반 Chooser
    public class Chooser_Array {
        private final Object[] choiceList; // 형변환 문제의 해결책은 Integer[]로 사용하는 것이다.
    
        public Chooser_Array(Collection choiceList) {
            this.choiceList = choiceList.toArray();
        }
    
        public Object choose() {
            ThreadLocalRandom rnd = ThreadLocalRandom.current();
            return choiceList[rnd.nextInt(choiceList.length)];
        }
    
        public static void main(String[] args) {
            List<Integer> intList = List.of(1, 2, 3, 4, 5, 6);
    
            Chooser_Array chooser = new Chooser_Array(intList);
    
            for (int i = 0; i < 10; i++) {
                Number choice = (Number) chooser.choose(); // choose가 제공하는 값은 항상 (Number) 타입이 아닐 수 있다.
                System.out.println(choice);
            }
        }
    }

    그렇지만 여전히 생성자에서 형변환 런타임 에러가 발생할 가능성이 존재한다. 아래와 같이 작성하면 형변환 에러가 발생한다.

    • Collection은 Raw 타입이기 때문에 List<String>도 들어올 수 있다.
    • Collection은 Raw 타입이기 때문에 컴파일되면 Object[]이 된다. Object[]은 Interger[]로 다운캐스팅 될 수 없다. Object 배열 안에 String이 들어있을 수도 있기 때문이다. 
    • 따라서 이 부분에서 ClassCastException이 발생한다. 
    public class Chooser_Array {
        private final Integer[] choiceList; // 형변환 문제의 해결책은 Integer[]로 사용하는 것이다.
    
        public Chooser_Array(Collection choiceList) {
            this.choiceList = (Integer[]) choiceList.toArray();
        }
    
    
        public static void main(String[] args) {
    
            List<String> stringLIst = List.of("a");
            Chooser_Array chooser1 = new Chooser_Array(stringLIst); // 형변환 에러 발생
            
        }

    만약 Object[]을 Interger[]로 범위를 축소해서 이런 형변환 문제를 해결하고 싶다면, 생성자 부분을 이렇게 수정해야한다.

    public Chooser_Array(Collection choiceList) {
        choiceArray = new Integer[choiceList.size()];
        choiceList.toArray(choiceArray);
    }

     


    핵심정리2 : 배열을 썻을 때의 문제점과 리스트로 고쳤을 때의 장점 살펴보기 → 리스트 사용 상태

    배열을 사용했을 때 런타임에서 발생하는 classCastException을 해결하고자 등장한 것이 제네릭이다. 배열을 그대로 사용하면서 제네릭을 사용하면 다음과 같이 쓸 수 있다.

    • E[]를 사용한다. E[]는 컴파일 이후 Object[]로 바뀐다. 
      • 따라서 (E[]) choicList.toArray()는 타입 캐스팅 에러가 발생하지 않는다. Object[]을 런타임에 Object[]로 변경하는 작업이기 때문이다. 
    • this.choicList에 컴파일 경고가 발생한다. E[] 자체는 타입 한정 매개변수가 아니기 때문에 Object이고, 컴파일러는 E의 타입이 뭔지 정확하게 추론할 수 없기 때문이다. 
      • 이 경우 @SuppressWarnings를 붙여서 해결한다. 
    // 코드 28-6 배열 기반 Chooser + Generic 사용
    public class Chooser_Array_Generic<E> {
        private final E[] choiceList;
    
        @SuppressWarnings("unchecked")
        public Chooser_Array_Generic(Collection<E> choiceList) {
            this.choiceList = (E[]) choiceList.toArray();
        }
    
        public E choose() {
            ThreadLocalRandom rnd = ThreadLocalRandom.current();
    
            return choiceList[rnd.nextInt(choiceList.length)];
        }
    
        public static void main(String[] args) {
            List<Integer> intList = List.of(1, 2, 3, 4, 5, 6);
    
            Chooser_Array_Generic<Integer> chooser = new Chooser_Array_Generic<>(intList);
    
            for (int i = 0; i < 10; i++) {
                Number choice = chooser.choose();
                System.out.println(choice);
            }
        }
    }

    또 다른 방법은 제네릭 배열을 사용하는 대신 제네릭 리스트를 사용하는 것이다. 아래 코드를 살펴보자.

    • 필드를 배열에서 List로 바꾼다.
    • 생성자는 Collecttion을 방어적인 복사를 이용해서 새롭게 ArrayList를 생성한다. 

    이 때는 타입 캐스팅 에러가 발생하지 않고, 제네릭을 이용해서 타입 안정성이 더욱 좋아진다. 

     

    // 코드 28-6 List + Generic 사용
    public class Chooser<E> {
        private final List<E> choiceList;
    
        public Chooser(Collection<E> choiceList) {
            // 방어적 복사 
            this.choiceList = new ArrayList<>(choiceList);
        }
    
        public E choose() {
            ThreadLocalRandom rnd = ThreadLocalRandom.current();
    
            return choiceList.get(rnd.nextInt(choiceList.size()));
        }
    
        public static void main(String[] args) {
            List<Integer> intList = List.of(1, 2, 3, 4, 5, 6);
    
            Chooser<Integer> chooser = new Chooser<>(intList);
    
            for (int i = 0; i < 10; i++) {
                Number choice = chooser.choose();
                System.out.println(choice);
            }
        }
    }

     


    제네릭을 사용하면 사용자쪽 코드도 바뀐다. 

    Object[]을 사용하던 코드에서 제네릭을 사용하게 되면 사용하는 쪽의 클라이언트 코드도 바뀌게 된다. 클라이언트 코드에 영향을 미치는 것은 제네릭이 가지는 장점과 동일해진다. 

    • 클라이언트 코드에서 형변환을 할 필요가 없어진다. 왜냐하면 제네릭 타입으로 타입이 정해져있기 때문이다.
    • 컴파일 타임에 타입 안정성을 확보할 수 있게 된다. 

    컴파일 타임에 타입 안정성을 확보할 수 있었다는 곳은 이 코드를 의미한다. 만약 Object[]을 사용했다면 choose.choose()의 결과를 매번 (Number)로 타입 캐스팅을 해야했다. 그런데 Object[] 내부에 값이 항상 Number 타입만 들어있는 것이 아닐 수 있기 때문에 런타임 에러가 발생할 수 있다. 

    for (int i = 0; i < 10; i++) {
        Number choice = chooser.choose(); // 컴파일 타임의 타입 안정성 확보
        System.out.println(choice);
    }

    댓글

    Designed by JB FACTORY