반응형

개요

 배열과 리스트 모두 여러 값을 관리하게 위해 사용한다. 기능적으로 같은 역할을 하는 배열과 리스트는 어떤 차이점이 있고, 이 중 어떤 타입을 사용하는게 더 기능적으로 유용할까?

 


결론부터 말하면 리스트

결론부터 말하면 리스트를 사용해야 한다. 왜? 그걸 이해하기 위해서는 변성, 공변, 불공변(무공변), 소거, 실체화 타입, 실체화 불가 타입과 같은 개념들을 이해해야한다. 하나씩 이해하며 왜 리스트를 사용해야하는지 알아보자.

 


첫번째 차이. 변성

 

 리스트와 배열의 첫번째 차이는 변성이다. 변성이란 타입의 계층 관계에서 서로 다른 타입 간에 어떤 관계가 있는지 나타내는 개념이다. 변성은 크게 공변, 반공변, 불공변(== 무공변) 으로 나뉘며 배열과 리스트와 연관성이 있는 공변과, 불공변에 대해 알아보자.

 


배열은 공변 (共變)

 공변은 '함께 공(共)', '변할 변(變)' 이라는 한자어 그대로 '함께 변한다'는 뜻이다. 함께 변하는 주체는 바로 계층관계이다. 즉, 타입의 계층관계에 따라 배열의 계층관계도 함께 변하는 것이다. 예를들어 Sub 클래스가 Super 클래스의 하위 클래스라면 배열 Sub[]도 배열 Super[]의 하위 타입이 된다.

 공변과 불공변을 구분할 때 업 캐스팅이 가능한가의 여부로 판단하기도 하는데, 공변일 경우 계층 관계가 유지되니 다형성으로 인해 업 캐스팅이 가능하기 때문이다.

 

다형성
한 타입의 참조변수를 통해 여러 타입의 객체를 참조할 수 있도록 만든 것을 의미한다. 좀 더 구체적으로, 상위 클래스 타입의 참조 변수로 하위 클래스의 객체를 참조할 수 있도록 하는 성질이다.

 

 정리하면 배열은 공변성을 띄므로 계층관계를 갖고, 아래와 같이 업 캐스팅이 가능하다.

public class Super {
}

public class Sub extends Super {
}

public static void main(String[] args) {
	Super[] sup = new Sub[10];
}

리스트는 불공변

 리스트는 불공변성을 띈다. Super 클래스와 Sub 클래스가 계층관계에 있더라도 리스트의 계층관계가 함께 변하지 않는다. 예를들어 Sub가 Super의 하위 클래스라도 List<Sub>는 List<Super>의 하위 타입이 되지 않는다. 그저 다른 타입으로 인식한다.

List<Super> supList = new ArrayList<Sub>(); // 타입 불일치 관련 컴파일 에러가 발생한다.

 


공변은 컴파일 타임에 타입 에러를 발견하지 못할 수 있다.

Object[] objectArray = new Long[1];
objectArray[0] = "안녕하세요";

 

 이 코드에서 컴파일 에러는 발생하지 않는다. 배열의 공변성에 의해 Object 배열과 Long 배열은 계층 관계를 갖게 되고, 다형성에 의해 상위 클래스 타입 변수에서 하위 타입 인스턴스를 참조할 수 있기 때문이다. Object 타입의 objectArray가 String 타입 값을 참조할 수 있는것도 마찬가지이다.

 

 문법적으로는 문제가 없기 때문에 컴파일 오류는 발생하지 않으나 컴파일 시 업캐스팅했던 objectArray의 실제 타입이 Long 타입으로 바뀔 것이기 때문에 ArrayStoreException 가 발생한다는 경고가 나온다.

 

//----- 컴파일 전 (.java)
Object[] objectArray = new Long[1];

// 경고 : 타입 'java.lang.String'의 요소를 'java.lang.Long' 요소의 배열에 저장하면 'ArrayStoreException'이 발생합니다
objectArray[0] = "안녕하세요"; 

//----- 컴파일 후 (.class)
Long[] arrayOfLong = new Long[1];
arrayOfLong[0] = "안녕하세요";

 

 Long용 저장소에 String 값을 넣을 수 없는 건 당연하다. 다만 배열 사용시 그 실수를 런타임에야 알게 되지만, 리스트를 사용하면 컴파일타임에 알 수 있다. 이게 배열보다 리스트를 사용해야 하는 가장 큰 이유 중 하나이다.

