본문 바로가기

인터페이스를 활용한 설계

필요한 기능을 인터페이스로 정하고 구현 객체를 생성자로 넘겨 받는 설계로, 구현을 바꾸어 끼우는 다형성과 개방-폐쇄 원칙, 의존성 역전 원칙을 예제로 확인하고, 메모리 대역으로 호출 순서를 검사한 뒤 인터페이스 확장을 복습합니다.

데이터를 처리하는 클래스가 진행 상황을 기록해야 한다고 해 봅시다. 처리 클래스 안에서 콘솔에 기록하는 클래스를 직접 만들면, 나중에 파일에 기록하거나 테스트에서 기록을 모아 확인하고 싶을 때마다 처리 클래스를 고쳐야 합니다. 기록하는 방법이 바뀌었을 뿐인데, 데이터를 처리하는 코드까지 함께 바뀌는 것입니다.

이 글은 처리 클래스가 필요한 기능을 인터페이스로 정하고, 실제로 기록하는 객체는 바깥에서 넘겨받게 바꾸어 이 문제를 풉니다. 그 과정에서 다형성과 개방-폐쇄 원칙, 의존성 역전 원칙이 무엇을 뜻하는지 확인하고, 테스트에서 쓰는 메모리 대역으로 호출 순서를 검사합니다. 예제는 ts-first 프로젝트에 design.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.

필요한 기능을 인터페이스로 정하기

먼저 처리 클래스가 기록 기능에서 무엇을 필요로 하는지를 Logger 인터페이스로 적습니다. 그다음 이 모양을 갖춘 클래스를 여러 개 만들고, 처리 클래스는 구체적인 클래스가 아니라 Logger 타입의 객체를 생성자로 받습니다.

