제네릭(Generics)으로 당신의 타입스크립트를 DRY하게 유지하는 방법

ggong·2021년 5월 20일
0

원본 : https://blog.asayer.io/keeping-your-typescript-code-dry-with-generics 의 해석입니다.

자바스크립트의 인기가 높아지고 많이 사용되면서, 타입 체킹에 익숙했던 타 언어 개발자들이 자바스크립트를 사용해보며 '느슨한 타입 체크'에 대해 불평하는 경우도 많아졌다. 이를 해결하기 위해 자바스크립트의 수퍼셋으로 타입스크립트가 도입되었다. 타입스크립트는 정적 타입 정의를 통해 개발자들이 실제로 코드를 실행해보기 전에도 에러를 추적할 수 있어 효율적으로 개발이 가능하도록 한다.

이 글에서는 타입스크립트의 중요한 컨셉 중 하나인 제네릭(Generics)에 대해 알아보고, 이것이 어떻게 개발자들에게 Don't-Repeat-Yourself (DRY) 원칙에 따라 개발할 수 있도록 도움을 주는지 알아보고자 한다. 이 글은 당신이 타입스크립트에 대해 기본 지식이 있다는 전제로 쓰여졌다. (없으면 여기) 글에 나온 예제를 따라하기 위해서는 아래와 같은 환경이 필요하다.

  • Node v12 혹은 그 이상
  • npm v5.2 혹은 그 이상
  • 코드 에디터
  • 터미널

타입스크립트 설치

프로젝트를 시작하기 위해서 터미널을 열고 작업할 폴더를 만든다.

mkdir typescript-generics-example

해당 디렉토리 내부에서 package.json 파일을 만든다.

npm init -y

타입스크립트 라이브러리를 설치한다.

# using npm
npm install --global typescript # Global installation
npm install --save-dev typescript # Local installation

tsconfig.json 파일을 만든다.

tsc --init

여기까지 하면 당신의 프로젝트 디렉토리에 tsconfig.json 파일이 생성되었을 것이다. tsconfig.json 파일은 루트 파일들을 명시하고 프로젝트를 컴파일하기 위한 컴파일러 옵션을 설정한다.

셋업된 내용을 테스트하기 위해서 루트 디렉토리에 app.ts 파일을 생성하고, 아래 코드를 넣는다.

function showMessage(name: string) {
  console.log('Hello ' + name);
}
showMessage('Sama');

그리고 package.json에 가서 script 속성을 아래처럼 변경한다.

"scripts": {
  "dev": "tsc app.ts && node app.js && rm -rf app.js"
},

dev 명령어는 프로그램 실행 전에 타입스크립트를 자바스크립트로 트랜스파일하고, 실행 후에 자바스크립트 파일을 제거한다. npm run dev로 프로그램을 실행시켜보자. 터미널 콘솔에 'Hello Sama'가 찍힌 것을 볼 수 있다.

(근데 문서 끝까지 읽고 나면 위 단계는 왜 했는지 잘 모르겠다...)

제네릭(Generic)이란?

제네릭은 Java나 C#처럼 엄격한 타입의 언어(strongly typed languages)가 가진 중요한 특성이다.

  • strongly typed languages : 프로그래밍 언어에서 타입 체계가 컴파일 중이나 실행 중에 모든 타입 오류를 찾아낼 수 있을 경우 이를 엄격한 타입의(strongly typed) 언어 라고 한다. 엄격한 타입의 언어는 동적 타입 결정 또는 정적 타입 결정일 수 있다.

타입스크립트에서는 제네릭을 통해 컴포넌트와 함수의 타입이 나중에 정해질 수 있게 되고, 따라서 컴포넌트가 각기 다른 케이스에서 사용될 수 있도록 함으로써 재사용성을 높여준다. 아래 예시를 보자.

function returnInput <Type>(arg: Type): Type {
  return arg;
};
const returnInputStr = returnInput<string>('Foo Bar');
const returnInputNum = returnInput<number>(5);

console.log(returnInputStr); // Foo Bar
console.log(returnInputNum); // 5

