안녕하세요!
오랜만에 글을 쓰네요. 오늘도 이상한 삽질기 들고왔습니다.
이번 주제는 바로 "타입스크립트의 메타데이터는 실제로 어떻게 주입될까?"인데요! NestJS 삽질기를 보신 분은 아시겠지만, 클래스에 design:paramtypes와 같은 메타데이터들이 생성자나 메서드에 들어가있습니다. 이 메타데이터들은 타입스크립트가 만들어서 넣어주게 되는데요.
그런데, 결국 코드를 실행하는 주체는 타입스크립트가 아니라 NodeJS에요. 실행 시점에 타입스크립트는 전혀 관혀할 수가 없습니다. 그러면 타입스크립트의 메타데이터는 언제 우리의 클래스에 들어오게 된걸까요?
오늘은 이와 관련된 부분을 코드 레벨에서 살펴보려 합니다.
시작해봅시다.
먼저, 타입스크립트가 관여하지 않는 런타임에 어떻게 타입스크립트가 주입해주는 메타데이터가 존재할 수 있는지부터 살펴보겠습니다.
답은 간단한데요. 타입스크립트가 자바스크립트로 트랜스파일되는 과정에서 JS 코드 파일에 메타데이터 주입 코드를 같이 심어주기 때문입니다. 예시로 살펴볼게요.
const EmptyDecorator: ClassDecorator = () => {};
@EmptyDecorator
class Hello {
  constructor(name: string) {}
}이 코드를 tsconfig.json에서 experimentalDecorators 옵션과 emitDecoratorMetadata 옵션을 킨 상태로 tsc로 트랜스파일하면 아래와 같은 JS 코드가 나오게 됩니다.
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
const EmptyDecorator = () => { };
let Hello = class Hello {
    constructor(name) { }
};
Hello = __decorate([
    EmptyDecorator,
    __metadata("design:paramtypes", [String])
], Hello);
design:paramtypes메타데이터는 생성자 및 메서드 등의 파라미터들에 대한 생성자를 순서대로 갖는 메타데이터입니다.Hello클래스가string타입의name파라미터를 받고 있으니,design:paramtypes에 문자열의 생성자인String이 들어가게 된 것입니다.
갑자기 우리가 작성하지도 않은 __decorate와 __metadata라는 코드가 나타나게 됩니다. 이름만 봤을 때는, __decorate가 데코레이터를 적용하고, __metadata가 메타데이터를 붙이는 코드로 보이죠?
각 코드를 좀 더 살펴보면, __decorate는 Reflect.decorate를, __metadata는 Reflect.metadata를 호출한다는 걸 알 수 있습니다.
그런데, Reflect.decorate와 Reflect.metadata에 대한 코드는 존재하지 않고, 해당 함수가 존재할 때만 실행되도록 구현되어 있는데요.
즉, 타입스크립트는 메타데이터를 생성은 하지만 관리는 하지 않는다는 걸 알 수 있습니다.
두 함수에 대해서는 아래에서 더 살펴보겠습니다.
조금 더 깊이 들어가기 전에, 먼저 타입스크립트에서 메타데이터는 어떻게 조회할 수 있는지부터 간단하게 살펴보겠습니다.
타입스크립트에서 메타데이터는 아직 표준이 아닙니다. 그래서, 보통 타입스크립트 진영에서 메타데이터를 사용할 때는 reflect-metadata라는 라이브러리를 추가적으로 사용하는데요.
import "reflect-metadata";
class Hello {}
Reflect.defineMetadata("hello", "world", Hello);
console.log(Reflect.getMetadata("hello", Hello)); // worldreflect-metadata 라이브러리를 사용하면 위와 같이 메타데이터를 정의하고 사용할 수 있습니다.
근데, reflect-metadata 라이브러리를 사용해야만 메타데이터를 조회할 수 있으면.. 타입스크립트가 이 라이브러리에 의존하고 있는걸까요?
하지만 이 라이브러리 없이도 타입스크립트는 잘 돌아가는걸요! 어떻게 된걸까요?
reflect-metadata는 필수일까?결론부터 말하자면, 아닙니다. reflect-metadata 없이도 타입스크립트에서 메타데이터를 받을 수 있습니다. 물론 몇 가지 함수를 직접 구현해줘야 합니다.
위에서 살펴봤던 Reflect.decorate와 Reflect.metadata 함수를 다시 떠올려봅시다. 두 함수는 자신이 정의되어 있을 때에만 호출되도록 구현된다는 것까지 알아봤는데요.
둘을 합쳐서 상황을 해석해보면, reflect-metadata는 Reflect.decorate와 Reflect.metadata에 대한 구현을 제공한다는 걸 예상해 볼 수 있습니다.
실제로 reflect-metadata의 구현을 살펴보면 두 함수가 구현되어 있는 걸 알 수 있습니다.
decorate: Reflect.ts#L674-L688metadata: Reflect.ts#L735-L744그렇다는 건, Reflect.metadata의 형태에 맞춰 직접 구현해주면 reflect-metadata 없이도 메타데이터를 등록 및 조회할 수 있다는 걸까요?
맞습니다.
const metadataMap: Record<any, Record<any, any>> = {};
const getMetadata = (key: any, target: any) => metadataMap[target][key];
const metadataFn = (key: any, value: any): ClassDecorator => {
  return (target: any) => {
    metadataMap[target] = { ...metadataMap[target], [key]: value };
  };
};
Object.assign(Reflect, { metadata: metadataFn });
const SomeDeco: ClassDecorator = () => {};
@SomeDeco
class Hello {
  constructor(name: string) {}
}
console.log(getMetadata('design:paramtypes', Hello));
// [ [Function: String] ]위와 같이 reflect-metadata를 사용하지 않고도, 타입스크립트에서 제공해주는 메타데이터인 design:paramtypes를 주입 받을 수 있습니다.
이제 본격적으로 이야기를 시작해봅시다. 메타데이터 자체는 reflect-metadata와 관련이 전혀 없다는 걸 알아냈으니, 이제 타입스크립트만 파보면 되겠죠?
그럼 타입스크립트는 도대체 어디서 메타데이터를 생산하는 코드를 우리의 JS 파일에 넣어준 걸까요?
아래 코드는 타입스크립트 v5.7.3 (
a5e123d)을 기준으로 합니다.
// src/compiler/transformers/ts.ts#L1126-L1129
function getOldTypeMetadata(node: Declaration, container: ClassLikeDeclaration) {
  if (typeSerializer) {
    let decorators: Decorator[] | undefined;
    if (shouldAddTypeMetadata(node)) {
      const typeMetadata = emitHelpers().createMetadataHelper("design:type", typeSerializer.serializeTypeOfNode({ currentLexicalScope, currentNameScope: container }, node, container));
      decorators = append(decorators, factory.createDecorator(typeMetadata));
    }
    if (shouldAddParamTypesMetadata(node)) {
      const paramTypesMetadata = emitHelpers().createMetadataHelper("design:paramtypes", typeSerializer.serializeParameterTypesOfNode({ currentLexicalScope, currentNameScope: container }, node, container));
      decorators = append(decorators, factory.createDecorator(paramTypesMetadata));
    }
    if (shouldAddReturnTypeMetadata(node)) {
      const returnTypeMetadata = emitHelpers().createMetadataHelper("design:returntype", typeSerializer.serializeReturnTypeOfNode({ currentLexicalScope, currentNameScope: container }, node));
      decorators = append(decorators, factory.createDecorator(returnTypeMetadata));
    }
    return decorators;
  }
}무작정 코드에서 검색해봤을 때, src/compiler/transformers/ts.ts에서 관련 로직을 발견할 수 있었습니다.
어차피 저 로직은 tsc를 실행했을 때 호출될 거니까, 해당 로직에 에러를 심으면 call stack을 얻을 수 있겠죠?

