가장 쉬운 타입스크립트 시작하기 1편 ‘타입스크립트 소개와 기초’에 이어 2편 ‘인터페이스 및 클래스와 고급 기능’입니다.
1편 바로가기: https://bit.ly/3vbhJgM
[Typescript] 가장 쉬운 타입스크립트 시작하기 ❷
3. 인터페이스와 클래스
지금까지 타입스크립트가 무엇인지와 타입스크립트의 기본 사용법을 알아보았습니다. 기본적인 학습만 하더라도 타입스크립트를 이해하기에는 부족함이 없을 겁니다. 다만, 타입스크립트 장점은 복잡한 구조를 가지고 있는 애플리케이션을 작성할 때 더욱 빛납니다. 복잡한 애플리케이션을 만들려면 타입을 구조화할 수 있는 기능이 필요합니다. 바로 인터페이스와 클래스*가 등장할 차례입니다.
다른 언어에서는 보통 클래스를 먼저 설명하고 인터페이스를 설명합니다만, 인터페이스는 타입을 구조적으로 정의하는 방법 중 하나이기에 먼저 설명드립니다. 또한 인터페이스를 구현(Implement)해 클래스를 작성하는 방법을 많이 사용합니다. 그러한 이유로 클래스에 인터페이스를 구현하는 방법을 후에 알아보겠습니다.
* 자바스크립트의 클래스 https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Classes
3.1 인터페이스와 타입 별칭 비교
인터페이스 타입을 선언하는 문법은 객체 타입의 별칭을 만드는 문법과 비슷합니다. 인터페이스는 type을 사용한 객체의 별칭 타입과 비교해 더 읽기 쉬운 오류 메시지, 더 빠른 컴파일러 성능, 클래스와 함께 사용할 수 있는 장점을 제공합니다. type과 interface의 차이점을 표에 정리해두었으니 참고해주세요.
▼ type과 interface의 차이점
책이라는 타입을 표현하는 예제를 type과 interface로 각각 만들겠습니다.
type BookType = { // ❶ BookType 타입
title: string;
price: number;
author: string;
};
interface Book { // ❷ Book 인터페이스
title: string;
price: number;
author: string;
}
let bookType: BookType = { // ❸ BookType 타입 객체 할당
title: "백엔드 개발자 되기",
price: 10000,
author: "박승규",
};
let book: Book = { // ❹ Book 인터페이스 객체 할당
title: "백엔드 개발자 되기",
price: 10000,
author: "박승규",
};
❶ BookType은 type 키워드로 생성한 타입입니다. 제목(title), 가격(price), 저자(author) 속성을 가지고 있습니다. ❷ Book 인터페이스 역시 동일하게 제목(title), 가격(price), 저자(author) 속성을 가지고 있습니다. ❸ 과 ❹ 는 생성한 타입과 인터페이스의 객체를 할당하는 코드입니다. 자바스크립트에서 객체를 만들어서 할당하는 방법과 같습니다.
3.2 인터페이스의 선택적 속성과 읽기 전용 속성
‘2.2 타입 표기’에서 선택적 속성을 알아보았습니다. 인터페이스도 선택적 속성을 사용할 수 있습니다. Car 인터페이스를 만들고 차량의 옵션을 선택적 속성으로 주어서 객체를 만들어봅시다.
interface Car {
name: string;
price: number;
brand: string;
options?: string[]; // ❶ 차량의 옵션은 선택적 속성
}
let avante: Car = { // ❷ 아반떼에는 에어컨과 내비게이션의 옵션이 있음
name: "아반떼",
price: 1500,
brand: "현대",
options: ["에어컨", "내비게이션"],
};
let morning: Car = { // ❸ 모닝은 아무런 옵션이 없음
name: "모닝",
price: 650,
brand: "기아",
};
❶ 속성에 ‘?’를 붙이면 선택적 속성입니다. 이 말은 해당 인터페이스 형태인 객체에는 options값은 있어도 되고 없어도 된다는 뜻입니다. ❷ 아반떼 차량 객체에 옵션으로 에어컨과 네이게이션을 설정했습니다. ❸ 모닝에는 아무런 옵션을 주지 않았는데, options는 선택적 속성이므로 에러가 나지 않습니다.
다음으로 읽기 전용 속성을 알아봅시다. 읽기 전용 속성으로 지정하면 말 그대로 해당 속성의 값이 한번 정해지면 수정할 수 없습니다.
interface Citizen { // 시민을 의미하는 인터페이스 정의 id: string;
name: string;
region: string;
readonly age: number; // ❶ 나이는 변경할 수 없음.
}
let seungkyoo: Citizen = { // Citizen 인터페이스 객체 생성
id: "123456",
name: "박승규",
region: "경기",
age: 40,
};
seungkyoo.age = 39; // 2 age 속성은 읽기 전용(read-only)이므로 에러
❶ Citizen 인터페이스의 age 속성을 읽기 전용으로 선언합니다. ❷ 40으로 설정된 나이를 39로 변경하려고 하면 읽기 전용이므로 변경할 수 없다는 에러가 납니다.
3.3 인터페이스 확장하기
인터페이스는 확장이 가능합니다. 개발을 하다 보면 비슷한 속성을 가진 인터페이스를 여러 개 만들게 되는 상황이 옵니다. 이 경우 공통 속성을 가진 인터페이스를 상속받아서 사용하면 편리합니다. 예를 들어 웹툰 한 편을 의미하는 에피소드와 여러 개의 에피소드를 모은 시리즈 인터페이스를 만들었다고 가정할 때 제목, 생성 일시, 수정 일시는 공통으로 넣을 수 있습니다. 각각 Episode, Series, WebtoonCommon으로 구분 지어서 인터페이스를 만들고 Episode, Series는 WebtoonCommon의 확장(extends) 인터페이스로 만들어봅시다.
interface WebtoonCommon { // ❶ 공통으로 사용할 인터페이스
title: string;
createdDate: Date;
updatedDate: Date;
}
interface Episode extends WebtoonCommon { // ❷ 에피소드 인터페이스.
episodeNumber: number;
seriesNumber: number;
}
interface Series extends WebtoonCommon { // ❸ 시리즈 인터페이스.
sereisNumber: number;
author: string;
}
const episode: Episode = { // 4 에피소드 객체 title: "나 혼자도 레벨업 1화",
createdDate: new Date(),
updatedDate: new Date(),
episodeNumber: 1,
seriesNumber: 123,
};
const series: Series = { // 5 시리즈 객체 title: "나 혼자도 레벨업",
createdDate: new Date(),
updatedDate: new Date(),
sereisNumber: 123,
author: "천재작가", };
❶ WebtoonCommon은 공통으로 사용할 인터페이스입니다. 제목(title), 생성 일시(createdDate), 수정 일시(updatedDate)를 속성으로 가집니다. ❷ 에피소드 인터페이스는 에피소드 번호(episodeNumber)와 시리즈 번호(seriesNumber)를 속성으로 가지며 공통 인터페이스를 확장했습니다. ❸ 시리즈 인터페이스는 시리즈 번호(seriesNumber)와 작가(author) 속성을 가지며 공통 인터페이스를 확장했습니다. ❹ 에피소드 객체는 Episode와 WebtoonCommon에 있는 속성들이 모두 있어야 객체를 생성할 수 있습니다. ❺ 시리즈 객체도 역시 Series와 WebtoonCommon에 있는 속성들이 모두 있어야 객체를 생성할 수 있습니다.
3.4 인터페이스 병합
보통은 같은 이름의 인터페이스가 있다면 에러가 난다고 생각합니다. 그렇지만 타입스크립트에서는 같은 이름의 인터페이스가 있다고 해서 에러가 나지는 않습니다. 오히려 같은 이름의 인터페이스가 여러 개인데, 각각 속성이 다르다면 해당 인터페이스를 내부적으로는 병합해줍니다. 코드를 보면서 자세히 알아보겠습니다.
interface Clock {
time: Date;
}
interface Clock {
brand: string;
}
interface Clock {
price: number;
}
const wrongClock: Clock = { // ❶ brand, price 속성이 없어서 에러
time: new Date(),
};
const clock: Clock = { // ❷ Clock 인터페이스 병합
time: new Date(), brand:
"놀렉스",
price: 10000,
};
예제에서는 Clock 인터페이스를 세 번 정의했습니다. 각각 하나의 속성을 가지도록 했습니다. ❶ 에서 하나의 속성만 지정했을 때 brand, price 속성을 지정하라고 에러가 납니다. ❷ 3가지 속성을 모두 지정하면 타입 에러가 나지 않습니다.
즉 인터페이스는 병합되어서 내부적으로는 다음과 같은 선언과 같습니다.
interface Clock {
time: Date;
brand: string;
price: number;
}
3.5 클래스의 메서드와 속성
자바스크립트에 클래스가 추가된 것은 ES2015부터입니다. 이름에서 유추할 수 있듯 ES2015*는 2015년 6월에 스펙이 나왔습니다. 타입스크립트는 ES2015에 추가된 클래스를 완벽하게 지원합니다. 이제부터 클래스의 메서드와 속성을 정의하는 방법을 알아봅시다.
class Hello { // 클래스 선언부
// ❶ 생성자 메서드
constructor() {
this.sayHello("created");
}
// ❷ 메서드
sayHello(message: string) {
onsole.log(message);
}
}
// ❸ Hello 클래스의 인스턴스 생성.
const hello = new Hello();
hello.sayHello("안녕하세요~");
created ← 생성자에서 실행
안녕하세요~ ← hello.sayHello("안녕하세요~")의 결과
클래스는 class <클래스명> 형식으로 선언합니다. 클래스명은 기본적으로 파스칼케이스*를 따르되 첫 글자를 대문자로 씁니다. ❶ 생성자 메서드는 constructor(<매개변수>:타입, …, <매개변수>:타입) { } 형식으로 정의합니다. 여기서는 매개변수를 받지 않았습니다. this는 인스턴스 자신을 가리킬 때 사용하는 키워드입니다. ❷ 클래스 내부에 정의된 함수를 메서드라고 합니다. sayHello( )는 Hello 클래스의 메서드입니다. ❸ 클래스의 인스턴스는 new <클래스명>(); 형식으로 생성합니다. 그러면 생성자를 실행합니다.
다음으로 클래스 속성을 알아봅시다. 클래스 몸체 블록에 변수를 선언하면 클래스 속성이 됩니다. 해당 클래스 내부의 메서드에서 클래스 속성에 접근할 때는 this.< 변수명> 형식으로 사용해야 합니다. 사각형을 뜻하는 클래스를 예제로 만들겠습니다.
class Rectangle {
width: number; // ❶ 클래스 변수, 가로를 의미.
height: number; // 클래스 변수, 세로를 의미
// 클래스 생성 시 가로, 세로 값을 넣어줌
constructor(width: number, height: number) {
// ❷ this.width는 클래스 변수이며 width는 매개변수로 받은 값을 담은 변수.
this.width = width;
this.height = height;
}
// ❸ 반환 타입은 number 타입.
getArea() {
return this.width * this.height;
}
}
// 클래스 인스턴스 생성
const rectangle = new Rectangle(10, 5);
// getArea() 메서드 실행
rectangle.getArea();
50
❶ Rectangle 클래스 내에 가로, 세로를 뜻하는 width, height 클래스 변수를 지정했습니다. ❷ 클래스 변수는 클래스의 메서드 내에서는 this.< 변수명> 형식으로 접근할 수 있습니다. ❸ getArea( ) 메서드는 반환값을 명시적으로 지정하지 않았지만, 가로 X 세로는 number 타입인 것이 명백하므로 타입스크립트에서 암묵적으로 number 타입을 넣어줍니다. VSCode라면 마우스 커서를 getArea( ) 함수에 대보면 알 수 있습니다.
3.6 인터페이스를 구현한 클래스
클래스에서 인터페이스 상속을 할 때 implements 키워드를 사용합니다. 클래스가 인터페이스를 구현(Implement)하도록 하면 클래스에 반드시 선언해야 하는 속성과 메서드를 강제할 수 있습니다. 클릭할 때마다 카운트가 올라가는 기능에 사용할 인터페이스를 만들고 해당 인터페이스를 클래스에 구현하는 코드를 작성하겠습니다. 인터페이스를 구현 해두었는데, 아무것도 만들지 않으면 에러가 납니다.
interface IClicker {
count: number;
click():number;
}
class Clicker implements IClicker {
// 인터페이스를 상속받아 놓고 인터페이스를 구현하지 않아서 에러 발생
}
다음과 같이 IClicker 인터페이스를 잘못 구현했다는 에러가 나게 됩니다.
간단하게 인터페이스 구현을 추가해봅시다.
interface IClicker {
count: number;
click():number;
}
class Clicker implements IClicker {
// count의 기본값(0)을 설정
count: number = 0;
click(): number {
this.count += 1
console.log(`Click! [count] : ${this.count}`);
return this.count;
}
}
const clicker = new Clicker();
clicker.click(); // Click! [count] : 1
clicker.click(); // Click! [count] : 2
clicker.click(); // Click! [count] : 3
이처럼 인터페이스는 클래스에 구현을 강제하기 위한 용도로도 사용할 수 있습니다.
3.7 추상 클래스
추상 클래스는 abstract 키워드가 붙어 있는 클래스로 추상 메서드를 가지고 있는 클래스를 말합니다. 추상 메서드는 메서드의 구현체가 없는 메서드입니다. 추상 메서드를 사용하면 반드시 구현해야 하는 기능을 강제할 수 있어 깜빡하고 구현하지 않는 실수를 예방할 수 있습니다. 추상 클래스는 인터페이스와 유사하지만, 조금은 다른 용도를 가지고 있습니다. 대부분의 코드가 비슷하지만, 특정 부분만 다르게 구현하는 클래스를 여러 개 작성할 때 유용합니다.
추상 클래스는 선언 시 class 앞에 abstract 키워드를 추가해 선언합니다. abstract 키워드가 있는 클래스는 추상 클래스가 됩니다. 추상 클래스는 추상 메서드를 선언할 수 있습니다. 추상 메서드는 메서드명 앞에 abstract 키워드를 붙이며 인터페이스와 마찬가지로 메서드의 구현부가 없습니다.
로그를 남기는 방법을 추상 클래스로 구현하고, 어떻게 로그를 남길지는 개별 클래스로 만들겠습니다.
abstract class Logger { // abstract 키워드가 있으면 추상 클래스
prepare() {.
console.log("=======================").
console.log("로그를 남기기 위한 준비")
}
// ❶ 로그를 남기는 절차를 정의한 메서드
log(message: string) {
this.prepare();
this.execute(message);
this.complete();
};
// 추상 메서드
abstract execute(message: string): void;
complete() {.
console.log("작업 완료")
console.log("")
}
}
// ❷ 추상 클래스는 상속해 사용.
class FileLogger extends Logger {
filename: string;
// ❸ 상속을 받은 경우, 기본 생성자가 아니라면 super()를 먼저 실행
constructor(filename:string) {
super();
this.filename = filename;
}
// ❹ 추상메서드구현
execute(message: string): void {
// 파일에 직접 쓰지는 않지만 쓴다고 가정
console.log(`[${this.filename}] > `, message);
}
}
class ConsoleLogger extends Logger {
// 추상 메서드 구현
execute(message: string): void {
console.log(message);
}
}
const fileLogger = new FileLogger("test.log");
fileLogger.log("파일에 로그 남기기 테스트")
const consoleLogger = new ConsoleLogger();
consoleLogger.log("로그 남기기")
=======================. ← fileLogger.log() 실행 결과
로그를 남기기 위한 준비
[test.log] > 파일에 로그 남기기 테스트
작업 완료
======================= ← consoleLogger.log() 실행 결과
로그를 남기기 위한 준비
로그 남기기
작업 완료
❶ 로그를 남기는 절차를 정의한 Logger 클래스의 핵심 메서드입니다. 준비(prepare), 실행(execute), 완료(complete) 단계로 되어 있습니다. execute( ) 메서드를 추상 메서드로 정의해 자녀 클래스에서 구현하도록 강제했습니다. ❷ extends 키워드를 사용해 추상 클래스를 상속합니다. 추상 클래스를 상속받은 자녀 클래스는 추상 클래스의 모든 메서드를 사용할 수 있습니다. 또한 추상 메서드인 execute( )를 반드시 구현해야 하며, 구현하지 않으면 컴파일 에러가 발생합니다. ❸ 상속을 받은 자녀 클래스의 생성자는 부모 클래스의 생성자를 반드시 호출해야 합니다. 부모 클래스인 Logger는 기본 생성자만 있으므로 super( )로 매개변수가 없는 부모의 기본 생성자 메서드를 호출합니다. ❹ FileLogger와 ConsoleLogger의 execute( ) 메서드는 추상 메서드를 구현한 메서드입니다. 반드시 구현해야 한다는 점외에는 다른 메서드와 차이는 없습니다.
3.8 클래스의 접근 제어자
클래스 내의 변수와 메서드에는 접근 제어자인 public, protected, private을 사용할 수 있습니다. 접근 제어자가 있는 변수와 메서드는 클래스 외부에서 해당 클래스의 구현을 공개할지 여부를 결정합니다. 접근 제어자는 타입스크립트의 기능으로, 자바스크립트로 컴파일 후에는 제거됩니다.
▼ 접근 제어자
접근 제어자도 예제로 확인하겠습니다. 이해를 돕기 위해 클래스명을 Parent, Child, Someone으로 지었습니다.
// 부모 클래스
class Parent {
openInfo = "공개 정보"
protected lagacy = "유산";
private parentSecret = "부모의 비밀 정보";
// private 정보에 접근 가능
checkMySecret() {
console.log(this.parentSecret);
}
}
// 자녀 클래스, 부모 상속
class Child extends Parent{
private secret = "자녀의 비밀 정보";
// ❶ 자녀는 부모의 protected 확인 가능
checkLagacy() {
console.log(super.lagacy);
}
// ❷ 부모의 private 변수에는 접근 불가능.
checkParentSecret() {
console.log(super.parentSecret);
}
}
class Someone {
checkPublicInfo() {
const p = new Parent();
// 다른 클래스가 public 변수 접근 가능
console.log(p.openInfo);
// protected와 private는 접근 불가능
console.log(p.lagacy) console.log(p.parentSecret)
}
}
❶ 자녀 클래스는 부모의 보호된 변수인 protected에 접근할 수 있습니다. checkLagacy( ) 메서드에서 유산 정보에 접근합니다. ❷ 그렇지만 아무리 자녀라 하더라도 부모의 비밀 정보에는 접근하지 못합니다. 여기서는 에러가 발생합니다. parentSecret 정보는 Parent 클래스 내에서만 접근할 수 있습니다.
여기까지 인터페이스와 클래스를 알아보았습니다. 타입스크립트는 클래스와 인터페이스를 활용한 객체지향 프로그래밍을 지원하고 있으니, 잘 활용해보세요.
4. 타입스크립트의 고급 기능
타입을 추상화하는 데 사용하는 제네릭과 타입을 만들 때 복사붙이기가 아닌 프로그래밍적인 방법으로 타입을 만들 수 있게 하는 맵드 타입, 실험적인 기능인 데코레이터을 알아보겠습니다.
4.1 제네릭 함수
제네릭은 함수의 매개변수와 결괏값의 타입을 함수 선언 시점이 아니라, 함수를 호출하는 시점에 정하는 기법입니다. 다음과 같이 any로 선언한 echo( ) 함수가 있다고 합시다.
function echo(message: any) : any {
console.log("in echo : ", message);
return message;
}
type phone = {
name: string,
price : number,
brand: string,
}
const myPhone = {name: "iPhone", price: 1000, brand: "Apple"}
echo(1)
echo("안녕")
echo(myPhone);
echo( ) 함수의 매개변수인 message는 any 타입이기 때문에 무엇이든 받을 수 있습니다. 다만 타입스크립트에서는 any를 사용하는 것을 지양합니다. echo( )와 같은 다양한 타입을 받을 수 있는 함수를 만드는 방법으로 타입스크립트에서는 제네릭을 사용하면 됩니다. 제네릭은 클래스, 인터페이스, 타입 별칭, 함수 등 사실상 타입을 선언하는 모든 곳에서 사용할 수 있습니다. echo( ) 함수를 any가 아닌 제네릭을 활용하는 함수로 변경하겠습니다.
function genericEcho<T>(message: T) : T {
console.log(message);
return message;
}
해당 제네릭 함수를 사용하는 코드는 다음과 같습니다.
genericEcho(1) // 타입을 명시하지 않으면 컴파일러가 타입 추론
genericEcho<string>("안녕") // 타입을 명시적으로 지정
genericEcho<any>(myPhone); // any를 타읍으로 넣으면 제네릭을 쓸 이유가 없음
genericEcho<string>(myPhone); // ERROR 타입이 달라서 에러 발생
함수의 선언과 사용 시 함수명 뒤에 <T>가 있습니다. T는 타입 매개변수라고 부릅니다. T는 어떤 특별한 타입을 지칭하는 것이 아닌 런타임에 변경되는 타입 대신 T라고 선언해둔 것에 불과합니다. 따라서 T가 아닌 Type, Return, Value 등으로 변경해도 문제는 없습니다. 제네릭 타입 매개변수는 첫 글자를 대문자로 적는 파스칼케이스를 사용하는 것이 관례입니다.
▼ 관례적으로 사용하는 제네릭 문자들
타입 매개변수는 해당 함수를 실행하는 런타임에 변경이 됩니다. 사용 시에는 명시적으로 genericEcho<string>(“hello”)와 같이 타입을 지정할 수 있으며, 타입을 지정하지 않는 경우 컴파일러가 알아서 타입을 추론합니다. 명시적으로 타입을 적어주면 컴파일러에서 타입을 체크할 수 있으므로 실수를 방지할 수 있습니다. any를 사용하면 컴파일 시에는 타입 검증을 하지 않습니다.
4.2 제네릭 인터페이스
인터페이스의 매개변수 타입도 제네릭으로 지정할 수 있습니다. 함수에서와 마찬가지로 타입 매개변수는 인터페이스명 뒤에 타입 매개변수를 추가합니다. label이라는 속성을 하나 가지고 있는 ILabel 인터페이스를 제네릭으로 선언하면 다음과 같습니다.
interface ILabel<Type> {
label:Type;
}
사용할 때도 마찬가지로 인터페이스명 뒤의 < > 기호 안에 타입을 명시하면 됩니다.
const stringLabel:ILabel<string> = {
label: "Hello"
}
const numberLabel:ILabel<number> = {
label: 100
}
const booleanLabel:ILabel<boolean> = {
label: 3.14 // ❶ 컴파일 에러 boolean에 number를 넣을 수 없음
}
❶ booleanLabel에서 타입을 boolean으로 선언하고 값으로 number 타입을 넣었기 때문에 컴파일 에러가 납니다.
제네릭 클래스와 제네릭 타입 별칭 부분도 큰 차이가 없으므로 관련 내용은 생략합니다.*
4.3 제네릭 제약 조건
제네릭은 컴파일 시에 타입이 적용다는 점 이외에는 any와 큰 차이점이 없어 보입니다만, 제약 조건을 추가하면 any와는 다르게 조금 더 세밀하게 타입을 제약할 수 있습니다. 예를 들어 속성에 숫자타입 length가 있는 타입을 매개변수로 넘기는 것을 허용하는 함수를 작성할 수 있습니다. 제약 조건 추가할 때는 extends 키워드를 사용합니다. 사용법은 다음과 같습니다.
interface ICheckLength {
length: number;
}
function echoWithLength<T extends ICheckLength>(message: T){
console.log(message);
}
T extends ICheckLength에서 T는 임의의 타입입니다. 또한 T는 ICheckLength를 확장한(extends) 타입이므로 최소한 ICheckLangth의 속성을 가져야만 하는 겁니다. <T extends string | number>와 같이 유니온 타입을 넣을 수도 있습니다.
echoWithLength( ) 함수의 사용 예시도 살펴보겠습니다.
echoWithLength("Hello");
echoWithLength([1,2,3]);
echoWithLength({length: 10});
echoWithLength(10); // 10 length가 없기 때문에 에러 발생
10은 number 타입으로 length 속성을 가지고 있지 않습니다. 따라서 에러가 발생합니다.
지금까지 제네릭의 제약 조건을 알아보았습니다. 제네릭은 어떤 타입이라도 받을 수 있어야 하면서 컴파일할 때 타입 체크까지 되므로 범용으로 사용해야 하는 함수나 클래스, 인터페이스를 만들어야 하는 때 좋습니다. 이에 프레임워크를 만드는 곳에서 많이 사용합니다.
4.4 데코레이터
데코레이터는 클래스, 메서드, 속성, 매개변수, 접근자(get, set)에 추가할 수 있는 특수한 문법입니다. @데코레이터명의 형식을 사용합니다. 데코레이터가 적용된 클래스, 메서드, 속성, 매개변수의 정보를 읽어서 동작을 변경할 수 있기에 메타-프로그래밍*을 지원하는 기능입니다. 여러 개의 클래스 혹은 메서드에 같은 패턴의 코드가 나오는 경우 데코레이터를 사용하면 좋습니다.
자바스크립트에서는 아직 정식 기능은 아니지만, 향후 릴리즈 포함 후보*에 들어 있는 3단계에 와있습니다. 2023년 2월 기준 타입스크립트에서 사용하려면 tsconfig.json에 “experimentalDecorators”: true 설정을 추가해야 합니다. 기본적으로 만들어져 있는 tsconfig.json에는 주석 처리가 되어 있으므로 주석을 풀기만 하면 됩니다. 타입스크립트 플레이그라운드에는 기본적으로 활성화가 되어 있습니다만, 그렇지 않다면 직접 해당 기능을 체크해주면 됩니다.
가장 간단한 데코레이터는 다음과 같이 생겼습니다.
function HelloDecorator(constructor: Function) { // ❶ 데코레이터 정의
console.log(`HELLO!`);
};
@HelloDecorator // ❷ 데코레이터 실행됨
class DecoratorTest {
constructor() {
console.log(`인스턴스 생성됨`);
}
}
❶ 코드에서 보시듯 데코레이터는 함수입니다. 타입스크립트에서는 함수의 시그니처12를 확인해 데코레이터를 구분합니다. 다음의 표에 각 데코레이터별 시그니처를 정리해두었습니다.
▼ 데코레이터 함수의 시그니처
클래스 데코레이터의 사용은 간단합니다. 클래스명 앞에 @<데코레이터명>을 붙이면 됩니다. 코드에서는 가독성을 고려해 개행을 해두었습니다. 예제 코드를 실행하면 클래스를 생성하지 않았는데도 HELLO! 문자열이 나옵니다. 코드를 로딩할 때 @HelloDecorator가 실행됐기 때문입니다. 모든 데코레이터는 런타임에 클래스, 메서드, 속성, 매개변수, 접근자의 동작을 변경해야 합니다. 그러므로 코드를 로딩할 때 미리 실행합니다.
예제 코드에 있는 데코레이터는 로그만 남기고 하는 것이 없습니다. 클래스 데코레이터는 클래스의 생성자 함수의 동작을 변경할 수 있습니다. 간단하게, 생성자 실행 시 추가로 로그를 남기는 데코레이터로 수정하겠습니다.
예제를 다음과 같이 변경해봅시다.
type Constructor = new(...args: any[]) => {} ; // ❶ 생성자 메서드 타입
function HelloDecorator(constructor: Constructor) {
return class extends constructor { // ❷ 익명 클래스 반환
constructor() { // ❸ 생성자 재정의
console.log(`HELLO!`);
super(); // ❹ DecoratorTest의 생성자 실행
}
}
};
@HelloDecorator
class DecoratorTest {
constructor() {.
console.log(`인스턴스 생성됨`);
}
}
const decoTest = new DecoratorTest();
HELLO! ← ❸ constructor()의 실행 결과
인스턴스 생성됨. ← ❹ DecoratorTest()의 생성자의 실행 결과
❶ 데코레이터에 넘겨지는 constructor는 생성자 메서드입니다. typeConstructor=new(…args:any[ ]) => { };는 생성자 메서드 나타내는 타입입니다. new로 실행을 하며 any의 배열인 여러 인자를 받을 수 있습니다. 몸체가 비어 있는 타입으로 정의했습니다. 타입스크립트에서는 생성자 타입을 생성 시 관용적으로 나오니 알아두시면 좋습니다. ❷ 데코레이터를 적용한 클래스를 상속받은 익명 클래스를 반환합니다. ❸ 생성자를 재정의하였으며 인스턴스 생성 시 “HELLO!”라는 로그를 출력합니다. ❹ super( ) 메서드는 데코레이터를 적용한 클래스의 생성자 메서드입니다. 예제에서는 ‘인스턴스 생성됨’을 출력합니다.
다음으로 많이 사용하는 메서드 데코레이터를 만들겠습니다. 메서드의 실행 시간을 측정하는 코드는 자주 사용합니다. 자바스크립트에서는 console.time(label), console.timeEnd(label)를 사용해 만들 수 있습니다. label값에 같은 값을 넣어주면 실행한 시간을 확인할 수 있습니다. 예제로 살펴보겠습니다.
console.time("실행 시간"); // ❶ 실행 시간 측정 시작
execute(); // ❷ 오래 걸리는 함수 실행
function execute() {
setTimeout(() => {
console.log(`실행`);
console.timeEnd("실행 시간"); // ❸ 시간 측정 끝
}, 500);
}
실행
실행 시간: 503.181ms
❶ console.time(label);로 실행 시간 측정을 시작합니다. ❷ execute( ) 함수는 내부에서 setTimeout( )을 사용해서 0.5초가 걸리도록 했습니다. ❸ execute( ) 함수 실행 종료 후 console.timeEnd(label)을 사용해 실행 시간 측정을 종료합니다.
시간을 측정하는 작업은 간단한 작업이지만, 시작 지점과 끝 지점에서 각각 한 번씩 두 번 코드를 작성해야 하기 때문에 실수하기 십상입니다. 실행 시간 측정을 하는 코드를 메서드 데코레이터로 만들어봅시다. 코드는 다음과 같습니다.
function Timer() { // ❶ 데코레이터 팩토리 함수
return function (target: any, key: string, descriptor: PropertyDescriptor) {.
// ❷ 데코레이터
const originalMethod = descriptor.value; // ❸ 메서드.
descriptor.value = function (...args: any[]) { // ❹ 메서드의 동작을 변경함
console.time(`Elapsed time`);
const result = originalMethod.apply(this, args); // ❺ 메서드 실행.
console.timeEnd(`Elapsed time`);
return result;
};
}
}
class ElapsedTime {
@Timer()
hello() {
console.log(`Hello`);
}
}
Hello ← ❺ ElapsedTime 클래스의 hello() 메서드 실행 결과
Elapsed time: 0.062ms ← @Timer() 데코레이터 실행 결과
❶ function Timer( )는 데코레이터 팩토리 함수입니다. 데코레이터를 만들어서 반환하는 함수를 데코레이터 팩토리라고 합니다. ❷ 메서드 데코레이터 선언입니다. 결괏값으로 익명 함수를 반환합니다. ❸ 메서드 데코레이터의 매개변수 중 descriptor의 value값에는 기존 메서드가 값으로 할당되어 있습니다. 자바스크립트/타입스크립트에서는 함수도 값으로 할당할 수 있습니다. 기존 메서드의 동작을 ❹에서 덮어쓸 것이므로 originalMethod 변수에 담아둡니다. ❹ 기존 메서드의 동작을 변경합니다. console.time(label) console.timeEnd(label)을 사용해 시간 측정 동작을 추가하였습니다. 5 this는 데코레이터가 적용된 클래스의 인스턴스를 의미합니다. args는 기존 메서드의 매개변수 인자들입니다. 따라서 기존 메서드를 실행합니다.
결괏값으로 “Hello”가 나오고 다음 행에 “Elapsed time : <실행시간>ms”가 출력됩니다. console.time(label)의 label값을 변경하려면 어떻게 할까요? 바로 데코레이터 팩토리 함수에 매개변수로 label을 추가하고, label을 console.time( )과 console.timeEnd의 매개변수로 주면 됩니다. 해당 코드는 소스의 A.4.4-decorator.ts에 NamedTimer 데코레이터를 만들어두었으니 관심 있는 독자는 코드를 확인해보길 바랍니다.
데코레이터는 이처럼 런타임에 클래스, 메서드, 매개변수, 속성, 접근자의 동작을 변경하는 데 사용합니다. 본문에서는 클래스와 메서드 데코레이터를 만드는 방법만 알아보았습니다만, 두 가지 데코레이터의 사용법만 익혀두시면 매개변수 데코레이터, 속성 데코레이터, 접근자 데코레이터들도 활용하는 데 문제가 없을 겁니다.
4.5 맵드 타입
맵드 타입(Mapped Type)은 기존의 타입으로 새로운 타입을 만들어내는 타입스크립트 문법입니다. 기존 타입의 속성들을 배열처럼 사용해서 새로운 타입을 만드는 데 사용합니다. 문법은 다음과 같습니다.
{ [ key in <기존타입> ] : <새로운 타입 속성의 타입> }
문법에서 key는 임의의 키워드입니다.
어떤 기능을 표현한 타입이 있고, 해당 기능에 대한 권한을 부여하는 타입을 따로 만들면 다음과 같이 됩니다.
type Feature = { // 기능을 표현한 타입
event: string;
coupon: string;
}
type FeaturePermission = { // 해당 기능에 대한 권한을 표현한 타입
event?: boolean;
coupon?: boolean;
}
FeaturePermission 타입은 기존 Feature 타입의 속성명을 같이 사용합니다. Feature와 관련된 타입을 또 만들어야 하면 복사붙이기를 해야 합니다. 이 경우 기존 타입의 속성들을 활용해 새로운 타입을 만드는 데 사용하는 것이 맵드 타입입니다. FeaturePermission 타입을 맵드 타입으로 변경하면 다음과 같습니다.
type Feature = {
event: string;
coupon: string;
}
type FeaturePermission = { [key in keyof Feature]?: boolean };
// ❶ 맵드 타입으로 변경
❶ keyof 연산자는 객체 타입을 값으로 받으며 해당 객체 타입 키들의 리터럴 타입을 결괏값으로 반환합니다. 따라서 이 코드는 { [key in “event” | “coupon” ]?: boolean }와 같습니다. 실행하면 결과로 이전에 직접 타이핑한 FeaturePermission과 동일한 { event? : boolean; coupon?: boolean; } 타입을 얻을 수 있습니다.
타입스크립트에서는 같은 타입인데 기존 속성을 선택 속성으로 변경한다거나, 읽기 전용으로 변경하는 등의 자주 사용하는 로직을 유틸리티 타입으로 제공합니다. 유틸리티 타입을 만들 때 맵드 타입 문법을 사용했습니다. 선택 속성으로 바꾸고 싶을 때의 유틸리티 타입은 Partial이며, 읽기 전용은 Readonly입니다. 코드로 작성해 본다면 다음과 같습니다. 맵드 타입을 직접 만들어서 사용할 경우도 있겠지만, 이미 정의된 유틸리티 타입이 많이 있으니 활용해보시길 추천드립니다.
// 선택 가능 속성으로 모두 변경
type PartialFeature = Partial<Feature>;
// 읽기 전용으로 변경
type ReadonlyFeature = Readonly<Feature>;
PartialFeature는 다음과 같이 모든 속성이 ?가 붙은 선택 속성으로 변경되었습니다.
type PartialFeature는 = {
event?: string | undefined;
coupon?: string | undefined;
}
ReadonlyFeature는 모든 속성이 readonly가 붙은 읽기 전용으로 변경되었습니다.
type ReadonlyFeature = {
readonly event: string;
readonly coupon: string;
}
더 알아보기
- 인터페이스의 인덱스 시그니처: https://www.typescriptlang.org/docs/handbook/interfaces.html#indexable-types
- 타입스크립트 생성자의 제네릭 타입: https://www.simonholywell.com/post/typescript-constructor-type.html
- 타입스크립트 믹스인: https://www.typescriptlang.org/docs/handbook/mixins.html
- 유틸리티 타입: https://www.typescriptlang.org/docs/handbook/utility-types.html
- 맵드 타입: https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
지금까지 2편에 걸쳐서 ‘타입스크립트 소개와 기초’와 ‘인터페이스 및 클래스와 고급 기능’을 알아보았습니다. 타입스크립트를 시작하는 모든 분께 도움되길 바랍니다.