SWC를 통한 더 빠른 typescript 빌드 도입
2021년 09월 13일

서론

업무용 노트북 기준으로 NestJS 기반 로지파스타 API를 빌드하는데 대략 8초 정도 걸렸습니다. 배포할때도 그렇지만 코드를 변경하고 결과를 보는 iteration이 느려 이 병목 지점이 개발 과정에 많은 병목을 제공했습니다.

스프린트 종료 후 잠시 코드를 정리하는 기간을 활용하여 이 부분을 해결해보기로 했습니다.

최적화

빌드하는 상세 과정을 verbose 옵션 등으로 보고 싶었지만 실질적으로 생각하는 옵션을 못찾았습니다.

그래서 대신 --diagnostics 옵션을 넣어 대략적으로 조회해보았더니

특정 옵션 때문에 병목이 난다보다는 전반적으로 균등하게(?) 느렸습니다.

Performance · microsoft/TypeScript Wiki

타입스크립트를 최적화하는 방법은 typescript 프로젝트 위키에서 문서가 잘 되어있고, 여기에도 최적화 방법은 여러가지가 제시되어 있었습니다.

그래서 이 문서를 참조하여 소위 시도해보기 쉬워보이는 방법(tsconfig.json 등 빌드 옵션 수정) 몇 가지를 시도하여 약간의 효과는 얻었지만 그 효과는 실질적으로 미비했습니다.

그 다음 체크 사항으로는 type definition 쪽을 컴파일러 친화적으로 변경하여 체크 및 파싱 시간을 줄여보거나, 컴파일 대상인 파일을 glob 형식이 아닌 직접 지정하므로써 파일시스템 쪽 부하를 줄이는 등 방법이 있지만, 이 역시 드는 품에 비해 많은 이득을 볼수 있을 지가 불확실했습니다.

대체제 탐색

그리하여 생각이 돌고 돌아 빌드를 아예 다른 툴을 이용하면 어떨까라는 생각이 들었습니다. 예전에도 NestJS를 단일 js 파일로 번들링하여 lambda에 올리는 시도를 할때 webpack으로 작업한 경험이 있어, 어떻게든 방법이 있겠지 하는 마음에 다시 한번 리서치를 해보았습니다.

webpack

처음엔 이 방법으로 시도하려고 했습니다. 하지만 요새 퍼포먼스쪽 개선이 된 대체 번들러가 많이 등장하여 새로운 방법들로 먼저 시도해 보기로 했습니다.

esbuild

이것도 tsc에 비해 정말 빠릅니다. 하지만 공교롭게도 nestJS는 decorator 기반 아키텍쳐가 핵심인데 esbuild는 decorator를 미지원하고 잠정적으로 개발 계획도 없다고 한다.

사실 decorator는 실질적으로 많이 사용하고 있기는 하지만 아직 공식적으로 지원하는 기능은 아니고, 툴 자체가 "javascript bundler"이기 때문에 typescript 관련 옵션을 모두 지원해야 할 이유는 없습니다.

따라서 아쉽지만 적용하기에 어려움이 있다고 판단되어 다른 솔루션을 찾아야만 했습니다.

Feature request: Decorators support · Issue #104 · evanw/esbuild

swc

swc

지향점 차이이긴한데 위에서 언급한 webpack, esbuild 등의 라이브러리들은 사실 용도 자체가 프론트에서 주로 사용하고 있는 번들러이고, 이 중 ts 로드 기능이 있는 부분을 뽑아 활용하는 접근 방식에 가까웠습니다. 하지만 swc는 번들러가 아니라 컴파일러(트랜스파일러)를 표방하고 있습니다!

🛠 Transcompile
swc is a typescript / javascript compiler.
It consumes a javascript or typescript file which uses recently added features like async-await and emits javascript code which can be executed on old browsers.
It supports all published typescript versions and all valid ecmascript, including some stage 3 proposals as an input, and supports es3 or higher as an output
🚀 Super fast
It's 20x faster than babel on single thread, and 70x faster on 4 core benchmark