node_modules의 트랜스파일된 tsc 코드를 살펴봤을 때, lib/_tsc.js 안에서 해당 코드를 찾을 수 있었습니다. 여기에 에러를 심고 tsc를 호출합니다.
/Users/lery/dev/hello/node_modules/typescript/lib/_tsc.js:121836
      throw e;
      ^
Error: Hello World!
    at getOldTypeMetadata (/Users/lery/dev/hello/node_modules/typescript/lib/_tsc.js:92588:15)
    at getTypeMetadata (/Users/lery/dev/hello/node_modules/typescript/lib/_tsc.js:92576:81)
    at injectClassTypeMetadata (/Users/lery/dev/hello/node_modules/typescript/lib/_tsc.js:92550:22)
    at visitClassDeclaration (/Users/lery/dev/hello/node_modules/typescript/lib/_tsc.js:92407:19)
    at visitTypeScript (/Users/lery/dev/hello/node_modules/typescript/lib/_tsc.js:92286:16)
    at visitorWorker (/Users/lery/dev/hello/node_modules/typescript/lib/_tsc.js:92077:14)
    at sourceElementVisitorWorker (/Users/lery/dev/hello/node_modules/typescript/lib/_tsc.js:92092:16)
    at saveStateAndInvoke (/Users/lery/dev/hello/node_modules/typescript/lib/_tsc.js:92043:21)
    // ... 생략
