최초에 Javascript 프로젝트의 소스코드를 그대로 복사해서 새롭게 Typecript 프로젝트를 시작했다. 최초에 타입체크를 사용하기 위해서 마이그레이션 작업을 진행하자는 이야기가 나왔었다. 하지만 변경해야할 파일의 수가 적지 않았기때문에 마이그레이션 작업을 진행하기 위해 개발작업을 멈출 수 없었기 때문에 그 이후로 별다른 진전 없이 프로젝트의 규모만 더 커지게 되었다.
목차
펼치기
최초 시도; 변경된 파일만 검사를 진행하자
마이그레이션과 관련해서 아무런 이야기가 나오지 않은건 아니었다. 아무런 조치도 취하지 않고 이대로 개발을 진행하면 나중에 고쳐야할 코드만 많아지고, 안정성에 문제가 생길것이다라는 의견이 나왔었다. 하지만 당시에는 개발 일정이 급했고, 마이그레이션을 위해서 개발을 멈출 수는 없었다. 그래서 변경된 파일만 타입체크를 할 수 없냐는 의견이 나와서 방안을 알아 봤지만 그런 방법을 찾지는 못했다. 그렇게 마이그레이션에 대한 이야기는 흐지부지 되었다.
일단 에러나는 모든 부분을 any로 타입 단언하고 천천히 마이그레이션하자
그렇게 시간이 지나면서 프로젝트 규모가 커지기 시작했다. 하지만, 개발 일정은 여전히 빡빡했고, 여유가 생기면 하자는 마이그레이션은 영영 진행할 수 없을것만 같았다. 그와 더불어 속절없이 커진 소스코드는 한 사람의 인지능력으로는 도저히 본인이 작업한 내용의 영향범위를 일일이 추적하지 못했다. 하나의 파일을 고쳤는데 다른 곳에서 에러가 발생하는 경우가 빈번히 발생하면서 안정성에 대한 이슈가 발생하기 시작했다.
이제 일정을 핑계로 마이그레이션을 안할 수 없는 지경이었다. 언제까지 Typescript의 기능을 반도 사용하지 못하고 있을 수 없었다. 일단 ci 과정에 typecheck를 어떻게든 강제할 수 있어야 했다. 그러면 일단 지금 발생하는 type error가 발생하지 않게 작업을 진행하자는 이야기가 나왔다. 에러가 발생하는 곳에 any로 타입 단언 처리하고 천천히 마이그레이션 하자는 의견에 이르렀다. 그럼에도 불구하고 수천곳에서 발생하고 있는 에러를 고치는 작업은 쉽게 끝날 작업이 아니었기 때문에 누구하나 나서지 못하고 시간이 흘렀다.
Typescript 주석을 사용하면 되겠다. airbnb ts-migrate
그러던 중 우연찮게 airbnb에서 만든 ts-migrate 패키지를 발견했다. @ts-
주석기능을 활용하면 에러 발생하는 곳마다 일일이 any로 변경해 주지 않더라도 쉽게 에러가 발생하지 않게 처리할 수 있겠다는 희망을 보았다. 처음에는 해당 패키지를 그대로 사용하려 했지만 생각처럼 쉽게 되지 않았다. 그리고 vue
파일에서 그대로 사용하기도 무리가 있었기 때문에, 타입에러가 나는 곳에 주석처리를 해야겠다는 아이디어로 gpt와 함께 작업했다.
- 우선 타입 체크를 진행해서 에러가 나는 위치 정보를 텍스트로 저장한다.
- 해당 텍스트를 한줄씩 읽어서 주석처리(
@ts-expect-error
)하는 스크립트를 작성한다. vue
파일의 경우에는 template에서 나는 에러는 처리하기 까다롭기 때문에 파일을@ts-nocheck
처리하고 나중에 작업하는 사람이 해당 주석을 제거하고 마이그레이션 진행한다.- ci 스크립트를 작성하고, 통과하지 못하면 머지하지 못하게 금지한다.
사용한 script 전문
- 우선 타입 체크를 진행해서 에러가 나는 위치 정보를 텍스트로 저장한다.
npm run typecheck -- --noEmit --pretty false > type-errors.txt
- 해당 텍스트를 한줄씩 읽어서 주석처리(
@ts-expect-error
)하는 스크립트를 작성한다.
import * as fs from 'fs';
import * as readline from 'readline';
import * as path from 'path';
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) {
// ex: path/to/file.ts(12,34): error TSXXXX
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;
// Skip if already expect
if (src[index - 1]?.includes('@ts-expect-error')) continue;
// Insert above target line
src.splice(index, 0, '// @ts-expect-error');
}
fs.writeFileSync(filePath, src.join('\n'), 'utf8');
console.log(`✅ Patched: ${filePath}`);
}
};
(async () => {
await parseErrors();
patchFiles();
})();
vue
파일의 경우에는 template에서 나는 에러는 처리하기 까다롭기 때문에 파일을@ts-nocheck
처리하고 나중에 작업하는 사람이 해당 주석을 제거하고 마이그레이션 진행한다.
import * as fs from 'fs';
import * as readline from 'readline';
const errorFile = 'type-errors.txt';
const files = new Set<string>();
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] = match;
const cleanPath = match[1].trim();
files.add(cleanPath);
}
}
};
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; // 이미 있음
}
// Vue 파일인 경우 <script> 태그 찾기
if (filePath.endsWith('.vue')) {
const scriptTagIndex = content.indexOf('<script');
if (scriptTagIndex === -1) {
console.warn(`🚫 No <script> tag found in: ${filePath}`);
continue;
}
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}`);
}
};
(async () => {
await parseErrors();
patchFiles();
})();
- ci 스크립트를 작성하고, 통과하지 못하면 머지하지 못하게 금지한다.
마지막으로 .gitlab-ci.yml
작성하고 마무리 했고, 추가로 eslint에 @typescript-eslint/ban-ts-comment
rule을 이용해서 @ts-
주석과 any
에 warn 처리를 하고 마무리 했다.
결과
이제 브랜치에 머지하는 과정에서 타입체크가 강제되면서, 예상치 못한 버그를 미연에 방지할 수 있게 됐고, 프로젝트의 안정성이 높아졌다. 추후에는 유닛테스와 컴포넌트 테스트도 작성하고 강제하면서 보다 안정성있는 개발을 하고 싶은 기대가 있다. 개인적으로는 ai가 발전하면서, TDD 방식의 개발이 어느때보다 중요해 졌다고 생각한다. 결국 코드의 동작이 예상대로만 동작하면 되고 매번 코드는 ai가 새로 생성해주는게 생산성 측면에서 뛰어나기 때문이다. 그리고 앞으로 더 싸지고 효율적으로 발전함에따라 그 방식은 더 생산적이게 될것이라 생각한다.