[MGS 3기 - 16일차] TypeScript

박철연·2022년 5월 2일
0

MGS STFE 3기

목록 보기
16/35

오늘 정리 내용은 타입스크립트 심화편입니다. 컴파일부터 인터페이스, 클래스 문법까지를 커버했습니다.

해당 정리글은 교육 과정 중 학습한 내용과 제가 개인적으로 요약한 타입스크립트 이론을 바탕으로 작성하였습니다.

타입스크립트 컴파일

TypeScript 설치법

저는 npm을 통해 타입스크립트를 설치하였고, 따라서 npm을 통한 타입스크립트 설치법을 소개해 보겠습니다.

(당연하게도, Node.js, npm이 미리 설치되어 있어야 합니다.)

// local 설치
$ npm install typescript

// global 설치
$ npm install -g typescript

// 타입스크립트 버전 확인
$ tsc -v

설치가 필요한 디렉토리에서 터미널을 열고, 위 명령어들을 입력하시면 끝입니다.

이미지에서 확인할 수 있듯이 "tsc -v" 명령어를 통해 타입스크립트를 제대로 설치하였는지, 버전은 몇인지 확인할 수 있습니다. 저는 옛날에 찍어서 4.4.4 버전으로 나오네요.

$ npm i ts-node --save-dev

마지막으로 위 명령어를 입력하시면 됩니다. ts-node는 node.js 상에서 타입스크립트를 실행할 수 있게 해주는 패키지입니다.

(엄밀히 말하면, 실제로 타입스크립트 파일 자체를 node.js에서 실행시키는 것이 아니라 타입스크립트 파일을 자바스크립트 파일로 컴파일하여 실행시키는 것입니다.)

TypeScript 컴파일하기

타입스크립트 파일은 그 자체로 브라우저에서 실행 시킬 수 없습니다. 브라우저가 타입스크립트 파일 읽는 법을 모르기 때문입니다.

브라우저는 자바스크립트 파일만을 읽을 수 있기 때문에, 작성이 끝난 타입스크립트 파일은 자바스크립트 파일로 컴파일 하는 작업을 거쳐야 합니다.

타입스크립트 컴파일을 하기 위해서는 제일 먼저 tsconfig.json이라는 파일을 만들어야 합니다.

이 파일은 타입스크립트를 자바스크립트로 변환하는 과정에 필요한 각종 설정 사항들을 선언하는 파일입니다. 간략한 예시는 다음과 같습니다.

제가 타입스크립트 공부를 하면서 실제로 작성했었던 tsconfig 파일입니다.

tsconfig.json에 들어가는 옵션들은 대단히 방대해서, 하나하나 세세히 설명하면 글이 너무 길어질 것 같네요.

한국어로 된 타입스크립트 소개 페이지가 깃허브 상에 공개되어 있으니, 다음 링크에 접속해 필요하거나, 궁금한 옵션들을 검색해 보시는 것을 추천드립니다.

https://typescript-kr.github.io/pages/compiler-options.html

tsconfig.json을 만들어서 필요한 사항들을 설정한 후에, 터미널 상에서 컴파일 관련 명령어를 입력해주면 컴파일이 완료됩니다.

// 현재 파일에 대한 컴파일 실행
$ tsc

// 컴파일러 기본값으로 index.ts만 트랜스파일
$ tsc index.ts

