Skip to content
Go back

TypeScript의 변성(Variance) 완벽 이해하기

Published:  at  01:43 PM

TypeScript를 사용하다 보면 때때로 예상과 다른 타입 호환성을 마주치게 됩니다. 특히 함수 타입과 메서드 타입 간의 미묘한 차이는 많은 개발자들을 혼란스럽게 만듭니다. 이 글에서는 TypeScript의 변성(Variance) 개념을 실제 예제와 함께 살펴보겠습니다.

들어가며: TypeScript 퀴즈

다음 코드의 결과를 예측해보세요:

type X = (a: 42) => unknown;
type Y = { bi(a: number): unknown }["bi"];

type Eq = Y extends X ? true : false; // true일까 false일까?

Eqtrue입니다. 그리고:

type Eq2 = X extends Y ? (Y extends X ? true : false) : false; // 이것도 true

42만 받는 함수와 number를 받는 함수가 서로 호환됩니다. 이를 이해하려면 TypeScript의 변성을 알아야 합니다.

변성(Variance)이란?

변성은 타입 시스템에서 서브타입 관계가 복합 타입(함수, 제네릭 등)에서 어떻게 동작하는지를 설명하는 개념입니다.

1. 공변성 (Covariance) - “같은 방향”

일반적인 상속 관계를 생각하면 됩니다. 자식 타입이 부모 타입을 대체할 수 있습니다.

type Animal = { name: string };
type Dog = { name: string; breed: string };

// Dog는 Animal의 서브타입
let animal: Animal;
let dog: Dog = { name: "바둑이", breed: "진돗개" };

animal = dog; // ✅ OK! Dog가 Animal을 대체 (공변성)

실생활 비유: “과일을 달라”고 했을 때 “사과”를 주는 것은 OK

2. 반공변성 (Contravariance) - “반대 방향”

함수의 매개변수에서는 관계가 반대로 동작합니다. 더 일반적인 타입이 더 구체적인 타입을 대체합니다.

type DogHandler = (dog: Dog) => void;
type AnimalHandler = (animal: Animal) => void;

const handleAnimal: AnimalHandler = (a) => console.log(a.name);
const handleDog: DogHandler = (d) => console.log(d.breed);

let dogFunc: DogHandler;
dogFunc = handleAnimal; // ✅ OK! Animal 핸들러가 Dog 핸들러를 대체

// 왜 안전한가?
// handleAnimal은 name 속성만 사용하므로,
// Dog(name + breed 보유)를 받아도 문제없이 동작

실생활 비유:

3. 양변성 (Bivariance) - “특별한 예외”

양변성은 메서드(method) 한정으로 매개변수에서 공변과 반공변을 모두 허용하는 TypeScript의 특별한 규칙입니다.

이는 타입 시스템의 순수성보다는 실용성을 위한 일종의 타협점입니다. JavaScript에서는 클래스의 메서드를 콜백으로 전달할 때 서브타입 관계를 엄격하게 따지지 않는 패턴이 흔하기 때문입니다. TypeScript는 이러한 기존 JavaScript 코드와의 호환성을 유지하기 위해, strictFunctionTypes가 켜져 있어도 클래스 메서드의 타입 추론에는 양변성을 적용합니다.

따라서 양변성은 “안전해서 허용”하는 것이 아니라, “호환성을 위해 만들어 둔 예외적인 허용”이라고 이해하는 것이 정확합니다.

// 메서드 문법 - 양변성
type HasMethod = {
  method(x: Animal): void;
};

type HasDogMethod = {
  method(x: Dog): void;
};

declare let obj1: HasMethod;
declare let obj2: HasDogMethod;

obj1 = obj2; // ✅ OK (공변적)
obj2 = obj1; // ✅ OK (반공변적) - 이것도 허용!

strictFunctionTypes와 변성

TypeScript 2.6에서 --strictFunctionTypes 플래그로 도입된 이 기능은 함수 타입의 매개변수를 더 엄격하게 검사합니다. 중요한 점은 최신 TypeScript 프로젝트, 특히 strict: true 컴파일러 옵션을 사용하는 경우 이 기능이 기본적으로 활성화된다는 것입니다.

strict: true는 TypeScript가 권장하는 모범 사례의 묶음이며, strictFunctionTypes를 포함한 여러 타입 체크 규칙을 동시에 켭니다. 따라서 대부분의 현대적인 TypeScript 프로젝트에서는 함수 매개변수가 기본적으로 반공변적으로 동작한다고 생각하는 것이 안전합니다.

속성 함수 vs 메서드 문법

// 속성 함수 문법 - strictFunctionTypes의 영향을 받음
type PropertyFunc = {
  fn: (x: number) => void; // 화살표 함수 속성
};

// 메서드 문법 - 항상 양변성 (strictFunctionTypes 무관)
type MethodFunc = {
  fn(x: number): void; // 메서드 선언
};

실제 동작 차이:

type Func42 = (x: 42) => void;
type FuncNumber = (x: number) => void;

