Skip to main content

TypeScript의 진짜 힘은 설정과 습관에서 나온다

TypeScript를 조금 배운 뒤에는 종종 이런 생각이 듭니다. "문법은 알겠는데, 왜 내 프로젝트는 아직도 불안하지?"

이유는 의외로 단순합니다. TypeScript는 문법만 안다고 힘을 발휘하는 언어가 아니기 때문입니다. 진짜 차이는 프로젝트 설정과 개발 습관에서 납니다. 같은 TypeScript라도 strict를 켜고, tsconfig.json을 제대로 다루고, 디버깅과 테스트 흐름을 잡아 둔 팀은 훨씬 덜 흔들립니다.

쉽게 말해, 문법은 연장이고 설정은 작업대입니다. 망치가 좋아도 작업대가 흔들리면 손가락이 먼저 맞습니다. TypeScript도 비슷합니다.

이번 장에서는 "어떻게 쓰느냐"에 집중합니다. TypeScript를 아는 단계에서, TypeScript를 제대로 쓰는 단계로 넘어가 봅시다.

strict 모드: 잔소리가 아니라 안전벨트

strict 모드는 TypeScript의 대표적인 안전장치입니다. 이 옵션을 켜면 더 강한 타입 검사들이 한꺼번에 활성화됩니다. 공식 문서도 새 프로젝트라면 strict를 기본값처럼 생각하라고 말하는 분위기입니다. 다만 TypeScript 버전이 올라가면서 strict 아래에 더 엄격한 검사가 추가될 수도 있어서, 업그레이드 후 새 오류가 보일 수도 있습니다.1

초보자 눈에는 strict가 조금 무섭게 보일 수 있습니다. 하지만 실제로는 반대입니다. 무서운 건 strict가 아니라, 에러 없이 지나간 줄 알았는데 런타임에서 터지는 버그입니다.

예를 들어 이런 함수가 있다고 해 봅시다.

function greet(user) {
  return user.name.toUpperCase();
}

자바스크립트에서는 익숙한 코드입니다. 하지만 TypeScript에서 strict가 켜져 있으면 user의 타입이 무엇인지 묻습니다. 이때 작동하는 대표 규칙이 noImplicitAny입니다. 타입을 추론할 수 없는데도 조용히 any로 넘기지 않겠다는 뜻입니다.2

이 코드는 이렇게 바뀌어야 합니다.

function greet(user: { name: string }): string {
  return user.name.toUpperCase();
}

이제 함수가 무엇을 기대하는지 분명해졌습니다. 컴파일러도 이해하고, 동료도 이해하고, 몇 달 뒤의 나도 이해합니다.

strict에서 가장 체감이 큰 규칙들은 대체로 다음과 같습니다.

  • noImplicitAny: 타입을 모르면 그냥 넘어가지 않고, 직접 알려 달라고 합니다.

  • strictNullChecks: null과 undefined를 진짜 위험 요소로 취급합니다.

  • strictPropertyInitialization: 클래스 속성이 초기화되지 않은 채로 남는 일을 막아 줍니다.

  • useUnknownInCatchVariables: catch (error)의 error를 함부로 다루지 못하게 합니다.

  • noImplicitThis: this가 누구인지 애매한 코드를 경계합니다.

특히 strictNullChecks는 초보자에게 가장 큰 전환점이 됩니다.

function printLength(text: string | null): void {
  console.log(text.length);
}

이 코드는 위험합니다. text가 null이면 런타임에서 바로 터집니다. strictNullChecks는 이 문제를 컴파일 단계에서 잡아 줍니다.2

function printLength(text: string | null): void {
  if (text === null) {
    console.log("길이를 알 수 없어요.");
    return;
  }

  console.log(text.length);
}

조금 귀찮아 보이지만, 사실 이 코드는 더 친절합니다. "값이 없을 수도 있다"는 현실을 코드에 반영했기 때문입니다.

catch에서도 비슷한 일이 벌어집니다.

