프로젝트/경험정리

타입스크립트에서 중복되는 타입 선언 리팩토링하기

뽀글보리 2023. 11. 26. 23:36
반응형

당신의 타입은 중복되고 있지 않나요?

클린 코드에 관심있는 프로그래머라면, 소프트웨어 개발 3대 원칙에 해당하는 DRY(Do not Repeat Yourself)를 한 번쯤은 들어봤을 것이다. 우리는 코드의 중복을 줄이기 위해서 중복되는 부분은 함수로 엮어 재사용하기 쉬운 코드를 만들어야 한다는 것을 알고 있다. 그리고 이를 의식하며 재사용하기에 좋은 코드를 짠다. 그러나, 타입의 중복에 대해서는 깊게 생각하지 않는 경우가 많은 것 같다. 컴포넌트 계층 구조를 만들면서 같은 prop을 자식 컴포넌트로 전파할 때 등 코드를 짜다 보면 무심코 타입의 중복되는 경우가 많다.


 

이번 글에서는 코드를 통해서 타입 중복을 줄이는 방법에 대해서 설명하려고 한다. (타입스크립트에서 기본적으로 사용하는 타입 생성 방식, 유니온 연산, 인터섹션 연산은 미리 알고있다고 가정하고, 추가 타입스크립트 연산 방식에 대해 설명하며 타입을 리팩토링 할 것이다.) 마케팅을 위한 할인 정보를 입력하는 어드민을 개발한 적이 있다. 결제 시 할인 조건에 따라 일정 퍼센테이지 또는 금액을 할인해주는 어드민이었다. 백엔드에서는 다음과 같은 API 타입으로 데이터를 요청받는다.

interface DiscountRequestType {
    title: string;
    discountType : 'RATE' | 'AMOUNT';
    standards: {
        minPrice: number;
        amount?: number;
        percent?: number;
    }[]
}

{
    title: '1만원,2만원,3만원 이상 구매시 1천원, 2천원, 5천원 할인',
    discountType: 'AMOUNT',
    standards: {
        [
            {
                minPrice: 10000,
                amount: 1000,
            },
            {
                minPrice: 20000,
                amount: 2000,
            },
            {
                minPrice: 30000,
                amount: 5000,
            },
        ]
    }
}

{
    title: '1만원,2만원 이상 구매시 5%, 10% 할인',
    discountType: 'RATE',
    standards: {
        [
            {
                minPrice: 10000,
                percent: 5,
            },
            {
                minPrice: 20000,
                percent: 10,
            },
        ]
    }
}

 

API에서 discountType으로 RATE값을 받으면 정해진 퍼센테이지 만큼 할인해주고, AMOUNT값을 받으면 정해진 금액별로 할인을 해주는 데이터이다. 나는 Reaft, react-hook-form, typescript를 사용해서 어드민을 개발하였는 데, 폼을 관리하는 타입을 API와 동일하게 사용하지 않았다. 그 이유는 다음과 같았다.

이해가 쉬운 프론트에 최적화된 타입을 만들고 싶다.

같은 관심사는 모여있어야 한다는 응집성 원칙을 사용하기 위해서, 관련있는 필드를 비슷한 위치로 옮기고, 한번에 타입의 의미를 알 수 있도록 하고자 하였다. 현재 API 타입에서는 연관 없는 amount, percent 필드가 같은 곳에 위치하여, 관심사 분리를 위해 이를 분리하는 것이 필요했다.

// 1번
interface {
  discountType : 'RATE' | 'AMOUNT';
  standards: {
      minPrice: number;
      amount?: number;
      percent?: number;
  }[]
}

// 2번
interface RateDiscountType {
    discountType: 'RATE';
    standards: {
        minPrice: number;
        percent: number;
    }[]
}

interface AmountDiscountType {
    discountType: 'AMOUNT';
    standards: {
        minPrice: number;
        amount: number;
    }[]
}

type DiscountType = RateDiscountType | AmountDiscountType;

 

다음 1번과 2번 중에서 더 이해가 잘 되는 타입은 무엇일까? discountType=RATE일 경우에는 minPrice, percent 필드가 채워지고, discount=AMOUNT일 경우에는 minPrice, amount 필드가 채워져야 하는 데, 1번 타입을 사용하게 되면 amount, percent가 모두 없는 값과 다른 필드가 들어가는 것이 모두 가능해진다.

 

