공부방/JAVA

코드 품질 향상을 위한 제네릭(Generic) - 백기선 자바라이브스터디

EVO. 2023. 10. 30. 15:37
제네릭이 제대로 사용하기 위해서 왜 사용하는 지 부터 알아보겠다.

제네릭 사용 이유와 이점

컴파일 타임에 타입 검사를 통해 예외 방지 

제네릭은 JDK 1.5에 추가된 기능이다. JDK 1.5 이전에서는 여러 타입을 다루기 위해 Object 타입을 사용했었다. 하지만 Object로 타입 선언할 경우 반환된 Object 객체를 다시 원하는 타입으로 타입 변환을 해야 하며, 런타임 에러가 발생할 가능성도 존재한다. 아래 왼쪽 사진은 Object를 사용했을 경우 문제, 오른쪽은 제네릭을 사용했을 경우에 대한 예제 이다. 

불필요한 캐스팅을 없애 성능 향상

위 사진 처럼 Object를 사용한다면 다운 캐스팅을 해야 하고 , 컴파일러는 타입검사를 추가적으로 진행해야 했다. 반면 제네릭은 미리 타입을 지정하거나 제한하기 때문에 이런 다운 캐스팅이 필요가 없다.

 

다시 정리하면, 제네릭 타입을 사용함으로써 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 제거할 수 있기 때문에 사용한다고 볼 수가 있다.

 

제네릭 사용법

제네릭 관련 용어 정리

제네릭에 사용되는 용어는 조금 익숙치가 않다. 앞으로 설명할 용어들 모두 정리한 표이니 헷갈릴 때 참고하는 게 좋을 것 같다.

한글용어 영어용어 예시
매개변수화 타입 parameterized type List<String>
실제 타입 매개변수(타입 매개변수) actual type parameter String
제네릭 타입 generic type List<E>
정규 타입 매개변수 (타입 변수) formal type parameter E
비한정적 와일드카드 타입 unbounded wildcard type List<?>
로 타입 raw type List
한정적 타입 매개변수 bounded type parameter <E extends Number>
재귀적 타입 한정 recursive type bound <T extends Comparable<T>>
한정적 와일드카드 타입 bounded wildcard type List<? extends Number>
제네릭 메서드 generic method static <E> List<E> asLKist(E[] a)
타입 토큰 type token String.class

 

타입 변수

  • T가 익숙할 텐데 사실 아무런 이름이나 지정해도 컴파일 하는데 전혀 상관 없다. 하지만 컨밴션이 있으니 바로 아래서 확인해보자
  • 여러 개의 타입 변수를 지정할 때는 <K,V>처럼 쉼표로 구분하여 명시한다.
  • 타입 변수는 클래스에서 뿐 아니라 메서드의 매개변수나 반환 값으로도 사용할 수 있다. 뒤에서 설명하겠다.

 

타입 변수 네이밍 컨밴션

  • E : 요소 (Element의 앞글자를 따왔으며, ArrayList 코드를 확인해보면 E로 되있음을 확인할 수 있다.)
  • K : 키 (Key의 앞글자를 따왔다. Map 코드를 보면 <K,V>로 되있다.)
  • V : 값 (Value의 앞글자를 따왔다.)
  • N : 숫자 (Number)
  • T : 타입 (Type의 앞글자를 따왔다. 보통 래퍼타입이 들어올 수 있음을 알리기 위해 T를 쓴다)
  • S, U, V : 두 번째, 세 번째, 네 번째에 선언된 타입

 

제네릭 사용할 때 주의할 점

제네릭 타입의 객체는 생성이 불가하다. 즉, new 연산자 뒤에 제네릭 타입 파라미터가 올 수가 없다.

 

static을 사용한 멤버에 제네릭 타입이 올 수 없다. 왜냐하면 static 멤버는 클래스가 동일하게 공유하는 변수로서 제네릭 객체가 생성되기도 전에 이미 자료 타입이 정해져 있어야 하기 때문이다. 제네릭의 타입이 결정되는 구체적인 과정은 뒤에서 설명하겠다.

 

제네릭 타입의 배열 선언도 가능하다. 단, 제네릭 클래스 자체를 배열로 만들면 안된다.

 

제네릭 객체 만들어보기

앞에 예제는 전부 다 제네릭 클래스 이므로 생략하겠다. 클래스 선언문 옆에 제네릭 타입 매개변수가 쓰이면, 이를 제네릭 클래스라고 한다.

 

제네릭 인터페이스 : 인터페이스에서도 제네릭을 적용할 수 있다. 단, 해당 인터페이스를 구현한 클래스에서도 오버라이딩한 메서드의 제네릭 타입에 맞춰서 똑같이 구현해야 한다.

 

제네릭 함수형 인터페이스: 제네릭 인터페이스는 람다 표현식의 함수형 인터페이스에서 자주 쓰인다. 

 

제네릭 주요 개념 (바운디드 타입, 와일드 카드)

