icon
4장 : 클래스와 인터페이스

인터페이스를 활용한 설계


우리는 2장에서 인터페이스의 기본 문법을, 그리고 4.3절에서는 클래스와 인터페이스의 구현(implements) 관계를 살펴보았습니다. 이제 이 지식들을 바탕으로 인터페이스가 단순히 타입을 정의하는 것을 넘어, 더 유연하고 확장 가능한 소프트웨어 설계를 위한 강력한 도구로서 어떻게 활용될 수 있는지 심층적으로 탐구해 보겠습니다. 인터페이스는 객체 지향 설계 원칙, 특히 개방-폐쇄 원칙(Open/Closed Principle)의존성 역전 원칙(Dependency Inversion Principle) 을 구현하는 데 핵심적인 역할을 합니다.


인터페이스가 제공하는 '계약'의 중요성

인터페이스의 가장 중요한 역할은 클래스가 따라야 할 '계약' 또는 '청사진'을 정의하는 것입니다. 이 계약은 클래스가 특정 속성과 메서드를 반드시 가져야 한다고 명시함으로써, 개발자가 클래스의 내부 구현에 관계없이 해당 클래스가 특정 기능을 수행할 것임을 확신할 수 있게 합니다.

// 서비스가 제공해야 할 '로그' 기능을 정의하는 인터페이스
interface Logger {
  log(message: string): void;
  error(message: string): void;
  warn(message: string): void;
}

