인터페이스를 활용한 설계
인터페이스를 구현 세부사항과 분리된 계약으로 사용해 다형성·의존성 역전·확장 가능한 교체 구조를 설계합니다.
우리는 2장에서 인터페이스 기본 문법을,
4장 3절에서 클래스와 인터페이스의 구현(implements) 관계를 살펴봤습니다.
이제는 인터페이스를 타입 선언 수준에서 한 단계 더 확장해 보겠습니다.
인터페이스는 유연하고 확장 가능한 설계를 만드는 강력한 도구이기도 합니다.
특히 객체 지향 설계 원칙인 개방-폐쇄 원칙(Open/Closed Principle)과 의존성 역전 원칙(Dependency Inversion Principle)을 구현할 때 인터페이스가 핵심 역할을 합니다.
소비자 소유 계약으로 구현과 테스트를 교체한다 — 상태 추적
정적 Logger 관계와 별도로 실제 MemoryLogger 대역 entries에 log/warn/log 세 기록을 넣는 코드가 있다. 같은 소비자/대역/기록 상태로 runtime interface 객체가 없음을 보여 줄 수 있다.
TYPE / RUNTIME / CONTRACT
DataProcessor는 Logger 계약으로 로거를 사용합니다
초기화 코드가 ConsoleLogger 또는 FileLogger를 만들어 생성자에 전달합니다. DataProcessor의 호출 코드는 같은 Logger 계약을 사용합니다.
STATIC CONTRACT
필요한 메서드를 함께 검사
DataProcessor는 Logger의 log·warn·error를 사용합니다. 로거 구현도 같은 구조를 만족해야 합니다. 이 계약이 자동으로 객체를 만들거나 연결하지는 않습니다.
RUNTIME WIRING
호출자가 구현을 선택해 전달
new DataProcessor(consoleLogger) 또는 fileLogger를 전달합니다. 새 로거를 추가할 때 소비자 코드는 유지될 수 있지만 객체를 조립하는 초기화 코드는 바뀔 수 있습니다.
ACTUAL FILELOGGER
파일 기록은 생략된 예제
FileLogger도 콘솔에 파일명 app.log를 포함해 출력합니다. 실제 파일 쓰기는 없습니다. processData의 정상 경로는 시작 log·임시 파일 warn·완료 log입니다.
TEST DOUBLE
같은 계약으로 바꾸는 설계 확장
MemoryLogger 대역을 주입해 log·warn·log의 정상 호출 순서를 실제 배열에 기록합니다. 본문 별도 확장 실습에서 세 기록과 공유 대역의 여섯 기록을 확인합니다. 뒤의 RunnableProcess는 run·isRunning·상속한 dispose를 구현합니다.
윗부분 화살표는 정적 구조 의존이고, 아랫부분은 실제 객체 주입과 호출입니다. 두 영역 사이에 실행 간선은 없으며 interface가 실행 중간 객체로 존재하지는 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 위 정적 영역 DataProcessor→Logger 및 구현 클래스→Logger의 노드 3개·간선 2개다. 아래 실행 영역 MemoryLogger 객체→생성자 주입→실제 log·warn·log 호출의 노드 3개·간선 2개다. 영역 사이에 간선은 없고 interface는 런타임 중간 객체가 아니다. 같은 Logger 구조를 만족하는 실제 객체를 소비자에게 전달한다. |
| 전체 관계·조건 설명 | 위 정적 영역 DataProcessor→Logger 및 구현 클래스→Logger의 노드 3개·간선 2개다. 아래 실행 영역 MemoryLogger 객체→생성자 주입→실제 log·warn·log 호출의 노드 3개·간선 2개다. 영역 사이에 간선은 없고 interface는 런타임 중간 객체가 아니다. 같은 Logger 구조를 만족하는 실제 객체를 소비자에게 전달한다. 모바일에서도 노드와 관계 방향·독립 영역이 같다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 대역 주입 | entries: 빈 배열 / consumer: 실제 객체 참조 | 실제 MemoryLogger 객체를 DataProcessor 생성자에 준다 | Logger interface는 runtime의 중간 객체가 아니다 |
| 처리 호출 | entries: 작성된 기록 누적 / 실행: 실제 메서드 | DataProcessor가 대역의 log와 warn을 호출한다 | 호출마다 MemoryLogger의 배열이 바뀐다 |
| 마지막 log | entries: 3개 / 객체: 같은 대역 | log → warn → log 뒤 기록 3개를 검사한다 | 소스의 의존 관계와 runtime의 기록 변화는 서로 다른 관찰이다 |
소비자 소유 계약으로 구현과 테스트를 교체한다 — 상태 추적
정적 Logger 관계와 별도로 실제 MemoryLogger 대역 entries에 log/warn/log 세 기록을 넣는 코드가 있다. 같은 소비자/대역/기록 상태로 runtime interface 객체가 없음을 보여 줄 수 있다.
TYPE / RUNTIME / CONTRACT
DataProcessor는 Logger 계약으로 로거를 사용합니다
초기화 코드가 ConsoleLogger 또는 FileLogger를 만들어 생성자에 전달합니다. DataProcessor의 호출 코드는 같은 Logger 계약을 사용합니다.
STATIC CONTRACT
필요한 메서드를 함께 검사
DataProcessor는 Logger의 log·warn·error를 사용합니다. 로거 구현도 같은 구조를 만족해야 합니다. 이 계약이 자동으로 객체를 만들거나 연결하지는 않습니다.
RUNTIME WIRING
호출자가 구현을 선택해 전달
new DataProcessor(consoleLogger) 또는 fileLogger를 전달합니다. 새 로거를 추가할 때 소비자 코드는 유지될 수 있지만 객체를 조립하는 초기화 코드는 바뀔 수 있습니다.
ACTUAL FILELOGGER
파일 기록은 생략된 예제
FileLogger도 콘솔에 파일명 app.log를 포함해 출력합니다. 실제 파일 쓰기는 없습니다. processData의 정상 경로는 시작 log·임시 파일 warn·완료 log입니다.
TEST DOUBLE
같은 계약으로 바꾸는 설계 확장
MemoryLogger 대역을 주입해 log·warn·log의 정상 호출 순서를 실제 배열에 기록합니다. 본문 별도 확장 실습에서 세 기록과 공유 대역의 여섯 기록을 확인합니다. 뒤의 RunnableProcess는 run·isRunning·상속한 dispose를 구현합니다.
윗부분 화살표는 정적 구조 의존이고, 아랫부분은 실제 객체 주입과 호출입니다. 두 영역 사이에 실행 간선은 없으며 interface가 실행 중간 객체로 존재하지는 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 위 정적 영역 DataProcessor→Logger 및 구현 클래스→Logger의 노드 3개·간선 2개다. 아래 실행 영역 MemoryLogger 객체→생성자 주입→실제 log·warn·log 호출의 노드 3개·간선 2개다. 영역 사이에 간선은 없고 interface는 런타임 중간 객체가 아니다. 같은 Logger 구조를 만족하는 실제 객체를 소비자에게 전달한다. |
| 전체 관계·조건 설명 | 위 정적 영역 DataProcessor→Logger 및 구현 클래스→Logger의 노드 3개·간선 2개다. 아래 실행 영역 MemoryLogger 객체→생성자 주입→실제 log·warn·log 호출의 노드 3개·간선 2개다. 영역 사이에 간선은 없고 interface는 런타임 중간 객체가 아니다. 같은 Logger 구조를 만족하는 실제 객체를 소비자에게 전달한다. 모바일에서도 노드와 관계 방향·독립 영역이 같다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 대역 주입 | entries: 빈 배열 / consumer: 실제 객체 참조 | 실제 MemoryLogger 객체를 DataProcessor 생성자에 준다 | Logger interface는 runtime의 중간 객체가 아니다 |
| 처리 호출 | entries: 작성된 기록 누적 / 실행: 실제 메서드 | DataProcessor가 대역의 log와 warn을 호출한다 | 호출마다 MemoryLogger의 배열이 바뀐다 |
| 마지막 log | entries: 3개 / 객체: 같은 대역 | log → warn → log 뒤 기록 3개를 검사한다 | 소스의 의존 관계와 runtime의 기록 변화는 서로 다른 관찰이다 |
인터페이스가 제공하는 '계약'의 중요성
인터페이스의 가장 중요한 역할은 클래스가 따라야 할 '계약' 또는 '청사진'을 정의하는 것입니다.
이 계약은 필요한 속성과 메서드의 타입 형태를 정합니다. log 메서드가 실제로 메시지를 기록하는지, 언제 예외를 던지는지 같은 동작의 의미는 구현·명세·검증으로 별도 확인합니다. 타입 계약을 만족하는 빈 log 메서드도 작성할 수 있기 때문입니다.
// 서비스가 제공해야 할 '로그' 기능을 정의하는 인터페이스
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는 구체적인 로거 클래스 대신 Logger 인터페이스에 의존합니다. 이 예제의 FileLogger도 실제 파일 쓰기는 생략하고 콘솔에 파일명을 표시합니다. 그림의 테스트 대역은 같은 계약으로 교체할 수 있는 설계 예시입니다.
다형성을 통한 유연한 설계
인터페이스를 활용한 설계의 핵심은 다형성(Polymorphism)입니다.
다형성은 하나의 인터페이스(또는 부모 클래스) 타입으로 여러 구체 구현체를 다룰 수 있게 해줍니다.
위 DataProcessor 예시에서 this.logger는 ConsoleLogger일 수도 있고 FileLogger일 수도 있지만, Logger 인터페이스가 정의한 메서드(log, error, warn)를 호출하는 방식은 동일합니다.
이는 다음과 같은 장점을 제공합니다.
- 코드의 유연성:
DataProcessor는 어떤Logger구현체를 사용할지 미리 알 필요가 없습니다. 런타임에 어떤Logger인스턴스가 주입되든 관계없이 동일한 방식으로log기능을 사용할 수 있습니다. - 쉬운 확장성: 새로운 로거 구현체(예:
DatabaseLogger,CloudLogger)가 추가되어도,Logger인터페이스만 구현하면DataProcessor코드를 전혀 수정할 필요 없이 새로운 로거를 사용할 수 있습니다. 이 경우 소비자 호출 코드를 유지하면서 새 구현을 추가하는 방식은 개방-폐쇄 원칙(확장에는 열려 있고, 수정에는 닫혀 있다)의 적용 예입니다. 실제 객체를 선택하고 생성자로 전달하는 초기화 코드는 바뀔 수 있으며, Logger 계약 자체가 바뀌면 소비자도 영향을 받습니다. - 테스트 용이성: 실제
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는 이 모든 계약을 구현해야 합니다.
인터페이스를 확장할 때는 "무엇을 물려받는가"와 "구현체가 무엇을 책임지는가"를 나눠 보면 구조가 선명해집니다.
인터페이스는 타입스크립트에서 코드 구조와 객체 간 계약을 표현하는 도구입니다.
단순히 타입을 정의하는 것을 넘어 객체 지향 원칙, 협업, 테스트 용이성을 높이는 기준으로 활용할 수 있습니다.
인터페이스 기반 설계에서는 호출자가 구체 클래스 대신 필요한 계약에 의존하도록 바꾸고, 초기화 시점에 실제 구현 객체를 전달합니다.
호출자는 계약에 기대고, 구현체는 계약을 만족하도록 교체됩니다.
예시
첫 Logger/DataProcessor 코드 블록을 실행한 뒤 아래 메모리 대역을 이어 붙입니다. 파일을 만들지 않고 실제 정상 경로의 호출 순서와 메시지를 배열에 기록합니다. 인터페이스 객체를 생성하는 것이 아니라 같은 구조를 만족하는 객체를 생성자로 전달합니다.
class MemoryLogger implements Logger {
readonly entries: string[] = [];
log(message: string): void { this.entries.push(`INFO: ${message}`); }
warn(message: string): void { this.entries.push(`WARN: ${message}`); }
error(message: string): void { this.entries.push(`ERROR: ${message}`); }
}
const memoryLogger = new MemoryLogger();
new DataProcessor(memoryLogger).processData();
console.log(memoryLogger.entries.join(' | '));
// INFO: 데이터 처리 시작... | WARN: 임시 파일 생성됨. | INFO: 데이터 처리 완료.processData는 log → warn → log를 호출하고 MemoryLogger는 순서대로 push합니다. entries의 readonly는 배열 참조의 재대입을 제한하며 내부 push를 막지 않습니다. 이 정상 경로에 오류가 없어서 error는 아직 호출되지 않습니다. 이 검사 하나로 실패 경로·파일 쓰기·영속성이 검증됐다고 판단하지 않습니다.
혼자 대역과 계약을 변경하기
① 같은 memoryLogger에 DataProcessor를 하나 더 생성해 processData를 한 번 실행하면 기록 개수와 순서는 어떻게 될까요? ② MemoryLogger.error를 삭제하면 현재 정상 경로에서는 쓰지 않아도 타입 계약은 통과할까요? ③ 원 BackgroundTask를 run() 전, run() 직후, dispose() 직후에 읽는 isRunning 값은 무엇일까요?
주입과 실제 동작 해설
① 같은 배열에 앞의 세 기록 다음 같은 세 기록이 추가되어 총 6개입니다. DataProcessor가 새로 만들어져도 주입한 메모리 로거 객체는 공유됩니다. ② Logger의 필수 error 계약과 호환되지 않아 타입 오류이며, 정상 경로에서 호출하지 않는다는 사실이 필수 멤버를 생략하게 하지는 않습니다. ③ 값은 false → true → false입니다. BackgroundTask의 구현은 메시지와 boolean 변경만 수행하며 실제 OS 작업이나 자원 해제 로직은 없는 학습 예제입니다.