// strictFunctionTypes: true일 때

// 속성 함수 - 반공변성만
type PropTest = { fn: (x: number) => void }["fn"];
type Test1 = PropTest extends Func42 ? true : false; // true ✅
type Test2 = Func42 extends PropTest ? true : false; // false ❌

// 메서드 - 여전히 양변성
type MethodTest = { fn(x: number): void }["fn"];
type Test3 = MethodTest extends Func42 ? true : false; // true ✅
type Test4 = Func42 extends MethodTest ? true : false; // true ✅ (양변성!)

왜 메서드는 양변성을 유지할까?

이는 기존 JavaScript 라이브러리와의 호환성을 위한 의도적인 설계 결정입니다.

// Array의 메서드들이 양변성을 가정하고 설계됨
interface Array<T> {
  push(item: T): number;
  pop(): T | undefined;
  // ...
}

// 만약 메서드가 엄격한 반공변성을 가진다면
// 많은 기존 코드가 동작하지 않을 것

변성의 안전성: 리스코프 치환 원칙

공변성과 반공변성의 안전성은 ‘리스코프 치환 원칙(Liskov Substitution Principle)‘과 깊은 관련이 있습니다. 간단히 말해, “프로그램의 정확성을 깨뜨리지 않으면서 서브타입의 인스턴스로 슈퍼타입의 인스턴스를 교체할 수 있어야 한다”는 원칙입니다.

1. 왜 반환 타입은 공변적(Covariant)으로 동작해야 안전한가?

// 반환 타입 공변성의 안전성
type AnimalFactory = () => Animal;
type DogFactory = () => Dog;

declare let animalFactory: AnimalFactory;
let dogFactory: DogFactory = () => ({ name: "바둑이", breed: "진돗개" });

animalFactory = dogFactory; // ✅ 안전! Dog는 Animal의 모든 기능을 가지고 있음
const result = animalFactory(); // Animal을 기대하지만 Dog를 받음 - 문제없음
console.log(result.name); // ✅ 항상 작동

2. 왜 매개변수는 반공변적(Contravariant)으로 동작해야 안전한가?

// 매개변수 반공변성의 안전성
type DogHandler = (dog: Dog) => void;
type AnimalHandler = (animal: Animal) => void;

declare let dogHandler: DogHandler;
let animalHandler: AnimalHandler = (animal) => console.log(animal.name);

dogHandler = animalHandler; // ✅ 안전! Animal 핸들러는 Dog도 처리 가능
dogHandler({ name: "바둑이", breed: "진돗개" }); // ✅ 항상 작동

요약: 계약 원칙

타입 안전성을 위한 모범 사례

양변성으로 인한 버그를 방지하고 더 안전한 TypeScript 코드를 작성하기 위한 실용적인 가이드라인입니다.

1. 함수 속성 문법 선호하기

최대한의 타입 안전성을 위해서는 메서드 문법보다 함수 속성 문법을 사용하세요:

// ❌ 메서드 문법 - 양변성으로 인한 잠재적 위험
interface EventHandler {
  handle(event: MouseEvent): void; // 메서드 문법
}

// ✅ 함수 속성 문법 - 더 안전한 타입 체크
interface SafeEventHandler {
  handle: (event: MouseEvent) => void; // 함수 속성 문법
}

권장사항: 클래스 구조를 의도적으로 미러링하는 경우가 아니라면, 타입과 인터페이스 정의에서는 함수 속성 문법을 사용하세요.

2. 컴파일러 설정 최적화

// tsconfig.json
{
  "compilerOptions": {
    "strict": true // strictFunctionTypes를 포함한 모든 엄격한 검사 활성화
    // 또는 개별적으로:
    // "strictFunctionTypes": true
  }
}

3. ESLint 규칙 설정

더 안전한 함수 속성 문법을 자동으로 강제하려면:

// .eslintrc.json
{
  "rules": {
    "@typescript-eslint/method-signature-style": "error"
  }
}

이 규칙은 메서드 문법을 함수 속성 문법으로 자동 변환을 제안합니다.

4. 제네릭과 변성

// 공변적 위치 (반환 타입)
type Producer<T> = () => T;

// 반공변적 위치 (매개변수)
type Consumer<T> = (value: T) => void;

// 불변적 위치 (둘 다)
type Processor<T> = (value: T) => T;

5. 실제 문제 상황 예시

class Animal {
  name: string = "";
}

class Dog extends Animal {
  breed: string = "";
}

// 양변성으로 인한 런타임 에러 가능성
interface Zoo {
  addAnimal(animal: Animal): void; // 메서드 문법
}

class DogPark implements Zoo {
  addAnimal(animal: Dog): void {
    // Dog로 좁혀도 컴파일 OK (양변성)
    console.log(animal.breed); // 런타임에 에러 가능!
  }
}

const park: Zoo = new DogPark();
park.addAnimal(new Animal()); // 💥 런타임 에러!

