futurebright dev

자바/코틀린 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 {}
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를 발생시킨다. ItalianBaristaBarista이지만, Cafe<ItalianBarista>Cafe<Barista>가 아니기 때문이다. ItalianBaristaBarista의 하위 타입이므로 Barista라는 점을 생각해 보면, Cafe<ItalianBarista>Cafe<Barista>의 하위 타입이 아니라는 추론을 해볼 수 있다. 이 추론이 맞을까?

2. 하위 타입(Subtype)은 하위 타입다워야 한다.

당연한 소리이지만 짚고 넘어가야 할 부분이 있다. 하위 타입다워야 한다는 것은, 상속을 통한 다형성을 달성하기 위해서 상위 타입을 하위 타입으로 대체해도 전체 프로그램은 문제없이 작동해야 한다는 의미라고 생각해 보자. 무엇이 하위 타입을 하위 타입답게 만드는 것일까? 우리의 예시로 치환해 보자면, 무엇이 이탈리아인 바리스타를 바리스타답게 만드는 것일까? 바리스타 예시를 조금 더 구체적으로 들어본다. 단, 잠시 자바와 코틀린 코드, 문법은 잊어버리자.

아래 예시 중 특정 부분은 자바/코틀린 및 기타 여러 언어에서 허용되지 않는다. 어떤 부분이 어떤 이유로 그러한지는 후술한다.

당신은 카페 사장이고, 바리스타를 고용해서 카페를 운영한다.

바리스타는 어떤 일을 해야 할까?

한편, 당신의 카페에는 아래와 같은 바리스타들이 일하고 있다.

  1. 한국인 바리스타
    • 우리나라의 화폐인 원화를 받는다.
    • 에스프레소, 아메리카노 만들어서 제공한다.
  2. 이탈리아인 바리스타
    • 마찬가지로 원화를 받는다.
    • 에스프레소는 만들지만, 이탈리아인의 신념에 따라 아메리카노는 절대 만들지 않는다.
  3. 미국인 바리스타
    • 기본적으로 원화를 받지만, 환전을 깜빡한 외국인을 위해 달러 등의 외국 화폐도 받는다.
    • 에스프레소, 아메리카노를 만들어서 제공한다.
  4. 민초단 바리스타
    • 원화를 받는다.
    • 에스프레소, 아메리카노를 만들지만, 가끔 사장 몰래 민트초코 음료를 손님에게 건네며 전도를 시도한다.
  5. 비트코인 신봉자 바리스타
    • 중앙화된 화폐를 불신하기 때문에 탈중앙화된 비트코인만을 받는다.
    • 에스프레소, 아메리카노를 만들어서 제공한다.

이해를 돕기 위해 각 바리스타의 특성을 표로 나타내면 아래와 같다.

바리스타 손님에게 받는 돈 손님에게 주는 것
한국인 원화 에스프레소, 아메리카노
이탈리아인 원화 에스프레소
미국인 원화, 달러 등 에스프레소, 아메리카노
민초단 원화 에스프레소, 아메리카노, 민트초코 음료
비트코인 신봉자 비트코인 에스프레소, 아메리카노

바리스타라는 상위 타입과 각 바리스타라는 하위 타입의 관점에서 생각해 보자. 손님이 자신의 주문을 받는 바리스타가 구체적으로 어떤 바리스타인지 모르는 상황에서, 즉 바리스타라는 상위 타입만 아는 상태에서, 이 카페가 잘 운영될 수 있을까?

손님 입장에서, 한국인, 이탈리아인, 미국인 바리스타를 마주하게 되더라도 카페 이용에는 문제가 없다. 원화를 내고 커피 범주 내의 음료를 받는다는 기대가 충족되기 때문이다.

반면 손님이 민초단, 비트코인 신봉자 바리스타를 마주하게 되면, 손님 입장에서는 문제가 생긴다.

위 내용을 정리하면 아래와 같다.

다시 이 절의 본론으로 돌아가 보자. 무엇이 하위 타입을 하위 타입으로 만드는가? 바리스타 예시를 통해 도출한 규칙을 일반화해 보자.

첫 번째 규칙을 읽고 위화감을 느낄 수 있다. 이 절 처음에서 언급했다시피 이는 자바/코틀린 등 특정 프로그래밍 언어의 규칙과는 무관한 일반론적인 규칙임을 되짚으며, 관련된 내용은 이후에 다시 설명한다.

이 규칙은 우리가 면접을 준비하면서, 이 밤의 끝을 잡고 달달 외우던 SOLID 원칙 중 L에 해당하는 Liskov substitution principle의 몇 가지 원칙과도 일치한다. 사실 LSP는 위 두 가지 규칙에 더해 하위 타입이 지켜야 할 행위적인 측면을 정의한 것이므로, 형식 관련 측면에서 하위 타입이 지켜야 할 일반적인 규칙은 위 두 규칙 그 자체라고 생각해도 무방할 것이다.

LSP에서 언급하는 또 하나의 형식 관련 규칙으로, 하위 타입에서 메서드는 상위 타입 메서드에서 던져진 예외의 하위 타입을 제외하고 새로운 예외를 던지면 안 된다.가 있다. 그러나 예외를 던져서 control flow를 중단하는 것이 아니라 Either/Result monad와 같이 반환값으로서 예외를 다루는 방식을 생각해 본다면, 이는 두 번째 규칙을 되풀이하는 것으로도 볼 수 있을 것이다.

3. Variance

이제 Variance라는 용어가 등장할 차례다. 이전 절에서 도출한 하위 타입이 지켜야 할 규칙을 다시 짚어보자.