타입 한정 키워드 extends

기본적인 용법은 <T extends [제한타입]> 이다. 이 키워드는 상속에서 보던 extends 키워드와 똑같아 혼동할 수 있다. 꺾쇠 괄호 안에 extends가 있으면 제한을 의미하며 괄호 바깥에 있으면 상속으로 보면 된다.

extends 키워드 다음으로 일반 클래스 뿐만 아니라 추상 클래스, 인터페이스 모두 올 수 있다. 만약 인터페이스가 온다면 인터페이스를 구현한 클래스 만이 타입변수로 올 수 있다.

 

다중 타입 한정

만일 2개 이상의 타입을 동시에 상속한 경우로 타입 제한하고 싶다면 & 연산자를 같이 이용하면 된다. 자바에서는 추상 클래스가 다중 상속하는 경우는 불가능 하므로 인터페이스만 가능하다.

 

제네릭 캐스팅 문제  + 주의할 점

배열과 같은 일반적인 변수 타입과 달리 제네릭 서브 타입 간에는 형 변환이 불가능하다. 다형성이 적용되지 않고 전달 받은 딱 그 타입으로만 서로 캐스팅이 가능하다. 지금 말하고자 하는 것은 extends를 말하는 게 아니다. 즉 타입 파라미터의 다형성은 포함 원소로서 가능하다는 것이고 형변환 처럼 객체에서 다른 객체로 형변환 하는 것이 불가능 하다는 것이다. 

 

배열은 가능하지만 제네릭은 ERROR

이러한 사실을 왜 주의해야 하나면 제네릭 객체에 요소를 넣거나 가져올 때, 캐스팅 문제로 컴파일 에러가 발생하기 때문이다.

배열을 이용했다면 부모타입으로 파라미터 매개변수를 받아도 문제가 없었다.

하지만 배열을 리스트의 제네릭으로 바꾸면 업캐스팅이 적용되지 않아 컴파일 에러가 생긴다(다형성 이용 불가능) 

이처럼 제네릭은 불공변이라는 특징을 갖는다. 불공변이란, 서로 다른 제네릭 타입 간에는 상하위 관계가 없다는 특징이라고 한다. 불공변성의 특징을 갖는 이유는 제네릭 자체가 Type Safety를 도모하기 위해 탄생한 개념인 만큼 이 특징을 갖는 것은 당연하다.

 

제네릭 와일드 카드

제네릭 간의 형변환을 할 수 있는 방법이 존재한다. 제네릭에서 제공하는 와일드 카드 ? 문법을 이용하면 되겠다.

  • <?> : Unbounded Wildcards (제한 없음) 
    구체적인 타입으로 모든 클래스나 인터페이스 타입이 올 수 있다.
  • <? extends 상위타입> : Upper Bounded Wildcards (상위 클래스 제한)
    구체적인 타입으로 상위 타입이나 상위 타입을 상속 받는 자식 타입들만 올 수 있다.
  • <? super 하위타입> : Lower Bounded Wildcards (하위 클래스 제한)
    구체적인 타입으로 하위 타입이나 하위타입의 부모타입만 올 수 있다.

상단 문제를 해결할 수 있게 되었다.

참고한 문헌에 따르면, 제네릭 와일드 카드가 현업 개발자들도 굉장히 어려워 하는 문제라고 한다. 

 

PECS

개발자들이 어려워 하는 이유는 언제 extends를 또 언제 super를 써야 할지 어렵기 때문이다. 이 공식을 사용하면 좀 더 실무에 적용하기 쉬울 것이라 생각한다.

 

PECS란, Producer Extends Consumer Super의 약자이며 Producer, 즉 데이터를 생산해내는 조회 기능으로 이해하면 된다. 즉, 조회 기능을 사용하는 컴포넌트에서는 extends를 사용하면 된다. Consumer, 즉 데이터를 소비하는(저장, 수정 등의 기능) Component 에서는 super를 사용한다는 의미이다. 

 

예를들어, TV 클래스와 Radio클래스 그리고 이들의 부모인 Electoronics라는 클래스가 있다고 가정한다. 

위의 코드에서 List<? extends Electonics> electronics는 "내가 참조하는 리스트 객체의 element는 정확한 타입이 뭔지는 알 수 없지만 아무튼 Electronics의 하위 타입이기는 하다"라고 표현 된다. 때문에 Tv가 오던 Radio가 오던 상위 타입인 Electronics로 조회가 가능하다.

 

반면 위 코드처럼 저장하려고 하면 컴파일 에러를 나타난다. electronics가 참조하는 객체가 new ArrayList<Tv>()일 수도 있지만 new ArrayList<Radio>()일 수도 있기 때문에 컴파일러는 등록하는 것을 허락하지 않는다. 쉽게 말하면 Tv를 다루는 참조객체를 만들었는 데 Radio 객체가 들어오려고 하면 당연히 안되는 것이다.

 