양변성으로 인한 실제 버그 시나리오

strictFunctionTypes가 켜져 있어도 메서드에 허용되는 양변성은 실제 애플리케이션에서 미묘하고 찾기 어려운 버그를 유발할 수 있습니다.

시나리오 1: React 컴포넌트와 이벤트 핸들러

// 기본 이벤트 타입
interface BaseEvent {
  target: Element;
}
// 더 구체적인 마우스 이벤트 타입
interface MouseClickEvent extends BaseEvent {
  x: number;
  y: number;
}

// 기본 버튼 Props
interface BaseButtonProps {
  onClick: (event: BaseEvent) => void;
}

class ImageButton extends React.Component<BaseButtonProps> {
  // 메서드 양변성 때문에 BaseButtonProps 타입에 할당 가능
  handleClick = (event: MouseClickEvent) => {
    // 이 메서드는 event 객체에 x, y 좌표가 반드시 있을 것이라고 가정
    console.log(`Clicked at: ${event.x}, ${event.y}`);
  };

  render() {
    return <BaseButton onClick={this.handleClick} />;
  }
}

// 버그 발생: BaseButton이 키보드(Enter 키) 이벤트로도 onClick을 트리거하면
// event.x, event.y는 undefined가 되어 런타임 에러 발생

시나리오 2: 플러그인 아키텍처

interface Content {
  type: string;
}
interface TextContent extends Content {
  text: string;
}
interface ImageContent extends Content {
  src: string;
}

// 플러그인이 구현해야 할 인터페이스
interface Plugin {
  process(content: Content): void;
}

class ImageResizePlugin implements Plugin {
  // 메서드 양변성으로 인해 'Plugin' 인터페이스 구현이 가능해짐
  process(content: ImageContent) {
    console.log(`Resizing image from: ${content.src}`); // src가 undefined일 수 있음!
  }
}

const plugin: Plugin = new ImageResizePlugin();
// TextContent가 전달되면 src 속성이 없어 런타임 에러
plugin.process({ type: "text", text: "안녕하세요" }); // 💥 런타임 에러!

시나리오 3: 데이터 처리 파이프라인

interface DataProcessor {
  process(data: Animal): void;
}

class DogProcessor implements DataProcessor {
  // 양변성으로 인해 이 오버라이드가 허용됨
  process(data: Dog): void {
    console.log(data.breed.toUpperCase()); // breed가 undefined일 수 있음!
  }
}

const processors: DataProcessor[] = [new DogProcessor()];
processors[0].process(new Animal()); // 💥 런타임 에러!
// Animal 인스턴스가 전달되면 breed 속성이 존재하지 않으므로 undefined가 반환되고,
// 이 값을 사용하려 하면 (예: animal.breed.toUpperCase())
// "TypeError: Cannot read properties of undefined"와 같은 런타임 에러가 발생합니다.

핵심 요약

  1. 공변성: 자식 → 부모 대체 가능 (일반적인 상속)
  2. 반공변성: 함수 매개변수에서 부모 → 자식 대체 가능
  3. 양변성: 양방향 모두 가능 (타입 안전성 낮음)
  4. 메서드 문법은 항상 양변성 (하위 호환성)
  5. 속성 함수 문법strictFunctionTypes로 반공변성 강제 가능

결론

TypeScript의 변성은 처음에는 복잡해 보이지만, 타입 안전성과 실용성 사이의 신중한 균형을 맞춘 설계입니다. 특히 메서드의 양변성은 JavaScript 생태계와의 호환성을 위한 의도적인 타협이라는 점을 이해하는 것이 중요합니다.

처음 제시한 퀴즈의 답이 이제 명확해집니다. 메서드 문법으로 정의된 Y는 양변성을 가지므로, XY가 서로를 대체할 수 있어 양방향 extends가 모두 true가 됩니다.

핵심 요약

  1. 공변성: 자식 → 부모 대체 가능 (반환 타입에 안전)
  2. 반공변성: 부모 → 자식 대체 가능 (매개변수에 안전)
  3. 양변성: 양방향 모두 가능 (안전성 낮음, 호환성 위한 예외)
  4. 모범 사례: 함수 속성 문법 + strict: true + ESLint 규칙

버그 예방: 메서드 양변성으로 인한 런타임 에러를 예방하려면, 가능한 한 함수 속성 문법을 선호하고 적절한 컴파일러 및 ESLint 설정을 활용하세요.


TypeScript의 변성을 이해하는 것은 더 안전하고 예측 가능한 코드를 작성하는 데 핵심입니다. 이러한 미묘한 차이를 이해하고 적절한 전략을 활용하면, 런타임 에러를 줄이고 유지보수성이 뛰어난 TypeScript 애플리케이션을 구축할 수 있습니다.


Share this post on:

Previous Post
Claude Code 설정과 최적화 - 개발자를 위한 완벽 가이드
Next Post
Claude Code로 Git 워크플로우 자동화하기 - 스마트 커밋 커맨드 만들기