본문 바로가기

타입스크립트 디버깅 기법

컴파일러의 진단을 먼저 읽고, 증상에 따라 중단점과 로그, 네트워크, 프로파일 가운데 어떤 도구를 쓸지 고른 뒤, 소스맵으로 실행 중인 자바스크립트를 원래의 타입스크립트에 맞추어 브라우저 개발자 도구와 VS Code에서 디버깅하는 방법을 다룹니다.

테스트를 꼼꼼히 써도 예상하지 못한 버그는 생깁니다. 디버깅(debugging)은 버그가 일어나는 조건을 다시 만들고, 프로그램이 기대와 다르게 움직이기 시작하는 지점을 찾아 원인을 고치는 과정입니다. 타입스크립트는 많은 실수를 컴파일할 때 막아 주지만, 실행 중의 값이 기대와 다르거나 서버와의 통신이 어긋나는 문제는 실행하면서 확인해야 합니다.

이 글은 컴파일러의 진단을 읽는 것에서 시작해, 증상에 맞는 도구를 고르는 기준을 세웁니다. 이어서 컴파일된 자바스크립트를 원래의 타입스크립트에 맞추어 주는 소스맵과, 브라우저의 개발자 도구, VS Code의 디버거, 로그를 쓰는 방법을 봅니다.

먼저 컴파일러의 진단을 읽는다

가장 먼저 볼 것은 tsc --noEmit이나 편집기가 보여 주는 진단입니다. 타입스크립트의 진단은 무엇이 틀렸는지와 함께 고칠 방향까지 알려 주는 경우가 많아서, 실행하기 전에 고칠 수 있는 버그를 가장 싸게 찾는 방법입니다.

src/product.ts
interface Product {
  id: number;
  name: string;
  price: number;
}

function getProductName(product: Product): string {
  return product.Name;
}

const myProduct = { id: 1, name: "Laptop", cost: 1200 };
getProductName(myProduct);
error TS2551: Property 'Name' does not exist on type 'Product'. Did you mean 'name'?
error TS2345: Argument of type '{ id: number; name: string; cost: number; }' is not assignable to parameter of type 'Product'.
  Property 'price' is missing in type '{ id: number; name: string; cost: number; }' but required in type 'Product'.

첫 진단은 속성 이름의 대소문자가 틀렸다는 것과 비슷한 이름 name을 알려 주고, 둘째 진단은 넘긴 객체에 price가 없다는 것을 들여쓴 줄로 자세히 설명합니다. 진단이 여러 줄이면 맨 아래의 들여쓴 줄이 가장 구체적인 원인인 경우가 많으므로 끝까지 읽습니다. 실제 출력에는 각 진단 앞에 파일 이름과 줄, 열의 위치가 함께 붙습니다.

strict 설정을 켜 두면 null이나 undefined일 수 있는 값을 그냥 쓰는 코드처럼, 실행 중에 TypeError로 이어질 실수도 이 단계에서 진단됩니다. 진단을 없애려고 as나 !, any로 덮는 것은 실행 중의 버그를 나중으로 미루는 것이므로, 단언을 쓰기 전에 그 값이 정말 그런 타입인지 확인합니다.

증상에 맞는 도구 고르기

컴파일러가 잡지 못한 문제는 실행하면서 찾습니다. 그 전에 같은 입력과 같은 빌드로 증상을 다시 일으킬 수 있는지부터 확인하고, 문제가 어디에서 갈라지는지에 따라 도구를 고릅니다.

EVIDENCE ROUTING

증상에 맞는 관찰 도구를 먼저 고른다

먼저 tsc·IDE의 정적 오류를 확인합니다. 실행 중 문제라면 같은 입력·build와 올바른 source map으로 재현하고, 상태·요청 경로·자원 비용 중 가설을 구분하는 도구를 고릅니다. trace 계측은 별도 구성입니다.

