Dev Thinking
14완료

왜 자바스크립트 개발자가 다시 타입을 배워야 할까?

2025-10-20
6분 읽기

공식문서 기반의 타입스크립트 입문기

들어가며

Node.js API 서버에서 req.body.id를 데이터베이스 쿼리에 바로 넣었더니 프로덕션에서 404가 발생했습니다. 프론트엔드에서 "0" 같은 문자열이 들어왔고, 서버에서는 숫자로 취급하던 코드가 +req.body.id로 변환했습니다. === 0 검사가 통과되면서 없는 데이터를 조회하게 되었고, console.log(typeof req.body.id)로 로그를 뒤지며 원인을 찾아야 했습니다. "그 값은 number다"라는 가정 아래 JS의 느슨한 타입이 디버깅 시간을 늘렸습니다.

이런 상황은 흔합니다. "1" + 2처럼 작동하는 조합을 믿다가, 다른 값에서 무너지는 경우가 생깁니다. "API가 이 값을 항상 보낼 테니 괜찮다"고 가정했다가, 다른 팀원이 문자열로 보내면서 오류가 발생합니다. JS의 암묵적 형변환과 유연함은 디버깅 비용을 숨깁니다. 그 비용을 명시적인 계약으로 바꾸는 것이 TypeScript를 배우는 이유입니다.

이 글은 다음 흐름으로 이야기를 전개합니다.

  1. JS에서 "그저 작동한다"라고 믿은 코드를 다시 살펴보고,
  2. TypeScript로 그 코드를 쓰면 어떤 변화가 생기는지를 비교하고,
  3. 리액트에서 어떻게 연결되는지를 확인하고,
  4. 마지막으로 가장 흔한 함정과 자주 묻는 질문을 정리합니다.

TypeScript의 제안

문제의 핵심은 req.body를 아무런 타입으로 선언하지 않아서, 컴파일러가 any처럼 동작하게 둔 점이었습니다.
any는 "모든 걸 허용하겠다"는 약속이기 때문에, +req.body.idreq.body.id.length를 해도 TypeScript는 아무런 경고를 주지 않습니다.
그 결과 JS에서 했던 "그냥 숫자인 줄 알고 썼던" 습관을 TypeScript에서도 반복하게 만듭니다.

TypeScript가 제안하는 해결책은 간명합니다. "이 값을 어떻게 사용하겠다는 걸 먼저 정의하라."
예를 들어 상품 생성 API가 id, name, price를 받는다면, 아래처럼 타입을 선언합니다.

백엔드: 타입 안전한 API 핸들러

type CreateProductRequest = {
  id: number;
  name: string;
  price: number;
};
 
function handleCreate(req: Request<{}, {}, CreateProductRequest>, res: Response) {
  const productId = req.body.id; // number로 추론됨
  const productName = req.body.name; // string으로 추론됨
  const productPrice = req.body.price; // number로 추론됨
 
  // string이 들어오면 컴파일 단계에서 오류가 납니다.
}

Request<{}, {}, CreateProductRequest>는 Express의 제네릭을 활용해 req.body를 구체화하는 방식입니다.
이렇게 타입을 좁히는 순간, 각 필드가 어떤 타입인지 TypeScript가 알고 있으며, string을 넣으면 tsc가 경고를 줍니다.
프론트엔드에서도 "id": "0"이 아니라 id: 0을 보내야 한다는 계약이 만들어집니다.
따라서 TypeScript는 단순한 문법이 아니라 "입구에서부터 타입을 잡아놓는 설계 도구"가 됩니다.

심층 분석

1) 검증 로직: unknown에서 안전하게 좁히기

좋은 전략은 처음부터 req.bodyunknown으로 두고, 안전하게 좁히는 것입니다. any는 "무엇이든 된다"는 지시이지만, unknown은 "모른다. 그래서 확인하고 쓰겠다"는 선언입니다. 이렇게 설계된 이유는 개발자가 의도적으로 타입을 확인하도록 유도하기 위해서입니다. 이전 CreateProductRequest 타입을 활용해 안전한 검증 함수를 만들어보겠습니다.

function validatePayload(body: unknown): CreateProductRequest {
  if (
    typeof body === "object" &&
    body !== null &&
    "id" in body &&
    typeof (body as { id: unknown }).id === "number" &&
    "name" in body &&
    typeof (body as { name: unknown }).name === "string" &&
    "price" in body &&
    typeof (body as { price: unknown }).price === "number"
  ) {
    return body as CreateProductRequest;
  }
 
  throw new Error("잘못된 요청입니다.");
}

unknown을 시작으로 typeof, in, instanceof 등의 검사를 거치면 TypeScript가 "이 조건을 만족했기 때문에 이제 우리는 CreateProductRequest라고 확신할 수 있다"고 판단합니다. as CreateProductRequest를 남발하는 대신, 조건문 안에서 타입 추론이 따라오게 하면 훨씬 안전합니다.

2) 타입 좁히기의 기본 원리

if (typeof body === "object") 안에서 TypeScript는 body가 객체임을 기억하고, 이후 코드에서 객체 메서드를 사용할 수 있게 해줍니다. 이처럼 조건문을 통한 타입 좁히기는 TypeScript의 핵심 기능입니다. (typeof, in, instanceof 등의 자세한 패턴은 1-2편에서 다룹니다)

3) 구조적 타이핑이 만드는 계약

구조적 타이핑은 TypeScript의 핵심 개념으로, 객체의 구조(Shape)가 같으면 타입 호환되는 방식입니다. 상속이나 명시적 선언 없이도 프로퍼티 타입과 이름이 맞으면 사용할 수 있습니다.

