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 원칙 중 하나로, 다음과 같이 요약됩니다.

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

Logger 예시에서,

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

DataProcessorConsoleLoggerFileLogger 같은 구체 저수준 모듈에 직접 의존하지 않고, Logger라는 추상화(인터페이스)에 의존합니다.

그리고 ConsoleLoggerFileLoggerLogger 인터페이스라는 추상화에 의존하여 구현됩니다.

이렇게 의존성 방향을 역전시키면 각 모듈의 결합도를 낮추고 유연성을 높일 수 있습니다.


인터페이스 활용 시나리오

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

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

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

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

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

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


인터페이스 확장 (복습)

인터페이스는 다른 인터페이스를 확장(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는 이 모든 계약을 구현해야 합니다.


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

목차