센드버드 SDK 의 타입 정의는 어떻게 빌드되고 있을까? dts-bundler 개발기

HYUNGU, KANG·2024년 7월 23일
post-thumbnail

발표 영상

1부: https://www.youtube.com/watch?v=Tn63o4Q22n8
2부: https://www.youtube.com/watch?v=nehqnuJvh84


작업을 한지 2년이 넘게 지났지만 꽤 재미있게 했던 작업이라서
기록을 남기고 싶어, 시간이 더 흐르기 전에 기억을 더듬어 작성을 해본다!

여정의 시작

회사에 막 합류했을 당시 자바스크립트로 작성된 chat sdk 를 바닥부터 타입스크립트로 재작성하고 전환하여 v4 로 메이저 버전을 올리는 작업을 팀에서 진행하고 있었다.

소스 코드는 거의 완성이 되어있었고 npm 에 배포하기 위하여 번들링을 해야하는 상황이었는데, 여기에 몇가지 이슈가 있었다.

새로운 라이브러리의 콘셉트는 사용하는 모듈들만 import 하여 SDK 를 초기화 하는 시점에 넘겨주어서, 사용하지 않는 코드들을 애초에 분리할 수 있도록 하는것이 목적이었다.

import SendbirdChat from '@sendbird/chat';
import { GroupChannelModule } from '@sendbird/chat/groupChannel';
import { OpenChannelModule } from '@sendbird/chat/openChannel';

const sdk = SendbirdChat.init({
  modules: [new GroupChannelModule(), new OpenChannelModule()]
});

때문에 추출되는 모듈별로 엔트리 포인트를 설정해주어야 했고, 이때문에 자바스크립트와 타입 정의 파일(이하 dts) 간에 불일치가 생기는 문제가 있었다.

예를 들어서 타입스크립트 프로젝트가 아래와 같은 구조를 가지고 있다면

- src
  - model
    - message.ts
  - manager
    - channelManager.ts
  - core
    - commandRouter.ts
  - query
    - messageListQuery.ts

tsc 를 사용하는 경우 일반적으로 아래와 같이, 동일한 구조로 컴파일이 된다.

- dist
  - model
    - message.js
    - message.d.ts
  - manager
    - channelManager.js
    - channelManager.d.ts
  - core
    - commandRouter.js
    - commandRouter.d.ts
  - query
    - messageListQuery.js
    - messageListQuery.d.ts

만약 Rollup 과 같은 번들러를 사용하여 index, messageModule, channelModule 에 대해서 세개의 엔트리 포인트를 설정하고, 파일들을 여러 청크로 쪼개는 등의 최적화 설정들을 하게 되면, 아래처럼 js 파일과 dts 파일간의 구조 불일치가 생기게 된다.

- dist
  - index.js
  - messageModule.js
  - channelModule.js
  - chunk.2aa8028.js
  - chunk.3741b90.js
  - model
    - message.d.ts
  - manager
    - channelManager.d.ts
  - core
    - commandRouter.d.ts
  - query
    - messageListQuery.d.ts

이러한 구조 불일치를 맞춰주기 위해 플러그인을 찾아보았고, 기억하기로는 dts 파일들 또한 엔트리에 맞춰서 추출을 해주는 플러그인이나 도구들이 몇몇 있기는 했던것으로 기억한다.

https://github.com/ezolenko/rollup-plugin-typescript2
https://github.com/wessberg/rollup-plugin-ts
https://github.com/Swatinem/rollup-plugin-dts
https://github.com/timocov/dts-bundle-generator

- dist
  - index.js
  - index.d.ts
  - messageModule.js
  - messageModule.d.ts
  - channelModule.js
  - channelModule.d.ts
  - chunk.2aa8028.js
  - chunk.3741b90.js
  - model
    - message.d.ts
  - manager
    - channelManager.d.ts
  - core
    - commandRouter.d.ts
  - query
    - messageListQuery.d.ts

대부분의 도구들은 dts 파일간의 re-exports 를 통하여 타입들을 내보내주는 구조였고, 단순한 구조의 프로젝트에서는 제법 잘 동작하는것처럼 느껴졌지만, 실제로 적용을 하면서는 많은 타입과 인터페이스에 대해 named export 와 default export 를 올바르게 처리하는 과정에 문제가 있었다.

예를 들어서 중복된 이름을 가진 named export 된 인터페이스가 있는 경우 제대로 분류를 못한다던가, default export 된 타입의 이름이 정상적으로 추출되지 않아서 제대로 re-exports 가 되지 않는다던가 하는 등의 버그들이 각 플러그인들에 있었다.

