TypeScript를 이용한 대수적 타입

김민상 · 원프레딕트 프론트엔드 엔지니어
September 30, 2023

기존에 발행된 글을 정리해서 다시 소개합니다. 주1

Intro.

고전적인 웹 애플리케이션들은 순수한 HTML, CSS, Javascript의 세 언어만 이용하여 구성되었습니다. 그중에서 웹페이지의 상호작용 및 동작을 수행하는 JavaScript는 동적 타입 언어로써 프로그램에 사용된 각 심볼의 타입이 실행되는 순간에 평가되기 때문에 매우 유연하게 작성할 수 있다는 장점이 있고, 브라우저에서 실행 가능한 언어로써 지금까지도 많이 쓰이고 있습니다.

그러나 JavaScript를 이용하여 작성된 프로그램에서는 많은 에러들이 런타임에 발생하고, 이런 런타임 에러들은 발생하기 전에는 찾거나 수정하기 상당히 까다롭다는 단점을 가지고 있기 때문에 모던 웹 개발에서는 이러한 문제를 해결하고자 TypeScript라는 JavaScript의 확장을 이용하고 있습니다.

원프레딕트 프론트엔드 팀은 위에서 언급한 이유로 TypeScript를 이용한 타입 시스템을 기반으로 개발하고 있습니다. 타입 시스템을 기반으로 하는 개발은 런타임에 발생할 수 있는 예외 상황들을 린터 등을 통해 컴파일 타임에 미리 배제할 수 있으며, 함수에 유효하지 않은 값이 넘겨지는 상황 등을 미연에 방지할 수 있습니다.

타입 시스템을 기반으로 실행 전에 신뢰할 수 있는 프로그램의 형태를 구성하기 위해서 **“대수적 데이터 타입(Algebraic Data Type, 이하 ADT)”**이라는 개념을 이용하고 있습니다. 본 글에서는 이 대수적 데이터 타입이 무엇인지, 간단한 예시를 통해 설명하고자 합니다.

함수형 프로그래밍과 범주론에서는 ADT이라는 합성 타입을 이용하여 하위 타입들을 확장하고, 더 큰 범주의 타입을 설계할 수 있습니다.

가장 일반적인 ADT의 종류에는 다음의 두 가지가 있습니다.

  • 합 타입(Sum Type, 혹은 Variants, Tagged Union)
  • 곱 타입(Product Type, 혹은 Record, Tuple)

합 타입(Sum Type)

합 타입은 하위 타입 중 하나만 가지는 타입입니다. 합 타입으로 구성되는 타입 집합의 크기(카디널리티)는 하위 타입 집합 크기의 합과 같습니다

예를 들어, 트럼프 카드 세트와 타로 카드 세트를 포함하며 현실 세계의 카드 게임에 쓰이는 카드들의 세트를 모방하는 임의의 CardSet 타입은 다음과 같이 나타낼 수 있습니다.

type CardSet = TrumpCard | TarotCard;

위의 CardSetTrumpCard가 나타낼 수 있는 모든 카드와 TarotCard가 나타낼 수 있는 모든 카드를 포함합니다. 이는 트럼프 카드 세트의 “♠️4”나 타로 카드 세트의 “Fool” 카드가 하나의 카드라는 집합에 속하지만 하나의 카드가 한 번에 하나의 하위 타입에만 속한다는 것을 생각하면 당연한 구조입니다. 따라서 타입 CardSet이 나타낼 수 있는 카드의 수 NCardSet는 (트럼프 카드의 수 NTrumpCard + 타로 카드의 수 NTarotCard)와 같습니다.

곱 타입(Product Type)

곱 타입은 하위 타입을 동시에 가지는 타입입니다. 곱 타입으로 구성되는 타입 집합의 크기는 하위 타입 집합의 곱과 같습니다.

위의 예제에 이어, 하나의 트럼프 카드는 문양을 나타내는 Suit와 그 카드의 값인 Rank를 동시에 가지므로, 다음과 같이 나타낼 수 있습니다.

