한국어

TypeScript의 변성 어노테이션과 타입 매개변수 제약의 강력한 기능을 활용하여 더 유연하고 안전하며 유지보수하기 쉬운 코드를 만드세요. 실용적인 예제와 함께 심도 있게 알아봅니다.

TypeScript 변성 어노테이션: 강력한 코드를 위한 타입 매개변수 제약 마스터하기

JavaScript의 상위 집합인 TypeScript는 정적 타이핑을 제공하여 코드의 신뢰성과 유지보수성을 향상시킵니다. TypeScript의 고급 기능 중 하나이면서도 강력한 기능은 변성 어노테이션타입 매개변수 제약의 조합입니다. 이러한 개념을 이해하는 것은 진정으로 강력하고 유연한 제네릭 코드를 작성하는 데 중요합니다. 이 블로그 게시물에서는 변성, 공변성, 반공변성, 불변성에 대해 깊이 파고들어, 더 안전하고 재사용 가능한 컴포넌트를 구축하기 위해 타입 매개변수 제약을 효과적으로 사용하는 방법을 설명합니다.

변성(Variance) 이해하기

변성은 타입 간의 하위 타입 관계가 구성된 타입(예: 제네릭 타입) 간의 하위 타입 관계에 어떻게 영향을 미치는지 설명합니다. 주요 용어를 살펴보겠습니다:

비유를 통해 기억하면 가장 쉽습니다. 개 목걸이를 만드는 공장을 생각해보세요. 공변적인 공장은 개 목걸이를 생산할 수 있다면 모든 종류의 동물 목걸이를 생산할 수 있을 것이며, 이는 하위 타입 관계를 보존합니다. 반공변적인 공장은 개 목걸이를 *소비*할 수 있다면 어떤 종류의 동물 목걸이든 소비할 수 있는 공장입니다. 만약 공장이 개 목걸이만 다룰 수 있고 다른 것은 다룰 수 없다면, 그 공장은 동물 타입에 대해 불변적입니다.

변성이 왜 중요한가?

변성을 이해하는 것은 타입-안전한 코드를 작성하는 데 매우 중요하며, 특히 제네릭을 다룰 때 그렇습니다. 공변성이나 반공변성을 잘못 가정하면 TypeScript의 타입 시스템이 방지하도록 설계된 런타임 오류로 이어질 수 있습니다. 다음의 잘못된 예시를 살펴보겠습니다 (JavaScript 예시이지만 개념을 설명하기 위함입니다):

// JavaScript 예시 (개념 설명용, TypeScript 아님)
function modifyAnimals(animals, modifier) {
  for (let i = 0; i < animals.length; i++) {
    animals[i] = modifier(animals[i]);
  }
}

function sound(animal) { return animal.sound(); }

function Cat(name) { this.name = name; this.sound = () => "Meow!"; }
Cat.prototype = Object.create({ sound: () => "Generic Animal Sound"});
function Animal(name) { this.name = name; this.sound = () => "Generic Animal Sound"; }

let cats = [new Cat("Whiskers"), new Cat("Mittens")];

// 이 코드는 Animal을 Cat 배열에 할당하는 것이 올바르지 않기 때문에 오류를 발생시킵니다
//modifyAnimals(cats, (animal) => new Animal("Generic")); 

// 이 코드는 Cat을 Cat 배열에 할당하므로 작동합니다
modifyAnimals(cats, (cat) => new Cat("Fuzzy"));

//cats.forEach(cat => console.log(cat.sound()));

이 JavaScript 예시는 잠재적인 문제를 직접 보여주지만, TypeScript의 타입 시스템은 일반적으로 이런 종류의 직접적인 할당을 *방지*합니다. 변성에 대한 고려는 더 복잡한 시나리오, 특히 함수 타입과 제네릭 인터페이스를 다룰 때 중요해집니다.

타입 매개변수 제약

타입 매개변수 제약은 제네릭 타입과 함수에서 타입 인수로 사용될 수 있는 타입을 제한할 수 있게 해줍니다. 이는 타입 간의 관계를 표현하고 특정 속성을 강제하는 방법을 제공합니다. 이것은 타입 안전성을 보장하고 더 정확한 타입 추론을 가능하게 하는 강력한 메커니즘입니다.

extends 키워드

타입 매개변수 제약을 정의하는 주요 방법은 extends 키워드를 사용하는 것입니다. 이 키워드는 타입 매개변수가 특정 타입의 하위 타입이어야 함을 명시합니다.

function logName<T extends { name: string }>(obj: T): void {
  console.log(obj.name);
}

// 유효한 사용법
logName({ name: "Alice", age: 30 });

