자바/코틀린 Generic 이해하기 #1: Variance
1. Introduction
아래와 같은 예시를 생각해 보자.
class Cafe<T> {
private T employee;
void hire(T employee) {
this.employee = employee;
}
T availableEmployee() {
return employee;
}
}
class Barista {
public Coffee serveCoffee(KoreanMoney money) {
return new Coffee();
}
}
class ItalianBarista extends Barista {}
Cafe
는 하나의 Type Parameter를 선언한 Generic Class이다.Barista
은ItalianBarista
의 상위 타입(supertype)이고, 반대로ItalianBarista
는Barista
의 하위 타입(subtype)이다.
ItalianBarista italianBarista = new ItalianBarista();
Barista barista = new ItalianBarista();
위 두 줄의 코드에는 아무런 문제가 없다. 간단히 설명하자면 ItalianBarista
를 상위 타입인 Barista
로 upcasting 할 수 있기 때문이다.
그렇다면 아래의 코드는 어떨까?
ItalianBarista italianBarista = new ItalianBarista();
Cafe<ItalianBarista> italianCafe = new Cafe<>();
Cafe<Barista> cafe = italianCafe; // incompatible types: Cafe<ItalianBarista> cannot be converted to Cafe<Barista>
4번째 라인은 주석에 나와 있는 compile error를 발생시킨다. ItalianBarista
는 Barista
이지만, Cafe<ItalianBarista>
는 Cafe<Barista>
가 아니기 때문이다.
ItalianBarista
가 Barista
의 하위 타입이므로 Barista
라는 점을 생각해 보면, Cafe<ItalianBarista>
는 Cafe<Barista>
의 하위 타입이 아니라는 추론을 해볼 수 있다. 이 추론이 맞을까?
2. 하위 타입(Subtype)은 하위 타입다워야 한다.
당연한 소리이지만 짚고 넘어가야 할 부분이 있다. 하위 타입다워야 한다는 것은, 상속을 통한 다형성을 달성하기 위해서 상위 타입을 하위 타입으로 대체해도 전체 프로그램은 문제없이 작동해야 한다는 의미라고 생각해 보자. 무엇이 하위 타입을 하위 타입답게 만드는 것일까? 우리의 예시로 치환해 보자면, 무엇이 이탈리아인 바리스타를 바리스타답게 만드는 것일까? 바리스타 예시를 조금 더 구체적으로 들어본다. 단, 잠시 자바와 코틀린 코드, 문법은 잊어버리자.
아래 예시 중 특정 부분은 자바/코틀린 및 기타 여러 언어에서 허용되지 않는다. 어떤 부분이 어떤 이유로 그러한지는 후술한다.
당신은 카페 사장이고, 바리스타를 고용해서 카페를 운영한다.
바리스타는 어떤 일을 해야 할까?
- 고객으로부터 돈을 받는다.
- 커피를 만들고, 고객에게 에스프레소, 아메리카노 중 한 가지 커피를 건네준다.
한편, 당신의 카페에는 아래와 같은 바리스타들이 일하고 있다.
- 한국인 바리스타
- 우리나라의 화폐인 원화를 받는다.
- 에스프레소, 아메리카노 만들어서 제공한다.
- 이탈리아인 바리스타
- 마찬가지로 원화를 받는다.
- 에스프레소는 만들지만, 이탈리아인의 신념에 따라 아메리카노는 절대 만들지 않는다.
- 미국인 바리스타
- 기본적으로 원화를 받지만, 환전을 깜빡한 외국인을 위해 달러 등의 외국 화폐도 받는다.
- 에스프레소, 아메리카노를 만들어서 제공한다.
- 민초단 바리스타
- 원화를 받는다.
- 에스프레소, 아메리카노를 만들지만, 가끔 사장 몰래 민트초코 음료를 손님에게 건네며 전도를 시도한다.
- 비트코인 신봉자 바리스타
- 중앙화된 화폐를 불신하기 때문에 탈중앙화된 비트코인만을 받는다.
- 에스프레소, 아메리카노를 만들어서 제공한다.
이해를 돕기 위해 각 바리스타의 특성을 표로 나타내면 아래와 같다.
바리스타 | 손님에게 받는 돈 | 손님에게 주는 것 |
---|---|---|
한국인 | 원화 | 에스프레소, 아메리카노 |
이탈리아인 | 원화 | 에스프레소 |
미국인 | 원화, 달러 등 | 에스프레소, 아메리카노 |
민초단 | 원화 | 에스프레소, 아메리카노, 민트초코 음료 |
비트코인 신봉자 | 비트코인 | 에스프레소, 아메리카노 |
바리스타라는 상위 타입과 각 바리스타라는 하위 타입의 관점에서 생각해 보자. 손님이 자신의 주문을 받는 바리스타가 구체적으로 어떤 바리스타인지 모르는 상황에서, 즉 바리스타라는 상위 타입만 아는 상태에서, 이 카페가 잘 운영될 수 있을까?
손님 입장에서, 한국인, 이탈리아인, 미국인 바리스타를 마주하게 되더라도 카페 이용에는 문제가 없다. 원화를 내고 커피 범주 내의 음료를 받는다는 기대가 충족되기 때문이다.
- 이탈리아인 바리스타의 경우 아메리카노를 주지는 않지만, 여전히 손님이 받기를 기대하는 커피라는 범주 내의 음료인 에스프레소를 받을 수 있다.
- 미국인 바리스타의 경우 원화뿐만 아니라 달러 등 외국 화폐도 받지만, 손님이 원화를 지불하는 데에는 아무 문제가 없다. (오히려 더 많은 고객이 카페를 이용할 수 있다.)
반면 손님이 민초단, 비트코인 신봉자 바리스타를 마주하게 되면, 손님 입장에서는 문제가 생긴다.
- 민초단 바리스타의 경우, 손님이 기대하지 않는(일부는 혐오하는) 민트초코 음료를 받게 될 수 있다.
- 비트코인 신봉자 바리스타의 경우, 손님이 지불하고자 하는 원화를 받지 않아 결제 자체가 불가능하다.
위 내용을 정리하면 아래와 같다.
- 바리스타가 받아야 하는 것
- 바리스타는 최소한 원화는 받아야 한다. (비트코인만 받는 것은 안된다)
- 하지만 이외의 화폐(달러 등)를 추가적으로 받는 것은 괜찮다.
- 바리스타가 만들어야 하는 음료
- 바리스타는 아메리카노, 에스프레소 외의 음료(민트초코 음료)는 만들면 안 된다.
- 하지만 두 가지 커피 중 만들지 않는 음료(아메리카노)가 있어도 괜찮다. 우리 카페에서 일하는 바리스타는 위 조건을 만족해야 한다. 즉, 민초단 바리스타와 비트코인 신봉자 바리스타는 우리 카페의 바리스타로 일해서는 안 된다.
다시 이 절의 본론으로 돌아가 보자. 무엇이 하위 타입을 하위 타입으로 만드는가? 바리스타 예시를 통해 도출한 규칙을 일반화해 보자.
- 하위 타입의 메서드는 상위 타입의 메서드에서 받기로 선언한 타입과 같거나 더 넓은 범위의 타입을 받아들여야 한다.
- 하위 타입의 메서드는 상위 타입의 메서드에서 반환하기로 한 타입과 같거나 더 좁은 범위의 타입만을 반환해야 한다.
첫 번째 규칙을 읽고 위화감을 느낄 수 있다. 이 절 처음에서 언급했다시피 이는 자바/코틀린 등 특정 프로그래밍 언어의 규칙과는 무관한 일반론적인 규칙임을 되짚으며, 관련된 내용은 이후에 다시 설명한다.
이 규칙은 우리가 면접을 준비하면서, 이 밤의 끝을 잡고 달달 외우던 SOLID 원칙 중 L에 해당하는 Liskov substitution principle의 몇 가지 원칙과도 일치한다. 사실 LSP는 위 두 가지 규칙에 더해 하위 타입이 지켜야 할 행위적인 측면을 정의한 것이므로, 형식 관련 측면에서 하위 타입이 지켜야 할 일반적인 규칙은 위 두 규칙 그 자체라고 생각해도 무방할 것이다.
LSP에서 언급하는 또 하나의 형식 관련 규칙으로,
하위 타입에서 메서드는 상위 타입 메서드에서 던져진 예외의 하위 타입을 제외하고 새로운 예외를 던지면 안 된다.
가 있다. 그러나 예외를 던져서 control flow를 중단하는 것이 아니라 Either/Result monad와 같이 반환값으로서 예외를 다루는 방식을 생각해 본다면, 이는 두 번째 규칙을 되풀이하는 것으로도 볼 수 있을 것이다.
3. Variance
이제 Variance라는 용어가 등장할 차례다. 이전 절에서 도출한 하위 타입이 지켜야 할 규칙
을 다시 짚어보자.
- 하위 타입의 메서드는 상위 타입의 메서드에서 받기로 선언한 타입과 같거나 더 넓은 범위의 타입을 받아들여야 한다.
- 하위 타입의 메서드는 상위 타입의 메서드에서 반환하기로 한 타입과 같거나 더 좁은 범위의 타입만을 반환해야 한다.
용어부터 먼저 소개한다. 이 절에서는 공변(Covariance), 반공변(Contravariance), 불공변(Invariance)라는 세 가지 개념1을 소개한다. 공통적으로 포함된 공변
이라는 단어는 같이 변한다
라는 뜻이다. 무엇과 무엇이 같이 변한다는 것일까?
바리스타 예시로 돌아가서 살펴보자면, 이탈리아인 바리스타(ItalianBarista
)는 모든 종류의 커피 대신 커피의 하위 타입인 에스프레소(Espresso
)만을 제공한다.
Barista의
serve
메서드는 Coffee
을, ItalianBarista
의 serve
메서드는 Espresso
를 반환한다. 여기서 Coffee
는 Espresso
의 상위 타입이다. 즉, Barista
와 ItalianBarista
, Coffee
와 Espresso
의 상속 관계 방향은 같으며, 이를 공변(Covariance)이라고 한다.
미국인 바리스타(AmericanBarista
)는 원화(KoreanWon
)뿐만 아니라 달러 등 외국 화폐(Money
) 또한 받는다.
Barista의
serve
메서드는 koreanWon
을, AmericanBarista
의 serve
메서드는 money
를 받는다. 여기서 Money
는 KoreanWon
의 상위 타입이다. 즉, Barista
와 AmericanBarista
, Money
와 KoreanWon
의 상속 관계 방향은 정반대이며, 이를 반공변(Contravariance)이라고 한다.
다시 규칙으로 돌아간다. 이제 두 규칙을 아래처럼 유식한 버전으로 고쳐 쓸 수 있다.
- 하위 타입과 상위 타입은 메서드의 매개변수가 반공변(Contravariance)이어야 한다.
- 하위 타입과 상위 타입은 메서드의 반환 타입이 공변(Covariance)이어야 한다.
그러나 우리의 목적을 달성하기 위해 공변, 반공변, (잠시 뒤에 나올) 불공변 등의 용어가 중요한 것이 아니라 그 의미가 중요하다. 따라서 유식한 버전 대신 의미를 알기 쉬운 원래 버전을 머리에 넣어두는 것이 좋다. 그런 의미에서 한 번 더 규칙을 상기하고 넘어간다.
- 하위 타입의 메서드는 상위 타입의 메서드에서 받기로 선언한 타입과 같거나 더 넓은 범위의 타입을 받아들여야 한다.
- 하위 타입의 메서드는 상위 타입의 메서드에서 반환하기로 한 타입과 같거나 더 좁은 범위의 타입만을 반환해야 한다.
2절 말미에 언급했다시피, 이 규칙의 첫 번째는 어느 정도 자바/코틀린에 익숙한 사람에게는 위화감이 드는 문장이다. 왜냐하면 실제로 자바/코틀린뿐만 아니라 많은 언어에서 첫 번째와 같은 메서드 매개변수의 반공변은 허용하지 않기 때문이다. 다시 말해, 하위 타입에서 더 넓은 범위의 타입을 매개변수로써 선언하도록 허용하지 않으며, 같거나 더 넓은 범위의 타입 중 더 넓은 범위는 허용하지 않으므로 같은 타입의 매개변수만 선언할 수 있음을 의미한다.
Barista
와 AmericanBarista
, Money
와 KoreanWon
같은 상속 구조는 실제로는 예외를 발생시킨다. 하지만 우리가 2절에서 살펴본 바와 같이, 이 구조는 충분히 타입 안정적(type-safe)이고 프로그래밍 언어론적으로도 유효하다. 다만 언어의 특성상 이를 허용하지 않을 뿐이다. 아래 예시 코드를 보자.
class Barista {
public Coffee serveCoffee(KoreanWon koreanWon) {
return new Coffee();
}
}
class KoreanBarista extends Barista {
@Override // Method does not override method from its superclass
public Coffee serveCoffee(Money money) {
return new Coffee();
}
}
자바/코틀린에서 상위 타입의 메서드를 Override하기 위해서는 메서드의 시그니쳐가 동일해야 한다. 즉, 메서드의 이름과 매개변수의 개수 및 타입들의 리스트가 동일해야만 Override가 되고 다형성을 활용할 수 있다. 위 코드는 에러 메시지에서 드러나듯, Barista
의 serveCoffee
메서드를 Override한 것이 아닌데 이는 매개변수의 타입이 다르기 때문이다. @Override
annotation을 지우면 위 코드는 컴파일이 되지만, Override가 아니라 Overloading이 일어난다.
공변, 반공변과 더불어 불공변(Invariance)이라는 개념 또한 존재한다. 우리가 도출한 두 가지 규칙 중 첫 번째 규칙은, 실제로는 자바/코틀린 등 우리가 사용하는 언어에서 상위 타입과 하위 타입의 공통 메서드가 정확히 같은 타입만을 허용하고 있음을 확인했다.
Barista의
serve
메서드는 koreanWon
을, KoreanBarista
의 serve
메서드 또한 koreanWon
를 받는다. KoreanBarista
는 Barista
의 하위 타입이지만, (언어의 제약으로 인해) 메서드의 매개변수는 정확히 같은 타입이어야 하고, Barista와 KoreanBarista의 상속 관계에 영향을 받지 않으므로 이를 불공변(Invariance)라고 한다.2
참고로 오른쪽의 KoreanWon
이 같은 타입인 것이 중요한 것은 아니다. 공변이 같이 변한다
라는 의미임에 주목해 보자. Barista
와 KoreanBarista
의 상속 관계에 따라 변하는 것이 없음이라는 것이 포인트인데, 이 예시에서는 명확히 드러나지 않지만 다음 장에서 Generic 관련하여 조금 더 명확한 예를 살펴본다.
다시 1절의 마지막 코드 예시로 돌아가 보자.
ItalianBarista italianBarista = new ItalianBarista();
Cafe<ItalianBarista> italianCafe = new Cafe<>();
Cafe<Barista> cafe = italianCafe; // incompatible types: Cafe<ItalianBarista> cannot be converted to Cafe<Barista>
ItalianBarista
는Barista
이지만,Cafe<ItalianBarista>
는Cafe<Barista>
가 아니기 때문이다.ItalianBarista
가Barista
의 하위 타입이므로Barista
라는 점을 생각해 보면,Cafe<ItalianBarista>
는Cafe<Barista>
의 하위 타입이 아니라는 추론을 해볼 수 있다.
공변, 반공변, 불공변의 개념으로 위 코드와 위 문단을 다시 점검해 보자. ItalianBarista
는 Barista
의 하위 타입이지만, Cafe<ItalianBarista>
는 Cafe<Barista>
의 하위 타입이 아니라는, 즉 공변이 아니라는 추론이었다. 그렇다면 반공변일까? 아니면 불공변?
정답은 불공변이다. 자바/코틀린에서 타입 B
가 타입 A
의 하위 타입일 때, Generic class T
의 Parameterized Type인 T<A>
, T<B>
는 A
, B
의 상속 관계와 무관하며, T<A>
, T<B>
는 아무런 상속 관계도 없다.
T<Barista>
, T<ItalianBarista>
라는 두 타입은 아무런 관계가 없으니, T<Barista>
타입의 변수에 T<ItalianBarista>
타입의 객체를 할당하려는 시도는 컴파일러에 의해 에러가 발생하는 것이 당연하다.
그런데, 애당초 자바/코틀린의 Generic
은 왜 불공변일까?
다음 절에서는 Variance
라는 관점에서 자바/코틀린의 Generic
을 자세히 살펴본다.