// 기본 설정으로 src 폴더 안에 모든 ts 파일을 트랜스파일
$ tsc src/*.ts

// watch mode로 ts 컴파일 실행
$ tsc -w

기본 명령어는 tsc이며, tsconfig 파일과 상관없이 특정 파일만 컴파일 하고 싶으면 tsc + 파일명 형식으로 명령어를 입력하면 됩니다.

또한, tsc -w를 통해 tsc 파일을 실시간으로 컴파일하는 것도 가능하니 꼭 알아두세요.

타입스크립트는 문법에 아주 엄격하기 때문에, 어떤 이유로든 타입스크립트 파일에 에러가 있으면 정상적으로 자바스크립트 파일로 컴파일 되지 않습니다.

타입스크립트 Interface

Interface 개요

인터페이스는 일반적으로 타입 체크를 위해 사용되며 변수, 함수, 클래스에 사용할 수 있습니다.

인터페이스는 여러가지 타입을 갖는 프로퍼티로 이루어진 새로운 타입을 정의하는 형식으로 이루어집니다.

바닐라 자바스크립트(ES6)는 인터페이스를 지원하지 않지만 TypeScript는 인터페이스 문법을 지원합니다.

인터페이스 정의

인터페이스는 변수의 타입으로 사용할 수 있습니다. 이때 인터페이스를 타입으로 선언한 변수는 해당 인터페이스를 준수하여야만 합니다.

따라서 인터페이스를 정의하는 것은 새로운 타입을 정의하는 것과 매우 유사합니다.

// 인터페이스의 정의
interface Person {
  id: number;
  name: string;
  isMarried: boolean;
}

// 변수 paul의 타입으로 Person 인터페이스를 선언
let paul: Person;

// 변수 paul은 Person 인터페이스를 준수
paul = { id: 1, name: 'Paul', isMarried: false };

아래 코드 블럭과 같이, 인터페이스를 사용하여 함수 파라미터의 타입을 선언할 수도 있습니다.

이때 해당 함수에는 함수 파라미터의 타입으로 지정한 인터페이스를 준수하는 인수를 전달하여야 합니다.

이러한 방식은 함수에 객체를 전달할 때 복잡한 매개변수 체크가 필요없어서 유용하게 활용할 수 있습니다.

interface Todo {
  id: number;
  content: string;
  completed: boolean;
}

let todos: Todo[] = [];

// 파라미터 todo의 타입으로 Todo 인터페이스를 선언
function addTodo(todo: Todo) {
  todos = [...todos, todo];
}

// 파라미터 todo는 Todo 인터페이스를 준수.
const newTodo: Todo = { id: 1, content: 'typescript', completed: false };
addTodo(newTodo);
console.log(todos)
// [ { id: 1, content: 'typescript', completed: false } ]

extends의 사용

interface 문법에는 큰 특징 하나가 있는데, 바로 extends라는 키워드를 사용할 수 있다는 것입니다.

우리가 두 가지 interface를 만든다고 가정해보겠습니다.

interface Toy { 
  color :string, 
  quantity :number, 
}
  
interface Robot { 
  color :string, 
  quantity :number,
  isTransforming: boolean,
}

이렇게 Toy와 Robot 두 가지 interface를 만들어 보았습니다. 보시다시피, 두 interface는 color와 quantity를 공유하고 있습니다.

이 때 사용할 수 있는 것이 바로 extends입니다.

interface Toy { 
  color :string, 
  quantity :number, 
}
  
interface Robot extends Toy { 
  isTransforming: boolean,
}

Robot은 Toy의 연장선상이기 때문에, 문자열인 속성 color와 숫자인 quantity를 가지게 됩니다.

거기에 불리언 타입의 isTransforming이 추가되게 됩니다.

이런 식으로 extends 문법을 사용하면 비슷한 형태의 객체를 디테일하게 바꿔서 사용할 수 있을 것입니다.

type 문법과의 차이점

앞서서 interface 문법이 type 문법과 비슷하다고 말씀드렸는데, 이는 extends 문법과 어느 정도 관련이 있습니다.

interface 문법을 활용한 코드를 한 번 살펴보겠습니다.

// interface
interface Animal { 
  name :string 
} 
interface Dog extends Animal { 
  legs :number 
}

// type 문법
type Animal = { 
  name :string 
} 
type Dog = Animal & { legs: number }

interface는 간편하게 extends를 쓸 수 있지만, type은 그런 장치가 없습니다.

그래서 마지막 줄 처럼 다른 타입을 유니언 타입 지정 하듯이 합쳐줘야 합니다.

(다만, 인터섹션 기호인 &를 사용하는 방식은 interface로 선언해도 사용 가능합니다.)

또 타입을 중복 선언했을 때에도 interface 문법과 type 문법은 차이를 보입니다.

interface Animal { 
  name :string 
} 
interface Animal { 
  legs :number 
}

이런 식으로 이름을 중복하면 interface는 알아서 해당 내용을 override 해줍니다.

즉, 위 코드 블럭의 결과로 Animal 타입은 name과 legs 모두를 속성으로 가지게 되겠죠.

type Animal = { 
  name :string 
} 
type Animal = { 
  legs :number 
}

type 문법에서는 타입 이름 중복하면 바로 에러가 뜹니다. 좀 더 엄격하다고 할 수 있겠습니다.

그래서 일반적으로는 type 문법을 쓰되, 수정이 자유롭도록 유연한 타입 지정을 하고 싶을 때에는 interface 문법을 쓰면 되겠습니다.

타입 이름 중복도 허용하고, extends로 수정 및 추가도 가능하기 때문이죠.

함수와 인터페이스

인터페이스는 함수의 타입으로도 사용할 수 있습니다. 이때 함수의 인터페이스에는 타입이 선언된 파라미터 목록과 반환될 타입을 정의합니다.

함수 인터페이스를 구현하는 함수는 인터페이스 문법을 준수하여야만 합니다.

// 함수 인터페이스의 정의
interface SquareFunc {
  (num: number): number;
}

// 함수 인테페이스를 구현하는 함수는 인터페이스를 준수하여야 한다.
const squareFunc: SquareFunc = function (num: number) {
  return num * num;
}

console.log(squareFunc(10)); // 100

클래스

Typescript가 지원하는 클래스는 ES6의 클래스와 상당히 유사하지만 몇 가지 Typescript만의 고유한 확장 기능이 있습니다.

클래스 정의

ES6 클래스는 클래스 몸체에 메소드만을 포함할 수 있습니다. 클래스 몸체에 클래스 프로퍼티를 선언할 수 없고 반드시 생성자 내부에서 클래스 프로퍼티를 선언하고 초기화합니다.

아래 자바스크립트 코드를 한 번 참고해보세요.

// person.js
class Person {
  constructor(name) {
    // 클래스 프로퍼티의 선언과 초기화
    this.name = name;
  }

  walk() {
    console.log(`${this.name} is walking.`);
  }
}

위 코드를 자바스크립트가 아니라, 타입스크립트 파일로 변환한 뒤 컴파일을 시도하면 오류가 발생합니다.

그 이유는 타입스크립트에서의 클래스는 클래스 몸체에 클래스 프로퍼티를 사전 선언하여야 하기 때문입니다.

따라서 타입스크립트에서는 다음과 같은 형식으로 코드를 작성해야 에러가 발생하지 않습니다.

// person.ts
class Person {
  // 클래스 프로퍼티를 사전 선언
  name: string;

  constructor(name: string) {
    // 클래스 프로퍼티에 값을 할당
    this.name = name;
  }

  walk() {
    console.log(`${this.name} is walking.`);
  }
}

const person = new Person('Lee');
person.walk(); // Lee is walking

Class와 필드값

자바스크립트의 class 문법은 비슷한 형태의 객체 데이터를 찍어내는 데 아주 적합합니다.

class 문법에는 모든 자식 객체들이 활용할 수 있는 속성을 공용으로 만드는 기능도 있습니다.

이를 필드라고 보통 이야기하고, 다음과 같은 방식으로 작성합니다.


class Toy {
  color = "red";
}

이런 식으로 작성해주면, Toy라는 클래스로 생성된 모든 객체들은 color="red"를 속성으로 가지게 됩니다. 따로 지정하지 않아도요.

필드 타입 지정하기

우리의 타입스크립트는 기대를 저버리지 않습니다. 필드에도 타입 지정이 가능합니다.

다음과 같은 방식으로 작성하시면 됩니다.

사실 필드의 타입 지정은 제가 "red"라는 문자열을 기입했을 때 부터 암묵적으로 string으로 정해집니다.

그래서 굳이 따로 타입 지정을 하지 않고, 내가 집어 넣고 싶은 형태의 데이터를 작성하면 알아서 타입 지정이 되긴 합니다.

constructor 함수

constructor 함수는 ES6에서 새롭게 등장한 class 관련 문법입니다. 필드처럼 자식 객체들에게 속성을 공유시켜 주는 동시에, 인자를 활용할 수 있게 해줍니다.

말이 약간 복잡한데, 아래 코드 블럭을 참고하시면 이해가 빠르실 것 같습니다.


class Freshman {
  constructor (a){
    this.name = a;
    this.age = 20;
  }
}

new Freshman("Park")

이러면 age는 20이지만 name은 "Park"인 객체가 생성이 되겠죠?

타입스크립트에서 constructor 함수를 정의할 때 주의할 점은, 내가 사용하고자 하는 속성들이 미리 정의되어 있어야 한다는 것입니다. 아니면 에러가 납니다.

위 예시에서는 name과 age가 여기에 해당됩니다. 그래서 아래 코드 블럭처럼 작성해야 오류를 피할 수 있어요.


class Freshman {
  name;
  age;
  constructor (a){
    this.name = a;
    this.age = 20;
  }
}

constructor 함수에서 타입 지정하기

위의 예시에서 우리는 constructor 함수에 들어가는 인자 a가 객체의 name 속성에 들어가게끔 해주었습니다.

코드를 작성하다 보니, name에 문자열을 제외한 다른 타입이 들어가지 못하게 해주고 싶다고 생각했습니다.

애초에 constructor 함수도 함수이기 때문에 그냥 인자에 타입 지정하듯이 하면 간단합니다.


class Freshman {
  name;
  age;
  constructor (a: string){
    this.name = a;
    this.age = 20;
  }
}

추가로, 메서드도 똑같이 타입 지정할 수 있습니다. 함수이기 때문에, 넣을 인자와 결과 값 모두에 타입 지정할 수 있습니다.

접근 제한자

타입스크립트 클래스는 클래스 기반 객체 지향 언어가 지원하는 접근 제한자(Access modifier) public, private, protected 를 지원하며 의미 또한 기본적으로 동일합니다.

접근 제한자를 명시하지 않았을 때, 다른 클래스 기반 언어의 경우, 암묵적으로 protected로 지정되어 패키지 레벨로 공개됩니다.

단, Typescript의 경우, 접근 제한자를 생략한 클래스 프로퍼티와 메소드는 암묵적으로 public이 선언됩니다.

따라서 public으로 지정하고자 하는 멤버 변수와 메소드는 접근 제한자를 생략하면 되는 식입니다.

접근 제한자를 선언한 프로퍼티와 메소드에 대한 접근 가능성은 아래와 같습니다.

제네릭

제네릭의 정의

제네릭은 C#, Java 등의 언어에서 재사용성이 높은 컴포넌트를 만들 때 자주 활용되는 특징입니다. 특히, 한가지 타입보다 여러 가지 타입에서 동작하는 컴포넌트를 생성하는데 사용됩니다.

타입스크립트에서 제네릭이란 타입을 마치 함수의 파라미터처럼 사용하는 것을 의미합니다.

function getText(text) {
  return text;
}

getText('hi'); // 'hi'
getText(10); // 10
getText(true); // true

위 코드 블럭의 getText 함수는 파라미터에 값을 넘겨 받아 text를 반환해줍니다. hi, 10, true 등 어떤 값이 들어가더라도 그대로 반환합니다.

이를 제네릭 문법을 활용하여 다음과 같이 변환해볼 수 있습니다.

function getText<T>(text: T): T {
  return text;
}

getText<string>('hi');
getText<number>(10);
getText<boolean>(true);

'hi'를 입력한 예시를 풀어보면 다음과 같습니다.

function getText<string>(text: string): string {
  return text;
}

getText<string>('hi');

제네릭의 사용 목적

다음 코드 블럭을 예시로 사용해보겠습니다.

function logText(text: any): any {
  return text;
}

함수의 인자와 값 모두가 any이지만, 당연히 함수의 동작 자체는 아무런 문제 없이 실행될 것입니다.

하지만 이런 식으로 any를 지정하면 함수의 인자로 어떤 타입이 들어갔고 어떤 값이 반환되는지는 알 수가 없습니다. any라는 타입은 타입 검사를 하지 않기 때문이죠.

이를 제네릭을 활용한 예시로 바꿔보겠습니다.

function logText<T>(text: T): T {
  return text;
}

먼저 함수의 이름 바로 뒤에 제네릭 코드를 추가했습니다. 이를 바탕으로 함수의 인자와 반환 값에 모두 T 라는 타입을 추가합니다.

이렇게 되면 함수를 호출할 때 넘긴 타입에 대해 타입스크립트가 추정할 수 있게 됩니다. 따라서, 함수의 입력 값에 대한 타입과 출력 값에 대한 타입이 동일한지 검증할 수 있게 됩니다.

제네릭 타입 변수

위의 logText 함수 예시를 그대로 가져와 정리해보겠습니다.

logText 함수를 작성할 때, 인자로 넣은 값의 length를 확인하고 싶다면 다음과 같이 작성해볼 수 있을 것입니다.

function logText<T>(text: T): T {
  console.log(text.length); // Error: T doesn't have .length
  return text;
}

코드 블럭에 명시된 것과 같이, 위와 같이 text.length에 접근하게 되면 에러가 발생합니다.

생각해보면 그럴 만 합니다. 왜냐하면 text에 .length가 있다고 판단할 근거가 전혀 없기 때문입니다.

다시 위 제네릭 코드의 의미를 살펴보면 함수의 인자와 반환 값에 대한 타입을 정하진 않았지만, 입력 값으로 어떤 타입이 들어왔고 반환 값으로 어떤 타입이 나가는지 알 수 있습니다.

따라서, 함수의 인자와 반환 값 타입에 마치 any를 지정한 것과 같은 동작을 한다는 것을 알 수 있습니다. 그래서 설령 인자에 number 타입을 넘기더라도 에러가 나진 않습니다.

이러한 특성 때문에 현재 인자인 text에 문자열이나 배열이 들어와도 아직은 컴파일러 입장에서 .length를 허용할 순 없습니다. 왜냐하면 number와 같은 타입이 들어왔을 때는 .length 코드가 유효하지 때문입니다.

그래서 아래와 같이 제네릭 문법을 조금 응용해 볼 수 있겠습니다.

function logText<T>(text: T[]): T[] {
  console.log(text.length); // 제네릭 타입이 배열이기 때문에 `length`를 허용합니다.
  return text;
}

이 제네릭 함수 코드는 일단 T라는 변수 타입을 받고, 인자 값으로는 배열 형태의 T를 받습니다.

예를 들면, 함수에 [1,2,3,4]처럼 숫자로 이뤄진 배열을 받으면 반환 값으로 number를 돌려주는 것입니다. 이런 방식으로 제네릭을 사용하면 꽤 유연한 방식으로 함수의 타입을 정의해줄 수 있습니다.

profile
Frontend Developer

0개의 댓글