인터페이스를 활용한 설계
우리는 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
메서드를 가져야 한다는 계약을 정의합니다. ConsoleLogger
와 FileLogger
는 이 계약을 구현하므로, DataProcessor
는 구체적인 로거 클래스(ConsoleLogger
나 FileLogger
)에 의존하는 대신 Logger
인터페이스에 의존합니다.
다형성을 통한 유연한 설계
인터페이스를 활용한 설계의 핵심은 다형성(Polymorphism) 입니다. 다형성은 하나의 인터페이스(또는 부모 클래스) 타입으로 여러 다른 구체적인 구현체들을 다룰 수 있게 해줍니다. 위의 DataProcessor
예시에서 this.logger
는 ConsoleLogger
일 수도 있고 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
인터페이스.
DataProcessor
는 ConsoleLogger
나 FileLogger
와 같은 구체적인 저수준 모듈에 직접 의존하지 않고, Logger
라는 추상화(인터페이스) 에 의존합니다. 그리고 ConsoleLogger
와 FileLogger
는 Logger
인터페이스라는 추상화에 의존하여 구현됩니다. 이렇게 의존성의 방향을 역전시킴으로써, 각 모듈의 결합도를 낮추고 유연성을 높일 수 있습니다.
인터페이스 활용 시나리오
인터페이스는 다양한 설계 패턴과 시나리오에서 강력한 도구로 활용됩니다.
-
전략 패턴 (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();
RunnableProcess
는 Disposable
의 dispose
메서드를 포함하면서 자신만의 run
메서드와 isRunning
속성을 추가합니다. BackgroundTask
는 이 모든 계약을 구현해야 합니다.
인터페이스는 타입스크립트에서 코드의 구조를 잡고, 유연하며 확장 가능한 설계를 구축하는 데 필수적인 도구입니다. 단순히 타입을 정의하는 것을 넘어, 객체 지향의 핵심 원칙들을 구현하고 협업 및 테스트 용이성을 높이는 데 그 진정한 가치가 있습니다. 인터페이스를 적극적으로 활용하여 더 견고하고 유지보수하기 쉬운 애플리케이션을 만들어 나가시길 바랍니다.