Devin.KR
로그인

타입스크립트로 넘어가기 - 왜 필요한가와 자바스크립트 프로젝트 점진적 도입

개발자 조회 1

이 단원에서 배우는 것

22단원에서 빌드 파이프라인을 갖췄다. 그 파이프라인에 검사 단계를 하나 더 끼우는 것이 이번 주제다. 지금까지 만든 스터디 관리 대시보드에서 member.session처럼 s 하나 빠뜨린 오타는 실행하기 전까지 아무도 잡아 주지 않았다. 20단원에서 Proxy로 그것을 잡아 보긴 했지만, 그건 실행해야만 알 수 있는 방법이었다. 타입스크립트는 실행 전에 잡는다.

  • 타입스크립트가 실제로 무엇을 해 주고 무엇을 해 주지 않는지를 구분한다.
  • 돌아가는 자바스크립트 프로젝트를 멈추지 않고 checkJs와 JSDoc부터 단계적으로 도입한다.
  • 타입 검사와 런타임 검증의 경계를 설계해 서버 응답 같은 외부 데이터를 안전하게 다룬다.

기준은 TypeScript 5.x다. 도입 결정 자체가 비용이 드는 일이므로, 문법 나열보다 무엇을 어떤 순서로 켤 것인가에 무게를 둔다.

왜 필요한가

대시보드가 커지면서 이런 함수가 생겼다고 하자.

function formatMember(member) {
  return `${member.name} (${member.sessions}회, ${member.paid ? "완납" : "미납"})`;
}

여기서 사고가 나는 경로는 크게 넷이다. 첫째, 호출하는 쪽이 member 대신 member.id를 넘긴다. 둘째, API 응답 필드명이 sessions에서 sessionCount로 바뀌었는데 여기만 안 고쳤다. 셋째, paidboolean이 아니라 "Y"/"N" 문자열로 오면서 미납자가 전부 완납으로 표시된다. 넷째, 누군가 membernull일 수 있는 경로를 새로 만든다.

네 경우 모두 즉시 예외가 나지 않는다. undefined가 화면에 찍히거나 잘못된 값이 조용히 표시된다. 테스트로 전부 막으려면 조합이 너무 많고, 코드 리뷰로 잡으려면 사람이 계속 집중해야 한다. 타입스크립트는 이 네 가지를 파일을 저장하는 순간 빨간 줄로 보여 준다.

더 큰 이득은 리팩터링이다. 필드 이름 하나를 바꿀 때 "이 이름을 쓰는 곳을 전부 찾았는가"를 사람이 확신할 필요가 없어진다. 참고로 이건 검색으로 대체되지 않는다. sessions라는 문자열은 다른 객체에도 들어 있다.

문법과 예제

먼저 무엇을 안 해 주는지부터

도입 실패의 절반은 기대를 잘못 잡아서 생긴다. 타입스크립트가 하는 일은 정적 검사 하나뿐이다.

해 주는 것해 주지 않는 것
코드 안 값의 흐름 검사런타임 값 검증(서버 응답, 사용자 입력)
편집기 자동완성과 정의 이동성능 개선(타입은 컴파일 후 사라진다)
이름 변경 등 안전한 리팩터링논리 오류 검출
// 컴파일도 되고 타입 오류도 없다. 그런데 런타임에 터진다.
const res = await fetch("/api/members");
const members: Member[] = await res.json();   // json() 은 any 를 반환한다
console.log(members[0].name.toUpperCase());   // 서버가 name 을 안 주면 여기서 예외

as나 타입 주석은 "이렇게 생겼다고 치자"는 선언일 뿐 검사가 아니다. 경계에서의 검증은 별도로 해야 하며, 이 단원 뒤쪽에서 다룬다.

0단계 — 편집기부터 켠다

파일 확장자를 바꾸기 전에 할 일이 있다. 프로젝트 루트에 jsconfig.json을 두면 .js 파일 상태로 검사가 시작된다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "checkJs": true,
    "strict": false,
    "noEmit": true
  },
  "include": ["src/**/*"]
}

저장하면 편집기에 오류가 쏟아진다. 그게 정상이다. 한 번에 다 고칠 필요는 없다. 급한 파일 맨 위에 // @ts-nocheck를 붙여 잠시 제외하고, 대신 새로 만드는 파일에는 붙이지 않는다는 규칙을 세운다. 이 시점에 이미 오타와 undefined 접근이 상당수 잡힌다. 빌드 설정은 하나도 바꾸지 않았고 배포에도 영향이 없다.

1단계 — JSDoc 으로 타입을 쓴다

.js 파일 그대로 주석에 타입을 적을 수 있다. 문법이 장황한 대신 도입 비용이 0이다.

