본문으로 건너뛰기

안동민 개발노트

본문 시작

인터페이스를 활용한 설계

인터페이스를 구현 세부사항과 분리된 계약으로 사용해 다형성·의존성 역전·확장 가능한 교체 구조를 설계합니다.

우리는 2장에서 인터페이스 기본 문법을, 4장 3절에서 클래스와 인터페이스의 구현(implements) 관계를 살펴봤습니다.

이제는 인터페이스를 타입 선언 수준에서 한 단계 더 확장해 보겠습니다.

인터페이스는 유연하고 확장 가능한 설계를 만드는 강력한 도구이기도 합니다.

특히 객체 지향 설계 원칙인 개방-폐쇄 원칙(Open/Closed Principle)의존성 역전 원칙(Dependency Inversion Principle)을 구현할 때 인터페이스가 핵심 역할을 합니다.

인터페이스는 호출자가 필요한 최소 모양을 고정한다

구현체 목록을 먼저 만들지 않는다. 상위 로직이 실제로 호출하는 능력을 좁은 계약으로 뽑고 조립 지점에서 구현을 선택한다.

  1. 호출자 요구

    processor → logger.log() 쓰는 능력만 추출 구현체 전체 API가 아니라 소비자의 필요에서 시작한다.

  2. 좁은 계약

    interface Logger { log(message: string): void } 필수 멤버의 이름과 타입만 공개한다.

  3. 구조적 호환

    ConsoleLogger FileLogger FakeLogger 필요한 모양을 가지면 같은 계약 위치에 들어간다.

  4. 조립 지점 선택

    new Processor(logger) production ↔ test 호출 코드를 바꾸지 않고 실제 구현과 fake를 교체한다.

  5. 컴파일 시 모양 검사

    implements 클래스 타입이나 메서드 추론을 바꾸지 않고 계약 충족 여부만 확인한다.

  6. 인터페이스는 값이 아님

    runtime 실행 시에는 factory·객체·DI token 중 하나가 구현체를 제공해야 한다.


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

인터페이스는 구현보다 먼저 소비자가 믿을 계약을 세운다

구현 클래스가 바뀌어도 소비자는 인터페이스가 약속한 메서드와 속성만 의존한다.

  1. Contract 필요한 기능의 모양만 먼저

    정의

  2. Implementation 여러 클래스

    같은 계약을 구현

  3. Consumer 구체 클래스

    아니라 인터페이스에 의존

  4. Swap 호출 코드

    바꾸지 않고 구현 교체

점검기준
다형성같은 호출 코드로 여러 구현을 교체
DIP상위 로직이 구체 구현보다 추상 계약에 의존
경계바뀔 구현과 오래 유지할 사용 계약을 분리

인터페이스의 가장 중요한 역할은 클래스가 따라야 할 '계약' 또는 '청사진'을 정의하는 것입니다.

이 계약은 클래스가 특정 속성과 메서드를 반드시 가져야 한다고 명시함으로써, 개발자가 클래스의 내부 구현에 관계없이 해당 클래스가 특정 기능을 수행할 것임을 확신할 수 있게 합니다.

// 서비스가 제공해야 할 '로그' 기능을 정의하는 인터페이스
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 인터페이스라는 추상화에 의존하여 구현됩니다.

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

아래 다이어그램은 Logger 계약을 중심으로 확장, 교체, 테스트가 어떤 코드 변경 없이 연결되는지 정리합니다.

Logger 확장 지점

소비 코드는 Logger 메서드만 알고, 실제 기록 방식은 생성자에서 주입되는 구현체가 담당한다.

  1. DataProcessor

    구현 클래스 이름 없이 log , warn , error 만 호출한다.

  2. Console/FileLogger

    계약을 채우는 세부 사항이며 런타임 선택으로 갈아 끼울 수 있다.

  3. DatabaseLogger

    새 저장소가 필요해도 기존 소비자는 수정 대상이 아니다.

  4. MockLogger

    파일이나 콘솔 부작용 없이 호출 기록만 검증한다.


인터페이스 활용 시나리오

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