try {
  riskyWork();
} catch (error) {
  console.log(error.message);
}

strict 환경에서는 error가 unknown으로 취급될 수 있습니다. 정말 Error 객체인지 확인하라는 뜻입니다.2

try {
  riskyWork();
} catch (error) {
  if (error instanceof Error) {
    console.log(error.message);
  } else {
    console.log("알 수 없는 오류:", error);
  }
}

컴파일러가 괜히 까다롭게 구는 게 아닙니다. "세상엔 생각보다 별일이 다 생긴다"는 사실을 대신 기억해 주는 것입니다.

strict 밖에 있지만 꼭 볼 옵션들

재미있는 사실이 하나 있습니다. strict: true를 켰다고 해서 모든 강력한 검사 옵션이 자동으로 켜지는 것은 아닙니다. 실제 프로젝트에서 특히 도움이 되는 옵션이 몇 개 더 있습니다.3

먼저 noUncheckedIndexedAccess입니다.

const colors = ["red", "green", "blue"];
const first = colors[0];

보통 우리는 first가 당연히 문자열이라고 생각합니다. 하지만 빈 배열이라면 어떨까요? 이 옵션을 켜면 colors[0]의 타입은 string | undefined가 됩니다.3

if (first !== undefined) {
  console.log(first.toUpperCase());
}

이 옵션은 특히 배열, Record, 딕셔너리처럼 인덱스로 꺼내는 데이터에서 빛납니다. "있을 것 같아"를 "있는지 확인했어"로 바꿔 줍니다.

다음은 exactOptionalPropertyTypes입니다. 이 옵션은 선택적 속성의 의미를 더 정확하게 만듭니다.3

type User = {
  nickname?: string;
};

이 속성은 "없을 수 있다"는 뜻입니다. 그런데 옵션이 느슨하면 "있는데 값이 undefined일 수도 있다"와 뒤섞이기 쉽습니다. exactOptionalPropertyTypes를 켜면 이 차이를 더 엄밀하게 구분합니다. 객체 설계가 조금 더 까다로워지는 대신, 데이터의 의미는 훨씬 또렷해집니다.

초보자라면 처음부터 모든 엄격 옵션을 다 켜야 한다는 뜻은 아닙니다. 하지만 최소한 이런 옵션이 존재한다는 사실은 알고 있으면 좋습니다. 프로젝트가 커질수록 "타입이 대충 맞는 상태"보다 "의미까지 정확한 상태"가 훨씬 중요해지기 때문입니다.

tsconfig.json, 처음엔 길어 보여도 핵심은 몇 개뿐이다

tsconfig.json은 TypeScript 프로젝트의 규칙집입니다. 컴파일러에게 "우리 프로젝트는 이런 방식으로 해석하고 검사해 주세요"라고 말하는 파일입니다.

처음 보면 옵션이 많아서 압도될 수 있습니다. 하지만 초보자가 먼저 익혀야 할 핵심은 그리 많지 않습니다. 아래 설정은 실무에서 자주 보는, 꽤 건강한 출발점입니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "strict": true,
    "rootDir": "./src",
    "outDir": "./dist",
    "sourceMap": true,
    "noEmit": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