JavaScript에서는 객체 리터럴이 들어오면 "그 안에 의도한 프로퍼티가 있는지?"를 도리어 if (req.body && req.body.id)로 확인해야 했습니다. TypeScript는 구조적 타이핑을 쓰므로, CreateProductRequest라는 타입은 { id: number; name: string; price: number }이라는 구조를 정의하고, 실제 JSON과 구조가 맞으면 통과됩니다. 이렇게 타입을 먼저 선언하면, req.body.id를 쓰기 전에 "이런 모양이 들어올 것이다"라는 생각이 자연스럽게 연결됩니다.

실전 패턴 (In React)

프론트엔드: 타입 안전한 데이터 소비

백엔드의 CreateProductRequest 타입을 활용한 프론트엔드 구현:

type Product = CreateProductRequest; // 백엔드 타입 재사용
 
function ProductDetail({ id }: { id: number }) {
  const [product, setProduct] = useState<Product | null>(null);
 
  useEffect(() => {
    let cancelled = false;
 
    async function load() {
      const res = await fetch(`/api/products/${id}`);
      const data: Product = await res.json(); // 타입 안전한 데이터 수신
      if (!cancelled) setProduct(data);
    }
 
    load();
    return () => {
      cancelled = true;
    };
  }, [id]);
 
  if (!product) return <p>불러오는 중…</p>;
  return (
    <div>
      {product.name}: ${product.price}
    </div>
  ); // price 필드도 안전하게 사용
}

data: Product를 선언하면, 프론트와 백엔드가 "이 구조로 통신한다"는 계약을 맺게 됩니다.
백엔드에서는 CreateProductRequest로 정의하고, 프론트에서는 같은 타입을 Product로 재사용하면, 서로 다른 사람의 코드라도 타입이 맞는지 tsc가 검증해줍니다.
결과적으로 data.description처럼 존재하지 않는 필드를 참조하거나, product.price처럼 존재하는 필드를 빠뜨리는 실수를 컴파일 타임에 잡을 수 있습니다.

함정

  1. any를 다시 꺼내면 TypeScript를 쓰는 의미가 없습니다. 기본 설정에서 noImplicitAny를 켜고, 필요한 경우 Record<string, unknown>(키가 문자열이고 값이 알 수 없는 객체)이나 unknown으로 제한하십시오. (Record는 4-1편에서 자세히 다룹니다)
  2. type이나 interface는 런타임 코드를 만들지 않으므로, 여전히 잘못된 JSON을 받으면 예외가 납니다. zod, yup, io-ts와 같은 런타임 검사와 함께 쓰거나, 앞에서 만든 validatePayload 같은 타입 가드 함수를 같이 만드십시오.
  3. as CreateProductRequest를 남발하면, TypeScript를 껍데기만 사용하는 셈입니다. 가능한 조건문/in/instanceof를 통해 타입을 좁히고, as는 진짜 안전하다고 판단되는 곳에서만 쓰십시오.
  4. 구조가 바뀌면 타입도 바꿔야 한다는 점이 번거롭게 느껴질 수 있습니다. 하지만 반대로 타입을 제대로 정리해두면, 구조가 바뀔 때 tsc가 오류를 내며 "여기를 수정하라"고 알려주기 때문에 리팩터링이 더 빠르고 안전해집니다.

예상 질문

Q1. 지금 쓰고 있는 JS 코드에 TypeScript를 붙이면 속도가 느려지지 않나요?
TypeScript는 런타임에서 아무 코드도 생성하지 않습니다. 대신 tsc가 컴파일 타임에 타입을 검사하면서 오류를 던지므로, 개발 속도는 약간 느릴 수 있지만, 그 시간에 디버깅할 가능성이 줄어듭니다. tsc --noEmit(타입 검증만 수행하고 JS 파일 생성 안 함)만 돌려봐도, 타입이 틀렸을 때 바로 잡아내는 걸 체감할 수 있습니다.

Q2. req.body 구조가 자주 바뀌면 타입 선언이 귀찮지 않나요?
맞습니다. TypeScript는 그 구조를 코드로 다시 쓰라고 요청하고, 그래서 처음에는 번거롭게 느껴질 수 있습니다. 하지만 구조가 바뀔 때마다 TypeScript가 type mismatch 오류를 내면, "어디를 고쳐야 하지"를 명확하게 알려줍니다. 결국 타입 선언을 잘 깔아두면, 리팩터링이 더 적은 실수로 끝납니다.

Q3. unknown이 너무 제한적인데 어떻게 쓰죠?
unknown은 "일단 모르겠다"는 선언입니다. unknown에서 바로 body.id처럼 쓰면 오류가 나지만, if (hasId(body)) { ... }처럼 좁힌 다음에 사용하면 타입이 따라옵니다. unknown은 안전하게 타입을 좁히기 위한 출발점이라고 생각하면 됩니다.

Q4. 타입은 어디에 두는 게 좋나요?
가능한 입구(서버 엔트리 포인트, API 라우트, 프론트의 fetch 함수)에서, 타입을 공유하는 파일에 두는 게 좋습니다. 예를 들어 types/apis.tstype CreateProductRequest = { ... }를 선언하고, 백엔드에서는 Request<{}, {}, CreateProductRequest>, 프론트에서는 Product라는 응답 타입으로 함께 쓰면 계약이 명확해집니다.

요약

req.body.id를 아무 타입으로 쓰는 것은 JS의 유연함이 만든 숨은 비용입니다. TypeScript는 그 비용을 명시적인 타입 계약으로 바꾸고, unknown에서 CreateProductRequest로 흐름을 좁히며, "입구에서 타입을 선언하는 사고"를 심어줍니다.
이것은 단순히 문법을 배우는 것이 아니라, 내가 만드는 API가 어떤 데이터를 주고받는지를 서로 다른 영역의 코드가 상호작용할 수 있도록 설계하는 일입니다.

참조