자바(SE)

[Generic] 배열과 List<E>의 차이점

은서파 2023. 1. 19. 21:11

배열과 리스트는 모두 선언 및 생성 시 어떤 타입을 담을 것인지 지정한다는 측면에서는 유사하다.

Long [] longArray = new Long[2];         // Long 타입을 담는 배열
List<Long> longList = new ArrayList<>(); // Long 타입을 담는 List

하지만 내부적인 동작은 매우 다르다. 이번 포스트에서는 배열과 List<E>의 차이점에 대해 살펴보자. (이 내용은 Effective Java 2E 규칙 25를 참조합니다.)

 

배열과 List<E>의 차이점

 

covariant vs invariant

상속 관계에서 자료형의 참조를 설명할 때 covariant, invariant, contravariant라는 용어가 사용된다.

  • covariant : 함께 변한다.
  • invariant: 변하지 않는다.
  • contravariant: 반대 방향으로 변한다.

결론부터 이야기 하면 배열은 covariant 하고 List<E>는 invariant 하다. 용어는 어렵지만 코드를 보면 훨씬 쉽다.ㅎ

다음은 covariant 한 배열의 특성을 잘 보여준다. Object와 Long은 부모-자식의 관계에 있기 때문에 Object[] 역시 Long[]의 부모 타입이 된다.

Object [] objArr = new Long[3];
objArr[0] = "Hello";

하지만 그 결과는 매우 위험한데 위와 같이 실제 메모리의 배열은 Long을 담겠다고 했지만 objArr이 Object를 저장할 수 있기 때문에 "Hello"를 저장하는 코드를 작성해도 컴파일에 문제가 없다. (당연히 런타임에는 java.lang.ArrayStoreException 이 발생한다.)

다음은 invariant한 List<E>의 특성을 살펴보자. Object와 Long이 부모-자식 관계이더라도 List<Object>, List<Long>은 부모,자식이 아니다. 따라서 컴파일 타임부터 아래와 같이 오류가 발생하고 배열에서 처럼 부적절한 할당이 일어날 기회조차 주지 않으므로 훨씬 타입 안정적인 모습을 보여준다.

// Type mismatch: cannot convert from ArrayList<Long> to List<Object>
List<Object> objList = new ArrayList<Long>();

List<Object> objList = new ArrayList<Object>(); // 정상

 

타입의 결정 시점

배열은 런타임에 저장되는 값의 타입을 체크한다. 타입에 따라 메모리 공간을 확보해야 하기 때문이다. 따라서 위에서 살펴봤던 예와 같이 실제 값을 할당하는 시점에 ArrayStoreException이 발생한다. 런타임 에러가 프로그램에서 얼마나 안좋은지는 다들 잘 아리라 생각된다.

반면에 List<E>는 소스 레벨에서만 Generic Type이 유지된다. 즉 컴파일이 되면서 Generic 정보는 삭제되어 그냥 List 형태로 동작한다.

 
// 컴파일 전(소스단계)
List<Object> objList = new ArrayList<>();
// 컴파일 후(클래스 단계)
List objList = new ArrayList();

이는 이미 소스 단계에서 E 타입으로 객체를 제한해서 사용되었으므로 런타임에는 구지 타입을 체크할 필요도 없고 기존에 Generic을 사용하지 않은 코드와의 하위 호환성을 위해서 이기도 하다.

이에 따라서 Generic 타입을 갖는 배열은 생성할 수 없다. 예를 들면 다음의 코드는 오류를 발생한다.

// Cannot create a generic array of List<Integer>
List<Integer>[] objs = new List<Integer>[2];

objs에는 Integer를 담을 수 있는 List를 담아야 하도록 규정하고 있는데 배열런타임에 타입을 체크하려 하고 Generic 정보는 런타임에는 존재하지 않기 때문에 Generic 배열은 생성이 불가하다. 사실 Generic 배열을 만들 수 있다면 더 큰일이다. 

아래 코드를 주석과 함께 음미해보자.

// 아래 코드는 동작하지 않지만 만약 된다고 가정하면..
List<String> [] stringListArr = new List<String> [1];
// 배열은 covariant 하므로 아래와 같이 사용 가능
Object [] objArr = stringListArr;
// Object[] 이므로 List<Integer> 타입의 객체 저장 가능!
objArr[0] = Arrays.asList(42);
// 아래와 같이 추출 가능하나 사실 거기 담긴것은 Integer!
String s = stringListArr[0].get(0);

이처럼 타입의 안정성이 깨져버릴 수 있기 때문에 Generic 배열은 생성할 수 없다. 억지로 배열에 Generic을 사용할 수도 있지만 타입 안정성이 깨져버렸기 때문에 Generic의 의미가 없다고 볼 수 있다.

import java.util.ArrayList;
import java.util.List;

public class GenericArrayProblem {
	public static <T extends Number> T[] test1(boolean isInt) {
		if (isInt) {
			return (T[]) new Integer[3];
		} else {
			return (T[]) new Number[3];
		}
	}

	public static <T extends Number> List<T> test2() {
		return new ArrayList<>();
	}

	public static void main(String[] args) {
		// 컴파일은 되지만 타입의 안정성을 보장 받을 수 없음
		//Double[] doubles = test1(true); // class cast exception

		List<Integer> ints = test2();
		ints.add(1);
		// books1.add(3.14); // 불가 - 타입 안정성 OK
		List<Number> nums = test2();
		nums.add(1);
		nums.add(3.14);
		
		List<Object> objList = new ArrayList<Object>();
	}
}