2024년 09월 28일
2년이 조금 넘게 타입스크립트를 사용하면서 타입에 대해 깊게 생각해본 적이 없었고 타입스크립트를 사용하는데 큰 어려움이 없었습니다. 프론트엔드에서 타입스크립트를 사용하면서 API 응답 구조에 맞게 타입을 정의하고, Pick과 Record 등과 같은 Utility Types를 사용하여 기존에 정의된 타입을 재사용하는 것이 전부였습니다.
그러던 중 문득 “타입스크립트를 너무 쉽게 사용하고 있는 것은 아닐까?”라는 생각이 들었습니다. 한 번도 타입을 정의하고 사용하는 것에 어려움을 느끼지 않았던 것이 “대충”, “편하게”사용하고 있었다는 의미 같았습니다. 그래서 “타입스크립트를 어떻게하면 잘 쓸 수 있을까?”를 시작으로 “타입스크립트를 잘 쓴다는 것은 무엇일까?”의 물음에 대한 답을 찾기 위해 여러 자료를 참고하면서 생각해봤습니다. 그리고 이 글을 통해 그 생각에 대한 주관적인 기준과 방법을 정리해보고자 합니다.
위의 질문에 대한 답을 도출하기 위해서 기준을 정할 필요가 있습니다. 기준이 있어야 평가할 수 있고 평가를 통해 잘 쓰고 있는지 판단할 수 있기 때문입니다. 이를 위해 타입스크립트의 목적을 간략하게 알아보겠습니다.
타입스크립트는 자바스크립트의 한계를 보완하기위해 만들어진 언어입니다. 자바스크립트는 런타임 시에 타입이 정해지는 동적 타입 언어로서 코드를 작성하는 시점에 타입을 아는 것이 어려워 여러 명의 개발자와 협업하는 상황에 많은 어려움이 있습니다. 예를 들어 A라는 개발자가 기존의 객체에서 특정 속성을 삭제했을 때 B라는 개발자는 그것을 모르고 해당 객체의 속성에 접근하는 코드를 작성했다면 런타임 시에 에러가 발생하여 애플리케이션을 정상적으로 사용할 수 없게될 수도 있습니다.
위의 예시에서 알 수 있는 타입스크립트의 주된 목적은 3가지입니다.
결론적으로 타입스크립트의 목적을 통해 알 수 있는 “타입스립트를 잘 쓴다.”에 대한 평가항목을 다음과 같이 정의했습니다.
위와 같이 타입스크립트를 사용할 때 주관적인 평가 항목을 정하고, 타입스크립트를 사용할 때 평가 항목에 부합하도록 작성하기 위해 노력했습니다. 이를 통해 타입스크립트의 이점을 충분히 활용할 수 있는 코드를 작성할 수 있게 되었으며, 다음 3가지는 제가 타입스크립트를 사용하면서 “타입스크립트를 잘 쓴다.”의 기준에 가장 부합하다고 생각한 사례입니다. 이 사례들을 통해 더욱더 그 의미를 이해하는 데 도움이 될 것입니다. (예시와 코드는 핵심을 전달하기 위해 간소화한 것입니다.)
Case 1 : 유니온 타입에 특정 타입이 추가될 것을 고려하기
Todo 앱에서 어떤 할 일의 상태를 조회하면 “INIT”, “PROCESSING”, “DONE” 중에 한 가지 상태가 응답으로 온다고 가정하겠습니다. 세 개의 상태는 각각 “시작”, “진행 중”, “완료”라는 문자로 표시되어야한다고 할 때 타입스크립트로 아래와 같이 코드를 작성할 수 있습니다.
type TodoState = "INIT" | "PROCESSING" | "DONE";
const mapStateToText = (state: TodoState) => {
switch (state) {
case "INIT":
return "시작";
case "PROCESSING":
return "진행 중";
case "DONE":
return "완료";
default:
return "-";
}
};
이후에 “HOLD”라는 상태가 추가되고 “일시중지”로 표시되어야 하는 조건이 추가됐습니다. 그런데 실수로 타입만 추가하고 mapStateToText()
에는 “HOLD”일 때 “일시중지”를 반환하는 조건을 추가하지 않았다면 런타임에 “-”가 표시될 것입니다. 이와 같은 문제를 해결하기 위해서 아래와 같이 코드를 수정할 수 있을 것입니다.
type TodoState = "INIT" | "PROCESSING" | "DONE" | "HOLD";
const mapStateToText = (state: TodoState) => {
switch (state) {
case "INIT":
return "시작";
case "PROCESSING":
return "진행 중";
case "DONE":
return "완료";
default:
let undefinedState: never = state;
return "-";
}
};
default문에 never타입을 갖는 변수를 선언하고 state를 대입합니다. 그렇다면 undefinedState에는 string타입을 never타입에 할당할 수 없다는 에러가 표시될 것입니다. 이를 통해 TodoState 중에 아직 문자로 정의되지 않은 상태가 있다는 것을 확인하게 되어 런타임에 올바르게 문자를 표시할 수 있게 될 것입니다.
Case 2 : 데이터의 특징에 따른 정확한 타입 정의하기
프론트엔드에서 상태를 다룰 때 상태의 특징에 따라 적절한 자료구조를 선택한다면 로직이 단순해지고 가독성이 높아집니다. 이처럼 타입도 상태(데이터)의 특징에 따라 정확하게 정의한다면 실수를 줄일 수 있고 가독성을 높일 수 있습니다.
조직도를 관리하는 기능을 개발한다고 가정하겠습니다. 그룹을 선택하면 부서 목록이 나열되고, 부서를 선택하면 팀 목록이 나열됩니다. 이때 현재 선택된 그룹, 부서, 팀의 ID를 저장하기 위해 배열을 사용하였습니다. 길이가 3인 배열을 사용하며 첫 번째 인덱스부터 선택된 그룹, 부서, 팀의 ID 또는 null 값을 갖습니다. null은 선택되지 않았다는 것을 의미합니다.
const [selectedGroup, setSelectedGroup] = useState<Array<number | null>>([]);
하지만 위와 같이 타입을 정의했을 때 상태가 가져야 할 값이 모호하고 잘못된 값을 초기 상태로 설정할 수도 있습니다. 또한 길이가 3인 배열인데 실수로 그 이상의 인덱스에 접근하는 등의 잘못된 로직을 작성할 수도 있습니다. 하지만 아래와 같이 Tuple의 개념을 사용하여 타입을 정의한다면 문제점들을 해결할 수 있습니다. 반드시 정확하게 초깃값을 설정해야 하며 잘못된 인덱스에 접근할 일도 없습니다.
type SelectedGroup = [number | null, number | null, number | null];
const [selectedGroup, setSelectedGroup] = useState<SelectedGroup>([
null,
null,
null,
]);
더 나아가 같은 타입을 갖는 Tuple 형식의 타입을 생성할 때 위와 같이 반복하는 것이 아니라 아래와 같이 Utility Type을 정의한다면 반복적인 코드를 제거할 수 있다는 장점이 있습니다.
type Tuple<
T,
Length extends number,
Result extends T[] = []
> = Result["length"] extends Length ? Result : Tuple<T, Length, [...Result, T]>;
type SelectedGroup = Tuple<number | null, 3>;
const [selectedGroup, setSelectedGroup] = useState<SelectedGroup>([
null,
null,
null,
]);
Case 3 : 최대한 타입의 범위를 좁히기
과일가게에 사과, 바나나, 코코넛이 있습니다. 과일이 하나씩 입고된다고 가정했을 때 각 과일의 ID를 “[과일이름]:[타임스탬프]” 형식으로 부여해야 합니다. 그리고 입고된 과일의 데이터를 아래와 같은 형식으로 서버에 보냅니다.
type Fruit = "APPLE" | "BANANA" | "COCONUT";
type NewFruit = {
id: string;
type: Fruit;
};
그리고 과일의 ID를 생성하는 함수를 아래와 같이 작성할 수 있습니다.
const getFruitId = (fruit: Fruit): string => {
const timestamp = new Date().getTime();
switch (fruit) {
case "APPLE":
return `APPLE:${timestamp}`;
case "BANANA":
return `BANANA:${timestamp}`;
case "COCONUT":
return `COCONUT:${timestamp}`;
default:
const _: never = fruit;
throw new Error();
}
};
위와 같이 타입과 함수를 작성했을 때 문제점은 무엇일까요? ID의 타입이 string으로 되어있기 때문에 히스토리 파악이 안된 개발자가 코드를 수정할 때 잘못된 형식의 문자열로 ID를 생성할 수 있다는 것입니다. 이를 해결하기 위해 타입의 범위를 좁히고 타입을 통해 ID의 형식을 알 수 있도록 한다면 더 간결하고 실수를 방지하는 코드를 작성할 수 있습니다.
type Fruit = "APPLE" | "BANANA" | "COCONUT";
type Timestamp = number;
type IdByFruit<F extends Fruit> = `${F}:${Timestamp}`;
type NewFruit<F extends Fruit> = {
id: IdByFruit<F>;
type: F;
};
const createNewFruit = <F extends Fruit>(fruit: F): NewFruit<F> => {
const timestamp = new Date().getTime();
const newFruit: NewFruit<F> = {
id: `${fruit}:${timestamp}`,
type: fruit,
};
return newFruit;
};
위의 코드를 보면 과일의 종류에 따른 ID 타입을 반환하는 Utility Type인 IdByFruit를 만들어서 사용했습니다. 해당 Utility Type을 통해 ID의 형식이 어떻게 구성되는지 파악할 수 있으며 실수로 잘못된 형식의 문자열을 작성할 가능성을 낮췄습니다.
지난 3개월정도 타입스크립트를 잘 쓴다는 것의 의미를 생각하고 정리하면서 가장 중요한 것은 코드를 통해 에러의 가능성을 줄이는 것이라는 것을 느꼈습니다. 더 자세하게는 개발자의 실수로 인한 에러가 최대한 발생하지 않도록 하는 것입니다. 위의 3가지 사례들을 자바스크립트로 구현한다고 했을 때 충분히 코드의 규모가 커지고 비즈니스 로직이 복잡할 때 실수가 발생할 가능성이 높은 것들입니다. 하지만 타입스크립트를 사용한다면 타입의 장점을 활용하여 원천적으로 실수의 가능성을 제거하는 코드를 작성하도록 노력해야 한다는 것을 깨달았습니다.