Skip to content

TypeScript 버전 업데이트 및 타입 체크 마이그레이션 여정

Published: at 오전 10:12

TypeScript 버전 업데이트 및 타입 체크 마이그레이션 여정

목차

펼치기

배경

프로젝트는 JavaScript 소스코드를 그대로 복사해서 시작한 TypeScript 프로젝트였습니다. TypeScript를 사용하고 있었지만 타입 체크는 하지 않는 상태였고, TypeScript 버전도 4.7로 오래된 상태였습니다. 마이그레이션 작업의 필요성은 계속 제기되었지만, 개발 일정에 의해서 프로젝트 규모만 커지게 되었습니다. 그에 따라서 프로젝트의 안정성이 떨어지기 시작했습니다.

왜 업데이트와 마이그레이션이 필요했나

TypeScript 버전 업데이트 동기

1. satisfies 키워드 사용

4.9 버전에서 추가된 satisfies 키워드를 사용하고 싶었습니다. 특정 타입을 만족하는지 검사하면서도 더 구체적인 타입 추론이 가능한 기능입니다.

type Config = {
  theme: "light" | "dark";
  fontSize: number;
  color: "Blue" | "Red" | "Green";
};

const myConfig = {
  theme: "dark", // "dark"
  fontSize: 16,  // number
  color: "Orange" // ❌ 에러
  extra: "ignored", // ❌ 에러
} satisfies Config;

2. vue-tsc 호환성

vue-tsc 2버전부터 TypeScript 5 이상을 peer dependency로 요구했습니다. VSCode는 워크스페이스마다 확장의 특정 버전을 설정하는 기능이 없어서, 다른 프로젝트 작업 시 매번 버전을 재설치해야 하는 불편함이 있었습니다.

3. 학습 동기부여 및 성능

구버전 패키지에 계속 의존하면 새로운 기능을 학습하고 적용할 동기가 사라집니다. 실제 업무에서 사용하는 경험이 학습 동기부여에 중요했습니다. 또한 버전 업데이트만으로도 프로젝트 성능 향상을 기대할 수 있었습니다.

타입 체크 마이그레이션 필요성

프로젝트 규모가 커지면서 한 사람의 인지능력으로는 작업 내용의 영향 범위를 추적하기 어려워졌습니다. 하나의 파일을 수정했는데 다른 곳에서 에러가 발생하는 경우가 빈번해지면서 안정성 이슈가 발생하기 시작했습니다. TypeScript의 기능을 제대로 활용하지 못하는 상황이었고, CI 과정에 타입 체크를 강제할 필요가 있었습니다.

시도했던 방법들

1차 시도: 변경된 파일만 검사

변경된 파일만 타입 체크를 할 수 없냐는 의견이 나왔지만, 그런 방법을 찾지 못했고 마이그레이션 논의는 흐지부지되었습니다.

2차 시도: 모든 에러를 any로 처리

에러가 발생하는 곳에 any로 타입 단언 처리하고 천천히 마이그레이션하자는 의견이 나왔습니다. 하지만 수천 곳에서 발생하는 에러를 고치는 작업은 쉽게 끝날 일이 아니었고, 누구 하나 나서지 못했습니다.

최종 해결책: TypeScript 주석 활용

Airbnb의 ts-migrate 패키지를 발견하고, @ts- 주석 기능을 활용하면 에러 발생 위치마다 any로 변경하지 않아도 쉽게 처리할 수 있다는 아이디어를 얻었습니다. 해당 패키지를 그대로 사용하기보다는 개념을 차용해 자체 스크립트를 작성했습니다.

진행 과정

TypeScript 버전 업데이트 체크리스트

업데이트 전 다음 사항들을 확인했습니다:

다행히 모두 해당사항이 없었습니다.

주요 변경 사항

1. 패키지 버전 업데이트

2. tsconfig 설정 변경

moduleResolution을 기존 node에서 bundler로 변경했습니다. 5.0 버전에서 추가된 이 옵션은 package.jsonexports 필드를 인식하여 번들러와 동일한 방식으로 모듈을 해석할 수 있게 합니다.

타입 체크 마이그레이션 프로세스

1. 타입 에러 수집

npm run typecheck -- --noEmit --pretty false > type-errors.txt

2. 자동 주석 처리 스크립트