2번 타입을 사용하게 되면 discountType=RATE일 때, amount값을 채우거나 percent값을 아예 채우지 않는 실수가 방지된다. 물론 실무에서 API 명세는 이미 정해지고, API 명세를 마음대로 바꿀 수는 없으니, 폼 타입에서라도 원하는 정확한 타입을 정의해서 사용하기로 하였다.


이러한 type이라는 공통 필드로 2가지 방식의 타입을 나누는 타입 작성 방법을 태그된 유니온(Tagged Union)이라고 한다. 태그된 유니온을 사용할 경우에는, 분기 처리를 통해서 타입 좁히기가 가능하다.

function func (req: RateDiscountType | AmountDiscountType) {
  if (req.type === 'AMOUNT') {
    // (1) AmountDiscountType 
  } else {
    // (2) RateDiscountType
  }
}

 

다음과 같이 분기 처리하면 1번 영역에서는 req를 AmountDiscountType으로 타입을 좁혀 인식하기 때문에 쉽게 필드 처리가 가능하다.

 

API와 같은 정보를 다른 타입으로 관리가 필요했다.

  <input
    style={{textAlign: 'right'}}
    name="price"
    type="text"
    onChange={(e) => {
        e.target.value = Number(e.target.value.replace(/[^\d]/g, '')).toLocaleString();
      }}
  />

다음은 숫자 필드에 숫자를 입력할 때마다 3자리 숫자마다 콤마(,)를 자동 삽입하여 보기 좋은 숫자로 자동 변환하는 코드이다. 이를 위해서 API에서 10000이라는 숫자를 폼에서는 "10,000"이라는 문자열로 변환하여 폼 입력값을 관리했다. 즉, 가격 필드를 number가 아닌 string으로 관리하고자 했다.

 

// API 타입
export interface DiscountRequestType {
    title: string;
    discountType : 'RATE' | 'AMOUNT';
    standards: {
        minPrice: number;
        amount?: number;
        percent?: number;
    }[]
} 


export interface RateDiscountType {
    type: 'RATE';
    standards: {
        minPrice: string; // number -> string으로 변경
        percent: string; // number -> string으로 변경
    }[]
}

export interface AmountDiscountType {
    type: 'AMOUNT';
    standards: {
        minPrice: string; // number -> string으로 변경
        amount: string; // number -> string으로 변경
    }[]
}

type DiscountType = RateDiscountType | AmountDiscountType;

// FormType
export type DiscountFormType = DiscountType & {
    title: string;
}

 

그러나, 다음과 같이 API 타입과 폼 타입을 두 벌로 만들면 생기는 문제점이 무엇일까? API 필드 명이 변경, 추가될 경우 API 타입과 폼 타입 두 벌 모두 수정해야한다. 같은 데이터 모델을 2가지의 타입으로 나타내고 있으므로, 데이터 모델이 수정될 때마다 2가지 타입 모두 변경해야할 것이다. 예를 들어 할인 설명에 대한 값인 description 필드가 추가되었다면, 두 타입에 모두 description 필드를 추가해야 된다.

 


중복된 타입 수정을 막기 위해서 타입스크립트의 Pick, Omit과 같은 유틸리티 타입을 사용하여, 타입의 일부를 재활용해보자.

 

Pick 타입

특정 타입에서 몇 개의 속성을 선택하여 타입을 정의한다.

interface Person {
  id: number;
  lastName: string;
  firstName: string;
  address: string;
}

type PersonName = Pick<Person, 'lastName' | 'firstName';

 

PersonName 타입은 Person 타입에서 lastName, firstName이라는 프로퍼티만 선택적으로 취하는 타입이다.

 

Omit 타입

특정 속성만 제거한 타입을 정의한다.

type PersonName = Omit<Person, 'id' | 'address';

/*
interface PersonName {
  lastName: string;
  firstName: string;
} */

 

위에서 만든 PersonName을 동일하게 Omit을 사용하여 만든 예제이다.


 