증상에 맞는 관찰 도구를 먼저 고른다 증상을 재현하고 관찰 경계를 판별해 코드 상태, 분산 경로, 자원 비용에 맞는 디버깅 도구로 분기하는 흐름. 상태 경로 비용 증상 재현 same input · build 어디서 갈리는가? runtime boundary Breakpoint · log local state Network / trace 요청 · 서비스 경로 Profile CPU · memory
flowchart 핵심 계약
  1. 증상 재현

    same input · build

  2. 어디서 갈리는가?

    runtime boundary

  3. 상태 분기 · Breakpoint·log

    중단점·조건·로그로 해당 스코프 값을 관찰합니다.

  4. 경로 분기 · Network·trace

    HTTP status·payload·timing을 보고, 구성된 trace로 여러 서비스 경로를 연결합니다.

  5. 비용 분기 · Profile

    CPU·메모리 profile로 자원 비용을 확인합니다. 세 분기는 연속 실행 단계가 아닌 도구 선택입니다.

  • 핵심 경계
  • 진행·선택 경로

source map은 실행 build와 일치해야 합니다. tsconfig.sourceMap만으로 Vite production 번들 map이 켜지지 않으며, 배포 map 제공 범위도 별도 설정합니다.

그림의 세 갈래는 차례로 거치는 단계가 아니라 증상에 따라 고르는 도구입니다. 함수 안의 값이 기대와 다르다면 중단점과 로그로 그 자리의 변수를 봅니다. 요청이 실패하거나 응답이 이상하다면 브라우저의 Network 탭으로 상태 코드와 본문, 걸린 시간을 보고, 여러 서비스를 거치는 요청이라면 따로 구성한 분산 추적(trace)으로 경로를 잇습니다. 느리거나 메모리가 늘어난다면 다음 글에서 볼 프로파일러로 CPU와 메모리의 비용을 잽니다.

소스맵

브라우저와 Node.js가 실행하는 것은 타입스크립트가 아니라 변환된 자바스크립트입니다. 소스맵(source map)은 변환된 코드의 각 위치가 원래 파일의 어느 줄과 열에 해당하는지를 적은 파일로, 디버거는 이것을 읽어 중단점과 오류의 위치를 원래의 .ts 파일로 보여 줍니다.

tsc로 빌드한다면 tsconfig.json에 "sourceMap": true를 적어, dist/index.js 옆에 dist/index.js.map이 만들어지게 합니다. 번들러를 쓴다면 소스맵은 번들러의 설정이 정합니다. Vite의 개발 서버는 기본으로 소스맵을 제공하지만 vite build의 build.sourcemap 기본값은 false이므로, tsconfig.json의 sourceMap만 켜서는 배포용 번들에 소스맵이 생기지 않습니다.

소스맵은 그것을 만든 빌드의 코드와 맞아야 합니다. 다른 빌드의 소스맵을 쓰면 엉뚱한 줄을 가리키므로, 배포한 코드의 오류를 추적하려면 그 배포와 함께 만든 소스맵을 보관해 둡니다. 소스맵에는 원래의 소스 코드가 들어 있을 수 있으므로, 배포 환경에서는 공개 서버에 올리지 않고 오류 수집 서비스에만 올리는 식으로 볼 수 있는 범위를 정합니다.

더 깊이: Node.js의 스택 트레이스에 소스맵 쓰기

tsc가 .js.map을 만들어 두어도, node dist/index.js로 실행한 프로그램의 스택 트레이스는 기본적으로 변환된 자바스크립트의 줄과 열을 가리킵니다. Node.js의 명령줄 문서에 따르면 --enable-source-maps를 붙여 실행해야 Node.js가 소스맵을 읽어 캐시해 두고, 스택 트레이스를 원래 소스 파일의 위치로 바꾸어 보여 줍니다. 문서는 이 변환을 할 수 있는 만큼 하는 최선의 노력(best effort)이라고 표현합니다.

이 옵션에는 알아 둘 점이 두 가지 있습니다. 하나는 Error.prepareStackTrace를 직접 바꾼 코드가 있으면 소스맵이 적용되지 않을 수 있다는 것이며, 문서는 바꾼 함수 안에서 원래의 Error.prepareStackTrace를 불러 그 결과를 돌려주라고 안내합니다. 다른 하나는 Error.stack을 읽을 때 지연이 생길 수 있다는 것이어서, 오류의 스택을 자주 읽는 프로그램이라면 성능에 주는 영향을 따져야 합니다. 한편 소스맵 파일에 원래 코드를 담을지는 tsc의 inlineSources가 정하며, 이 옵션을 켜면 .ts 파일의 내용이 소스맵의 sourcesContent 속성에 들어갑니다.

브라우저 개발자 도구