유저 인풋을 리턴하는 제네릭 함수를 만들었다. 코드를 실행하는 시점에 타입을 명시해주기 때문에, 함수를 호출할 때 이 함수의 타입이 안전하게 지켜졌을 거라고 확신할 수 있다. 이렇게 되면 함수를 다양한 경우에서 재사용하기가 쉬우면서도 함수의 리턴 타입을 안전하게 체크할 수 있다.

문제는?

당신이 만든 함수나 컴포넌트를 여러 케이스에서 사용해야 할 경우가 있다. 아래 케이스를 보자.

type TodoItem = {
  taskId: number;
  task: string | number;
  done: boolean;
};

let id: number = 0;
let todoList: Array<TodoItem> = [];

function printTodos(): void {
  console.log(todoList);
}

function addTodo(item: string): void {
  todoList.push({
    taskId: id++,
    task: item,
    done: false,
  });
}

function addTodoNumber(item: number): void {
  todoList.push({
    taskId: id++,
    task: item,
    done: false,
  });
}

addTodo('Learn Typescript');
addTodoNumber(22);
printTodos();

우리는 서로 다른 두 개의 타입을 가진 todoItem을 todoList 배열에 넣으려고 한다. todoItem이 가질 수 있는 타입이 2개이기 때문에, 우리는 todoList에 새로운 아이템을 추가하는 함수도 2개를 구현해야 했다. 이것은 Don't -Repeat-Yourself(DRY) 원칙에 위배된다.

