
Generic을 사용하다 보면 반드시 한 번은 마주치게 되는 개념이 바로 공변성이다. 문법 자체는 단순해 보이지만, 왜 이런 제약이 존재하는지 이해하지 못하면 컴파일 에러를 만날 때마다 혼란을 느끼게 된다. 특히 컬렉션과 상속 구조가 함께 등장하는 순간, Generic은 더 이상 직관적으로 동작하지 않는다.
이번 글에서는 Animal, Cat, Haru로 이어지는 간단한 상속 구조를 바탕으로 Generic의 기본 성질인 무공변성과, 이를 확장한 공변성, 반공변성을 차근차근 정리해 본다.
Generic Invariance 무공변성
Generic의 기본 동작은 무공변성이다. 이는 “상속 관계가 있어도 제네릭 타입은 서로 다른 타입으로 취급된다”는 의미다. Animal이 Cat의 상위 타입이라 하더라도 List<Animal>과 List<Cat> 사이에는 아무런 상속 관계가 없다.
이러한 제약은 타입 안정성을 지키기 위한 설계다. 만약 List<Cat>을 List<Animal>로 대입할 수 있다면, Animal의 또 다른 하위 타입인 Dog 역시 추가할 수 있게 된다. 하지만 실제 내부 리스트는 Cat만을 담고 있으므로 런타임 시점에 심각한 타입 오류가 발생할 수 있다.
컴파일러는 이러한 상황을 사전에 차단하기 위해 Generic을 기본적으로 무공변으로 설계했다. 따라서 타입이 정확히 일치하지 않으면 대입 자체가 허용되지 않는다.
만약 상위 타입 컬렉션이 필요하다면, 기존 리스트를 그대로 참조하는 것이 아니라 복사 생성자를 통해 새로운 컬렉션을 생성해야 한다. 이는 안전한 업캐스팅이며, 타입 안정성을 해치지 않는다.
Generic Covariance 공변성
공변성은 “하위 타입을 상위 타입으로 읽을 수 있도록 허용”하는 개념이다. Java에서는 ? extends T 문법을 통해 이를 표현한다. List<Cat>은 List<? extends Animal>로 취급될 수 있다.
이 구조의 핵심은 읽기 전용이다. 컴파일러는 실제 컬렉션이 Cat인지 Haru인지, 혹은 다른 하위 타입인지 알 수 없다. 따라서 안전을 위해 읽기만 허용하고 쓰기는 금지한다.
읽을 때는 항상 Animal 타입으로 보장되지만, 추가 작업은 전면 차단된다. 만약 쓰기가 허용된다면, 실제 Cat 리스트에 Dog가 들어갈 가능성이 생기기 때문이다.
공변성은 “Producer Extends”라는 규칙으로 기억하면 이해가 쉽다. 데이터를 꺼내는 용도라면 공변성이 적합하지만, 데이터를 추가하는 순간 타입 안정성이 무너질 수 있다. 그래서 공변성 컬렉션은 읽기 전용 뷰에 가깝다.
Generic Contravariance 반공변성
반공변성은 공변성과 반대 개념이다. ? super T 형태로 표현되며, 하위 타입을 안전하게 추가할 수 있도록 설계되었다. List<? super Cat>은 Cat과 그 상위 타입들의 컬렉션을 의미한다.
이 경우 쓰기는 허용되지만 읽기는 제한된다. 컬렉션 내부에 어떤 상위 타입이 섞여 있는지 알 수 없기 때문에, 컴파일러는 Object 타입으로만 반환을 허용한다. 명시적인 형변환 없이는 Cat이나 Animal로 바로 받을 수 없다.
반공변성은 “Consumer Super” 규칙으로 정리할 수 있다. 데이터를 추가하는 역할이라면 반공변성이 적합하다. 컬렉션 내부 구조가 불확실하더라도 Cat과 그 하위 타입을 추가하는 것은 타입 안정성을 해치지 않는다.
결국 공변성과 반공변성은 읽기와 쓰기 중 무엇이 중요한지에 따라 선택하는 도구다. Generic은 유연해 보이지만, 모든 제약은 타입 안정성을 지키기 위한 의도적인 설계라는 점을 기억해야 한다.
정리하며
Generic의 공변성은 문법보다 철학이 중요한 개념이다. 컴파일러가 왜 어떤 코드를 허용하지 않는지 이해하면 에러 메시지가 더 이상 불친절하게 느껴지지 않는다.
무공변성은 안전을 위한 기본값이며, 공변성과 반공변성은 그 안전을 유지한 채 읽기와 쓰기 역할을 분리하기 위한 확장이다. Generic을 제대로 이해하면 컬렉션과 상속 구조를 다루는 코드가 훨씬 명확해진다.