Chrome이나 Edge, Firefox의 개발자 도구에서 Sources 탭을 열면, 소스맵 덕분에 원래의 .ts와 .tsx 파일을 찾아 줄 번호를 눌러 중단점을 걸 수 있습니다. 실행이 중단점에서 멈추면 Scope 패널에서 그 자리의 변수를, Call Stack 패널에서 그 함수를 부른 순서를 볼 수 있고, Watch 패널에 식을 더해 계속 지켜볼 수도 있습니다.

줄 번호를 마우스 오른쪽 버튼으로 누르면 조건이 참일 때만 멈추는 조건부 중단점과, 멈추지 않고 메시지만 콘솔에 찍는 로그포인트(logpoint)를 걸 수 있습니다. 반복문 안에서 특정 값일 때만 보고 싶거나, 코드를 고치지 않고 로그를 남기고 싶을 때 씁니다. 코드에 debugger; 문을 적어 두면 개발자 도구가 열려 있을 때 그 줄에서 멈춥니다.

멈춘 뒤에는 F8로 다음 중단점까지 계속 실행하고, F10으로 함수 안에 들어가지 않고 다음 줄로, F11로 함수 안으로 들어가며, Shift+F11로 지금 함수를 끝내고 부른 곳으로 돌아갑니다. 서버와의 통신 문제는 Network 탭에서 요청을 골라 상태 코드, 요청과 응답의 본문, 헤더, 걸린 시간을 확인합니다.

VS Code로 디버깅하기

VS Code는 Node.js와 브라우저의 디버거를 내장하고 있어, 편집기 안에서 .ts 파일에 중단점을 걸고 실행할 수 있습니다. 디버깅 설정은 프로젝트의 .vscode/launch.json에 적습니다. Node.js와 Express처럼 tsx로 실행하는 서버라면 다음 설정을 씁니다.

.vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "tsx: current file",
      "type": "node",
      "request": "launch",
      "program": "${file}",
      "runtimeExecutable": "tsx",
      "console": "integratedTerminal",
      "skipFiles": ["<node_internals>/**", "${workspaceFolder}/node_modules/**"]
    },
    {
      "name": "Chrome: Vite app",
      "type": "chrome",
      "request": "launch",
      "url": "http://localhost:5173",
      "webRoot": "${workspaceFolder}"
    }
  ]
}

첫 설정은 편집기에 열린 파일을 tsx로 실행하는데, tsx가 소스맵을 함께 만들어 주므로 .ts 파일에 건 중단점에서 멈춥니다. skipFiles는 Node.js의 내부 코드와 의존성 패키지를 한 줄씩 실행할 때 건너뛰게 합니다. tsc로 미리 빌드한 dist를 실행한다면 "program"에 dist/index.js를 적고 "outFiles": ["${workspaceFolder}/dist/**/*.js"]로 소스맵을 찾을 위치를 알려 줍니다.

둘째 설정은 Vite 개발 서버의 앱을 VS Code가 띄운 Chrome으로 열어, .tsx 파일에 건 중단점에서 멈추게 합니다. 먼저 npm run dev로 개발 서버를 띄운 뒤 이 설정을 실행하며, url의 포트는 실제 개발 서버의 포트에 맞춥니다. 예전 자료에 나오는 pwa-chrome은 같은 디버거의 옛 이름이고, Webpack용 sourceMapPathOverrides 설정은 Vite에서는 필요하지 않습니다.

로그로 흐름 따라가기

로그는 실행을 멈추지 않고 흐름을 볼 수 있어, 비동기 코드처럼 멈추면 동작이 달라지는 곳이나 배포 환경에서 특히 유용합니다. 다만 무엇을 찍을지 정하지 않고 늘어놓으면 오히려 원인을 찾기 어려우므로, 갈림길이 되는 값을 골라 찍습니다.

src/sum.ts
function sumNumbers(items: string[]): number {
  let sum = 0;
  for (const item of items) {
    const num = Number.parseInt(item, 10);
    if (Number.isNaN(num)) {
      console.warn(JSON.stringify({ event: "skip", item }));
      continue;
    }
    sum += num;
  }
  console.log(JSON.stringify({ event: "done", count: items.length, sum }));
  return sum;
}

