한국어

TypeScript 인터페이스의 선언 병합 기능을 마스터하세요. 이 가이드는 인터페이스 확장, 충돌 해결, 강력하고 확장 가능한 애플리케이션 구축을 위한 실용적인 사용 사례를 다룹니다.

TypeScript 선언 병합: 인터페이스 확장 마스터하기

TypeScript의 선언 병합(declaration merging)은 동일한 이름의 여러 선언을 하나의 단일 선언으로 결합할 수 있게 해주는 강력한 기능입니다. 이는 기존 타입을 확장하거나, 외부 라이브러리에 기능을 추가하거나, 코드를 더 관리하기 쉬운 모듈로 구성할 때 특히 유용합니다. 선언 병합의 가장 일반적이고 강력한 적용 사례 중 하나는 인터페이스로, 우아하고 유지보수 가능한 코드 확장을 가능하게 합니다. 이 종합 가이드는 선언 병합을 통한 인터페이스 확장을 심도 있게 다루며, 이 필수적인 TypeScript 기술을 마스터하는 데 도움이 되는 실용적인 예제와 모범 사례를 제공합니다.

선언 병합 이해하기

TypeScript에서 선언 병합은 컴파일러가 동일한 스코프 내에서 같은 이름의 여러 선언을 마주쳤을 때 발생합니다. 컴파일러는 이 선언들을 하나의 단일 정의로 병합합니다. 이 동작은 인터페이스, 네임스페이스, 클래스, 이넘(enum)에 적용됩니다. 인터페이스를 병합할 때, TypeScript는 각 인터페이스 선언의 멤버들을 하나의 단일 인터페이스로 결합합니다.

핵심 개념

선언 병합을 이용한 인터페이스 확장

선언 병합을 통한 인터페이스 확장은 기존 인터페이스에 속성과 메서드를 추가하는 깔끔하고 타입-안전(type-safe)한 방법을 제공합니다. 이는 외부 라이브러리로 작업하거나 원본 소스 코드를 수정하지 않고 기존 컴포넌트의 동작을 사용자 정의해야 할 때 특히 유용합니다. 원본 인터페이스를 수정하는 대신, 원하는 확장을 추가하여 동일한 이름의 새 인터페이스를 선언할 수 있습니다.

기본 예제

간단한 예제부터 시작하겠습니다. Person이라는 인터페이스가 있다고 가정해 봅시다:

interface Person {
  name: string;
  age: number;
}

이제 원본 선언을 수정하지 않고 Person 인터페이스에 선택적 email 속성을 추가하고 싶습니다. 선언 병합을 사용하여 이를 달성할 수 있습니다:

interface Person {
  email?: string;
}

TypeScript는 이 두 선언을 하나의 단일 Person 인터페이스로 병합합니다:

interface Person {
  name: string;
  age: number;
  email?: string;
}

이제 새로운 email 속성을 가진 확장된 Person 인터페이스를 사용할 수 있습니다:

const person: Person = {
  name: "Alice",
  age: 30,
  email: "alice@example.com",
};

const anotherPerson: Person = {
  name: "Bob",
  age: 25,
};

console.log(person.email); // 출력: alice@example.com
console.log(anotherPerson.email); // 출력: undefined

외부 라이브러리 인터페이스 확장하기

선언 병합의 일반적인 사용 사례는 외부 라이브러리에 정의된 인터페이스를 확장하는 것입니다. Product라는 인터페이스를 제공하는 라이브러리를 사용하고 있다고 가정해 봅시다:

// 외부 라이브러리에서 가져옴
interface Product {
  id: number;
  name: string;
  price: number;
}

Product 인터페이스에 description 속성을 추가하고 싶습니다. 동일한 이름으로 새 인터페이스를 선언하여 이를 수행할 수 있습니다:

// 내 코드에서
interface Product {
  description?: string;
}

이제 새로운 description 속성을 가진 확장된 Product 인터페이스를 사용할 수 있습니다:

const product: Product = {
  id: 123,
  name: "Laptop",
  price: 1200,
  description: "전문가를 위한 고성능 노트북",
};

console.log(product.description); // 출력: 전문가를 위한 고성능 노트북

실용적인 예제 및 사용 사례

선언 병합을 통한 인터페이스 확장이 특히 유용할 수 있는 몇 가지 더 실용적인 예제와 사용 사례를 살펴보겠습니다.

1. Request 및 Response 객체에 속성 추가하기