여차 저차해서 이것들을 어떻게 잘 사용한다고 하더라도, 모든 플러그인들이 만족하지 못하는 작업이 있었다. 바로 빌드 과정에서 인터페이스를 핸들링하지 못한다는것.

자바스크립트 파일들은 보안을 위해서 모두 난독화 처리가 되어서 빌드가 되는 상태이고, 여기에 마찬가지로 dts 파일들에 나타나는 타입들 또한 SDK 내부의 internal/private 인터페이스가 노출되지 않고, 사용자가 사용하는 public 인터페이스들만 dts 파일에 노출을 시켜줘야 하는 요구사항이 있었다.


이러한 버그들이 없고 요구사항들을 모두 충족 시켜줄만한 도구가 없나 이리저리 탐색을 하던 중 발견하였다..!

🎁 API Extractor

api-extractor 는 마이크로소프트에서 운영중인 오픈소스 rush-stack 모노레포 관리 툴 셋에 속해있는 도구 중 하나였고

존재 이유 자체가 dts 파일을 롤업(하나로 말아주는) 해주기 위해 존재하는 도구였다..! 🥺

테스트를 해본 결과 default export 처리에 문제가 없고, 중복된 Interface 이름 또한 Interface_2 와 같이 내부적으로 처리하면서 postfix 를 붙여서 alias 처리를 해줬다.

또한 추가적으로 interface trimming 까지 jsdoc 으로 아름답게 지원을 해주고 있었다.

/**
 * @internal
*/
interface PrivateInterface {
  hello(): void;
}

릴리즈 태그라는 부가적인 규칙이 있기는 하지만, 아무튼 타입들을 타입을 롤업하는 과정에서 @internal 로 마크를 하면 dts 파일에 나타나지 않게 할 수 있는것이다. 세상에 🤦‍♂️

아무튼 대부분의 요구사항들이 충족되는 도구였지만 몇가지 문제가 남아있었다.

문제 1 - 단일 엔트리 파일에 대해서만 지원이 되었고

문제 2 - 모든 소스코드에 jsdoc 으로 private, internal 주석을 달아야 한다는것이었다.

어떻게 해결했나요?

대부분의 문제는 api-extractor 에서 아름답게 해결을 해주고 있었으므로, 만들어진 결과물을 잘 다뤄보기로 생각하고 바로 작업에 착수했다.

자바스크립트 번들링은 롤업에게 맡기고, 타입 정의 파일을 번들링 해주는 내부 도구를 만들자.

각 엔트리에서 export 되는 타입과 모듈들은 타입스크립트 원본 파일을 기준으로 명확하기 때문에, 각자 할 일만 잘 하면 되는것이었다.

도구의 이름은 dts 를 번들링 해주기 때문에 dts-bundler 로 지어주었다.


api-extractor 를 통해서 특정 엔트리에 대해서 dts 롤업을 진행하게 되면
아래의 예시 같이 단일 dts 파일에 해당 파일에 사용되는 모든 타입들이 들어가게 되고, 여기서 private, internal 은 제거된 뒤, 엔트리에서 export 되는 모듈들에 대해서 ExportDeclaration 이 붙는다.

// messageModule.d.ts entry
// from model/message.ts
export declare interface Message { // ExportDeclaration 
  message: string;
}
// from core/commandRouter.ts
export declare type CommandRouter { // ExportDeclaration
  command: Command;
}
// from core/commandRouter.ts
declare type Command = 'A' | 'B' | 'C';

--- 
  
// index.d.ts entry
export declare interface SDK { // ExportDeclaration 
  static init(): SDK;
}
// from core/commandRouter.ts
declare type CommandRouter {
  command: Command;
}

문제1 해결을 위해, 멀티 엔트리 지원을 하는 dts-bundler 의 콘셉트는 단순했다.

  1. api-extractor 로 N 개의 엔트리별 dts 파일을 추출한다.
  2. N 개의 dts 파일별로 exports 되는 타입들을 마크한다.
  3. N 개의 dts 파일들로부터 중복 타입들을 제거하여 모든 타입을 하나의 dts 파일에 모은다.
  4. 각 엔트리 dts 파일에서는 (3) 에서 만든 dts 파일로부터 타입들을 (2) 에 맞게 re-exports 만 한다.

각 단계별로 진행을 하기 위해서는 dts-bundler 는 TypeScript compiler 를 사용해서 작성을 해야했다.

TypeScript Compiler API