sumNumbers(["10", "x", "5"]);
{"event":"skip","item":"x"}
{"event":"done","count":3,"sum":15}

로그를 JSON.stringify로 한 줄의 객체로 남긴 것은, 사람이 읽기도 쉽고 로그 수집 도구가 필드별로 검색하기도 쉽게 하기 위해서입니다. console.warn은 표준 오류로, console.log는 표준 출력으로 나가므로 터미널에서는 두 줄이 함께 보이지만 수집할 때는 다른 흐름으로 나뉠 수 있습니다. 배포 환경에서는 비밀번호나 토큰 같은 값이 로그에 남지 않도록 찍는 필드를 정해 두고, 디버깅을 위해 넣은 로그는 일을 마치면 지우거나 수준을 낮춥니다.

정리

  • 디버깅은 컴파일러의 진단부터 읽으며, 여러 줄의 진단은 들여쓴 마지막 줄이 가장 구체적인 원인을 알려 줍니다.
  • 실행 중의 문제는 같은 입력과 빌드로 다시 일으킨 뒤, 값의 문제는 중단점과 로그로, 통신의 문제는 Network 탭과 추적으로, 비용의 문제는 프로파일러로 봅니다.
  • 소스맵은 변환된 코드의 위치를 원래 파일로 이어 주며, 번들러를 쓸 때는 번들러의 설정이 소스맵을 정하고 Vite의 배포 빌드는 기본으로 만들지 않습니다.
  • 브라우저와 VS Code의 디버거는 중단점과 조건부 중단점, 로그포인트, 한 줄씩 실행으로 그 자리의 변수와 호출 순서를 보여 줍니다.
  • tsx로 실행하는 서버는 VS Code의 runtimeExecutable로 tsx를 지정해 디버깅하고, 미리 빌드한 코드는 outFiles로 소스맵의 위치를 알려 줍니다.
  • 로그는 갈림길이 되는 값을 골라 구조화된 한 줄로 남기고, 비밀 값이 남지 않게 필드를 정합니다.

연습 문제

1. 진단 읽기

getProductName({ id: 1, name: "Laptop", cost: 1200 })처럼 객체 리터럴을 바로 넘기면, 변수에 담아 넘긴 경우와 진단이 어떻게 달라지나요?

해설 보기

객체 리터럴을 바로 넘기면 초과 속성 검사가 먼저 적용되어, cost는 Product에 없는 속성이라는 진단이 나옵니다. 변수에 담아 넘긴 경우에는 초과 속성 검사가 적용되지 않고 빠진 price만 진단됩니다. 두 진단 모두 넘긴 값이 Product의 모양과 다르다는 같은 문제를 가리키므로, cost를 price로 고치면 둘 다 사라집니다.

2. 소스맵이 맞지 않을 때

배포한 서버의 오류가 index.ts의 30번째 줄을 가리키는데, 그 줄은 빈 줄이었습니다. 무엇을 의심해야 하나요?

해설 보기

오류의 위치를 바꾼 소스맵이 실제로 실행된 빌드와 맞지 않을 가능성을 먼저 의심합니다. 다른 빌드의 소스맵을 썼거나, 소스맵을 만든 뒤 코드가 다시 변환되었다면 위치가 어긋납니다. 배포할 때마다 그 빌드의 소스맵을 함께 보관하고, 오류의 기록에 빌드의 버전을 남겨 맞는 소스맵을 고를 수 있게 합니다.

3. 도구 고르기

"목록 화면이 가끔 비어 보인다"는 보고를 받았습니다. 어떤 순서로 원인을 좁혀 가나요?

해설 보기

먼저 같은 계정과 같은 데이터, 같은 빌드로 증상을 다시 일으킬 수 있는지 확인합니다. 그다음 Network 탭에서 목록 요청의 상태 코드와 응답 본문을 보아, 서버가 빈 배열을 보냈는지 오류를 보냈는지를 가립니다. 응답에 데이터가 있는데도 비어 보인다면 화면 쪽의 문제이므로, 응답을 상태에 넣는 코드에 중단점이나 로그포인트를 걸어 값이 어디서 사라지는지 봅니다.

다음 글 성능 최적화와 프로파일링에서는 느린 원인을 측정으로 찾고, 컴파일 시간과 실행 시간의 병목에 각각 맞는 도구를 고르는 방법을 다룹니다.