Don't-Repeat-Yourself(중복 배제 원칙)
중복 배제 (Don't repeat yourself; DRY)는 소프트웨어 개발 원리의 하나로, 모든 형태의 정보 중복을 지양하는 원리이다. 시스템 안에서 모든 지식과 논리는 유일하고, 모호하지 않은 형태로 표현되어야 한다. 중복을 피하기 위해 추상화나 일반화를 사용한다.

위 코드에서는 두 개의 함수가 타입만 다를 뿐 같은 로직을 수행하기 위해 선언되었다. 이를 해결하기 위해 먼저 생각할 수 있는 대안은 any 타입을 사용하는 것이다.

type TodoItem = {
  taskId: number;
  task: string | number;
  done: boolean;
};

let id: number = 0;
let todoList: Array<TodoItem> = [];

function printTodos(): void {
  console.log(todoList);
}

function addTodo(item: any): void {
  todoList.push({
    taskId: id++,
    task: item,
    done: false,
  });
}

addTodo('Learn Typescript');
addTodoNumber(22);
printTodos();

이렇게 되면 DRY 원칙에는 부합하지만 또 다른 문제가 생긴다. any를 사용하면 모든 타입이 가능해지므로 addTodo 함수가 받고 리턴하는 타입을 컨트롤하기가 어려워지고, 타입 체크를 하는 이점이 사라진다.

다음 섹션에서는 제네릭을 사용해 타입의 불변성을 유지하면서 DRY 원칙을 지키는 방법에 대해 알아보려고 한다.

제네릭(Generics) 사용하기

let id: number = 0;
let todoList = [];

function printTodos(): void {
  console.log(todoList);
}
function addTodo<Type>(item: Type): void {
  todoList.push({
    taskId: id++,
    task: item,
    done: false,
  });
}

addTodo<string>('Learn TypeScript');
addTodo<number>(22);
printTodos();

위의 솔루션에서는 addTodo 함수를 제네릭으로 만들었다.
function addTodo<Type>(item: Type): void 에서 쓰인 <Type>은 실제로 함수가 동작할 때 받을 타입에 의해 대체되어질 플레이스 홀더이다. 예를 들어 addTodo<string> 처럼 함수를 사용할 때 파라미터의 타입이 string임을 명시해주면, 함수에 전달된 파라미터가 string이 아닐 경우 에러가 날 것이다.

또한 제네릭 함수를 정의할 때 default 타입을 명시할 수도 있다.

function addTodo<Type = string>(item: Type): void {
  todoList.push({ taskId: id++, task: item, done: false });
}

addTodo('Learn TypeScript');

지금까지 파라미터 한 개를 가진 제네릭 함수 만드는 법을 알아보았다. 그런데 만약 파라미터가 여러 타입을 가진 여러개라면?

function addTodo<T, S>(item: T, status: S): void {
  todoList.push({ taskId: id++, task: item, done: status });
}

addTodo<string, boolean>('Learn TypeScript', true);
addTodo<number, boolean>(22, false);
printTodos();

여러 개의 파라미터를 <T, S> 플레이스 홀더로 넘기고, 각 argument에 명시함으로써 해결할 수 있다. 함수를 실행할 때 플레이스 홀더를 실제 사용할 타입으로 지정해서 addTodo<string, boolean>('haha', true)처럼 넘기면 된다.

제네릭 클래스(Generic Classes)

클래스도 제네릭으로 만들 수 있다. todo 클래스를 만들어서 어떻게 동작하는지 알아보자.

let id: number = 0;
type TodoItem = {
  taskId: number;
  task: string;
  done: boolean;
}

class Todo<Type> {
  _todoList: Array<Type> = [];
  addTodo(item: Type): void {
    this._todoList.push(item);
  }
  printTodos(): void {
    console.log(this._todoList);
  }
}

const Todos = new Todo<TodoItem>();
Todos.addTodo({ taskId: id++, task: 'learn TypeScript', done: true });
Todos.addTodo({ taskId: id++, task: 'Practice TypeScript', done: false });
Todos.printTodos();

제네릭 클래스는 제네릭 함수와 비슷한데, 가장 큰 차이점은 사용법이다. 제네릭 함수는 호출 시점에 타입 파라미터를 받지만 제네릭 클래스는 인스턴스가 생성되는 시점에 파라미터 타입이 주어진다.

제네릭 제약

제네릭은 DRY 원칙을 지키는데 도움이 되지만, 아래와 같은 의문이 들기도 한다.

만약 제네릭 함수에서 특정한 타입을 배제하고 싶다면 어떻게 해야 할까? 혹은 함수가 가진 로직이 특정한 타입을 지원하지 않을 때는?

함수가 제네릭이라는 것은 어떤 타입도 가능하다는 의미이다. 만약 제네릭 함수가 받는 파라미터를 몇 가지 타입으로 규정하고 싶다면 어떻게 해야 할까?
이를 위해서 우리는 extends 키워드를 사용해 우리가 원하는 타입들을 규정할 수 있다.

function addTodo<T extends string | number>(item: T): void {
  todoList.push({ taskId: id++, task: item, done: false });
}

위 코드처럼 제네릭 함수가 string이나 number 타입의 파라미터만 받을 수 있도록 명시하는 것이 가능하다.

또 다른 예시로, 만약 배열의 length를 리턴하는 아래 함수가 있다고 하자.

function getTodoListLength<T>(arr: T): void {
  return arr.length;
}

만약 length 값을 얻을 수 없는 타입의 파라미터가 들어가면 에러가 날 것이다. 이 문제를 해결하려면 아까처럼 extends 키워드를 사용해 함수가 실행될 수 있는 가능한 타입들을 명시해주면 된다.

function getTodoListLength<T extends Array<TodoItem>>(arr: T): void {
  return arr.length;
}

getTodoListLength<Array<TodoItem>>(todoList);

이 글에서 우리는 타입스크립트 제네릭을 이용해 함수와 클래스를 만들고, 재사용성과 DRY 원칙을 함께 지킬 수 있는 방법에 대해 알아보았다. 또한 extends를 사용해 제네릭을 조건적으로 사용하는 방법에 대해서도 알아보았다.
제네릭은 이해하기 까다로운 개념이지만, 이 글의 목적은 구체적인 코드 사례로 제네릭의 개념을 덜 복잡하게 설명하려는 것이었다. 연습을 통해 실제 여러분의 애플리케이션에 사용할 수 있게 되기를 바란다.



참고 :
Don't-Repeat-Yourselt
(https://en.wikipedia.org/wiki/Don%27t_repeat_yourself)

profile
파닥파닥 FE 개발자의 기록용 블로그

0개의 댓글