// 오류: '{}' 타입의 인자는 '{ name: string; }' 타입의 매개변수에 할당할 수 없습니다.
// logName({});

이 예제에서, 타입 매개변수 Tstring 타입의 name 속성을 가진 타입으로 제약됩니다. 이것은 logName 함수가 인자의 name 속성에 안전하게 접근할 수 있도록 보장합니다.

교차 타입을 사용한 다중 제약

교차 타입(&)을 사용하여 여러 제약을 결합할 수 있습니다. 이를 통해 타입 매개변수가 여러 조건을 만족해야 함을 지정할 수 있습니다.

interface Named {
  name: string;
}

interface Aged {
  age: number;
}

function logPerson<T extends Named & Aged>(person: T): void {
  console.log(`Name: ${person.name}, Age: ${person.age}`);
}

// 유효한 사용법
logPerson({ name: "Bob", age: 40 });

// 오류: '{ name: string; }' 타입의 인자는 'Named & Aged' 타입의 매개변수에 할당할 수 없습니다.
// '{ name: string; }' 타입에 'age' 속성이 없지만 'Aged' 타입에는 필요합니다.
// logPerson({ name: "Charlie" });

여기서, 타입 매개변수 TNamed이면서 동시에 Aged인 타입으로 제약됩니다. 이는 logPerson 함수가 nameage 속성 모두에 안전하게 접근할 수 있도록 보장합니다.

제네릭 클래스에서 타입 제약 사용하기

타입 제약은 제네릭 클래스를 다룰 때에도 똑같이 유용합니다.

interface Printable {
  print(): void;
}

class Document<T extends Printable> {
  content: T;

  constructor(content: T) {
    this.content = content;
  }

  printDocument(): void {
    this.content.print();
  }
}

class Invoice implements Printable {
  invoiceNumber: string;

  constructor(invoiceNumber: string) {
    this.invoiceNumber = invoiceNumber;
  }

  print(): void {
    console.log(`Printing invoice: ${this.invoiceNumber}`);
  }
}

const myInvoice = new Invoice("INV-2023-123");
const document = new Document(myInvoice);
document.printDocument(); // 출력: Printing invoice: INV-2023-123

이 예제에서, Document 클래스는 제네릭이지만 타입 매개변수 TPrintable 인터페이스를 구현하는 타입으로 제약됩니다. 이는 Documentcontent로 사용되는 모든 객체가 print 메서드를 가질 것임을 보장합니다. 이는 다양한 형식이나 언어로 인쇄해야 할 수 있는 국제적인 맥락에서 특히 유용하며, 공통된 print 인터페이스가 필요합니다.

TypeScript에서의 공변성, 반공변성, 불변성 (재고찰)

TypeScript에는 다른 언어들처럼 명시적인 변성 어노테이션(in이나 out 같은)은 없지만, 타입 매개변수가 사용되는 방식에 따라 암시적으로 변성을 처리합니다. 이것이 어떻게 작동하는지, 특히 함수 매개변수와 관련하여 그 미묘한 차이를 이해하는 것이 중요합니다.

함수 매개변수 타입: 반공변성

함수 매개변수 타입은 반공변적입니다. 이것은 예상보다 더 일반적인 타입을 받는 함수를 안전하게 전달할 수 있음을 의미합니다. 함수가 Supertype을 처리할 수 있다면, 확실히 Subtype도 처리할 수 있기 때문입니다.

interface Animal {
  name: string;
}

interface Cat extends Animal {
  meow(): void;
}

function feedAnimal(animal: Animal): void {
  console.log(`Feeding ${animal.name}`);
}

function feedCat(cat: Cat): void {
  console.log(`Feeding ${cat.name} (a cat)`);
  cat.meow();
}

// 함수 매개변수 타입이 반공변적이기 때문에 이것은 유효합니다
let feed: (animal: Animal) => void = feedCat; 

let genericAnimal:Animal = {name: "Generic Animal"};

feed(genericAnimal); // 작동하지만 meow는 호출되지 않습니다

let mittens: Cat = { name: "Mittens", meow: () => {console.log("Mittens meows");}};

feed(mittens); // 이것도 작동하며, 실제 함수에 따라 meow를 호출할 수도 있습니다.

이 예제에서, feedCat(animal: Animal) => void의 하위 타입입니다. 이는 feedCat이 더 구체적인 타입(Cat)을 받기 때문이며, 함수 매개변수의 Animal 타입에 대해 반공변적입니다. 중요한 부분은 할당입니다: let feed: (animal: Animal) => void = feedCat;은 유효합니다.