전략 패턴 (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는 이 모든 계약을 구현해야 합니다.

인터페이스를 확장할 때는 "무엇을 물려받는가"와 "구현체가 무엇을 책임지는가"를 나눠 보면 구조가 선명해집니다.

인터페이스 확장 책임 범위

확장된 인터페이스는 부모 계약을 포함하고, 구현 클래스는 누적된 모든 멤버를 만족해야 합니다.

  1. base
    Disposable

    자원을 해제하는 최소 계약

  2. extends
    RunnableProcess

    실행 동작을 추가 · 실행 상태를 추가 · 부모 계약을 그대로 포함

  3. implements
    BackgroundTask

    작업 시작 구현 · 상태 저장 구현 · 정리 로직 구현

  4. 좁게 받기

    해제만 필요하면 매개변수 타입을 Disposable 로 둡니다.

  5. 넓게 구현

    실행 가능한 작업이라면 RunnableProcess 전체를 구현합니다.


인터페이스는 타입스크립트에서 코드 구조와 객체 간 계약을 표현하는 도구입니다.

단순히 타입을 정의하는 것을 넘어 객체 지향 원칙, 협업, 테스트 용이성을 높이는 기준으로 활용할 수 있습니다.

인터페이스 기반 설계는 구현체를 바꾸는 일이 아니라 의존 방향을 바꾸는 일입니다.

호출자는 계약에 기대고, 구현체는 계약을 만족하도록 교체됩니다.

인터페이스 의존 흐름

인터페이스는 구현체를 감추는 벽이 아니라, 호출자가 의존할 안정적인 계약을 앞에 세우는 설계 방식입니다.

  1. 1
    호출자

    구체 클래스가 아니라 필요한 메서드 모양만 알고 작업을 요청합니다.

  2. 2
    계약

    입력, 출력, 속성 이름을 고정해 구현체가 지켜야 할 표면을 만듭니다.

  3. 3
    구현체

    파일 로그, 콘솔 로그, 테스트 더블처럼 같은 계약을 다른 방식으로 만족합니다.

  4. 4
    교체

    호출자 변경 없이 구현체만 바꿈

  5. 5
    테스트

    짜 구현으로 의존성 격리


다음 다이어그램은 인터페이스를 활용한 설계를 다형성과 의존성 역전 기준으로 정리한 표입니다.

인터페이스 사용자 기대

계약, 다형성, 의존성 역전, 확장을 함께 보면 인터페이스가 코드 결합을 줄이는 방향이 보입니다.

  1. 사용법 명시

    계약 중심 클래스 내부보다 외부에서 기대하는 메서드와 반환 타입을 먼저 정의합니다. Repository

  2. 구현 교체

    다형성 같은 인터페이스를 만족하면 메모리 저장소와 API 저장소를 같은 코드로 다룰 수 있습니다. save()

  3. 추상에 기대기

    의존 역전 상위 모듈은 구체 클래스가 아니라 필요한 동작만 담은 포트에 의존합니다. ILogger

  4. 작은 계약

    확장 관리 한 인터페이스가 너무 커지면 역할별로 나눠 변경 영향을 줄입니다. Readable

인터페이스 의존 방향 설계

클래스가 구체 구현에 기대지 않고 인터페이스 계약에 의존하면 교체 가능성과 테스트 용이성이 높아집니다.

  1. 필요 기능만 공개

    계약 사용자가 알아야 할 메서드와 속성만 인터페이스에 남겨 구현 세부를 숨깁니다. interface

  2. 교체 가능한 구현

    다형성 같은 인터페이스를 구현한 여러 클래스를 동일한 코드에서 다룰 수 있습니다. polymorphism

  3. 구체 클래스 분리

    의존 역전 상위 로직이 세부 구현이 아니라 추상 계약에 기대도록 구조를 바꿉니다. DIP

  4. 작은 계약 조합

    확장 큰 인터페이스 하나보다 역할별 작은 인터페이스를 확장해 필요한 만큼 묶습니다. extends

아래 다이어그램은 인터페이스 기반 설계를 의존성 역전과 테스트 용이성까지 연결합니다.

인터페이스 기반 설계 흐름

인터페이스는 구현체를 고정하지 않고 필요한 능력만 요구하게 만들어 확장과 테스트를 쉽게 합니다.

  1. 계약 선언
    필요한 기능만 명시

    계약 선언 Logger처럼 사용자가 기대하는 메서드 형태를 먼저 정의합니다. interface Logger

  2. 구현 교체
    Console에서 File로 변경

    구현 교체 같은 인터페이스를 구현한 클래스는 호출부 수정 없이 바꿀 수 있습니다. new FileLogger()

  3. 다형성
    공통 타입으로 처리

    다형성 서로 다른 구현체를 하나의 인터페이스 배열이나 매개변수로 받을 수 있습니다. Logger[]

  4. 테스트 대역
    mock 주입이 쉬움

    테스트 대역 실제 파일이나 네트워크 대신 가짜 구현체를 넣어 단위 테스트를 단순화합니다. FakeLogger