type TrumpRank =
  1 | 2 | 3
  | 4 | 5 | 6
  | 7 | 8 | 9 | 10
  | "Jack" | "Queen" | "King";

type TrumpSuit =
  "♠️" | "♣️" | "♥️" | "♦️";

type TrumpCard = [TrumpSuit, TrumpRank];

여기서는 TrumpCard라는 하나의 타입을 TrumpSuitTrumpRank로 구성된 튜플 타입으로 나타내었습니다. 여기서 타입 TrumpCard가 나타낼 수 있는 카드의 수 NTrumpCard는 (카드의 문양 수 NSuit * 카드의 랭크 수 NRank)입니다. 이는 TrumpRankTrumpSuit가 가지는 집합의 크기는 각각 13과 4이므로, TrumpCard 타입 집합의 크기는 52로 결정됨을 의미합니다.

튜플 타입의 하위 타입은 인덱스를 통해 접근할 수 있으나, 코드를 작성할 때 튜플 타입을 인덱스로 접근하는 것이 직관적이지 않고 코드의 가독성을 해치므로, 일반적으로 TypeScript에서는 튜플 타입 대신 Record Type을 통해 Product Type을 이용합니다. 위의 TrumpCard 타입은 Record로 변환하면 다음과 같이 작성할 수 있습니다.

type TrumpCard = {
  suit: TrumpSuit;
  rank: TrumpRank;
};

이제 각 TrumpCard 타입 값의 문양과 랭크는 각각의 이름인 suitrank를 통해 접근할 수 있습니다. 아래 예제의 isSpade 함수는 주어진 TrumpCard 타입의 suit 속성을 통해 trumpCard의 문양이 ♠️인 경우에는 true, 아닌 경우에는 false를 반환합니다.

function isSpade(trumpCard: TrumpCard) {
  return trumpCard.suit === "♠️";
}

const spadeFour: TrumpCard = {
  suit: "♠️",
  rank: 4,
}

const heartKing: TrumpCard = {
  suit: "♥️",
  rank: "King",
}

const isSpadeFourSpade = isSpade(spadeFour); // true
const isHeartKingSpade = isSpade(heartKing); // false

구분 필드를 이용한 가독성 확보

위의 트럼프 카드 타입 예제에서 조커 카드가 빠졌다고 생각하셨나요? 위의 트럼프 카드를 확장하면 됩니다. 이처럼 대수적 타입은 확장에 유연한 구조로 되어 있습니다. 아래 코드에서는 흑백 조커 카드 BWJoker와 컬러 조커 카드 ColorJokerSum Type으로 확장한 TrumpJoker 타입을 기존의 TrumpCard 타입에 Sum Type으로 확장했습니다.

type TrumpRank =
  1 | 2 | 3
  | 4 | 5 | 6
  | 7 | 8 | 9 | 10
  | "Jack" | "Queen" | "King";

type TrumpSuit =
  "♠️" | "♣️" | "♥️" | "♦️";

type TrumpJoker = "BWJoker" | "ColorJoker";

type TrumpCard = {
  suit: TrumpSuit;
  rank: TrumpRank;
} | TrumpJoker;

이렇게 대수적 타입을 이용하면 하나의 트럼프 카드 세트에 있는 모든 카드를 빠르고 간단하게 정의할 수 있습니다.

그러나 TrumpJoker 타입을 추가함으로써 isSpade 함수에 에러가 발생하는 것을 볼 수 있습니다. TrumpJoker 타입에는 suit라는 속성이 없기 때문입니다.

Property ‘suit’ does not exist on type ‘TrumpCard’. Property ‘suit’ does not exist on type ‘“BWJoker”'(2399).

이렇게 변경된 TrumpCard에 대응하는 isSpade를 확장하는 방법은 다음의 세 가지가 있습니다.

  1. trumpCard 값을 조커의 하위 타입 리터럴과 비교하고, 예외 절에서 suit 속성을 통해 판별한다.
  2. trumpCard 값에 suit 속성이 존재하는 지 판별하고, suit 속성이 존재하는 경우에만 suit 속성을 통해 판별한다.
  3. TrumpCard 타입의 하위 타입을 확장하여, 두 하위 타입을 구분할 수 있는 별개의 속성 필드를 통해 판별한다.