// Logger 인터페이스를 구현하는 실제 로거 클래스
class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[INFO] ${message}`);
  }
  error(message: string): void {
    console.error(`[ERROR] ${message}`);
  }
  warn(message: string): void {
    console.warn(`[WARN] ${message}`);
  }
}

// Logger 인터페이스를 구현하는 다른 로거 클래스 (예: 파일에 기록)
class FileLogger implements Logger {
  private fileName: string;

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

  log(message: string): void {
    console.log(`[FILE LOG - INFO] ${this.fileName}: ${message}`);
    // 실제 파일 기록 로직 (생략)
  }
  error(message: string): void {
    console.error(`[FILE LOG - ERROR] ${this.fileName}: ${message}`);
    // 실제 파일 기록 로직 (생략)
  }
  warn(message: string): void {
    console.warn(`[FILE LOG - WARN] ${this.fileName}: ${message}`);
    // 실제 파일 기록 로직 (생략)
  }
}

// 어떤 Logger 구현체든 사용할 수 있는 모듈
class DataProcessor {
  private logger: Logger; // Logger 인터페이스 타입으로 의존성을 선언

  constructor(logger: Logger) { // 생성자 주입
    this.logger = logger;
  }

  processData(): void {
    this.logger.log("데이터 처리 시작...");
    try {
      // 데이터 처리 로직...
      this.logger.warn("임시 파일 생성됨.");
      // ...
      this.logger.log("데이터 처리 완료.");
    } catch (e: any) {
      this.logger.error(`데이터 처리 중 오류 발생: ${e.message}`);
    }
  }
}

// 애플리케이션 초기화 시점에 원하는 로거를 주입
const consoleLogger = new ConsoleLogger();
const fileLogger = new FileLogger("app.log");

const processor1 = new DataProcessor(consoleLogger);
processor1.processData();
// 출력:
// [INFO] 데이터 처리 시작...
// [WARN] 임시 파일 생성됨.
// [INFO] 데이터 처리 완료.

const processor2 = new DataProcessor(fileLogger);
processor2.processData();
// 출력:
// [FILE LOG - INFO] app.log: 데이터 처리 시작...
// [FILE LOG - WARN] app.log: 임시 파일 생성됨.
// [FILE LOG - INFO] app.log: 데이터 처리 완료.

이 예시에서 Logger 인터페이스는 log, error, warn 메서드를 가져야 한다는 계약을 정의합니다. ConsoleLoggerFileLogger는 이 계약을 구현하므로, DataProcessor는 구체적인 로거 클래스(ConsoleLoggerFileLogger)에 의존하는 대신 Logger 인터페이스에 의존합니다.


다형성을 통한 유연한 설계

인터페이스를 활용한 설계의 핵심은 다형성(Polymorphism) 입니다. 다형성은 하나의 인터페이스(또는 부모 클래스) 타입으로 여러 다른 구체적인 구현체들을 다룰 수 있게 해줍니다. 위의 DataProcessor 예시에서 this.loggerConsoleLogger일 수도 있고 FileLogger일 수도 있지만, Logger 인터페이스가 정의한 메서드(log, error, warn)를 호출하는 방식은 동일합니다.

이는 다음과 같은 장점을 제공합니다.

  • 코드의 유연성: DataProcessor는 어떤 Logger 구현체를 사용할지 미리 알 필요가 없습니다. 런타임에 어떤 Logger 인스턴스가 주입되든 관계없이 동일한 방식으로 log 기능을 사용할 수 있습니다.
  • 쉬운 확장성: 새로운 로거 구현체(예: DatabaseLogger, CloudLogger)가 추가되어도, Logger 인터페이스만 구현하면 DataProcessor 코드를 전혀 수정할 필요 없이 새로운 로거를 사용할 수 있습니다. 이것이 바로 개방-폐쇄 원칙("확장에는 열려 있고, 수정에는 닫혀 있다")의 핵심입니다.
  • 테스트 용이성: 실제 FileLogger 대신 테스트를 위한 가짜(Mock) Logger 구현체를 DataProcessor에 주입하여 단위 테스트를 훨씬 쉽게 수행할 수 있습니다.

의존성 역전 원칙

인터페이스를 활용한 설계는 의존성 역전 원칙(DIP) 과 밀접하게 관련됩니다. DIP는 SOLID 원칙 중 하나로, 다음과 같이 요약됩니다.

  1. 고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.
  2. 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.

Logger 예시에서,

  • 고수준 모듈: DataProcessor (로그 기능이라는 추상적인 아이디어를 사용).
  • 저수준 모듈: ConsoleLogger, FileLogger (로그 기능을 실제로 구현하는 세부 사항).
  • 추상화: Logger 인터페이스.

DataProcessorConsoleLoggerFileLogger와 같은 구체적인 저수준 모듈에 직접 의존하지 않고, Logger라는 추상화(인터페이스) 에 의존합니다. 그리고 ConsoleLoggerFileLoggerLogger 인터페이스라는 추상화에 의존하여 구현됩니다. 이렇게 의존성의 방향을 역전시킴으로써, 각 모듈의 결합도를 낮추고 유연성을 높일 수 있습니다.


인터페이스 활용 시나리오

인터페이스는 다양한 설계 패턴과 시나리오에서 강력한 도구로 활용됩니다.

  1. 전략 패턴 (Strategy Pattern): 동일한 문제를 해결하는 여러 알고리즘(전략)이 있을 때, 인터페이스로 전략을 정의하고 상황에 따라 다른 구현체를 주입하여 사용할 수 있습니다. (예: PaymentStrategy, SortingStrategy)

  2. 옵저버 패턴 (Observer Pattern): 주체(Subject)와 관찰자(Observer) 간의 느슨한 결합을 위해 인터페이스를 사용합니다. 주체는 Observer 인터페이스에만 의존하며, 구체적인 Observer 구현체들을 알 필요가 없습니다.

  3. 데이터 모델 정의: API 응답이나 데이터베이스 모델 등 데이터의 형태를 정의할 때 인터페이스를 사용하여 명확성을 높이고 타입 안정성을 확보합니다. (2.3절의 객체와 인터페이스에서 다룬 내용)

  4. Mocking/Stubbing을 통한 테스트: 단위 테스트 시 실제 복잡한 구현체 대신, 인터페이스를 구현한 간단한 Mock 객체나 Stub 객체를 주입하여 테스트 범위를 좁히고 효율성을 높입니다.

  5. 플러그인 아키텍처: 애플리케이션에 외부 플러그인을 통합해야 할 때, 플러그인이 구현해야 할 인터페이스를 정의함으로써 시스템의 확장성을 보장합니다.


인터페이스 확장 (복습)

인터페이스는 다른 인터페이스를 확장(extends)하여 더 구체적인 계약을 만들 수 있습니다. 이는 타입 계층 구조를 형성하는 데 유용합니다.

interface Disposable {
  dispose(): void; // 자원을 해제하는 기능
}

interface RunnableProcess extends Disposable {
  run(): void;       // 프로세스를 실행하는 기능
  isRunning: boolean; // 실행 중 상태
}

class BackgroundTask implements RunnableProcess {
  isRunning: boolean = false;

  run(): void {
    console.log("백그라운드 작업 시작...");
    this.isRunning = true;
  }

  dispose(): void {
    console.log("백그라운드 작업 자원 해제.");
    this.isRunning = false;
  }
}

const task = new BackgroundTask();
task.run();
// ... 작업 수행 ...
task.dispose();

RunnableProcessDisposabledispose 메서드를 포함하면서 자신만의 run 메서드와 isRunning 속성을 추가합니다. BackgroundTask는 이 모든 계약을 구현해야 합니다.


인터페이스는 타입스크립트에서 코드의 구조를 잡고, 유연하며 확장 가능한 설계를 구축하는 데 필수적인 도구입니다. 단순히 타입을 정의하는 것을 넘어, 객체 지향의 핵심 원칙들을 구현하고 협업 및 테스트 용이성을 높이는 데 그 진정한 가치가 있습니다. 인터페이스를 적극적으로 활용하여 더 견고하고 유지보수하기 쉬운 애플리케이션을 만들어 나가시길 바랍니다.