Node.js v20.17.0그럼 위와 같은 call stack을 얻을 수 있는데요. 여기서 중요한 부분은 위 5줄입니다. (getOldTypeMetadata ~ visitTypescript)
역방향으로 하나씩 살펴볼까요!
visitTypescript타입스크립트는 코드의 AST를 생성하고, 타입 체크 후 이를 기반으로 자바스크립트 코드로 트랜스파일링합니다.
이때, 각 노드는 모두 kind라는 프로퍼티를 갖고 있으며, 이를 통해 해당 노드가 어떤 노드인지를 판단합니다.
visitTypescript는 kind 프로퍼티를 기반으로 관련 함수를 호출해줍니다.
그 중 Hello 클래스는 ClassDeclaration입니다. 따라서, visitClassDeclaration을 호출합니다.
// src/compiler/transformers/ts.ts#L714-L723
function visitTypeScript(node: Node): VisitResult<Node | undefined> {
  // ...
  switch (node.kind) {
    // ...
    case SyntaxKind.ClassDeclaration:
      // This may be a class declaration with TypeScript syntax extensions.
      //
      // TypeScript class syntax extensions include:
      // - decorators
      // - optional `implements` heritage clause
      // - parameter property assignments in the constructor
      // - index signatures
      // - method overload signatures
      return visitClassDeclaration(node as ClassDeclaration);
    // ...
  }
}visitClassDeclarationClassDeclaration에 대한 트랜스파일링 책임을 갖고 있습니다.
// src/compiler/transformers/ts.ts#L914-L936
function visitClassDeclaration(node: ClassDeclaration): VisitResult<Statement> {
  const facts = getClassFacts(node);
  // ... 생략 ...
  if (facts & ClassFacts.HasClassOrConstructorParameterDecorators) {
    modifiers = injectClassTypeMetadata(modifiers, node);
  }
  // ... 생략 ...
  const classDeclaration = factory.updateClassDeclaration(
    node,
    modifiers,
    name,
    /*typeParameters*/ undefined,
    visitNodes(node.heritageClauses, visitor, isHeritageClause),
    transformClassMembers(node),
  );
  // ... 생략 ...
}여기서, 해당 노드가 클래스 데코레이터를 갖고 있으면 injectClassTypeMetadata를 호출하여 데코레이터 관련 modifier를 생성하고, 이를 ClassDeclaration에 적용합니다. (updateClassDeclaration)
{
  pos: 89,
  end: 155,
  kind: 263,
  id: 634,
  flags: 0,
  modifierFlagsCache: 536903680,
  transformFlags: 33563649,
  modifiers: [
    Node4 {
      pos: 89,
      end: 100,
      kind: 170,
      id: 632,
      flags: 0,
      modifierFlagsCache: 0,
      transformFlags: 33562625,
      parent: [Node4],
      original: undefined,
      emitNode: undefined,
      expression: [Identifier2]
    },
    pos: 89,
    end: 100,
    hasTrailingComma: false,
    transformFlags: 33562625
  ],
  typeParameters: undefined,
  heritageClauses: undefined,
  jsDoc: undefined
  // ... 이모저모 생략
}위 객체는 Hello 클래스의 ClassDeclaration 노드인데요. 여기서 “클래스 데코레이터를 갖고 있음”을 판단할 때는 modifierFlagsCache를 사용합니다.
데코레이터의 flag는 1 << 15인데, 헤당 값과 bit and 연산을 했을 때 truthy한 값이 나오면 데코레이터가 있다고 판단합니다.
536903680 = 100000000000001000000000000000
1 << 15   =               1000000000000000여담으로 관련 로직에 사용되지는 않지만, modifiers에 있는 Node4의 kind가 170인데, 해당 kind가 데코레이터입니다.
// src/compiler/transformers/ts.ts#L1078-L1090
function injectClassTypeMetadata(modifiers: NodeArray<ModifierLike> | undefined, node: ClassLikeDeclaration) {
  const metadata = getTypeMetadata(node, node);
  if (some(metadata)) {
    const modifiersArray: ModifierLike[] = [];
    addRange(modifiersArray, takeWhile(modifiers, isExportOrDefaultModifier));
    addRange(modifiersArray, filter(modifiers, isDecorator));
    addRange(modifiersArray, metadata);
    addRange(modifiersArray, filter(skipWhile(modifiers, isExportOrDefaultModifier), isModifier));
    modifiers = setTextRange(factory.createNodeArray(modifiersArray), modifiers);
  }
  return modifiers;
}getTypeMetadata를 통해 메타데이터를 가진 modifier들을 생성하고, 이를 modifiers 배열에 넣어 반환합니다.
Hello = __decorate([
  SomeDeco,
  __metadata("design:paramtypes", [String])
], Hello);이때, 두 번째 addRange에 의해 @SomeDeco 데코레이터가 __decorate의 파라미터 중 SomeDeco로 들어가고, 세 번째 addRange에 의해 design:paramtypes가 들어갑니다.
즉, getTypeMetadata가 __metadata("design:paramtypes", [String])에 대한 노드를 생성한다는 뜻이 됩니다.
// src/compiler/transformers/ts.ts#L1111-L1117
function getTypeMetadata(node: Declaration, container: ClassLikeDeclaration) {
  // Decorator metadata is not yet supported for ES decorators.
  if (!legacyDecorators) return undefined;
  return USE_NEW_TYPE_METADATA_FORMAT ?
    getNewTypeMetadata(node, container) :
  	getOldTypeMetadata(node, container);
}USE_NEW_TYPE_METADATA_FORMAT에 따라 다른 함수를 호출합니다.
해당 값은 항상 false로 정의되어 있어서, getOldTypeMetadata를 호출합니다.
// src/compiler/transformers/ts.ts#L1126-L1129
function getOldTypeMetadata(node: Declaration, container: ClassLikeDeclaration) {
  if (typeSerializer) {
    let decorators: Decorator[] | undefined;
    // ... 생략 ...
    if (shouldAddParamTypesMetadata(node)) {
      const paramTypesMetadata = emitHelpers()
        .createMetadataHelper(
          "design:paramtypes",
          typeSerializer.serializeParameterTypesOfNode(
            {
              currentLexicalScope,
              currentNameScope: container
            },
            node,
            container
          )
        ); // = __metadata("design:paramtypes", [String])
      decorators = append(
        decorators,
        factory.createDecorator(paramTypesMetadata)
      );
    }
    // ... 생략 ...
    return decorators;
  }
}아까 봤던 getOldTypeMetadata가 다시 등장했습니다.
paramTypesMetadata에서 __metadata("design:paramtypes", [String])에 대한 노드를 생성하고, 이를 데코레이터로 처리하기 위해 factory.createDecorator로 한 번 감싸서 decorators에 추가합니다.
이 과정에서 실제로 생성되는 AST node를 살펴보면,
Node4 {
  kind: 213, // CallExpression
  expression: Identifier2 {
    kind: 80, // Identifier
    escapedText: '___metadata'
  },
  arguments: [
    Node4 {
      kind: 11, // StringLiteral
      text: 'design:paramtypes'
    },
    Node4 {
      kind: 209, // ArrayLiteralExpression
      elements: [
        Identifier2 {
          kind: 80, // Identifier
          escapedText: 'String'
        }
	  ]
    }
  ]
}__metadata를 호출하는 CallExpression node가 생성되고, 여기에 인자로 design:paramtypes라는 문자열 리터럴과 배열 리터럴이 넘겨지며, 배열에는 String이라는 식별자를 요소로 넣게 됩니다.
즉, 코드로 옮기면 아래와 같은 코드가 만들어지는거죠.
__metadata("design:paramtypes", [String])__metadata 함수는 어디서 만들어질까?어떻게 잘 지지고 볶아서 __metadata 함수를 호출하는 코드가 만들어진다는 건 이제 이해했습니다. 그럼 __metadata 함수는 어디서 오는걸까요?
getOldTypeMetadata 코드를 다시 보면, emitHelpers().createMetadataHelper(/* ... */) 함수를 호출하고 있다는 걸 알 수 있는데요. 이 함수에 의해 design:paramtypes를 사용한다는 걸 기록해둡니다.
그러다 이후 JS 코드 생성 시, __metadata 함수를 코드 파일에 주입하게 되는데요. __metadata 함수의 구현을 저장하는 방식이 좀 놀라웠습니다.
// src/compiler/factory/emitHelpers.ts#L744-L753
const metadataHelper: UnscopedEmitHelper = {
    name: "typescript:metadata",
    importName: "__metadata",
    scoped: false,
    priority: 3,
    text: `
            var __metadata = (this && this.__metadata) || function (k, v) {
                if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
            };`,
};놀랍게도 __metadata 함수 구현체 자체를 리터럴로 관리하고 코드에서 관리하고 있었습니다. 물론 참 효율적인 방법이긴 한데.. 막연하게 의외네? 생각이 들었네요.
이번에는 타입스크립트가 어떻게 실제로 우리에게 메타데이터를 주입해주는지를 알아봤습니다.
사실 이 주제를 알아본 건 사내 테크톡에서 발표하기 위함이었는데요, 그 발표가 올해 2월이었습니다(..)
그 외에도 이것저것 삽질한 내역은 있지만 글로 옮기기 귀찮아서 안 하고 있네요.
아무튼, 읽어주셔서 감사합니다!