여기서는 세 번째 방법을 통해 이 문제를 해결하는 과정을 설명합니다. 각 방법에는 장/단점이 존재하지만, 세 번째 방법은 추후 TrumpCard의 하위 타입이 병렬적으로 증가할 때 유연하게 대처할 수 있고, 명확하게 하위 타입 이름을 참조함으로써 코드 가독성을 높이는 장점이 있기 때문입니다.

우선 조커가 아닌, suitrank 속성을 가지는 집합인 TrumpNormalCard 타입을 아래와 같이 정의합니다.

type TrumpNormalCard = {
  suit: TrumpSuit;
  rank: TrumpRank;
  trumpType: "normal";
};

suitrank 외에 trumpType: "normal"이 추가된 것에 주목하세요. 이제 TrumpCard 타입의 하위 타입은 이 속성을 통해 구분할 것입니다.

TrumpJoker 타입도 trumpType 속성을 가지도록 아래와 같이 확장합니다.

type TrumpJoker = {
  jokerType: "BW" | "Color";
  trumpType: "joker";
};

그리고 TrumpCard 타입을 TrumpNormalCardTrumpJoker의 Sum Type으로 구성합니다.

type TrumpCard = TrumpNormalCard | TrumpJoker;

이제 isSpade 함수에서 trumpCardsuit 속성을 바로 확인하지 않고, trumpType을 먼저 판별하도록 수정합니다. trumpTypenormal일 때만 suit 속성을 통해 판별합니다.

function isSpade(trumpCard: TrumpCard) {
  switch (trumpCard.trumpType) {
    case "normal": return trumpCard.suit === "♠️";
    case "joker":  return false;
  }
}

이제 isSpade 함수는 joker 값들로 확장된 TrumpCard의 모든 경우에 대응할 수 있는 함수로 거듭났고, 누가 보더라도 이해할 수 있도록 작성되었습니다. 다른 방식으로 isSpade를 구현하면 어떻게 될까요?

1. trumpCard 값을 조커의 하위 타입 리터럴과 비교하고, 예외 절에서 suit 속성을 통해 판별한다.

function isSpade(trumpCard: TrumpCard) {
  if (trumpCard === "BWJoker" || trumpCard === "ColorJoker")
    return false;
  return trumpCard.suit === "♠️";
}

TypeScript의 타입 추론은 강력하기 때문에, early return에서 반환되지 않은 trumpCard 타입은 suit 속성을 가진다는 것을 보장합니다. 그러나 조커의 종류가 흑백과 색상을 가지는 두 종류였기에 다행이지, 수십 개의 중첩된 타입 집합이었다면 모든 최하위 타입의 값을 전부 배제하도록 코드를 작성해야 했을 것입니다.

2. trumpCard 값에 suit 속성이 존재하는 지 판별하고, suit 속성이 존재하는 경우에만 suit 속성을 통해 판별한다.

function isSpade(trumpCard: TrumpCard) {
  if ("suit" in trumpCard)
    return trumpCard.suit === "♠️";
  return false;
}

이 경우, 첫 번째 방식과 같이 번잡하게 모든 경우를 쓸 필요는 없어졌습니다. 그러나 이 함수가 단순히 하나의 필드만 참조하고, 하위 타입을 단순히 하나의 필드를 통해 분별할 수 있어서 깔끔한 코드를 작성할 수 있었고, 하위 타입끼리 공유되는 필드를 가지고 있는 경우엔 그들을 구분할 수 있는 서브 타입을 찾아야 하며, 코드상으로 드러나는 구분 방식도 직관적이지 않을 것입니다.

예를 들어, 온라인 쇼핑몰 사례를 살펴보겠습니다. 고객(Customer), 관리자(Admin), 판매자(Seller)가 usernamepassword란 공통 속성을 가진 상황을 정의하겠습니다.