List<Object> list = new ArrayList<Long>(); // 컴파일 에러가 발생한다.
list.add("안녕하세요");

 


두번째 차이. 실체화 / 실체화 불가 타입

 

 실체화 타입이란 컴파일 타임에 사용된 타입이 런타임에 소거되지 않는 타입이다. 실체화 불가 타입컴파일 타임에 사용된 타입이 런타임에 소거되는 타입이다.

 

 조금 더 정확히 말하면 실체화 불가 타입은 해당 타입을 컴파일 타임에만 사용하여 타입 문제가 있는지 확인하고, 최종적으로 생성된 class 파일에서는 타입을 포함시키지 않는 것이다. 즉, 런타임에는 타입이 없는 상태, 소거된 상태로 실행되게 된다.

 

소거
원소 타입을 컴파일 타임에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것을 의미한다.

 

이 둘의 개념이 잘 이해가지 않는다면 소거와 실체의 의미를 생각해보자. 실체(reify)란 '실제적인 것으로 만든다'라는 뜻이다. 무엇인가 소거 되버린 것으로는 실제적인 것을 만들지 못한다는 맥락에서 '실체화 불가 타입', 소거가 되지 않는다면 실제적인 것을 만들 수 있으니 '실제화 타입'으로 이해해보자.

 

 그럼 런타임에 소거되는 타입은 뭘까? 바로 제네릭 타입이다. 제네릭을 사용하는 타입은 소거되어 런타임에 타입 정보를 알 수 없다. 아래 java 파일을 컴파일하면 타입 소거된 class 파일이 생성되는 것을 확인할 수 있다.

// 컴파일 전 (.java)
List<Integer> dice = List.of(1,2,3,4,5,6);
List<Integer> dices = new ArrayList<>();

// 컴파일 후 (.class)
List localList = List.of(Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4), Integer.valueOf(5), Integer.valueOf(6));
ArrayList localArrayList = new ArrayList();

 

 실체화라는 개념은 제네릭의 탄생과 연관이 있는 것 같다. 제네릭은 컴파일 타임에 타입 안전성을 확보하기 위해 Java 1.5 버전부터 등장했다. 그런데 제네릭이 등장하기 전 사용하던 Raw 타입과의 호환성을 유지해야 했기 때문에 제네릭 타입은 컴파일 시 타입 체크에만 사용한 후 소거하게 된것이고, 소거된 타입을 분리하기 위해 실체화 타입, 실체화 불가 타입이라는 개념이 등장한게 아닐까 싶다 (뇌피셜)

 

 int, double 과 같은 원시 타입, 일반 클래스 및 인터페이스 타입, Raw 타입, List<?> 와 Map<?,?>와 같은 비한정적 와일드카드 타입을 실체화 타입으로 구분하고, List<T>, List<String>, List<? extends Number> 등과 같은 제네릭 타입 매개변수를 갖는 타입을 실체화 불가 타입이라 한다. 즉, 배열은 실체화 타입, 리스트는 실체화 불가 타입이라는 차이점이 있다.

 


제네릭 배열을 만들지 못하는 이유

 이러한 주요 차이로 인해 배열과 제네릭은 잘 어우러지지 못한다. 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. 결과적으로 제네릭 배열 생성이 허용되면 타입 안전성이 깨질 수 있다. 이는 런타임 시 ClassCastException이 발생하는 것을 막아주겠다는 제네릭 타입의 취지에 어긋나게 되는 것이다.

 

만약 제네릭 배열을 만들 수 있다고 가정한다면 어떤 상황에서 ClassCastException 런타임 예외가 발생하는지 알아보자.


제네릭 배열의 사고 예제

// 컴파일 에러가 발생하지 않는다고 가정하며 제네릭 배열을 선언한다.
List<String>[] stringLists = new ArrayList<String>[1];

// Integer 타입의 리스트를 생성한다
List<Integer> intList = List.of(42);

// objects 배열의 시작주소에 stringLists 배열의 시작주소를 할당. 배열은 공변이니 문제없음
Object[] objects = stringLists;

// objects 첫번째 원소에 intList를 저장한다.
objects[0] = intList;

// stringLists[0] 에는 intList 인스턴스가 저장되어 있으며,
// get 메서드를 통해 조회 및 자동 형변환 시 ClassCastException 발생함.
String s = stringLists[0].get(0);

 

 즉, 제네릭을 사용하더라도 런타임에 ClassCastException이 발생하여 타입 안전성을 보장하지 못하게 되는 것이다. 이런 이유로 제네릭 배열을 만들지 못하도록 컴파일 에러를 발생시킨 것이다.

 