이제 중요한 옵션들을 쉬운 말로 풀어 보겠습니다.

  • target: 어떤 수준의 자바스크립트로 변환할지 정합니다. 오래된 실행 환경까지 지원해야 하면 더 낮게, 최신 환경만 노리면 더 높게 잡을 수 있습니다.

  • module: 모듈 방식을 정합니다. import, export가 실제 결과물에서 어떤 방식으로 다뤄질지와 관련이 있습니다.

  • rootDir, outDir: 원본 소스 위치와 출력 위치입니다. src에서 작성하고 dist에 결과가 쌓이게 만드는 전형적인 구조입니다.

  • strict: 아까 본 안전벨트입니다. 가능한 한 켜 두는 편이 좋습니다.

  • sourceMap: 디버깅할 때 원본 TypeScript 코드와 변환된 JavaScript 코드를 연결해 줍니다.

  • noEmit: JavaScript 파일을 실제로 만들지 않고 타입 검사만 수행합니다. 번들은 Vite, Next.js, esbuild 같은 다른 도구가 맡고, TypeScript는 검사만 맡는 프로젝트에서 자주 사용합니다.

  • esModuleInterop: CommonJS와 ES 모듈을 섞어 쓸 때 생기는 불편함을 줄여 줍니다.

  • resolveJsonModule: JSON 파일을 모듈처럼 불러올 수 있게 해 줍니다.

  • skipLibCheck: 외부 라이브러리의 타입 정의까지 전부 검사하지 않도록 해서 속도를 챙깁니다.

  • include, exclude: 어떤 파일을 검사 대상으로 포함하고 뺄지 정합니다.

여기서 특히 중요한 감각이 하나 있습니다. tsconfig.json은 "정답 파일"이 아니라 "프로젝트 성격 설명서"라는 점입니다.

브라우저 앱, Node.js 서버, 라이브러리, 테스트 코드, 모노레포는 필요한 옵션이 다릅니다. 그래서 인터넷에서 찾은 설정을 그대로 복붙하는 것보다, 각 옵션이 왜 필요한지 이해하는 편이 훨씬 오래 갑니다.

그리고 한 가지 더 기억해 둘 점이 있습니다. 설정은 한 번 쓰고 끝이 아닙니다. 프로젝트가 커지면 tsconfig.json도 함께 자랍니다. 처음엔 단순하게 시작하고, 필요할 때 이유를 알고 한 줄씩 추가하는 편이 좋습니다.

디버깅: 브레이크포인트가 TypeScript를 이해하게 만들기

TypeScript가 컴파일 단계에서 많은 버그를 잡아 주는 건 사실입니다. 하지만 모든 버그를 잡아 주지는 못합니다. 잘못된 비즈니스 로직, 예상 밖의 API 응답, 비동기 타이밍 문제는 여전히 런타임에서 확인해야 합니다.

이때 중요한 것이 디버깅입니다. 그리고 TypeScript 디버깅의 핵심 키워드는 sourceMap입니다. sourceMap: true를 켜 두면 디버거가 변환된 JavaScript가 아니라 원본 TypeScript를 기준으로 브레이크포인트를 맞출 수 있습니다. VS Code에서는 여기에 더해 launch.json의 outFiles 설정이 실제 빌드 결과 위치를 제대로 가리켜야 합니다.4

예를 들어 Node.js 프로젝트라면 이런 흐름이 자주 쓰입니다.

{
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "sourceMap": true
  }
}

그리고 VS Code의 launch.json은 대략 이런 식입니다.

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug TypeScript",
      "program": "${workspaceFolder}/src/index.ts",
      "preLaunchTask": "tsc: build - tsconfig.json",
      "outFiles": ["${workspaceFolder}/dist/**/*.js"]
    }
  ]
}

이제 브레이크포인트를 걸고 F5를 누르면, 디버거가 TypeScript 파일에서 멈춥니다. 이때 할 수 있는 일은 꽤 많습니다. 변수 값을 확인하고, 함수 안으로 들어가고, 한 줄씩 실행하고, 호출 스택을 볼 수 있습니다. 콘솔 로그만으로는 잡히지 않던 문제도 눈앞에서 흐름이 보이기 시작합니다.

초보자가 자주 겪는 함정도 있습니다.

  • 브레이크포인트가 회색이거나 아예 멈추지 않는다.

  • 내가 보고 있는 TypeScript 코드와 실제 실행 흐름이 어긋난다.

  • 수정했는데 디버깅 결과가 예전 코드처럼 보인다.

이럴 때는 대개 세 가지를 먼저 확인하면 됩니다. sourceMap이 켜져 있는지, 빌드가 최신인지, outFiles가 맞는 폴더를 보고 있는지입니다.4

