프론트엔드 개발에 사용되는 JavaScript는 동적언어이기 때문에, 데이터의 타입이 런타임에 결정된다. 쉽게 말해 프로그램을 돌려봐야 해당 데이터의 타입이 뭔지 알 수 있다는 것을 의미한다. 그리고 막상 돌려보니 데이터가 예상했던 것과 달라서 예상치 못 한 결과나 에러가 발생할 수도 있다는 것을 의미하기도 한다.
function concatTodoList(list, todo){
list.push(todo)
return list
}
concatTodoList({}, { id: 1, title: 'todo', content: 'content' } ) // Uncaught TypeError: list.push is not a function
// 개발자가 실수로 list에 배열이 아닌 객체를 전달한 코드를 작성한 경우, 실제로 실행해봐야 에러를 발견할 수 있다.
위의 예시에서, concatTodoList
라는 함수는 배열형태의 list
와 todo
객체를 필요로 하는 함수다. 하지만 해당 함수를 호출하는 부분에서 배열이 아닌 객체를 전달하는 실수를 하고 말았다. 하지만 이 코드는 실제로 실행해봐야 concatTodoList
에 맞지 않은 데이터 타입을 전달했다는 걸 알 수 있다.
const todos = [{ id: 1, title: 'title', content: 'content' }, { id: 2, title: 'title2', content: 'content' }]
function getTitle(id) {
const target = todos.find(todo => todo.id === id)
return target.titel // 오타
}
function printTitle(id){
const title = getTitle(id)
console.log(title) // undefined
}
위 예시에서는 title
을 titel
라고 잘못 작성했다. JavaScript는 객체에 없는 프로퍼티에 접근하면 undefined
를 반환하기 때문에 에러가 발생하지 않는다. 하지만 getTitle
함수의 결과값은 항상 undefined
일 것이고, 이 함수의 결과값을 사용하는 이후 모든 로직에 영향을 미칠 것이다.
getTitle
을 사용하는 printTitle
은 아무리 id를 정상적으로 넘긴다해도 undefined
만 출력하게된다. 에러조차 발생하지 않기 때문에 개발자는 어디가 문제인지 직접 디버깅하면서 문제점을 찾아야만한다! 지금은 함수가 2개 뿐이라서 원인을 찾기 쉽지만, 규모가 커지고, 모듈로 나누어지며 수많은 코드가 얽혀있다면 문제점을 찾기란 쉽지 않다.
프로그램이 커질수록 코드는 복잡해지고, 내가 작성하지 않은 다른 동료들의 코드를 가져다 쓰는 일이 많아진다. 개발자가 실수를 범할 확률도 높아진다. 실수로 작성한 코드가 서비스에서 에러를 발생시킨다면 서비스에 치명적이다. 서비스가 다운되거나, 예상대로 동작하지 않는다!
하지만 코드를 실행하기 전에(컴파일 타임) 데이터의 타입을 확인한다면 이러한 문제를 막을 수 있다. 이를 위해 우리는 JavaScript 대신 TypeScript를 사용한다.
TypeScript는 타입을 선언하고, 변수나 함수의 인자/인수, 반환값 또는 함수 그 자체 등 데이터의 타입을 지정할 수 있다. (이를 Annotation
이라 한다). 위에서 사용한 예시 코드를 TypeScript 코드로 작성하면 다음과 같다.
interface Todo {
id: number
title: string
content: string
}
function concatTodoList(list: Todo[], todo: Todo): Todo[]{
list.push(todo)
return list
}
concatTodoList([], { id: 1, title: 'todo', content: 'content'} )
// Argument of type '{}' is not assignable to parameter of type 'Todo[]'.
// Type '{}' is missing the following properties from type 'Todo[]': length, pop, push, concat, and 26 more.
TypeScript 컴파일러는 concatTodoList
의 첫 번째 인자가 Todo
타입의 배열이라는 것을 알고 있기 때문에, 해당 코드를 실행하지 않아도 {}
는 Todo[]
타입이 아니라는 것을 미리 감지하고, 개발자에게 알려줄 수 있다. (심지어 두 번째 인자는 반드시 Todo
타입어야 하기 때문에, 전혀 다른 타입의 인자가 배열에 들어가는 것도 방지할 수 있다!)
위의 두 번째 예시에서도 target
에는 titel
이라는 속성이 없다는 것을 미리 알려줄 수 있다.
타입스크립트를 사용함으로써, 개발자는 코드를 작성하면서 실수를 미연에 방지할 수 있다.
이렇게 좋은 TypeScript에도 한계가 존재하는데, 다음 두 가지 사실을 명심해야 한다
// todo.js
"use strict";
function concatTodoList(list, todo) {
list.push(todo);
return list;
}
concatTodoList([], { id: 1, title: 'todo', content: 'content' });
TypeScript를 JavaScript로 컴파일한 결과이다.
interface로 Todo
타입을 선언하는 것과 각종 annotation
이 사라졌다.
어차피 TypeScript에서 검증이 끝난 코드인데, JavaScript로 변경된 게 뭐가 문제일까 싶을 수 있다.
하지만 만약 getTodo
함수가 아래와 같은 형태라면 어떨까?
async function getTodo(id: number): Promise<Todo> {
return await fetch(`${baseURL}/todos/${id}`).then((res) => res.json());
}
이 경우 fetch를 통해 받아오는 데이터가 실제로 Todo의 타입인지 알 수 있는 유일한 방법은 실제로 API 요청을 통해 그 데이터를 받아오는 수 밖에 없다. 하지만 API 요청은 컴파일 타임이 아닌 런타임에 이루어진다. 따라서 interface로 선언한 타입은 이미 사라진 후이기 때문에, 받아온 데이터와 대조해볼 수 없다.
즉, 타입스크립트는 코드의 논리적 흐름을 검증하는 역할까지만 수행하며, 컴파일 단계에 존재하는 데이터에 대한 유효성만 검사할 수 있다. 논리적으로는 문제 없지만, 런타임에서 문제가 발생할 여지는 여전히 존재한다.
https://docs.superstructjs.org/
superstruct
는 런타임에서 사용할 인터페이스를 정의하고, 유효성 검사를 실행하는데 도움을 주는 라이브러리다. 이 외에도 zod
, yup
, joi
등 여러 라이브러리가 있다. 여기서는 superstruct의 간단한 예시만 살펴보자
import { object, number, string, assert } from
export const TodoSchema = object({
id: number(),
title: string(),
content: string()
}) // 스키마 정의
type Todo = Infer<typeof Todoschema> // 정의한 스키마로 TypeScript 타입 선언
const assertion = <T>(schema: Struct<T, unknown>) => (data: unknown): T => {
try{
assert(data, schema) // 데이터와 스키마 유효성 검사
return data
} catch(err) {
console.log('validate err!')
}
}
async function getTodo(id: number): Promise<Todo> {
return await fetch(`${baseURL}/todos/${id}`).then((res) => res.json()).then(assertion(TodoSchema)); // API 데이터를 가져온 후에 스키마 검사를 실행한다.
이처럼 API호출, 사용자 입력 등 외부입력
을 validate하는 로직을 추가해서 더 안정적으로 개발할 수 있다