코드 리팩토링하기 (Object[ ] > Generic[ ] > List 순)

 배열로 형변환할 때 형변환 경고가 뜨는 경우 대부분은 배열인 E[] 대신 List<E>을 사용하면 해결된다. 코드가 조금 복잡해지고 성능이 살짝 나빠질 수도 있지만, 타입 안전성과 상호운용성은 좋아진다.

 

public class Chooser {
    private final Object[] choiceArray;

    public Chooser(Collection choices){
        choiceArray = choices.toArray();
    }

    public Object choose(){
        Random rnd = ThreadLocalRandom.current();
        return choiceArray[rnd.nextInt(choiceArray.length)];
    }

    public static void main(String[] args) {
        List<Integer> dice = List.of(1,2,3,4,5,6);

        Chooser chooser = new Chooser(dice);

        Integer choose = (Integer) chooser.choose();
        System.out.println(choose);  
        // String choose1 = (String) chooser.choose(); 올바르지 않는 타입으로의 형변환 > 런타임 예외 발생
    }
}

 

위 코드는 choose 메서드를 호출할 때마다 반환된 Object를 원하는 타입으로 형변환해야한다. 만약 타입이 다르다면 런타임에 형변환 오류가 발생한다. 먼저 제네릭을 도입해 리팩토링하자.

 


배열에 Generic 적용하기

 형변환 코드를 제거하기 위해 제네릭을 사용했다. 클래스 내에서 사용될 타입 매개변수 T를 전달받고, 생성자 메서드에 T 타입 매개변수를 갖는 컬렉션 타입 인스턴스를 전달받도록 수정했다. 이로써 형변환 하는 코드를 굳이 넣어주지 않아도 되게 되었다.

public class Chooser<T> {
    private final T[] choiceArray;

    public Chooser(Collection<T> choices){
        choiceArray = (T[]) choices.toArray();
    }

    public T choose(){
        Random rnd = ThreadLocalRandom.current();
        return choiceArray[rnd.nextInt(choiceArray.length)];
    }
    
    public static void main(String[] args) {
        List<Integer> dice = List.of(1,2,3,4,5,6);

        Chooser<Integer> chooser = new Chooser<>(dice);
        Integer value = chooser.choose();
        System.out.println(value);
    }
}

 생성자에서 (T[ ]) 코드를 추가해 형변환하고 있다. 이유는 toArray() 메서드의 반환 타입이 Object[ ] 이기 때문이다. 그런데 (T[ ]) 를 추가한 부분에서 확인되지 않는 형변환 경고가 발생한다. 확인되지 않는 이유는 T가 무슨 타입인지 알 수 없으니 컴파일러는 이 형변환이 런타임에도 안전한지 보장할 수 없다는 메시지이다.

 안전하다고 확신이 든다면 @SuppressWarnings 어노테이션과 함께 주석을 달아줘도 되지만 배열 대신 리스트를 사용한다면 경고 자체를 제거할 수 있다.

 


배열을 List로 변경하기

멤버필드를 리스트로 수정하고, Chooser 생성자 메서드에서 ArrayList 의 생성자 메서드를 사용하여 멤버필드에 값을 넣고 있다. 리스트를 사용하였고, 컴파일 오류가 발생하지 않았으니 런타임 시 타입 안전성이 보장되게 되었다.

 

public class Chooser<T> {
    private final List<T> choiceList;

    public Chooser(Collection<T> choices){
        choiceList = new ArrayList<>(choices);
    }

    public T choose(){
        Random rnd = ThreadLocalRandom.current();
        return choiceList.get(rnd.nextInt(choiceList.size()));
    }
}

 


정리

 리스트는 컴파일 타입 에러를 잡아 런타임 시 타입 안전성을 확보할 수 있다는 이점이 있다. 배열과 제네릭을 사용하는 리스트에는 매우 다른 타입 규칙이 적용된다. 배열은 공변이고 실체화되는 반면, 리스트는 불공변이고 타입 정보가 소거된다. 그 결과 배열은 런타임에 타입 안전성을 확보할 수 없고. 리스트는 확보할 수 있다.

 성격이 다른 둘을 섞어 쓰기란 쉽지 않다. 만약 둘을 섞어 쓰다가 컴파일 오류나 경고를 만나면, 가장 먼저 배열을 리스트로 대체하는 방법을 적용해야한다.

 


참고

 이펙티브 자바 - 조슈아 블로크

반응형

+ Recent posts