/**
 * @typedef {object} Member
 * @property {number} id
 * @property {string} name
 * @property {number} sessions
 * @property {boolean} paid
 */

/**
 * @param {Member} member
 * @returns {string}
 */
export function formatMember(member) {
  return `${member.name} (${member.sessions}회, ${member.paid ? "완납" : "미납"})`;
}

/**
 * @param {Member[]} members
 * @param {number} totalSessions
 * @returns {Member[]}
 */
export function findUnpaid(members, totalSessions) {
  return members.filter((m) => !m.paid && m.sessions < totalSessions);
}

이제 formatMember(member.id)는 편집기에서 즉시 오류가 되고, member.까지 치면 필드 목록이 뜬다. 타입 정의를 별도 파일에 모으고 싶으면 types.d.ts를 만들어 @type {import("./types").Member} 형태로 참조한다. 이 단계까지 오면 팀에 "타입이 있으면 이런 게 편하다"는 경험이 생긴다. 설득은 문서가 아니라 이 경험이 한다.

2단계 — 파일 단위로 .ts 로 옮긴다

전면 전환은 하지 않는다. 새 파일과 자주 고치는 파일부터 바꾼다. allowJs: true.ts.js가 섞여 있어도 서로 import 할 수 있다.

// src/roster.ts
export interface Member {
  id: number;
  name: string;
  sessions: number;
  paid: boolean;
  memo?: string;             // 있을 수도 없을 수도 있다
}

export function formatMember(member: Member): string {
  return `${member.name} (${member.sessions}회, ${member.paid ? "완납" : "미납"})`;
}

export function findUnpaid(members: Member[], totalSessions: number): Member[] {
  return members.filter((m) => !m.paid && m.sessions < totalSessions);
}

제네릭은 자바스크립트 개발자가 가장 먼저 부딪히는 문법이다. "타입을 인자로 받는 함수"라고 생각하면 쉽다.

// 배열을 키 기준으로 묶는다. T 는 호출 시점에 정해진다.
function groupBy<T>(items: T[], keyOf: (item: T) => string): Map<string, T[]> {
  const result = new Map<string, T[]>();
  for (const item of items) {
    const key = keyOf(item);
    const bucket = result.get(key);
    if (bucket) bucket.push(item);
    else result.set(key, [item]);
  }
  return result;
}

const byStatus = groupBy(members, (m) => (m.paid ? "완납" : "미납"));
// byStatus 의 타입은 Map<string, Member[]> 로 자동 추론된다

groupBy<Member>(...)처럼 명시할 수도 있지만 대개 추론에 맡긴다. 타입 주석은 추론이 안 되거나 틀릴 때만 붙이는 것이 읽기에 좋다. 특히 함수의 반환 타입은 공개 API에는 명시하고 내부 함수에는 생략하는 정도가 균형점이다.

3단계 — strict 를 한 칸씩 올린다

strict: true는 여러 옵션의 묶음이다. 한꺼번에 켜면 오류가 수천 개 나온다. 하나씩 켜고 그 오류만 처리한다.

순서옵션잡아 주는 것
1noImplicitAny타입을 안 쓴 매개변수
2strictNullChecksnull/undefined 접근 (가장 값어치가 크다)
3strictFunctionTypes, strictBindCallApply함수 시그니처 불일치
4noUncheckedIndexedAccess배열 인덱스 접근 결과가 undefined일 가능성

strictNullChecks가 핵심이다. 실무 버그의 상당수가 "없을 수 있는 값을 있다고 가정한 것"이기 때문이다.

function findMember(members: Member[], id: number): Member | undefined {
  return members.find((m) => m.id === id);
}

const target = findMember(members, 7);
console.log(target.name);        // 오류: target 이 undefined 일 수 있다

if (target) {
  console.log(target.name);      // OK — 좁혀졌다
}

if (target) 한 줄로 타입이 Member로 좁혀지는 것을 타입 좁히기라 한다. typeof, Array.isArray, in, === 비교가 모두 좁히기에 쓰인다. 별도 문법을 배우는 게 아니라 평소 쓰는 자바스크립트 검사 코드가 그대로 타입 정보가 된다.

경계에서의 런타임 검증

앞에서 미룬 문제로 돌아온다. res.json()any이고, 여기에 타입을 붙이는 것은 검사가 아니라 주장이다. 경계에서 실제로 검사한다.