처음 사용하다보니 레퍼런스도 많지 않고 학습이 필요했지만 콘셉트 자체는 단순해서 크게 어렵지는 않았다. 아래의 자료들로 직접 타입 정의 파일에 대한 결과물에 대해 만들어진 AST 를 보면서 Compiler 인터페이스를 익히고, 코드를 작성해 동작하는 결과를 보면서 학습했다.

TS Compiler API 를 통해서 접근하는 각 노드는 SyntaxKind 라는 형태로 타입이 지정되고, 이 kind 를 구분하면서 대부분의 처리를 할 수 있다.

예를 들면 아래의 인터페이스는

export declare interface HelloWorld {
  foo(): void;
}

AST 상으로 대략 아래와 같이 나타낼 수 있다.

- InterfaceDeclaration
  - kind: SyntaxKind.InterfaceDeclaration
  - name: Identfier(HelloWorld)
  - modifiers
    - ExportKeyword
      - kind: SyntaxKind.ExportKeyword
    - DeclareKeyword
      - kind: SyntaxKind.DeclareKeyword
  - members
    - MethodSignature
      - kind: SyntaxKind.MethodSignature
      - name: Identifier(foo)
      - type: VoidKeyword

AST Viewer 를 통해서 원하는 타입을 정의하고 어떤 kind 인지 확인하는 식으로 작업하면 아무것도 몰라도 첫 걸음을 떼기에 수월하다. 또 특정 노드를 클릭하면, 해당 노드에 대한 정보를 어떻게 생성하는지 어떻게 표현되는지 자세하게 볼 수 있어서 큰 도움이 된다.

예를 들어서 요구사항 (2) 엔트리의 exports 타입들을 마크가 필요한 경우, 좀 복잡하지만 아래처럼 처리할 수 있다.

  • default export (export default XX)
    • ts.SourceFile.statements 에서 ts.isExportAssignment(statement) 를 통해 필터링 할 수 있다.
  • named export (export const XX 혹은 export { Named })
    • ts.Program.getTypeChecker().getSymbolAtLocation(ts.SourceFile) 를 통해서 소스 파일의 심볼을 불러오고, symbol.exports 를 통해서 해당 파일의 모든 named export 를 불러올 수 있다.
  • alias named export (export { Named as Alias })
    • ts.SourceFile.statements 에서 ts.isExportDeclaration(statement)ts.isNamedExports(statement.exportClause) 를 통해서 ExportDeclaration 과 NamedExport 를 만족하는 statement 를 필터한 뒤, statement.exportClause.elements 를 순회하면서 specifier.propertyName 이 지정되어있는지 체크하면 된다. (...)
    • ExportDeclaration 은 export { } 이고, NamedExports 는 export { Named1, Named2 as Alias } 처럼 작성된 statement 이다.
    • specifier.propertyNameexport { Named2 as Alias } 에서 Alias 이고, specifier.nameNamed2 를 뜻한다. as Alias 가 없다면 specifier.propertyNameundefined 이다.

엔트리가 기준이 다르다보니 동일한 타입에 대해서 Declaration 이 일부 다르게 정의된 케이스들이 존재하니, 이 또한 동일한 타입으로 잘 구분이 되도록 처리를 해야한다.

이런 저런 과정을 잘 거치면 타입 정의에 대한 텍스트를 getTextFromNode 로 가져와서 모을 수 있다.

이 때 도움이 될만한 정보는, 추가적인 핸들링이 필요하다면 텍스트만을 가져와 들고있기 보다는, 항상 원본 노드를 가지고 다니는게 좋다.

예를 들면 새로운 노드를 생성하고, 퍼블릭 인터페이스에 달아놓은 설명같은 주석(jsdoc) 을 원본 노드에서 가져와서 붙이려고 해보았지만, ts.getJSDocTags(node) 로는 모든 타입의 주석들이 불러와지지 않는등의 이슈가 있었다. (내가 못한걸지도..?)

핸들링할때는 최대한 원본을 업데이트 하는 방향으로 핸들링을 하는게 정신 건강에 이롭다..


이제 모든 타입을 하나로 모아서 쓸 준비가 됐다면, 단일 파일에 써주면 된다.

아래는 실제 이 과정을 통해서 추출된 dts 파일이다.

여기서!!! 인터페이스를 숨기는것과 문제 2 - 모든 코드에 private, internal 주석을 달아야 함 을 기술적으로 해결하기 위해, 두가지의 스텝을 추가했다.

보안적인 측면에서 타입상에서만 숨긴다고 하여 자바스크립트에서도 접근을 못하는것은 아니지만, 불필요한 인터페이스가 너무 많이 노출되어있으면 DX 자체에 좋지 않기때문에 이러한 처리는 반드시 필요했다.