interface Logger {
  log(message: string): void;
  warn(message: string): void;
  error(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[INFO] ${message}`);
  }
  warn(message: string): void {
    console.log(`[WARN] ${message}`);
  }
  error(message: string): void {
    console.log(`[ERROR] ${message}`);
  }
}

class FileLogger implements Logger {
  constructor(private fileName: string) {}

  log(message: string): void {
    console.log(`[FILE ${this.fileName}] INFO ${message}`);
  }
  warn(message: string): void {
    console.log(`[FILE ${this.fileName}] WARN ${message}`);
  }
  error(message: string): void {
    console.log(`[FILE ${this.fileName}] ERROR ${message}`);
  }
}

class DataProcessor {
  constructor(private logger: Logger) {}

  processData(): void {
    this.logger.log("데이터 처리 시작...");
    this.logger.warn("임시 파일 생성됨.");
    this.logger.log("데이터 처리 완료.");
  }
}

new DataProcessor(new ConsoleLogger()).processData();
new DataProcessor(new FileLogger("app.log")).processData();
[INFO] 데이터 처리 시작...
[WARN] 임시 파일 생성됨.
[INFO] 데이터 처리 완료.
[FILE app.log] INFO 데이터 처리 시작...
[FILE app.log] WARN 임시 파일 생성됨.
[FILE app.log] INFO 데이터 처리 완료.

DataProcessor의 코드는 한 글자도 바꾸지 않았는데, 넘겨준 객체에 따라 기록의 모양이 달라졌습니다. 이 예제의 FileLogger는 실제로 파일을 쓰지 않고 파일 이름을 붙여 출력만 하며, 실제 프로그램이라면 경고와 오류는 console.warn이나 console.error로 표준 오류에 보내기도 합니다. 여기서는 출력의 순서를 한 흐름으로 보기 위해 모두 console.log로 적었습니다.

Logger 인터페이스가 정하는 것은 메서드의 이름과 타입뿐입니다. 본문이 비어 있는 log 메서드도 이 모양을 갖추므로, 기록이 정말 남는지와 언제 예외를 던지는지 같은 동작은 구현과 테스트로 따로 확인해야 합니다.

소비자 소유 계약으로 구현과 테스트를 교체한다 — 상태 추적

정적 Logger 관계와 별도로 실제 MemoryLogger 대역 entries에 log/warn/log 세 기록을 넣는 코드가 있다. 같은 소비자/대역/기록 상태로 runtime interface 객체가 없음을 보여 줄 수 있다.

DataProcessor는 Logger 타입에 의존하고 ConsoleLogger·FileLogger가 그 구조를 구현합니다. 런타임에는 생성자로 실제 객체를 전달하며 interface는 제거됩니다.

TYPE / RUNTIME / CONTRACT

DataProcessor는 Logger 계약으로 로거를 사용합니다

초기화 코드가 ConsoleLogger 또는 FileLogger를 만들어 생성자에 전달합니다. DataProcessor의 호출 코드는 같은 Logger 계약을 사용합니다.

정적 계약과 실제 객체 주입의 구별위정적영역DataProcessor→Logger및구현클래스→Logger의3노드2간선이다. 아래실행영역MemoryLogger객체→생성자주입→실제log·warn·log호출의3노드2간선이다. 영역사이에간선은없고interface는런타임중간객체가아니다. 같은Logger구조를만족하는실제객체를소비자에게전달한다.정적 타입 관계DataProcessorLogger 타입 사용구현 클래스Console · File · MemoryLogger 계약log · warn · error실제 메모리 대역 실행MemoryLogger 객체기록 배열 entries생성자 주입new DataProcessor실제 메서드 호출log → warn → log기록 3개 추가
정적 계약과 실제 객체 주입의 구별 · 모바일위정적영역DataProcessor→Logger및구현클래스→Logger의3노드2간선이다. 아래실행영역MemoryLogger객체→생성자주입→실제log·warn·log호출의3노드2간선이다. 영역사이에간선은없고interface는런타임중간객체가아니다. 같은Logger구조를만족하는실제객체를소비자에게전달한다. 모바일에서도 노드와 관계 방향·독립 영역이 같다.정적 타입 관계DataProcessorLogger 타입 사용구현 클래스Console · FileMemoryLogger 계약log · warn · error실제 객체 주입MemoryLogger 객체기록 배열 entries생성자 주입new DataProcessor실제 메서드 호출log → warn → log기록 3개 추가

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의 배열이 바뀐다
마지막 logentries: 3개 / 객체: 같은 대역log → warn → log 뒤 기록 3개를 검사한다소스의 의존 관계와 runtime의 기록 변화는 서로 다른 관찰이다

소비자 소유 계약으로 구현과 테스트를 교체한다 — 상태 추적

정적 Logger 관계와 별도로 실제 MemoryLogger 대역 entries에 log/warn/log 세 기록을 넣는 코드가 있다. 같은 소비자/대역/기록 상태로 runtime interface 객체가 없음을 보여 줄 수 있다.

DataProcessor는 Logger 타입에 의존하고 ConsoleLogger·FileLogger가 그 구조를 구현합니다. 런타임에는 생성자로 실제 객체를 전달하며 interface는 제거됩니다.

TYPE / RUNTIME / CONTRACT

DataProcessor는 Logger 계약으로 로거를 사용합니다

초기화 코드가 ConsoleLogger 또는 FileLogger를 만들어 생성자에 전달합니다. DataProcessor의 호출 코드는 같은 Logger 계약을 사용합니다.

정적 계약과 실제 객체 주입의 구별위정적영역DataProcessor→Logger및구현클래스→Logger의3노드2간선이다. 아래실행영역MemoryLogger객체→생성자주입→실제log·warn·log호출의3노드2간선이다. 영역사이에간선은없고interface는런타임중간객체가아니다. 같은Logger구조를만족하는실제객체를소비자에게전달한다.정적 타입 관계DataProcessorLogger 타입 사용구현 클래스Console · File · MemoryLogger 계약log · warn · error실제 메모리 대역 실행MemoryLogger 객체기록 배열 entries생성자 주입new DataProcessor실제 메서드 호출log → warn → log기록 3개 추가
정적 계약과 실제 객체 주입의 구별 · 모바일위정적영역DataProcessor→Logger및구현클래스→Logger의3노드2간선이다. 아래실행영역MemoryLogger객체→생성자주입→실제log·warn·log호출의3노드2간선이다. 영역사이에간선은없고interface는런타임중간객체가아니다. 같은Logger구조를만족하는실제객체를소비자에게전달한다. 모바일에서도 노드와 관계 방향·독립 영역이 같다.정적 타입 관계DataProcessorLogger 타입 사용구현 클래스Console · FileMemoryLogger 계약log · warn · error실제 객체 주입MemoryLogger 객체기록 배열 entries생성자 주입new DataProcessor실제 메서드 호출log → warn → log기록 3개 추가

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의 배열이 바뀐다
마지막 logentries: 3개 / 객체: 같은 대역log → warn → log 뒤 기록 3개를 검사한다소스의 의존 관계와 runtime의 기록 변화는 서로 다른 관찰이다

위 그림의 위쪽은 정적인 타입의 관계로, DataProcessor는 Logger 타입에 의존하고 여러 구현 클래스가 그 모양을 갖춥니다. 아래쪽은 실행 중에 일어나는 일로, 초기화 코드가 실제 객체를 만들어 생성자에 넘기고 DataProcessor가 그 객체의 메서드를 부릅니다. 인터페이스는 컴파일한 뒤 지워지므로, 실행 중에는 인터페이스가 아니라 그 모양을 갖춘 실제 객체가 오갑니다. 그림의 메모리 대역은 이 글의 뒤에서 만듭니다.

다형성과 개방-폐쇄 원칙

DataProcessor의 this.logger는 어떤 때는 ConsoleLogger이고 어떤 때는 FileLogger이지만, DataProcessor는 그 차이를 모른 채 같은 메서드를 부릅니다. 앞의 글들에서 본 다형성이 클래스의 상속이 아니라 인터페이스로 이루어진 것입니다.

새로운 기록 방법이 필요해지면 Logger를 구현하는 클래스를 하나 더 만들면 되고, DataProcessor는 고치지 않아도 됩니다. 기능을 더할 때는 새 코드를 추가하고, 이미 잘 동작하는 코드는 고치지 않게 만드는 이 성질을 개방-폐쇄 원칙(open-closed principle)이라고 합니다. 다만 어떤 구현을 만들어 넘길지 정하는 초기화 코드는 바뀌어야 하고, Logger 인터페이스 자체가 바뀌면 DataProcessor도 영향을 받는다는 점은 남습니다.

의존성 역전 원칙

의존성 역전 원칙(dependency inversion principle)은 두 문장으로 요약됩니다. 첫째, 상위 모듈은 하위 모듈에 의존하지 않고 둘 다 추상에 의존해야 합니다. 둘째, 추상은 세부 구현에 의존하지 않고, 세부 구현이 추상에 의존해야 합니다.

이 글의 예제에서 상위 모듈은 기록이라는 기능을 쓰는 DataProcessor이고, 하위 모듈은 기록을 실제로 하는 ConsoleLogger와 FileLogger이며, 추상은 Logger 인터페이스입니다. 처음처럼 DataProcessor가 ConsoleLogger를 직접 만들면 상위 모듈이 하위 모듈에 의존하지만, 지금은 DataProcessor도 두 구현 클래스도 모두 Logger를 바라봅니다.

"역전"이라는 이름은 Logger를 누가 정하느냐에서 나옵니다. Logger는 기록 기능을 쓰는 DataProcessor가 필요로 하는 모양으로 정해지고, 구현 클래스들이 그 모양에 맞추어 만들어집니다. 그래서 의존의 방향이 "상위가 하위의 구체적인 클래스를 안다"에서 "하위가 상위가 정한 모양을 따른다"로 뒤집힙니다.

메모리 대역으로 호출 검사하기

인터페이스로 의존을 끊어 두면, 테스트에서는 실제 기록 대신 기록을 배열에 모으는 대역(test double)을 넘길 수 있습니다. 앞의 코드에 이어서 다음을 붙여 실행합니다.

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.length);
console.log(memoryLogger.entries.join(" | "));
3
INFO: 데이터 처리 시작... | WARN: 임시 파일 생성됨. | INFO: 데이터 처리 완료.

processData는 log, warn, log의 순서로 부르고, MemoryLogger는 그 순서대로 배열에 기록을 더하므로 기록은 세 개입니다. 파일이나 콘솔 없이도 어떤 메시지가 어떤 순서로 남았는지 확인할 수 있어서, 테스트가 빠르고 결과가 늘 같습니다. entries의 readonly는 다른 배열을 다시 넣는 것만 막고 push는 막지 않으며, 이 검사 하나로 파일 쓰기나 실패할 때의 경로까지 확인한 것은 아닙니다.

인터페이스를 쓰는 곳은 이 밖에도 많습니다. 같은 문제를 푸는 여러 알고리즘을 바꾸어 끼우는 전략 패턴(strategy pattern), 상태의 변화를 여러 구독자에게 알리는 옵저버 패턴(observer pattern), 외부에서 만든 기능을 정해진 모양으로 붙이는 플러그인 구조가 모두 같은 원리를 씁니다. 이런 설계는 SOLID 원칙 적용과 의존성 주입과 IoC 컨테이너에서 더 자세히 다룹니다.

인터페이스 확장 복습

2장에서 본 것처럼 인터페이스는 extends로 다른 인터페이스를 확장할 수 있고, 클래스는 확장된 인터페이스의 모든 멤버를 구현해야 합니다. 다음 코드는 정리 기능 하나만 가진 작은 인터페이스 Cleanable과, 그것을 확장한 RunnableProcess를 클래스 하나가 구현하는 예입니다. 구현해야 할 멤버가 하나라도 빠지면 실행하기 전에 class 선언에서 진단됩니다.

interface Cleanable {
  dispose(): void;
}

interface RunnableProcess extends Cleanable {
  isRunning: boolean;
  run(): void;
}

class BackgroundTask implements RunnableProcess {
  isRunning = false;

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

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

const task = new BackgroundTask();
task.run();
task.dispose();
백그라운드 작업 시작...
백그라운드 작업 자원 해제.

RunnableProcess는 Cleanable의 dispose를 물려받고 isRunning과 run을 더하므로, BackgroundTask는 세 멤버를 모두 갖추어야 합니다. 작은 인터페이스를 여럿 두고 필요한 만큼 확장해 조합하면, 어떤 코드는 Cleanable만, 어떤 코드는 RunnableProcess 전체를 요구하도록 의존하는 범위를 좁힐 수 있습니다. 이 클래스는 메시지를 출력하고 isRunning을 바꿀 뿐, 실제 운영체제의 작업이나 자원을 다루지는 않는 학습용 예제입니다.

정리

  • 기능을 쓰는 클래스가 필요한 모양을 인터페이스로 정하고 구현 객체를 생성자로 넘겨받으면, 그 클래스를 고치지 않고 구현을 바꾸어 끼울 수 있습니다.
  • 인터페이스는 메서드의 이름과 타입만 정하므로, 구현이 실제로 올바르게 동작하는지는 테스트로 따로 확인합니다.
  • 새 기능을 새 구현의 추가로 해결하고 기존 코드는 고치지 않게 하는 성질이 개방-폐쇄 원칙입니다.
  • 의존성 역전 원칙에서는 상위 모듈과 하위 모듈이 모두 상위 모듈이 필요로 하는 추상에 의존하며, 하위 모듈이 그 모양을 따릅니다.
  • 같은 인터페이스를 구현한 메모리 대역을 넘기면, 파일이나 콘솔 없이 호출의 순서와 내용을 검사할 수 있습니다.

연습 문제

1. 같은 대역을 두 번 쓰기

위의 memoryLogger를 그대로 두고 new DataProcessor(memoryLogger).processData();를 한 번 더 실행하면, entries에는 기록이 몇 개 있고 어떤 순서인가요?

해설 보기

기록은 6개입니다. 새 DataProcessor를 만들었어도 넘긴 memoryLogger는 같은 객체이므로, 앞의 세 기록 뒤에 같은 세 기록 INFO, WARN, INFO가 이어서 더해집니다. 생성자로 넘긴 것은 객체의 복사본이 아니라 같은 객체를 가리키는 참조라는 점을 보여 줍니다.

2. 쓰지 않는 메서드 지우기

지금의 processData는 error를 부르지 않습니다. 그렇다면 MemoryLogger에서 error 메서드를 지워도 검사를 통과하나요?

해설 보기

통과하지 않습니다. Logger의 error는 필수 멤버이므로, MemoryLogger가 error를 갖추지 않으면 Class 'MemoryLogger' incorrectly implements interface 'Logger'.로 시작하는 TS2420이 나옵니다. 지금의 정상 경로에서 부르지 않는다는 사실이 인터페이스의 필수 멤버를 줄여 주지는 않으며, DataProcessor가 나중에 실패 경로에서 error를 부를 수 있도록 모양을 지켜 두는 것입니다.

3. 작업 상태의 변화

BackgroundTask의 isRunning을 만든 직후, run()을 부른 직후, dispose()를 부른 직후에 각각 읽으면 어떤 값인가요?

해설 보기

차례로 false, true, false입니다. 속성의 초기값이 false이고, run이 true로, dispose가 다시 false로 바꿉니다. 인터페이스는 isRunning이 불리언이어야 한다는 것만 정할 뿐, 언제 어떤 값이 되어야 하는지는 구현이 정합니다.

이것으로 4장의 클래스와 인터페이스를 마칩니다. 다음 장의 타입 가드와 타입 좁히기에서는 지금까지 여러 번 쓴 typeof와 in 같은 좁히기를 정리하고, 직접 타입 가드 함수를 만드는 방법을 다룹니다.