// 의존성 없이 직접 쓰는 검증 함수
function parseMember(raw: unknown): Member {
  if (typeof raw !== "object" || raw === null) {
    throw new TypeError("참가자 응답이 객체가 아니다");
  }
  const o = raw as Record<string, unknown>;

  if (typeof o.id !== "number") throw new TypeError("id 가 숫자가 아니다");
  if (typeof o.name !== "string") throw new TypeError("name 이 문자열이 아니다");
  if (typeof o.sessions !== "number") throw new TypeError("sessions 가 숫자가 아니다");
  if (typeof o.paid !== "boolean") throw new TypeError("paid 가 불리언이 아니다");

  return { id: o.id, name: o.name, sessions: o.sessions, paid: o.paid };
}

export async function loadMembers(studyId: number): Promise<Member[]> {
  const res = await fetch(`/api/studies/${studyId}/members`);
  if (!res.ok) throw new Error(`참가자 조회 실패: ${res.status}`);

  const data: unknown = await res.json();
  if (!Array.isArray(data)) throw new TypeError("배열이 아니다");
  return data.map(parseMember);
}

손으로 쓰면 장황하니 실무에서는 Zod나 Valibot 같은 라이브러리를 쓴다. 스키마를 한 번 정의하면 검증 함수와 타입이 동시에 나온다. 어느 쪽이든 원칙은 같다. 외부에서 들어오는 값은 any가 아니라 unknown으로 받고, 검증을 통과한 뒤에야 타입이 붙는다. 이 경계를 세워 두면 서버가 필드명을 바꿨을 때 화면 한복판이 아니라 loadMembers에서 명확한 메시지와 함께 실패한다.

빌드에 어떻게 얹는가

22단원의 파이프라인과 연결된다. 중요한 사실 하나는 Vite나 esbuild는 타입을 검사하지 않는다는 것이다. 타입 주석을 그냥 지우고 지나간다. 그래서 타입 오류가 있어도 빌드가 성공한다.

{
  "scripts": {
    "dev": "vite",
    "build": "tsc --noEmit && vite build",
    "typecheck": "tsc --noEmit --watch"
  }
}

검사는 tsc --noEmit이 하고 변환은 번들러가 한다. 이 조합이 표준이다. CI에도 tsc --noEmit을 넣어야 타입 오류가 있는 코드가 배포되지 않는다.

Node.js에서 실행할 코드라면 선택지가 하나 더 있다. Node.js 22.6부터 실험적으로, 23.6부터 기본으로 .ts 파일의 타입 주석을 지우고 바로 실행한다.

node src/report.ts        # Node.js 23.6+ 에서는 별도 설정 없이 실행된다

다만 이것도 타입 검사가 아니라 제거다. 그리고 enum이나 매개변수 프로퍼티처럼 코드를 생성해야 하는 문법은 지원하지 않는다. 그 제약에 맞춰 코드를 쓰게 강제하려면 erasableSyntaxOnly 옵션을 켠다.

실무에서 자주 틀리는 것

1. as 로 오류를 지운다

const member = raw as Member;                 // 검사 없음. 그냥 그렇다고 치는 것
const el = document.querySelector(".row") as HTMLInputElement;   // 없으면 런타임 예외

빨간 줄이 사라지니 문제가 해결된 것처럼 보이지만, 타입스크립트를 도입한 목적을 정확히 무효화하는 코드다. 위 두 줄은 각각 "서버 응답이 항상 이 모양이다"와 "이 요소는 항상 존재하고 input 이다"라는 검증되지 않은 약속이다. 약속이 깨지면 예외는 훨씬 뒤에서 난다. as가 필요하다고 느끼는 순간이 대개 타입 가드나 런타임 검증을 써야 할 자리다. 특히 as any는 그 값에서 뻗어 나가는 모든 코드의 검사를 꺼 버린다.

2. any 를 방어막으로 쓴다

function process(data: any) {
  return data.items.map((x: any) => x.value);   // 전부 검사 밖
}

any는 전염된다. any를 반환하면 그 값을 받는 곳도, 그것으로 만든 객체도 검사 대상에서 빠진다. 파일 몇 개를 지나면 프로젝트에 타입이 있다는 사실이 무의미해진다. 잘 모르겠으면 any 대신 unknown을 쓴다. unknown은 쓰기 전에 검사를 강제하므로 전염되지 않는다. noImplicitAny를 켜면 실수로 들어온 any는 막히지만, 명시적으로 쓴 any는 막지 못한다. 린트 규칙으로 함께 막는 편이 낫다.

3. 타입만 믿고 외부 데이터를 검증하지 않는다

const members = (await res.json()) as Member[];   // 서버 응답 형태를 검사하지 않았다

가장 흔하고 가장 비싼 실수다. 타입은 빌드 후 사라지므로 런타임에는 아무 보호막이 없다. 서버가 필드를 하나 빼거나 이름을 바꾸면, 오류는 데이터가 들어온 곳이 아니라 그 값을 쓰는 화면 한복판에서 Cannot read properties of undefined로 터진다. 원인 추적이 몇 배 오래 걸린다. localStorage에서 읽은 값, URL 쿼리 파라미터, 폼 입력도 전부 같은 대상이다. 경계를 정하고 그 지점에서만 검증한다.