문제는 크게 보면 두가지였다.

인터페이스 자체의 노출 문제

interface PrivateInterface {
  kill(): void;
}

노출되면 안되는 인터페이스 그 자체가 노출되는것이다. 이러한 경우 쉽게 @internal 혹은 @private 을 통해서 api-extractor 가 걸러주었지만, 앞서 말했듯이 대부분의 인터페이스에는 마킹이 되어있지 않은 상태였다.

현재는 모두 작성이 되어있지만, 당시에는 노가다를 할 리소스가 없었다. 때문에 모든 타입 목록이 단일 dts 파일로 만들어지는 시점에 동작하는 CLI 도구를 작성했다.

이름하여 definition-versioning..! 작성된 definition.d.ts 파일을 읽어오고, 이곳에 정의된 모든 인터페이스에 대해서 public, trimmed 로 분류하여 기록을 해놓고 관리를 하도록 만들었다.

분류가 되어있지 않은 인터페이스가 새롭게 발견된다면 CLI 가 인터페이스를 노출할것인지 물어본다.

선택하면 알아서 기록이 되고, 이후 빌드부터는 해당 인터페이스는 별도의 주석 없이도 definition.d.ts 파일에서 퍼블릭 인터페이스가 아니라면 알아서 제거가 된다.

처음엔 몇백개여서(...) 심리적 고통을 덜기 위해서 progress 도 추가를 해놨다.

새롭게 작성하게 되는 인터페이스들은 주석을 달도록 하여 노출을 안시키도록 하고, 현재는 모두 분류가 되어서 가끔 주석을 놓치는 인터페이스에 대해서 휴먼 에러를 잡아주는 용도로 사용되고 있다.

인터페이스 내부의 노출 문제

interface PublicInterface {
  doSomethingDangerously(): void;
}

class PublicClass {
  // protected keyword
  protected member: string;
  
  // private keyword
  private member: string;
  
  // private identifier
  #member: string;
  
  // jsdoc
  /** @private */
  member: string;
  
  // name starts with "_"
  _member: string;
}

인터페이스 자체는 노출되어야 하는 public 인터페이스이지만, 내부에 private 혹은 internal 로 숨겨야 하는 프로퍼티나 메소드들을 들고있는 경우이다.

다행이게도 주석으로 처리가 가능하고, 주석으로 처리가 안되는 케이스들의 경우에도 코드의 대부분이 일관된 규칙을 가지고 작성이 되어 있어서 쉽게 해결이 가능했다.

definition.d.ts 파일을 write 하기 전에, 모든 노드들에 대해서 전처리를 진행하는 trimmer chain 을 만들었다.

ts.Node 를 받는 Trimmer 추상 클래스를 상속하여 처리를 하도록 만들었고, Trimmer 구현체에서는 실제 처리를 진행한다. 구현체는 간단하게 아래와 같다.

interface TrimmerInterface {
  trim(): ts.Node;
}

type TrimmerConstructor = {
  new (node: ts.Node): TrimmerInterface;
};

abstract class Trimmer implements TrimmerInterface {
  node: ts.Node;
  protected constructor(node: ts.Node) {
    this.node = node;
  }
  abstract trim(): ts.Node;
}

function trimmerChain(node: ts.Node, trimmers: TrimmerConstructor[]): ts.Node {
  return trimmers.reduce((node, Trimmer) => new Trimmer(node).trim(), node);
}

구현과 사용은 아래처럼

class ClassTrimmer extends Trimmer {
  constructor(node: ts.Node) {
    super(node);
  }
  
  trim(): ts.Node {
    if (ts.isClassDeclaration(this.node)) {
      // this.node 를 요리조리 만져서 제거한다.
    }
    return this.node;
  }
}

const trimmedNodeList = nodeList.map((node) => trimmerChain(node, [ClassTrimmer, InterfaceTrimmer, CustomTrimmer]))

Trimmer 구현체에서는 위에서 언급했던 일관된 규칙 을 기반으로, TS Compiler 를 이용해 쉽게 제거할 수 있다.

다시 한번 규칙들을 살펴보면 다음과 같은데, 모두 분류할 수 있는 키워드가 붙어있다.

class PublicClass {
  // protected keyword
  protected member: string;
  
  // private keyword
  private member: string;
  
  // private identifier
  #member: string;
  
  // jsdoc
  /** @private */
  member: string;
  