그리고 정말 간단하지만 강력한 방법도 있습니다. 필요한 줄에 debugger;를 써 두는 것입니다.

function calculateTotal(price: number, count: number): number {
  debugger;
  return price * count;
}

실행이 그 줄에 도착하면 자동으로 멈춥니다. "왜 여기서 값이 이상해지지?" 하는 순간에 꽤 유용합니다.

테스트: 타입이 못 잡는 버그를 잡는 마지막 그물

가끔 이런 오해가 있습니다. "TypeScript를 쓰니까 테스트를 덜 해도 되겠지?"

아쉽지만 아닙니다. TypeScript와 테스트는 경쟁자가 아니라 팀메이트입니다.

TypeScript는 주로 "형태가 맞는가?"를 검사합니다. 하지만 테스트는 "의도가 맞는가?"를 검사합니다. 예를 들어 할인 금액 계산식이 잘못되었다면 타입은 멀쩡해도 결과는 틀릴 수 있습니다.

export function getDiscountPrice(price: number, rate: number): number {
  return price - price * rate;
}

타입만 보면 완벽해 보입니다. 하지만 rate가 0.15인지 15인지, 음수는 허용하는지, 소수점은 어떻게 처리할지는 타입만으로 충분히 설명되지 않을 수 있습니다. 그래서 테스트가 필요합니다.

Jest를 쓴다면 기본 설정은 비교적 단순합니다. ts-jest를 사용하면 TypeScript 테스트 파일을 바로 실행할 수 있습니다.5

module.exports = {
  preset: "ts-jest",
  testEnvironment: "node"
};

테스트 코드는 이렇게 생겼습니다.

import { getDiscountPrice } from "./price";

test("15% 할인 가격을 계산한다", () => {
  expect(getDiscountPrice(10000, 0.15)).toBe(8500);
});

테스트를 작성할 때 초보자에게 특히 추천하는 습관이 있습니다.

  • 순수 함수부터 테스트합니다. 입력이 같으면 출력이 같은 함수는 테스트하기 쉽습니다.

  • 실패 메시지가 읽히는 테스트 이름을 씁니다.

  • 경계값을 꼭 넣습니다. 0, 빈 배열, null 가능성, 최소값, 최대값 같은 것들입니다.

  • 비동기 코드는 async와 await를 분명하게 씁니다.

테스트도 디버깅할 수 있습니다. Jest는 디버그 모드로 실행할 수 있고, VS Code의 launch.json으로 브레이크포인트를 걸어 한 줄씩 따라갈 수도 있습니다.5 특히 비동기 테스트가 실패할 때는 "실패했다"보다 "어디서 흐름이 꼬였는지"를 보는 것이 훨씬 중요합니다.

타입과 테스트를 함께 쓰면 어떤 일이 벌어질까요? 타입이 잘못된 호출을 미리 막고, 테스트가 올바르지 않은 동작을 마지막으로 검증합니다. 앞문과 뒷문을 모두 잠그는 셈입니다.

JavaScript 프로젝트를 TypeScript로 옮기는 현실적인 순서

기존 JavaScript 프로젝트를 TypeScript로 옮길 때 가장 위험한 생각은 "이번 주에 전부 바꾸자"입니다. 작은 프로젝트라면 가능할 수도 있지만, 대부분의 팀은 기능 개발과 운영을 멈출 수 없습니다. 그래서 보통은 점진적 마이그레이션이 더 현실적입니다.6

공식 문서도 allowJs를 사용해 JavaScript 파일을 입력으로 받아들이고, outDir로 출력 위치를 분리한 뒤, 파일을 하나씩 옮겨 가는 흐름을 안내합니다.6

시작점으로는 이런 설정이 무난합니다.

