frontend

TypeScript 공변성과 반공변성: 함수 타입 할당 에러를 해결하는 시니어의 시선

Visual representation of Covariance and Contravariance in TypeScript type system

타입스크립트로 복잡한 제네릭이나 고차 함수를 설계하다 보면 분명히 상위 타입을 하위 타입에 넣는 것 같은데 에러가 발생하는 당혹스러운 순간을 마주하곤 합니다. 특히 함수 타입 간의 할당 문제는 **공변성(Covariance)**과 **반공변성(Contravariance)**이라는 개념을 모르면 해결하기 어려운 숙제입니다.

오늘은 이 생소한 단어들이 실무 코드에서 어떻게 작동하는지, 그리고 왜 우리가 함수 매개변수 타입을 다룰 때 더 조심해야 하는지 상세히 살펴보겠습니다.

목차

  1. 공변성과 반공변성이란?
  2. 리턴 타입의 흐름: 공변성(Covariance)
  3. 매개변수의 역설: 반공변성(Contravariance)
  4. 실무 트러블슈팅: strictFunctionTypes 설정
  5. 마치며: 안전한 타입 설계를 위한 제언

공변성과 반공변성이란?

가장 먼저 짚고 넘어가야 할 점은 ‘변성(Variance)‘이라는 용어입니다. 이는 **타입 계층 구조(Type Hierarchy)**에서 서로 다른 타입들이 어떻게 결합되고 대체될 수 있는지를 나타내는 성질입니다.

  • 공변성 (Covariance): AB의 서브타입일 때, T<A>T<B>의 서브타입이 되는 성질입니다. (흐름이 같이 감)
  • 반공변성 (Contravariance): AB의 서브타입일 때, T<B>T<A>의 서브타입이 되는 성질입니다. (흐름이 반대로 감)

이 개념이 중요한 이유는 TypeScript가 런타임의 안전성을 보장하기 위해 함수 타입의 할당 가능성을 엄격하게 제한하기 때문입니다.

리턴 타입의 흐름: 공변성(Covariance)

함수의 리턴 타입은 우리가 흔히 아는 상식인 ‘공변성’을 따릅니다. 좁은 타입(구체적인 타입)은 넓은 타입(추상적인 타입)에 할당될 수 있습니다.

interface Animal { name: string; }
interface Dog extends Animal { breed: string; }

let getAnimal = (): Animal => ({ name: "Animal" });
let getDog = (): Dog => ({ name: "Puppy", breed: "Retriever" });

// 가능: Dog은 Animal의 하위 타입이므로 
// Animal을 기대하는 변수에 Dog을 리턴하는 함수를 넣어도 안전합니다.
getAnimal = getDog; 

왜 안전할까요? getAnimal을 호출하는 쪽에서는 최소한 Animal 타입의 객체(name 속성)가 올 것임을 기대합니다. getDog은 그 기대를 만족시키고도 남는 Dog을 주기 때문에 프로그램이 터지지 않습니다.

매개변수의 역설: 반공변성(Contravariance)

반면, 함수의 매개변수는 정반대로 작동합니다. 이것이 바로 반공변성입니다. 인자로 들어오는 타입은 오히려 ‘더 넓은 타입’을 처리할 수 있는 함수여야 ‘좁은 타입을 기대하는 곳’에 할당될 수 있습니다.

type LogAnimal = (a: Animal) => void;
type LogDog = (d: Dog) => void;

let logAnimal: LogAnimal = (a) => console.log(a.name);
let logDog: LogDog = (d) => console.log(d.breed);

// 에러 발생!
// logAnimal = logDog; 

// 가능!
logDog = logAnimal; 

많은 주니어 개발자들이 여기서 혼란을 겪습니다. “Dog이 Animal의 자식인데 왜 logDog 자리에 logAnimal을 넣을 수 있는 거지?”라고 말이죠.

핵심 로직은 이렇습니다: logDog이라는 자리는 **“Dog 타입의 데이터가 들어올 것”**이 보장된 자리입니다. 여기에 logAnimal을 넣으면 어떻게 될까요? logAnimalAnimal 수준의 속성만 사용하기 때문에 Dog이 들어와도 아무 문제 없이 동작합니다.

반대로 logAnimal 자리에 logDog을 넣는다면? 사용자는 Animal을 넣을 텐데, 함수 내부에서는 Dog에만 있는 breed 속성에 접근하려다가 런타임 에러(Undefined)를 낼 것입니다. 그래서 타입스크립트는 이를 금지합니다.

💡 개발자의 팁: unknown[] 에러의 이유 (...args: unknown[]) => void 타입의 자리에 (id: number) => void를 할당할 수 없는 이유도 동일합니다. unknown[] 자리는 어떤 값이든 들어올 수 있는데, number만 처리 가능한 함수가 오면 타입 안전성이 깨지기 때문입니다.

실무 트러블슈팅: strictFunctionTypes 설정

TypeScript 설정 파일(tsconfig.json)에는 strictFunctionTypes라는 옵션이 있습니다. 이 옵션이 true일 때 위에서 설명한 반공변성이 엄격하게 체크됩니다.

만약 이 옵션이 false라면 매개변수에 대해 **이변성(Bivariance)**을 허용하게 되어, 좁은 타입과 넓은 타입이 서로 할당 가능해집니다. 이는 과거 자바스크립트와의 호환성을 위해 존재하지만, 현대적인 프로젝트에서는 반드시 true로 설정하여 런타임 에러를 방어해야 합니다.

TypeScript 공식 문서 - strictFunctionTypes 확인하기

FAQ 섹션

공변성과 반공변성을 한 줄로 요약하면 무엇인가요?

리턴값은 나갈 때 더 구체적이어야 하고(공변), 매개변수는 들어올 때 더 관대해야(반공변) 안전하게 할당될 수 있다는 원칙입니다.

제네릭에서도 이 개념이 적용되나요?

네, 제네릭 타입 변수가 함수의 리턴 타입에 쓰이느냐 매개변수에 쓰이느냐에 따라 해당 제네릭 타입의 변성 방향이 결정됩니다.

이변성(Bivariance)은 언제 발생하나요?

주로 메서드 단축 구문(method shorthand)을 사용하거나 strictFunctionTypes 옵션이 꺼져 있을 때 발생하며, 양방향 할당을 모두 허용하는 위험한 상태를 의미합니다.


마치며: 안전한 타입 설계를 위한 제언

결국 공변성과 반공변성을 이해한다는 것은 **“내가 만든 함수를 다른 개발자가 안전하게 호출할 수 있는가?”**를 고민하는 과정입니다.

단순히 anyas를 써서 에러를 지우는 것은 로딩 처리를 무시하고 껍데기만 만드는 것과 다를 바 없습니다. 타입의 흐름을 정확히 이해하고 설계할 때, 비로소 견고한 코드베이스를 구축할 수 있습니다.

이 글이 마음에 드셨나요?

로딩 중...