위 코드에서 List<? super Electronics> list는 '내가 참조하는 리스트 객체의 element는 Electronics의 상위타입(Electronics나 Object)이기는 하다' 라고 표현 된다. 따라서 위 코드는 정상 작동한다. new ArrayList<Object>() 이거나 new ArrayList<Electronics>()이거나 Tv , Radio, Electronics 객체를 담을 수 있기 때문이다. 반면 조회를 하는 것은 불가능 하다. 만약 new ArrayList<Object>()를 참조한다면 이는 Electronics로 상속 받는 애들이 아닐 수도 있기 때문에 컴파일 에러를 발생시킨다.

 

제네릭 메서드 만들기

제네릭 메서드는 아래와 같이 반환 타입과 파라미터 타입을 제네릭 타입으로 설정했다고 제네릭 메서드라고 부르지 않는다.

제네릭 메서드란, 메서드의 선언부에 <T>가 선언된 메서드를 말한다.

 

위 예제는 단순히 클래스의 제네릭 <T>에서 설정된 타입을 받아와 반환 타입으로 사용할 뿐인 일반적인 메서드 지만, 제네릭 메서드는 직접 메서드에 <T> 제네릭을 설정함으로서 독립적으로 다른 타입 설정이 가능한 제네릭 메서드라고 이해하면 되겠다. 밑에 예시를 보면 심지어 static 메서드에도 제네릭 타입을 붙일 수 있다.

컴파일러가 제네릭 타입에 들어갈 데이터타입을 메서드의 매개변수 타입을 보고 추론할 수 있기 때문에 보통 제네릭 메서드의 타입 메서드의 타입파라미터를 생략하고 호출할 수 있다.

 

사실 위 예제처럼 L이 아닌 제네릭 클래스의 타입변수와 같은 T를 쓴다. 그런데도 독립적으로 운용이 가능하다. 처음 제네릭 클래스를 인스턴스화 하면 제네릭 메서드도 제네릭 클래스의 타입 매개변수에 전달한 타입과 똑같은 타입이 정해지게 된다.(당연히 둘다 T로 했으니) 그런데 만일 제네릭 메서드를 호출할 때 직접 타입 파라미터를 다르게 지정해주거나, 다른 타입의 데이터를 매개변수에 넘긴다면 독립적인 타입을 가진 제네릭 메서드로 운용되게 된다.

 

위의 예제는 static 메서드라 독립적으로 가능하지만 없다면 불가능한거 아니냐고 생각할 수 있을까봐 static을 없애고 또 다른 예시를 보이겠다. 

제네릭 메서드를 제대로 이해했는 지 실제 자바 API로 분석해보자. 전에 보다 조금 더 사용할 수 있을 것 같다.

 

Erasure(제네릭 타입 소거)

제네릭은 타입 안정성을 위해  JDK 1.5부터 도입된 문법으로 이전 자바에서는 제네릭 없이 코딩을 해왔다. 이런 이유로 이전의 자바 버전의 코드와 호환성을 위해 제네릭 코드는 컴파일 되면 제네릭 타입이 사라지게 된다. .class파일에는 제네릭 타입이라는 정보가 존재하지 않는다. 개발자는 설계를 잘못하면 heap pollution 문제가 생긴다. 그러므로 제네릭의 컴파일 과정을 알아볼 필요가 있다.

 

Class Type Erasure

클래스 수준에서 컴파일러는 타입 변수가 unbound인 경우(제한이 없는 경우) Object로 대체하고 bound인 경우 첫번째 bound로 대체한다.

컴파일 시 컴파일러는 위에처럼 바인딩 되지 않은 E를 Object로 바꾼다.

컴파일러는 위에처럼 바인딩 된 타입 변수 E를 첫 번째 바인딩된 클래스로 대체한다.

 

Method Type Erasure

클래스와 마찬가지로 메서드도 unbound 일때는 Object, bound 일때는 첫번째 바인딩된 클래스로 변환한다.

위에처럼 컴파일 시 E를 Object로 바꾼다.

 

위 코드는 컴파일 후 타입 변수 E를 지우고 Comparable로 대체한다.

 

Edge Cases

물론 컴파일 시 서로를 구별하기 위해 main 로직도 바뀐다.

컴파일 시 IntegerStack이 부모 클래스 Stack에서 push(Object)를 상속받을 것이다. 그렇기에 "Hello"라는 String을 푸시할 수 있다. 그리고 pop을 진행할 때 컴파일러는 당연히 String으로 형변환을 시도할 것이다. 여기까지는 문제가 안생긴다.

하지만 Integer를 받으려고 하는 data때문에 ClassCastException이 컴파일도중 발생할 것이다.

 

Bridge Methods

위에처럼 생기는 엣지 케이스 때문에 컴파일러는 브릿지 메서드를 생성한다. 이는 매개변수화된 클래스를 상속하거나 implements한 클래스를 컴파일 하는 동안 자바컴파일러가 불일치가 없는 지 확인한다.

 

 

참고 문헌