{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false,
    "strict": false,
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

이 설정의 의미는 이렇습니다. "일단 JavaScript도 받아 줄게. 지금 당장 엄격하게 혼내지는 않을게. 대신 TypeScript 환경 안으로 들어오게 하자."

그다음에는 다음 순서로 가면 안정적입니다.

  1. 가장 바깥쪽부터 바꾸지 말고, 작은 유틸 함수나 의존성이 적은 파일부터 .ts로 바꿉니다.

  2. 자주 쓰는 데이터 구조를 interface나 type으로 먼저 정의합니다.

  3. 외부 라이브러리에 타입이 없으면 @types/... 패키지를 찾고, 없으면 최소한의 .d.ts 선언을 직접 만듭니다.

  4. checkJs나 // @ts-check를 써서 아직 .js인 파일도 조금씩 검사합니다.

  5. 엄격 옵션은 한 번에 다 켜지 말고 noImplicitAny, strictNullChecks, strictPropertyInitialization처럼 단계적으로 올립니다.2

  6. 마지막에 strict: true로 정리합니다.

이 과정에서 중요한 원칙이 몇 가지 있습니다.

첫째, 동작을 망가뜨리지 말아야 합니다. 마이그레이션은 리팩터링이지, 재작성 대회가 아닙니다. 기존 테스트가 있다면 계속 돌리고, 없더라도 핵심 기능부터 테스트를 붙여 두는 것이 좋습니다.

둘째, any를 죄악처럼 보지 말되, 영주권은 주지 말아야 합니다. 마이그레이션 초반에는 any가 통로 역할을 할 수 있습니다. 하지만 너무 오래 남아 있으면 결국 타입 시스템에 큰 구멍이 생깁니다. 가능하면 unknown을 우선 고려하고, 정말 필요한 곳에만 임시로 any를 두는 편이 좋습니다.

셋째, 입력과 출력을 분리해야 합니다. TypeScript 공식 문서가 outDir를 강조하는 이유도 여기에 있습니다. 원본 소스를 덮어쓰지 않게 해 두면 실험도 안전해지고, 빌드 결과를 지우는 것도 쉬워집니다.6

넷째, 한 번에 완벽해지려 하지 말아야 합니다. 실제 마이그레이션은 "오류 300개 제거!"보다 "오늘 이 모듈을 더 안전하게 만들었다"의 반복에 가깝습니다.

오래 가는 팀의 습관

좋은 TypeScript 프로젝트는 멋진 고급 타입 몇 줄로 만들어지지 않습니다. 오히려 다음처럼 소박한 습관에서 완성됩니다.

  • 새 프로젝트라면 가능하면 처음부터 strict를 켭니다.

  • tsconfig.json 옵션을 복붙하지 말고, 왜 필요한지 한 줄씩 이해합니다.

  • 디버깅을 위해 sourceMap과 실행 구성을 프로젝트 초반에 잡아 둡니다.

  • 타입만 믿지 말고 테스트로 의도를 검증합니다.

  • 기존 JavaScript는 한 파일씩, 한 단계씩 옮깁니다.

TypeScript는 마법 지팡이가 아닙니다. 대신 아주 성실한 동료에 가깝습니다. 애매한 부분을 그냥 넘기지 않고, 위험한 코드를 보면 미리 말해 주고, 리팩터링할 때 뒤에서 조용히 손전등을 비춰 줍니다.

그러니 TypeScript를 잘 쓰는 가장 좋은 방법은 화려한 기술을 더 배우는 것만이 아닙니다. 설정을 이해하고, 습관을 다듬고, 컴파일러를 내 편으로 만드는 것입니다. 그 순간부터 TypeScript는 단순한 문법이 아니라, 프로젝트 전체를 지켜 주는 시스템이 됩니다.

참고

1TypeScript TSConfig Option: strict

2How to Enable and Use TypeScript Strict Mode Effectively

3The Strictest TypeScript Config

4Debugging TypeScript

5Debugging TypeScript Tests with Jest: A Comprehensive Guide

6TypeScript Documentation - Migrating from JavaScript

책 목록

한 번에 이해되는 TypeScript: 초보자를 위한 가장 쉬운 타입스크립트 입문

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기