반환 타입: 공변성

함수 반환 타입은 공변적입니다. 이는 예상보다 더 구체적인 타입을 안전하게 반환할 수 있음을 의미합니다. 함수가 Animal을 반환하기로 약속했다면, Cat을 반환하는 것은 완벽하게 허용됩니다.

function getAnimal(): Animal {
  return { name: "Generic Animal" };
}

function getCat(): Cat {
  return { name: "Whiskers", meow: () => { console.log("Whiskers meows"); } };
}

// 함수 반환 타입이 공변적이기 때문에 이것은 유효합니다
let get: () => Animal = getCat;

let myAnimal: Animal = get();

console.log(myAnimal.name); // 작동합니다

// myAnimal.meow();  // 오류: 'Animal' 타입에 'meow' 속성이 없습니다.
// Cat 고유의 속성에 접근하려면 타입 단언을 사용해야 합니다

if ((myAnimal as Cat).meow) {
  (myAnimal as Cat).meow(); // Whiskers meows
}

여기서, getCat은 더 구체적인 타입(Cat)을 반환하기 때문에 () => Animal의 하위 타입입니다. let get: () => Animal = getCat; 할당은 유효합니다.

배열과 제네릭: 불변성 (대부분)

TypeScript는 기본적으로 배열과 대부분의 제네릭 타입을 불변적으로 취급합니다. 이는 CatAnimal을 확장하더라도 Array<Cat>Array<Animal>의 하위 타입으로 간주되지 *않음*을 의미합니다. 이것은 잠재적인 런타임 오류를 방지하기 위한 의도적인 설계 선택입니다. 다른 많은 언어에서 배열이 공변적으로 *동작*하는 반면, TypeScript는 안전을 위해 불변적으로 만듭니다.

let animals: Animal[] = [{ name: "Generic Animal" }];
let cats: Cat[] = [{ name: "Whiskers", meow: () => { console.log("Whiskers meows"); } }];

// 오류: 'Cat[]' 타입은 'Animal[]' 타입에 할당할 수 없습니다.
// 'Cat' 타입은 'Animal' 타입에 할당할 수 없습니다.
// 'Animal' 타입에 'meow' 속성이 없지만 'Cat' 타입에는 필요합니다.
// animals = cats; // 이것이 허용된다면 문제가 발생할 것입니다!

// 하지만 이것은 작동합니다
animals[0] = cats[0];

console.log(animals[0].name);

//animals[0].meow();  // 오류 - animals[0]가 Animal 타입으로 간주되어 meow를 사용할 수 없습니다

(animals[0] as Cat).meow(); // Cat 고유의 메서드를 사용하려면 타입 단언이 필요합니다

animals = cats; 할당을 허용하면 안전하지 않습니다. 왜냐하면 그렇게 되면 animals 배열에 일반적인 Animal을 추가할 수 있게 되고, 이는 Cat 객체만 포함해야 하는 cats 배열의 타입 안전성을 위반하게 됩니다. 이 때문에 TypeScript는 배열이 불변적이라고 추론합니다.

실용적인 예제 및 사용 사례

제네릭 리포지토리 패턴

데이터 접근을 위한 제네릭 리포지토리 패턴을 고려해보세요. 기본 엔티티 타입과 해당 타입에서 작동하는 제네릭 리포지토리 인터페이스가 있을 수 있습니다.

interface Entity {
  id: string;
}

interface Repository<T extends Entity> {
  getById(id: string): T | undefined;
  save(entity: T): void;
  delete(id: string): void;
}

class InMemoryRepository<T extends Entity> implements Repository<T> {
  private data: { [id: string]: T } = {};

  getById(id: string): T | undefined {
    return this.data[id];
  }

  save(entity: T): void {
    this.data[entity.id] = entity;
  }

