홈시리즈멘토링

© 2026 정기창. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

@swc/jest로 갈았더니 NestJS Mongoose @Prop 28개가 깨졌다 — decoratorMetadata가 union을 collapse 안 하는 함정

정기창·2026년 4월 23일

앞선 글에서 ts-jest를 @swc/jest로 바꾼 뒤 로컬 테스트가 빛처럼 빨라진 이야기를 정리했습니다. 그런데 그 이관 PR을 GitHub Actions에 올리자마자 전혀 예상하지 못한 장면이 펼쳐졌습니다. 테스트 639개가 모두 통과했는데 Test Suites: 28 failed, 51 passed, 79 total. 테스트는 다 맞았는데 suite 28개가 컴파일 단계에서 죽어 있었습니다.

1. 수상한 CI 결과

로컬에서 수십 번 통과했던 테스트들이 CI 로그에서 이런 모양으로 나왔습니다.

FAIL src/users/users.controller.spec.ts
  ● Test suite failed to run

    Cannot determine a type for the "User.role" field
    (union/intersection/ambiguous type was used).
    Make sure your property is decorated with a
    "@Prop({ type: TYPE_HERE })" decorator.

      at @nestjs/mongoose/dist/decorators/prop.decorator.js:21:23
      at Reflect.decorate (reflect-metadata/Reflect.js:123:24)

@nestjs/mongoose가 직접 던지는 메시지였습니다. 처음에는 단순히 누락된 @Prop() 타입 주석 하나일 줄 알았는데, 실패 목록을 쭉 훑어보니 꽤 특정한 필드들이 반복적으로 등장했습니다.

  • BlogPost.language — 'ko' | 'en' | 'ja' 같은 string literal union
  • User.role — 'admin' | 'user' | 'super_admin'
  • Stock.market — 'KOSPI' | 'KOSDAQ' | 'KONEX'
  • ScraperDomain.parserType — 'css-selector' | 'custom'
  • AiRequestLog.requestType, GlossaryTerm.difficulty — 동일 패턴

공통점이 분명했습니다. 모두 string literal union으로 정의된 타입이었고, @Prop()에 type: 옵션이 명시되지 않은 채 타입 추론에만 의존하고 있었습니다. 그리고 tsc로는 문제없이 돌아가던 코드였습니다.

2. 왜 ts-jest에서는 괜찮고 @swc/jest에서는 깨졌을까

핵심은 emitDecoratorMetadata가 런타임에 실어 보내는 design:type 정보가 두 컴파일러에서 다르게 만들어진다는 점에 있었습니다.

타입 정의 tsc가 emit하는 design:type SWC가 emit하는 design:type
string String String
'a' | 'b' | 'c' (string literal union) String으로 collapse Object
number Number Number
SomeInterface Object Object

tsc는 string literal union을 만나면 모든 멤버가 같은 원시 타입임을 알아채고 String으로 축소해서 메타데이터에 심어 넣습니다. 반면 SWC는 현재 구현에서 union을 collapse하지 않고 그대로 Object를 내보냅니다. @nestjs/mongoose의 @Prop() 데코레이터는 design:type이 Object이면 "어떤 타입인지 결정할 수 없다"고 판단하고 앞서 본 에러를 발생시킵니다.

돌이켜보면 이것은 SWC의 버그라기보다 tsc가 암묵적으로 해주던 편의가 사라진 것에 가깝다는 생각이 들었습니다. TypeScript 타입은 런타임에는 존재하지 않으므로, 애초에 decorator metadata에 실리는 것은 타입 추론기 제작자의 선택에 달린 일입니다. SWC는 "정직한 대신 엄격"한 선택을 한 셈이고, 이를 감수해야 SWC의 성능 이득을 누릴 수 있습니다.

3. 해결 — 13개 schema에 type: String 명시

해결책은 놀랄 만큼 단순했습니다. 영향 받는 모든 @Prop에 type: String(또는 Number)을 명시적으로 추가하는 것입니다. 이것은 본래 NestJS 공식 문서가 union 타입에 대해 권장하는 패턴이기도 합니다.

// Before — SWC에서 깨짐
@Prop({ required: true, enum: SUPPORTED_LANGUAGES, default: 'ko', index: true })
language: SupportedLanguage;

// After — 모든 transpiler에서 안정
@Prop({ type: String, required: true, enum: SUPPORTED_LANGUAGES, default: 'ko', index: true })
language: SupportedLanguage;