Express.js와 같은 프레임워크로 웹 애플리케이션을 구축할 때, 종종 request나 response 객체에 사용자 정의 속성을 추가해야 합니다. 선언 병합을 사용하면 프레임워크의 소스 코드를 수정하지 않고도 기존 request 및 response 인터페이스를 확장할 수 있습니다.

예제:

// Express.js
import express from 'express';

// Request 인터페이스 확장
declare global {
  namespace Express {
    interface Request {
      userId?: string;
    }
  }
}

const app = express();

app.use((req, res, next) => {
  // 인증 시뮬레이션
  req.userId = "user123";
  next();
});

app.get('/', (req, res) => {
  const userId = req.userId;
  res.send(`안녕하세요, 사용자 ${userId}님!`);
});

app.listen(3000, () => {
  console.log('서버가 3000번 포트에서 실행 중입니다');
});

이 예제에서는 Express.Request 인터페이스를 확장하여 userId 속성을 추가하고 있습니다. 이를 통해 인증 중에 사용자 ID를 request 객체에 저장하고 후속 미들웨어 및 라우트 핸들러에서 접근할 수 있습니다.

2. 설정 객체 확장하기

설정 객체는 애플리케이션과 라이브러리의 동작을 구성하는 데 흔히 사용됩니다. 선언 병합을 사용하여 애플리케이션에 특정한 추가 속성으로 설정 인터페이스를 확장할 수 있습니다.

예제:

// 라이브러리 설정 인터페이스
interface Config {
  apiUrl: string;
  timeout: number;
}

// 설정 인터페이스 확장
interface Config {
  debugMode?: boolean;
}

const defaultConfig: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
  debugMode: true,
};

// 설정을 사용하는 함수
function fetchData(config: Config) {
  console.log(`${config.apiUrl}에서 데이터를 가져오는 중`);
  console.log(`타임아웃: ${config.timeout}ms`);
  if (config.debugMode) {
    console.log("디버그 모드 활성화됨");
  }
}

fetchData(defaultConfig);

이 예제에서는 Config 인터페이스를 확장하여 debugMode 속성을 추가하고 있습니다. 이를 통해 설정 객체를 기반으로 디버그 모드를 활성화하거나 비활성화할 수 있습니다.

3. 기존 클래스에 사용자 정의 메서드 추가하기 (믹스인)

선언 병합은 주로 인터페이스를 다루지만, 믹스인(mixins)과 같은 다른 TypeScript 기능과 결합하여 기존 클래스에 사용자 정의 메서드를 추가할 수 있습니다. 이는 클래스의 기능을 확장하는 유연하고 조합 가능한 방법을 가능하게 합니다.

예제:

// 기본 클래스
class Logger {
  log(message: string) {
    console.log(`[LOG]: ${message}`);
  }
}

// 믹스인을 위한 인터페이스
interface Timestamped {
  timestamp: Date;
  getTimestamp(): string;
}

// 믹스인 함수
function Timestamped<T extends Constructor>(Base: T) {
  return class extends Base implements Timestamped {
    timestamp: Date = new Date();

    getTimestamp(): string {
      return this.timestamp.toISOString();
    }
  };
}

type Constructor = new (...args: any[]) => {};

// 믹스인 적용
const TimestampedLogger = Timestamped(Logger);

// 사용법
const logger = new TimestampedLogger();
logger.log("안녕하세요, 세계!");
console.log(logger.getTimestamp());

이 예제에서는 Timestamped라는 믹스인을 만들어 적용되는 모든 클래스에 timestamp 속성과 getTimestamp 메서드를 추가하고 있습니다. 이것이 가장 간단한 방식으로 인터페이스 병합을 직접 사용하지는 않지만, 인터페이스가 증강된 클래스에 대한 계약을 어떻게 정의하는지 보여줍니다.

충돌 해결

인터페이스를 병합할 때, 이름이 같은 멤버 간의 잠재적인 충돌에 대해 인지하는 것이 중요합니다. TypeScript는 이러한 충돌을 해결하기 위한 특정 규칙을 가지고 있습니다.

충돌하는 타입

만약 두 인터페이스가 이름은 같지만 호환되지 않는 타입의 멤버를 선언하면, 컴파일러는 오류를 발생시킵니다.

예제:

interface A {
  x: number;
}

interface A {
  x: string; // 오류: 후속 속성 선언은 동일한 타입을 가져야 합니다.
}