용어부터 먼저 소개한다. 이 절에서는 공변(Covariance), 반공변(Contravariance), 불공변(Invariance)라는 세 가지 개념1을 소개한다. 공통적으로 포함된 공변이라는 단어는 같이 변한다라는 뜻이다. 무엇과 무엇이 같이 변한다는 것일까?

바리스타 예시로 돌아가서 살펴보자면, 이탈리아인 바리스타(ItalianBarista)는 모든 종류의 커피 대신 커피의 하위 타입인 에스프레소(Espresso)만을 제공한다.

futurebright dev

Barista의 serve 메서드는 Coffee을, ItalianBaristaserve 메서드는 Espresso를 반환한다. 여기서 CoffeeEspresso의 상위 타입이다. 즉, BaristaItalianBarista, CoffeeEspresso의 상속 관계 방향은 같으며, 이를 공변(Covariance)이라고 한다.

미국인 바리스타(AmericanBarista)는 원화(KoreanWon)뿐만 아니라 달러 등 외국 화폐(Money) 또한 받는다.

futurebright dev

Barista의 serve 메서드는 koreanWon을, AmericanBaristaserve 메서드는 money를 받는다. 여기서 MoneyKoreanWon의 상위 타입이다. 즉, BaristaAmericanBarista, MoneyKoreanWon의 상속 관계 방향은 정반대이며, 이를 반공변(Contravariance)이라고 한다.

다시 규칙으로 돌아간다. 이제 두 규칙을 아래처럼 유식한 버전으로 고쳐 쓸 수 있다.

그러나 우리의 목적을 달성하기 위해 공변, 반공변, (잠시 뒤에 나올) 불공변 등의 용어가 중요한 것이 아니라 그 의미가 중요하다. 따라서 유식한 버전 대신 의미를 알기 쉬운 원래 버전을 머리에 넣어두는 것이 좋다. 그런 의미에서 한 번 더 규칙을 상기하고 넘어간다.

2절 말미에 언급했다시피, 이 규칙의 첫 번째는 어느 정도 자바/코틀린에 익숙한 사람에게는 위화감이 드는 문장이다. 왜냐하면 실제로 자바/코틀린뿐만 아니라 많은 언어에서 첫 번째와 같은 메서드 매개변수의 반공변은 허용하지 않기 때문이다. 다시 말해, 하위 타입에서 더 넓은 범위의 타입을 매개변수로써 선언하도록 허용하지 않으며, 같거나 더 넓은 범위의 타입 중 더 넓은 범위는 허용하지 않으므로 같은 타입의 매개변수만 선언할 수 있음을 의미한다.

BaristaAmericanBarista, MoneyKoreanWon 같은 상속 구조는 실제로는 예외를 발생시킨다. 하지만 우리가 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가 되고 다형성을 활용할 수 있다. 위 코드는 에러 메시지에서 드러나듯, BaristaserveCoffee 메서드를 Override한 것이 아닌데 이는 매개변수의 타입이 다르기 때문이다. @Override annotation을 지우면 위 코드는 컴파일이 되지만, Override가 아니라 Overloading이 일어난다.

공변, 반공변과 더불어 불공변(Invariance)이라는 개념 또한 존재한다. 우리가 도출한 두 가지 규칙 중 첫 번째 규칙은, 실제로는 자바/코틀린 등 우리가 사용하는 언어에서 상위 타입과 하위 타입의 공통 메서드가 정확히 같은 타입만을 허용하고 있음을 확인했다.

futurebright dev

Barista의 serve 메서드는 koreanWon을, KoreanBaristaserve 메서드 또한 koreanWon를 받는다. KoreanBaristaBarista의 하위 타입이지만, (언어의 제약으로 인해) 메서드의 매개변수는 정확히 같은 타입이어야 하고, Barista와 KoreanBarista의 상속 관계에 영향을 받지 않으므로 이를 불공변(Invariance)라고 한다.2

참고로 오른쪽의 KoreanWon이 같은 타입인 것이 중요한 것은 아니다. 공변이 같이 변한다라는 의미임에 주목해 보자. BaristaKoreanBarista의 상속 관계에 따라 변하는 것이 없음이라는 것이 포인트인데, 이 예시에서는 명확히 드러나지 않지만 다음 장에서 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>

ItalianBaristaBarista이지만, Cafe<ItalianBarista>Cafe<Barista>가 아니기 때문이다. ItalianBaristaBarista의 하위 타입이므로 Barista라는 점을 생각해 보면, Cafe<ItalianBarista>Cafe<Barista>의 하위 타입이 아니라는 추론을 해볼 수 있다.

공변, 반공변, 불공변의 개념으로 위 코드와 위 문단을 다시 점검해 보자. ItalianBaristaBarista의 하위 타입이지만, Cafe<ItalianBarista>Cafe<Barista>의 하위 타입이 아니라는, 즉 공변이 아니라는 추론이었다. 그렇다면 반공변일까? 아니면 불공변?

정답은 불공변이다. 자바/코틀린에서 타입 B가 타입 A의 하위 타입일 때, Generic class TParameterized TypeT<A>, T<B>A, B의 상속 관계와 무관하며, T<A>, T<B>는 아무런 상속 관계도 없다.

futurebright dev

T<Barista>, T<ItalianBarista>라는 두 타입은 아무런 관계가 없으니, T<Barista> 타입의 변수에 T<ItalianBarista> 타입의 객체를 할당하려는 시도는 컴파일러에 의해 에러가 발생하는 것이 당연하다.

그런데, 애당초 자바/코틀린의 Generic은 왜 불공변일까?

다음 절에서는 Variance라는 관점에서 자바/코틀린의 Generic을 자세히 살펴본다.

  1. Covariance and contravariance (computer science)

  2. Summary of variance and inheritance

#generic #java #kotlin #variance