type ShoppingItem = string;

type Store = {
  name: string;
  location: string;
  items: ShoppingItem[];
};

type Seller = {
  username: string;
  password: string;
  profit: number;
  stores: Store[];
};

type Customer = {
  username: string;
  password: string;
  cart: ShoppingItem[];
};

type Admin = {
  username: string;
  password: string;
  storeList: Store[];
  userList: Customer[];
  sellerList: Seller[];
};

type UserType = Customer | Admin | Seller;

만약 UserType 타입의 값을 인자로 받는 함수가 있고, 이 함수가 사용자의 유형별로 처리해야 한다면, 하위 타입을 고유하게 구분할 수 있는 속성군을 추려내야 할 것입니다.

function applyToUserType(userType: UserType) {
  if ("cart" in userType) {
    //...Customer의 처리문
  } else if ("sellerList" in userType) {
    //...Admin의 처리문
  } else if ("profit" in userType) {
    //...Seller의 처리문
  }
}

이처럼 이 방법은, 코드를 작성할 때는 고유한 속성을 고려해야 하고, 코드를 읽을 때는 속성으로부터 고유한 집합군을 연상해야 하는 문제가 있습니다.

대수적 데이터 타입을 이용한 안전한 개발

대수적 데이터 타입을 이용하면 더 쉽게 안전한 개발이 가능합니다. 타입을 확장할 때 곱 타입을 이용한 확장인지, 합 타입을 이용한 확장인지 결정함으로써 새로 확장되는 타입이 가지게 될 역할의 범주가 결정되고, 이렇게 결정된 범주를 기반으로 TypeScript 린터와 컴파일러는 코드를 실행하기도 전에 값의 유효성을 검사하고 보장합니다.

예를 들어, 위의 TrumpCard 예제에서 TrumpCard 타입을 유효하지 않은 값으로 설정하려 하면 TypeScript 컴파일러는 다음과 같이 경고를 발생시킵니다.

const cardInstance: TrumpCard = {
  suit: "👍",
  rank: "😎",
  trumpType: "normal",
};

Type ‘“👍“’ is not assignable to type ‘TrumpSuit’(2322).

Type ‘“😎“’ is not assignable to type ‘TrumpRank’(2322).

뿐만 아니라 IDE에서는 이러한 ADT들을 이해하여 해당 타입의 심볼을 정의할 때에 유효한 값의 목록을 보여주기도 합니다.

Sum Type으로 정의된 타입에 대해서 switch-case 분기문을 작성할 때 default로 fallback을 기재하지 않으면, case로 명시하지 않은 Sum Type의 하위 타입이 있음을 나타내는 경고를 보여주기도 합니다.

function NameOfSuit(trumpSuit: TrumpSuit){
  switch (trumpSuit) {
    case "♠️":
      return "Spade";
  }
}

Not all code paths return a value(7030).

이러한 대수적 데이터 타입을 이용한 React 애플리케이션 개발에서는, Sum/Product 타입으로 컴포넌트의 입력 Prop(Property) 타입을 설계하고, 각 Prop으로부터 마크업으로의 단사 함수(Injective Function)를 정의함으로써 컴포넌트를 구현할 수 있습니다.

결론

지금까지 신뢰할 수 있는 원소 집합에서 타입을 합성하고 확장할 수 있게 하는 대수적 데이터 타입에 대해 알아보았습니다.

이처럼 견고한 타입 시스템은 사람이 코드를 작성할 때 발생할 수 있는 사소한 실수부터 작성된 프로그램이 실행될 때 발견될 수 있는 에러들을 컴파일 타임에 확인함으로써, 에러가 적고, 고치기 쉽고, 명확한 프로그램을 기술하는 데 큰 도움을 주고 있습니다.


  1. 2021년 11월 16일, 원프레딕트 기술 블로그에 발행된 글.
원프레딕트는 더 나은 제품을 고민하며 기술적인 문제를 함께 풀어낼 동료를 찾고 있습니다.
자세한 내용은 채용 사이트를 참고해 주세요.