6개 필드의 직접 지적 외에도, 방어적으로 같은 패턴의 후보 필드를 grep으로 뽑아 더 고쳐두었습니다.

grep -rn "@Prop(" --include="*.schema.ts" -A6 \
  | grep -B1 "enum:" | grep "@Prop(" | grep -v "type:"

최종적으로 13개 schema 파일에 type: String이 추가됐습니다. 이 중 직접 실패했던 suite는 28개였지만, 나머지는 같은 스키마를 import하는 전파 경로 위에 있었습니다. 수정 후 CI를 다시 돌렸더니 Test Suites: 79 passed, 79 total. Tests: 947 passed로 깨끗이 통과했습니다.

4. 재발 방지 — 새 schema를 짜는 순간부터 명시하기

한 번 겪었으니 같은 실수를 반복하지 않도록 두 가지 방책을 약속했습니다.

  • union 타입 필드에는 항상 type: 명시. @Prop() 빈 호출이나 옵션만 있는 호출로 union 타입 프로퍼티를 받는 순간 위험 신호로 간주합니다.
  • 코드 리뷰 체크리스트에 추가. NestJS Mongoose schema에 @Prop이 새로 추가될 때, 해당 프로퍼티가 string/number literal union이면 type:이 있는지 확인합니다.

같은 문제는 class-transformer나 class-validator처럼 런타임 타입 정보를 사용하는 다른 데코레이터 라이브러리에서도 나타날 수 있습니다. SWC로 갈아타는 프로젝트라면 이런 패턴을 한 번 전수 조사할 가치가 있습니다.

5. 남는 감상

tsc와 SWC 사이에는 이렇게 얇지만 날카로운 경계가 몇 군데 숨어 있습니다. 성능을 얻는 대가로 컴파일러가 "알아서 해주던 것"을 스스로 명시적으로 선언해야 한다는 사실 — 이는 결국 코드의 의도를 더 분명하게 적는 것이므로 장기적으로는 손해가 아니라고 느꼈습니다. 다만 CI가 빨간불을 띄우기 전까지 이 얇은 차이를 알아차리지 못했다는 점에서, 동등해 보이는 도구 교체일수록 통합 파이프라인 전 구간에서 회귀를 검증해야 한다는 것을 다시 배웠습니다.

앞서 글의 ts-jest → @swc/jest 이관과 이번 글의 @Prop 보강을 함께 놓고 보면, 결국 테스트 인프라를 가속하는 작업은 "속도 설정 한 번" 이 아니라 "기존 코드가 암묵적으로 기대하던 관행들"을 하나씩 드러내고 명시적으로 고치는 일의 연속이라는 생각이 듭니다. 이 두 단계의 여정은 앞으로 비슷한 이관을 고려하는 분들께 작은 이정표가 될 수 있길 바랍니다.

swcts-jestNestJSMongooseTypeScriptdecoratorMetadata

관련 글

맥북에서 jest가 OOM 났는데, 알고 보니 macOS 메모리 지표가 거짓말이었다 — ts-jest에서 @swc/jest로

jest가 OOM 날 것 같아 중단했지만, 알고 보니 macOS의 Pages free 지표가 거짓말이었습니다. memory_pressure로 본 실제 여유는 87%. 진짜 원인은 Jest 기본 워커 수와 ts-jest의 V8 heap 부담이었고, @swc/jest로 이관해 heap 50%·속도 5배 개선한 기록입니다.

관련도 92%

@Injectable()의 실체 — Reflect.metadata와 데코레이터가 하는 일 (2편)

@Injectable() 하나 붙이면 DI가 된다는 건 알지만, 이 데코레이터가 정확히 무슨 일을 하는 걸까요? NestJS 소스코드와 TypeScript 컴파일러 출력을 직접 추적하며, 데코레이터가 '실행'이 아니라 '등록'이라는 사실을 확인합니다.

관련도 88%

TypeScript verbatimModuleSyntax 마이그레이션 실전 가이드

TypeScript 5.0의 verbatimModuleSyntax 옵션을 모노레포 프로젝트에 적용하면서 배운 것들을 정리했습니다. 타입과 값의 import를 명확히 구분하는 것이 왜 중요한지, 그리고 실제 마이그레이션 과정에서 마주친 패턴들을 공유합니다.

관련도 87%