에러 발생 위치에 @ts-expect-error 주석을 자동으로 추가하는 스크립트를 작성했습니다.

import * as fs from 'fs';
import * as readline from 'readline';

const errorFile = 'type-errors.txt';
const filesToFix = new Map<string, Set<number>>();

const parseErrors = async () => {
  const rl = readline.createInterface({
    input: fs.createReadStream(errorFile),
    crlfDelay: Infinity,
  });

  for await (const line of rl) {
    const match = line.match(/^(.+)\((\d+),\d+\): error TS\d+: /);
    if (match) {
      const [, filePath, lineStr] = match;
      const normalizedPath = filePath.trim();
      const lineNumber = parseInt(lineStr, 10);

      if (!filesToFix.has(normalizedPath)) {
        filesToFix.set(normalizedPath, new Set());
      }
      filesToFix.get(normalizedPath)!.add(lineNumber);
    }
  }
};

const patchFiles = () => {
  for (const [filePath, lineNumbers] of filesToFix.entries()) {
    if (!fs.existsSync(filePath)) {
      console.warn(`⚠️ File not found: ${filePath}`);
      continue;
    }

    const src = fs.readFileSync(filePath, 'utf8').split('\n');
    const sortedLines = [...lineNumbers].sort((a, b) => b - a);

    for (const line of sortedLines) {
      const index = line - 1;
      if (src[index - 1]?.includes('@ts-expect-error')) continue;
      src.splice(index, 0, '// @ts-expect-error');
    }

    fs.writeFileSync(filePath, src.join('\n'), 'utf8');
    console.log(`✅ Patched: ${filePath}`);
  }
};

(async () => {
  await parseErrors();
  patchFiles();
})();

3. Vue 파일 처리

Vue 파일의 template 에러는 처리가 까다로워 @ts-nocheck로 처리하고, 추후 개발자가 직접 마이그레이션하도록 했습니다.

const patchFiles = () => {
  for (const filePath of files) {
    if (!fs.existsSync(filePath)) {
      console.warn(`⚠️  File not found: ${filePath}`);
      continue;
    }

    const content = fs.readFileSync(filePath, 'utf8');
    if (content.startsWith('// @ts-nocheck')) continue;

    if (filePath.endsWith('.vue')) {
      const lines = content.split('\n');
      const insertIndex = lines.findIndex((line) => line.includes('<script'));
      lines.splice(insertIndex + 1, 0, '// @ts-nocheck');
      fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
    }

    console.log(`✅ Patched (nocheck): ${filePath}`);
  }
};

4. CI/CD 적용

.gitlab-ci.yml에 타입 체크를 추가하고, ESLint에 @typescript-eslint/ban-ts-comment 룰을 설정하여 @ts- 주석과 any에 경고 처리를 했습니다.

발생한 문제들

ESLint 버전 문제

TypeScript를 업데이트했지만 eslint와 typescript-eslint 버전 업데이트를 놓쳤습니다. 콘솔에 warning이 발생하는 것을 확인하고 추가로 업데이트를 진행했습니다.

Node.js 버전 문제

typescript-eslint가 Node.js 18 이상을 요구했지만, 빌드용 이미지가 Node 16을 사용하고 있어 빌드 에러가 발생했습니다. Node 16은 2024년에 지원 종료된 버전이었기 때문에 Node 18로 업데이트를 진행했습니다.

이 과정에서 structuredClone이 Node 18부터 지원된다는 점도 알게 되었습니다.

결과 및 배운 점

성과

인사이트

  1. JavaScript 문법 우선: TypeScript를 사용하더라도 최대한 JavaScript에서 지원하는 문법을 사용하는 것이 좋습니다.

  2. —erasableSyntaxOnly 플래그: TypeScript 5.8부터 지원되는 이 플래그를 사용하면 추후 버전 업데이트 시 신경 쓸 일이 줄어들 것입니다.

  3. 점진적 마이그레이션: 한 번에 완벽하게 하려 하지 말고, 주석을 활용한 점진적 접근이 현실적입니다.

  4. 의존성 관리의 중요성: 하나의 패키지 업데이트가 연쇄적으로 다른 패키지와 환경의 업데이트를 요구할 수 있습니다.

댓글

아직 댓글이 없습니다.

GitHub 이슈를 통해 댓글이 관리됩니다.