그러면 다시 DiscountFormType으로 돌아와서, DiscountRequestType과 다르게 하는 필드를 Omit 연산으로 제외하고, 폼 전용으로 만든 DiscountType을 & 연산으로 합친다면 다음과 같이 작성할 수 있다.

type DiscountType = RateDiscountType | AmountDiscountType; // Form 전용
export type DiscountFormType = 
    Omit<DiscountRequestType, 'discountType' | 'standards'>
    & DiscountType

 

다음과 같이 DiscountFormType을 정의하면 이후 할인에 대한 설명이나 관리자 같은 description, manager과 같은 추가 필드가 생겼을 때 폼 타입에도 자동으로 적용되도록 관리할 수 있다. 이 정도만 해도 좋지만, 조금 더 나아가보려고 한다. standards 필드를 number -> string만 변환하는 식으로 재활용할 수 있을까?


이를 위해서 인덱스 시그니처를 사용해보려고 한다. 인덱스 시그니처란 객체가 <key, value> 형식일 때 key, value의 타입을 정확하게 명시할 때 유용하게 사용할 수 있다.

type Person = {
    [key: string]: string | number | boolean;
}

const user1: Person = {
    'name': 'Lee',
    'age': 20,
    'man': true
};

const user2: Person = {
    'name': 'Kim',
    'address': 'Seoul Street 112'
}

 

Person이라는 타입은 string 타입의 key를 가지고 string 또는 number 또는 boolean 타입의 value를 가지는 모든 객체 타입을 뜻한다. 따라서 user1과 user2 모두 이 조건에 만족하므로, Person 타입의 객체로 만들 수 있다.

 

interface Standard {
    minPrice: number;
    amount?: number;
    percent?: number;
}


type T = {[k in keyof Standard]: string}

 

그렇다면 이를 할인 조건 타입에 적용해보자. 이 Standard 타입에서 key는 minPrice, amount, percent를 가지고, value는 string 타입을 가지도록 T타입을 만들 수 있다. 여기에서 Omit을 활용해서 정액 할인일 경우에는 amount, minPrice만을 가지고, 정률 할인일 경우에는 minPrice, percent 프로퍼티만 가지는 타입을 만들 수 있다.

 

{[k in keyof Omit<Standard, 'amount'>]: string} 

 

인덱스 시그니처와 Omit을 모두 활용하여 다음과 같은 타입을 정의했다. Omit<Standard, 'amount'>는 amount 필드를 제외한 minPrice, percent 필드를 가지는 인터페이스 타입을 의미하고 이의 keyof는 'minPrice' | 'percent'가 될 것이다. 따라서 key는 minPrice, percent를 가지고 value는 number, number|undefined 타입이 아닌 string을 가지는 인터페이스 타입을 만들 수 있다. 모든 필드를 똑같이 나열하지 않고, Standard 타입을 재활용하여 새로운 타입을 생성하였다. 동일한 방식으로 AmountDiscountType도 만들 수 있다.

 

// API 타입
interface Standard {
    minPrice: number;
    amount?: number;
    percent?: number;
}

export interface DiscountRequestType {
    title: string;
    discountType : 'RATE' | 'AMOUNT';
    standards: Standard[]
} 

// Form 타입
interface RateDiscountType =  {
    type: 'RATE';
    standards: {[k in keyof Omit<Standard, 'amount'>]: string}[]
}

interface AmountDiscountType {
    type: 'AMOUNT';
    standards: {[k in keyof Omit<Standard, 'percent'>]: string}[]
}

type DiscountType = RateDiscountType | AmountDiscountType;

export type DiscountFormType = Omit<DiscountRequestType, 'discountType' | 'standards'> & DiscountType

 


여기까지 타입스크립트의 인덱스 시그니처와 Omit, Pick, 유니온 연산 등을 모두 활용하여 효과적인 타입을 생성하는 방법을 설명해보았다. 타입스크립트의 타입 연산자들은 강력하여 타입을 쉽게 재사용할 수 있게 해준다. 이 외에도 제네릭, 인터페이스 확장, 타입 alias 등을 활용하여 개발에 유용한 적절한 타입을 만들 수 있도록 해보자.

 

 


참고

 

Typescript Documentation

응집도에 대한 테크톡 영상 

도서 Effective Typescript

반응형