참고용 벤치마킹 페이지에 있는 이미지 중 하나, esbuild보다도 빠릅니다.

Transforms | swc

적용

적용 리서치 과정 중에는 항상 두가지 이슈가 따라다녔는데

  1. 데코레이터 관련 부분이 문제없이 컴파일 되는가?
  2. tsconfig-path를 활용해 import 시에 상대경로 대신 alias 처리된 경로를 사용하고 있는데 이부분이 잘 해석되어 컴파일 되는가?

였습니다. 적용 시도했던 라이브러리들이 이 부분에서 막히거나, 불가능한 지점이 있었습니다.

// .swcrc 옵션 파일, 데코레이터 옵션이 아주 잘 지원된다. // paths 관련 옵션도 지원되는데 굳이 사용하지 않아도 이슈가 없어서 패스 { "module": { "type": "commonjs" }, "jsc": { "parser": { "syntax": "typescript", "tsx": false, "decorators": true, "dynamicImport": false }, "transform": { "legacyDecorator": true, "decoratorMetadata": true }, "target": "es2017", "keepClassNames": false } }
  1. 새로운 이슈 등장, 뜬금없이 swagger쪽이 빌드에서 막히는 이슈를 만났습니다.

@nestjs/swagger는 프로젝트 내에 있는 모든 DTO 관련 파라미터, Body, Query 등을 긁어 문서를 빌드하는 기능이 있는데, 긁다 어딘가에서 type이 없는 지점을 만나 에러가 난듯 한데, 긁는 순서가 순차적이지 않기 때문에 찾는데에 애를 조금 먹었습니다.

범인은 이 코드에 Body 데코레이터 같은 케이스였고, 기존 tsc 빌드툴에서는 이런 코드를 잠정적으로 any로 바꿔 컴파일 하는데, swc에서는 그렇지 않아 예상하지 않은 에러가 난 것으로 추측 정도를 한 후 마저 리서치를 진행했습니다.

그리하여 tsconfig.json 옵션에 noImplicitAny를 넣어 lint를 더욱 strict하게 만든 후, 코드 수정 작업을 했습니다.

빌드

빌드가 성공했고 결과는 놀라울 정도로 빨랐습니다.

개인 imac 기준 (3.8 GHz 8코어 Intel Core i7, 40GB 2133 MHz DDR4)

  • 약 6.74s → 약 0.51s
  • 스크린샷

업무용 macbook 기준 (2.6 GHz 6코어 Intel Core i7, 16GB 2667 MHz DDR4) 기준

  • 약 7.54s → 약 0.773s
  • 스크린샷

이슈 트래킹

하지만 class-validator 기반 DTO가 정상적으로 작동하지 않는 이슈를 발견하여 실제 프로덕트 도입은 잠정 중단하고, 스프린트 일정이 빠듯하여 우선 남은 일정을 소화하는 것으로 1차 결론을 지었습니다.

// DTO 정의 export class PostListArgs { company_id?: number name?: string } // 컨트롤러 async find(@Query() args: PostListArgs) { console.log(args) const result = await this.postsService.findMany(args) // ... } /** 예를 들어 위와 같이 DTO와 컨트롤러를 정의한 후, example.com/api/posts?company_id=1&name=john 등 querystring으로 전달한 값이 swc로 빌드되었을 경우 무시되는 이슈가 있었습니다. */

원인을 딥하게 체크해보면, NestJS에서 DTO에 주로 사용되는 class-validator, class-transformer 라이브러리 관련 이슈인데, 가볍게 해결할 수 있는 문제는 아닌 것으로 보여 notification을 걸어놓고 모니터링을 계속 하고 있었습니다.

하지만 이슈가 당장 해결되기는 어려울 것으로 판단하여, 이 이슈에서 제시된 workaround를 사용했습니다. 모든 DTO 클래스 필드에 기본값을 undefined로 놓으면 이슈 자체는 해결됩니다.

// 즉 DTO 정의를 다음과 같이 변경합니다. export class PostListArgs { company_id?: number = undefined name?: string = undefined }