  // name starts with "_"
  _member: string;
}
  • proptected, private 과 같이 멤버 변수를 위한 modifier 가 있는 경우, node.members 를 순회하며 memberModifier.kind === ts.SyntaxKind.PrivateKeyword, ts.SyntaxKind.PrivateKeyword 로 Kind 를 구분하고 제거할 수 있다.
  • jsdoc 의 경우 ts.getJSDocPrivateTag(member) 로 private tag 가 있는지 체크하고 제거할 수 있다.
  • 변수명을 # 으로 시작하는 private identifier 의 경우, JS 의 기능으로 ts.isPrivateIdentifier(memberModifier) 로 구분을 할 수 있다.
  • 마지막으로 이름을 _ 로 시작하는 관용적 규칙의 경우, 간단하게 멤버의 이름을 가져와서 _ 로 시작하는지만 체크하여 제거하면 된다.

이 모든 작업이 끝난 이후에는 엔트리 파일별로 기록해놓은 exports 목록을, 단일 dts 파일에서 re-exports 해주도록 작성해주면 된다.

아래는 실제 이 과정을 통해서 추출된 dts 엔트리 파일의 예시이다.


라이브러리 사용자의 DX 향상시키기

여기서 끝이 아니다~

모든 타입을 단일 dts(definition.d.ts) 에 몰아넣고 사용하는것이 일반적이지 않다보니, 작업 당시에 번들된 결과물을 가지고 VSCode 와 WebStorm 에서 테스트를 하는 과정에서 구조라던가 일부 수정이 필요했다.

바로 타입 시스템을 기반으로 auto-import 가 잘 동작하지 않는것이었다.

  • 이를 해결하기 위해서, 타입 탐색 위치를 최상단(각 엔트리 파일)로 올리기 위해, definition.d.ts 의 위치를 조금 깊숙한 디렉토리로 이동을 하였다.
  • VSCode 에서는 한번이라도 import 되지 않은 dts 의 경우, auto-import 과정에서 탐색을 하지 않는 이슈가 있어서 SDK 가 있는 main entry 에 나머지 dts 파일들을 import 하도록 번들러에 코드를 추가했다.
  • 마지막으로 혹시나 실수로라도 클래스나 enum 같이 자바스크립트 파일과 동일한 경로에서 import 되어야 런타임에 영향을 미치지 않는것들을 definition.d.ts 에서 import 할 수 있으므로, 이름을 좀 수상쩍게 __definition.d.ts 로 변경했다.

이 모든 처리를 한 이후에는 VSCode 와 WebStorm 모두에서 auto-import 가 정상적으로 동작했다 👏


그리고 개발 당시에는 package.json 의 exports 를 통하여 types 가 지원이 안되어서, typesVersions 를 별도로 설정해주어야 했었다.

여기에 추가적으로 react-native 의 metro-bundler 에서 exports 를 지원하지 않던 시기여서, 모든 빌드 환경 뿐만 아니라 IDE 에서도 잘 동작하도록 디렉토리 구조를 잡는데 머리가 꽤나 아팠었다 😣


팀원들의 번들러 경험 향상시키기

이후에는 타입 분류를 해주는 CLI 와 같이, 팀을 위한 이런 저런 몇가지 장치들을 추가해놓았는데

제거된 인터페이스를 퍼블릭 인터페이스에서 참조하고 있는 경우, 이를 빌드 타임에 알 수 있도록 빌드된 결과물들을 한번 더 컴파일하여 타입 에러가 있는지 검사하는 기능, 분류하지 않은 타입이 있는 경우 CI 단계에서 알려주는 기능, 별도의 셋업 없이도 엔트리 경로에 추가만 하면 js, dts 모두 한번에 생성이 되도록 설정한다던가 🤔

실수를 미연에 방지하고 편의성을 높일 수 있도록 추가 작업들을 진행했다.


결론

최근에는 d.tsd.cts 까지 동시에 추출하도록 추가적인 작업도 하며 유지보수를 이어가고 있다.

남들이 열심히 일할수록 내 코드에는 디지털 풍화가 일어나는 개발 생태계.. 쉽지 않다.

이후 tsup 에서 동일한 콘셉트로 접근을 한 experimental PR 을 발견했었는데 신기했다. (반가워서 위의 DX 관련 팁도 남김;;)

feat: add experimental dts rollup using @microsoft/api-extractor
https://github.com/egoist/tsup/pull/983

현재는 머지가 된 것 같다.
만약 멀티 엔트리 타입 번들링이 필요하다면 tsup 을 써보자!!
(아니면 이곳을 예의주시 하자: https://github.com/microsoft/rushstack/issues/1596)

profile
JavaScript, TypeScript and React-Native

0개의 댓글