4. 팀 합의 없이 전면 전환을 시도한다

"이번 스프린트에 전부 .ts로 바꾸자"는 계획은 대개 중간에 멈춘다. 오류 수천 개를 처리하는 동안 기능 개발이 멈추고, 급한 일이 생기면 as any로 덮으면서 진행된다. 결과물은 타입이 있지만 믿을 수 없는 코드베이스이고, 이건 타입이 없는 것보다 나쁘다. 믿을 수 없는 타입은 잘못된 확신을 준다. 앞의 0~3단계처럼 배포 가능한 상태를 유지하면서 조금씩 올린다. 진행 상황은 as any 개수나 @ts-nocheck 파일 수처럼 세기 쉬운 지표로 관리한다.

5. 타입 검사와 번들 빌드를 같은 것으로 안다

vite build          # 타입 오류가 있어도 성공한다

번들러는 타입 주석을 지우기만 한다. 편집기에는 빨간 줄이 잔뜩인데 빌드는 초록불이라 그대로 배포되는 상황이 실제로 벌어진다. tsc --noEmit을 빌드 스크립트와 CI에 명시적으로 넣어야 한다. 관련해서 파일 단위로 변환하는 도구를 쓸 때는 isolatedModules를 켜 두는 것이 안전하다. 타입만 재export 하는 코드처럼 파일 하나만 봐서는 판단할 수 없는 문법을 미리 걸러 준다.

옵션별 정확한 동작은 TypeScript의 tsconfig 레퍼런스에 정리돼 있다.

스스로 확인하기

  1. 다음 코드는 타입 오류가 없는데도 런타임에 실패할 수 있다. 이유와 고치는 방향을 쓰라.
    const res = await fetch("/api/members");
    const members = (await res.json()) as Member[];
    console.log(members[0].name.trim());
  2. 기존 자바스크립트 프로젝트에 타입스크립트를 도입한다. 배포를 멈추지 않으면서 진행하는 단계를 순서대로 쓰고, 각 단계에서 무엇을 얻는지 쓰라.
  3. anyunknown의 차이를 설명하고, 외부 데이터를 받을 때 unknown을 써야 하는 이유를 쓰라.

정답

  1. res.json()any를 반환하고 as Member[]는 검사가 아니라 "이 모양이라고 치자"는 선언이다. 실제 응답이 다르면 타입 검사는 통과하지만 런타임에 members[0]undefined거나 name이 없어 예외가 난다. 게다가 res.ok를 확인하지 않아 404 응답의 에러 본문을 참가자 배열로 취급할 수도 있다. 고치려면 응답 상태를 먼저 확인하고, unknown으로 받아 배열 여부와 각 항목의 필드 타입을 검사하는 파싱 함수를 거쳐야 한다. Zod 같은 스키마 검증 라이브러리를 쓰면 스키마 하나로 검증과 타입을 동시에 얻는다.
  2. 0단계는 jsconfig.jsoncheckJs를 켜는 것이다. 확장자를 바꾸지 않고도 오타와 undefined 접근이 잡히며 빌드와 배포에는 영향이 없다. 1단계는 JSDoc으로 주요 데이터 구조와 공개 함수에 타입을 붙이는 것이다. 자동완성과 호출부 검사가 생기고, 팀이 이득을 체감한다. 2단계는 새로 만드는 파일과 자주 고치는 파일부터 .ts로 옮기는 것이다. allowJs로 혼재 상태를 유지하므로 한 번에 멈출 일이 없다. 3단계는 noImplicitAny, strictNullChecks 순으로 옵션을 하나씩 켜는 것이다. 특히 strictNullChecks에서 실제 버그가 가장 많이 드러난다. 마지막으로 tsc --noEmit을 CI에 넣어 되돌아가지 않게 고정한다.
  3. any는 모든 검사를 끄는 타입이라 어떤 속성에 접근하든 어떤 함수로 넘기든 오류가 나지 않고, 그 값에서 파생된 값까지 검사 대상에서 빠진다. unknown은 어떤 값이든 담을 수 있다는 점은 같지만 쓰기 전에 좁히기를 강제한다. typeof 검사나 타입 가드를 통과하기 전에는 속성 접근도 함수 호출도 오류다. 외부 데이터는 실제 형태를 알 수 없으므로 unknown으로 받으면 검증 코드를 빠뜨릴 수 없게 되고, 검증을 통과한 시점부터는 정확한 타입으로 다룰 수 있다.