  delete(id: string): void {
    delete this.data[id];
  }
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepository: Repository<Product> = new InMemoryRepository<Product>();

const newProduct: Product = { id: "123", name: "Laptop", price: 1200 };
productRepository.save(newProduct);

const retrievedProduct = productRepository.getById("123");
if (retrievedProduct) {
  console.log(`Retrieved product: ${retrievedProduct.name}`);
}

T extends Entity 타입 제약은 리포지토리가 id 속성을 가진 엔티티에 대해서만 작동하도록 보장합니다. 이는 데이터 무결성과 일관성을 유지하는 데 도움이 됩니다. 이 패턴은 다양한 형식의 데이터를 관리하고, Product 인터페이스 내에서 다른 통화 유형을 처리하여 국제화에 적응하는 데 유용합니다.

제네릭 페이로드를 사용한 이벤트 처리

또 다른 일반적인 사용 사례는 이벤트 처리입니다. 특정 페이로드를 가진 제네릭 이벤트 타입을 정의할 수 있습니다.

interface Event<T> {
  type: string;
  payload: T;
}

interface UserCreatedEventPayload {
  userId: string;
  email: string;
}

interface ProductPurchasedEventPayload {
  productId: string;
  quantity: number;
}

function handleEvent<T>(event: Event<T>): void {
  console.log(`Handling event of type: ${event.type}`);
  console.log(`Payload: ${JSON.stringify(event.payload)}`);
}

const userCreatedEvent: Event<UserCreatedEventPayload> = {
  type: "user.created",
  payload: { userId: "user123", email: "alice@example.com" },
};

const productPurchasedEvent: Event<ProductPurchasedEventPayload> = {
  type: "product.purchased",
  payload: { productId: "product456", quantity: 2 },
};

handleEvent(userCreatedEvent);
handleEvent(productPurchasedEvent);

이를 통해 타입 안전성을 유지하면서 다양한 페이로드 구조를 가진 여러 이벤트 타입을 정의할 수 있습니다. 이 구조는 지역화된 이벤트 세부 정보를 지원하도록 쉽게 확장될 수 있으며, 다른 날짜 형식이나 언어별 설명과 같은 지역적 선호도를 이벤트 페이로드에 통합할 수 있습니다.

제네릭 데이터 변환 파이프라인 구축

데이터를 한 형식에서 다른 형식으로 변환해야 하는 시나리오를 고려해보세요. 제네릭 데이터 변환 파이프라인은 타입 매개변수 제약을 사용하여 입력 및 출력 타입이 변환 함수와 호환되는지 확인하도록 구현할 수 있습니다.

interface DataTransformer<TInput, TOutput> {
  transform(input: TInput): TOutput;
}

function processData<TInput, TOutput, TIntermediate>(
  input: TInput,
  transformer1: DataTransformer<TInput, TIntermediate>,
  transformer2: DataTransformer<TIntermediate, TOutput>
): TOutput {
  const intermediateData = transformer1.transform(input);
  const outputData = transformer2.transform(intermediateData);
  return outputData;
}

interface RawUserData {
  firstName: string;
  lastName: string;
}

interface UserData {
  fullName: string;
  email: string;
}

class RawToIntermediateTransformer implements DataTransformer<RawUserData, {name: string}> {
    transform(input: RawUserData): {name: string} {
        return { name: `${input.firstName} ${input.lastName}`};
    }
}

class IntermediateToUserTransformer implements DataTransformer<{name: string}, UserData> {
    transform(input: {name: string}): UserData {
        return {fullName: input.name, email: `${input.name.replace(" ", ".")}@example.com`};
    }
}

const rawData: RawUserData = { firstName: "John", lastName: "Doe" };

const userData: UserData = processData(
  rawData,
  new RawToIntermediateTransformer(),
  new IntermediateToUserTransformer()
);

console.log(userData);

이 예제에서 processData 함수는 입력과 두 개의 변환기를 받아 변환된 출력을 반환합니다. 타입 매개변수와 제약은 첫 번째 변환기의 출력이 두 번째 변환기의 입력과 호환되도록 보장하여 타입-안전한 파이프라인을 만듭니다. 이 패턴은 필드 이름이나 데이터 구조가 다른 국제 데이터 세트를 다룰 때 매우 유용할 수 있으며, 각 형식에 맞는 특정 변환기를 구축할 수 있습니다.

모범 사례 및 고려 사항

결론

TypeScript의 변성 어노테이션(함수 매개변수 규칙을 통한 암시적 방식)과 타입 매개변수 제약을 마스터하는 것은 강력하고 유연하며 유지보수하기 쉬운 코드를 구축하는 데 필수적입니다. 공변성, 반공변성, 불변성의 개념을 이해하고 타입 제약을 효과적으로 사용함으로써, 타입-안전하고 재사용 가능한 제네릭 코드를 작성할 수 있습니다. 이러한 기술은 오늘날의 글로벌화된 소프트웨어 환경에서 흔히 볼 수 있듯이, 다양한 데이터 타입을 처리하거나 다른 환경에 적응해야 하는 애플리케이션을 개발할 때 특히 가치가 있습니다. 모범 사례를 따르고 코드를 철저히 테스트함으로써 TypeScript 타입 시스템의 모든 잠재력을 발휘하고 고품질 소프트웨어를 만들 수 있습니다.

TypeScript 변성 어노테이션: 강력한 코드를 위한 타입 매개변수 제약 마스터하기 | MLOG