이 충돌을 해결하려면, 타입이 호환되도록 해야 합니다. 한 가지 방법은 유니온 타입(union type)을 사용하는 것입니다:

interface A {
  x: number | string;
}

interface A {
  x: string | number;
}

이 경우, 두 인터페이스 모두에서 x의 타입이 number | string이므로 두 선언은 호환됩니다.

함수 오버로드

함수 선언이 있는 인터페이스를 병합할 때, TypeScript는 함수 오버로드를 단일 오버로드 집합으로 병합합니다. 컴파일러는 오버로드의 순서를 사용하여 컴파일 시에 사용할 올바른 오버로드를 결정합니다.

예제:

interface Calculator {
  add(x: number, y: number): number;
}

interface Calculator {
  add(x: string, y: string): string;
}

const calculator: Calculator = {
  add(x: number | string, y: number | string): number | string {
    if (typeof x === 'number' && typeof y === 'number') {
      return x + y;
    } else if (typeof x === 'string' && typeof y === 'string') {
      return x + y;
    } else {
      throw new Error('잘못된 인수입니다');
    }
  },
};

console.log(calculator.add(1, 2)); // 출력: 3
console.log(calculator.add("hello", "world")); // 출력: hello world

이 예제에서는 add 메서드에 대해 다른 함수 오버로드를 가진 두 개의 Calculator 인터페이스를 병합하고 있습니다. TypeScript는 이러한 오버로드들을 단일 오버로드 집합으로 병합하여, 숫자나 문자열로 add 메서드를 호출할 수 있게 합니다.

인터페이스 확장을 위한 모범 사례

인터페이스 확장을 효과적으로 사용하려면 다음 모범 사례를 따르십시오:

고급 시나리오

기본적인 예제를 넘어, 선언 병합은 더 복잡한 시나리오에서 강력한 기능을 제공합니다.

제네릭 인터페이스 확장

선언 병합을 사용하여 제네릭 인터페이스를 확장하고, 타입 안전성과 유연성을 유지할 수 있습니다.

interface DataStore {
  data: T[];
  add(item: T): void;
}

interface DataStore {
  find(predicate: (item: T) => boolean): T | undefined;
}

class MyDataStore implements DataStore {
  data: T[] = [];

  add(item: T): void {
    this.data.push(item);
  }

  find(predicate: (item: T) => boolean): T | undefined {
    return this.data.find(predicate);
  }
}

const numberStore = new MyDataStore();
numberStore.add(1);
numberStore.add(2);
const foundNumber = numberStore.find(n => n > 1);
console.log(foundNumber); // 출력: 2

조건부 인터페이스 병합

직접적인 기능은 아니지만, 조건부 타입과 선언 병합을 활용하여 조건부 병합 효과를 얻을 수 있습니다.

interface BaseConfig {
  apiUrl: string;
}

type FeatureFlags = {
  enableNewFeature: boolean;
};

// 조건부 인터페이스 병합
interface BaseConfig {
  featureFlags?: FeatureFlags;
}

interface EnhancedConfig extends BaseConfig {
  featureFlags: FeatureFlags;
}

function processConfig(config: BaseConfig) {
  console.log(config.apiUrl);
  if (config.featureFlags?.enableNewFeature) {
    console.log("새로운 기능이 활성화되었습니다");
  }
}

const configWithFlags: EnhancedConfig = {
  apiUrl: "https://example.com",
  featureFlags: {
    enableNewFeature: true,
  },
};

processConfig(configWithFlags);

선언 병합 사용의 이점

선언 병합의 한계

결론

TypeScript의 선언 병합은 인터페이스를 확장하고 코드의 동작을 사용자 정의하는 강력한 도구입니다. 선언 병합이 어떻게 작동하는지 이해하고 모범 사례를 따름으로써, 이 기능을 활용하여 견고하고, 확장 가능하며, 유지보수 가능한 애플리케이션을 구축할 수 있습니다. 이 가이드는 선언 병합을 통한 인터페이스 확장에 대한 포괄적인 개요를 제공하여, TypeScript 프로젝트에서 이 기술을 효과적으로 사용할 수 있는 지식과 기술을 갖추게 했습니다. 코드의 명확성과 유지보수성을 보장하기 위해 타입 안전성을 우선시하고, 잠재적인 충돌을 고려하며, 확장 내용을 문서화하는 것을 잊지 마십시오.

TypeScript 선언 병합: 인터페이스 확장 마스터하기 | MLOG