본문 바로가기

v10 리팩토링 과정에서 얻은 TypeScript 성능 교툼

· 1분 읽기
Sachin Raja
Sachin Raja
tRPC Core Team Member (alumni)
비공식 베타 번역

이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →

라이브러리 개발자로서 우리의 목표는 동료 개발자들에게 최상의 개발자 경험(DX)을 제공하는 것입니다. 오류 발생 시간을 단축하고 직관적인 API를 제공함으로써 개발자들이 가장 중요한 것(최종 사용자 경험)에 집중할 수 있도록 정신적 부담을 덜어주는 것이죠.

TypeScript가 tRPC의 탁월한 DX를 가능하게 하는 핵심 동력이라는 것은 공공연한 비밀이 아닙니다. TypeScript 채택은 현대적인 JavaScript 기반 경험을 제공하는 표준이 되었지만, 이러한 타입 안정성 향상에는 일정한 트레이드오프가 존재합니다.

현재 TypeScript 타입 검사기는 속도가 느려질 수 있습니다(TS 4.9 같은 릴리스가 개선을 약속하긴 하지만!). 라이브러리는 거의 항상 코드베이스에서 가장 정교한 TypeScript 기술을 포함하고 있어 TS 컴파일러의 한계를 시험합니다. 이런 이유로 우리 같은 라이브러리 개발자는 이러한 부담에 기여하지 않도록 주의를 기울이고, 여러분의 IDE가 최대한 빠르게 작동하도록 노력해야 합니다.

라이브러리 성능 자동화

tRPC가 v9에 있을 때, 개발자들로부터 대규모 tRPC 라우터가 타입 검사기에 악영향을 미치기 시작했다는 보고를 받기 시작했습니다. 이는 tRPC 개발의 v9 단계에서 엄청난 채택을 경험하면서 처음 마주한 문제였습니다. 더 많은 개발자들이 tRPC로 더 크고 복잡한 제품을 만들면서 일부 한계점이 드러나기 시작한 것입니다.

현재 여러분의 라이브러리가 느리지 않을 수 있지만, 라이브러리가 성장하고 변화함에 따라 성능을 계속 주시하는 것이 중요합니다. 자동화된 테스트는 커밋마다 라이브러리 코드를 프로그래매틱하게 테스트함으로써 라이브러리 개발(및 애플리케이션 구축!)에서 엄청난 부담을 덜어줍니다.

tRPC의 경우, 3,500개의 프로시저와 1,000개의 라우터를 가진 라우터를 생성하고 테스트함으로써 이를 보장하기 위해 노력합니다. 하지만 이는 TS 컴파일러가 고장 나기 전까지 어디까지 밀어붙일 수 있는지 테스트할 뿐, 타입 검사 시간을 측정하지는 않습니다. 서버, 바닐라 클라이언트, React 클라이언트 등 라이브러리의 세 가지 부분을 모두 테스트하는데, 각각 다른 코드 경로를 가지기 때문입니다. 과거에는 라이브러리의 한 부분에만 고립된 퇴행 현상이 발생한 적이 있으며, 이러한 예상치 못한 동작이 발생할 때 테스트가 이를 보여주도록 의존하고 있습니다. (아직 컴파일 시간 측정을 위해 더 많은 작업을 하고 싶긴 합니다)

tRPC는 런타임 부하가 큰 라이브러리가 아니므로 성능 지표는 타입 검사를 중심으로 합니다. 따라서 우리는 다음과 같은 사항을 염두에 둡니다:

  • tsc를 사용한 타입 검사 속도 저하

  • 초기 로딩 시간이 긺

  • TypeScript 언어 서버가 변경 사항에 응답하는 데 오래 걸림

마지막 사항은 tRPC가 가장 주의해야 할 부분입니다. 개발자가 변경 후 언어 서버 업데이트를 기다려야 하는 상황은 절대 원하지 않습니다. 이것이 바로 tRPC가 탁월한 DX를 누릴 수 있도록 성능을 유지해야만 하는 영역입니다.

tRPC에서 성능 개선 기회를 발견한 방법

TypeScript 정확성과 컴파일러 성능 사이에는 항상 트레이드오프가 존재합니다. 둘 다 다른 개발자들에게 중요한 문제이므로 타입을 작성하는 방식에 대해 극도로 신중해야 합니다. 특정 타입이 "너무 느슨해서" 애플리케이션이 심각한 오류를 만날 가능성이 있을까요? 성능 향상이 그만한 가치가 있을까요?

과연 의미 있는 성능 향상이 있을까요? 아주 좋은 질문입니다.

TypeScript 코드에서 성능 개선 기회를 찾는 _방법_을 살펴보겠습니다. PR #2716을 생성해 TS 컴파일 시간을 59% 단축한 과정을 따라가 보죠.


TypeScript에는 타입의 병목 현상을 찾는 데 도움이 되는 내장 트레이싱 도구가 있습니다. 완벽하지는 않지만, 현재 사용 가능한 최고의 도구입니다.

라이브러리가 실제 개발자에게 미치는 영향을 시뮬레이션하려면 실제 애플리케이션에서 테스트하는 것이 이상적입니다. tRPC의 경우, 많은 사용자가 사용하는 환경을 모방한 기본 T3 앱을 생성했습니다.

tRPC를 트레이싱하기 위해 따랐던 단계는 다음과 같습니다:

  1. 예제 앱에 라이브러리를 로컬로 링크합니다. 이렇게 하면 라이브러리 코드를 변경하고 즉시 로컬에서 변경 사항을 테스트할 수 있습니다.

  2. 예제 앱에서 다음 명령어를 실행합니다:

    sh
    tsc --generateTrace ./trace --incremental false
    sh
    tsc --generateTrace ./trace --incremental false
  3. 컴퓨터에 trace/trace.json 파일이 생성됩니다. 이 파일을 트레이스 분석 앱(Perfetto 사용) 또는 chrome://tracing에서 열 수 있습니다.

여기서부터 흥미로워지며 애플리케이션 내 타입의 성능 프로파일을 파악할 수 있습니다. 첫 번째 트레이스는 다음과 같았습니다: 트레이스 바에서 src/pages/index.ts가 타입 검사에 332ms 소요된 모습

막대가 길수록 해당 프로세스에 더 많은 시간이 소요되었음을 의미합니다. 이 스크린샷에서는 가장 상단의 녹색 막대를 선택했는데, 이는 src/pages/index.ts가 병목 현상임을 나타냅니다. Duration 필드 아래에는 332ms가 소요된 것으로 표시됩니다. 타입 검사에 엄청난 시간을 소비한 셈입니다! 파란색 checkVariableDeclaration 막대는 컴파일러가 대부분의 시간을 하나의 변수에 소비했음을 알려줍니다. 해당 막대를 클릭하면 어떤 변수인지 확인할 수 있습니다: 변수 위치가 275임을 보여주는 트레이스 정보 pos 필드는 파일 텍스트 내 변수의 위치를 나타냅니다. src/pages/index.ts에서 해당 위치로 이동하면 문제의 원인이 utils = trpc.useContext()임을 알 수 있습니다!

하지만 어떻게 이런 일이? 단순한 훅을 사용하는데! 코드를 살펴봅시다:

tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;
tsx
import type { AppRouter } from '~/server/trpc';
const trpc = createTRPCReact<AppRouter>();
const Home: NextPage = () => {
const { data } = trpc.r0.greeting.useQuery({ who: 'from tRPC' });
const utils = trpc.useContext();
utils.r49.greeting.invalidate();
};
export default Home;

자, 여기서는 특별한 게 없습니다. 단일 useContext와 쿼리 무효화만 보입니다. 표면적으로는 TypeScript에 부하를 줄 만한 요소가 없어 문제가 스택 더 깊은 곳에 있음을 암시합니다. 이 변수 뒤에 있는 타입을 살펴봅시다:

ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @see https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;
ts
type DecorateProcedure<
TRouter extends AnyRouter,
TProcedure extends Procedure<any>,
TProcedure extends AnyQueryProcedure,
> = {
/**
* @see https://tanstack.com/query/v4/docs/framework/react/guides/query-invalidation
*/
invalidate(
input?: inferProcedureInput<TProcedure>,
filters?: InvalidateQueryFilters,
options?: InvalidateOptions,
): Promise<void>;
// ... and so on for all the other React Query utilities
};
export type DecoratedProcedureUtilsRecord<TRouter extends AnyRouter> =
OmitNeverKeys<{
[TKey in keyof TRouter['_def']['record']]: TRouter['_def']['record'][TKey] extends LegacyV9ProcedureTag
? never
: TRouter['_def']['record'][TKey] extends AnyRouter
? DecoratedProcedureUtilsRecord<TRouter['_def']['record'][TKey]>
: TRouter['_def']['record'][TKey] extends AnyQueryProcedure
? DecorateProcedure<TRouter, TRouter['_def']['record'][TKey]>
: never;
}>;

좋습니다, 이제 분석하고 배울 것들이 생겼습니다. 먼저 이 코드가 무엇을 하는지 파악해 봅시다.

라우터의 모든 프로시저를 순회하며 React Query의 invalidateQueries와 같은 유틸리티로 "데코레이팅"(메서드 추가)하는 재귀 타입 DecoratedProcedureUtilsRecord이 있습니다.

tRPC v10에서는 이전 v9 라우터를 여전히 지원하지만, v10 클라이언트는 v9 라우터의 프로시저를 호출할 수 없습니다. 따라서 각 프로시저에 대해 v9 프로시저인지(extends LegacyV9ProcedureTag) 확인하고, 해당하면 제거합니다. TypeScript가 처리해야 할 작업이 상당합니다...지연 평가(lazy evaluation)되지 않는다면 말이죠.

지연 평가

여기서 문제는 TypeScript가 즉시 사용되지 않는 코드까지도 타입 시스템에서 평가한다는 점입니다. 코드에서는 utils.r49.greeting.invalidate만 사용하고 있으므로, TypeScript는 r49 프로퍼티(라우터)를 언래핑한 다음 greeting 프로퍼티(프로시저), 마지막으로 해당 프로시저의 invalidate 함수 타입만 찾으면 충분합니다. 다른 타입들은 필요하지 않으며, 모든 tRPC 프로시저에 대한 React Query 유틸리티 메서드의 타입을 즉시 찾는 것은 TypeScript를 불필요하게 느리게 만듭니다. TypeScript는 객체의 프로퍼티 타입 평가를 직접 사용될 때까지 지연시키므로, 이론적으로 위 타입은 지연 평가(lazy evaluation)를 적용할 수 있어야 합니다... 맞을까요?

사실 이 타입은 정확히 객체가 아닙니다. 전체를 감싸는 OmitNeverKeys 타입이 존재합니다. 이 유틸리티 타입은 값이 never인 키를 객체에서 제거합니다. 여기서 v9 프로시저를 제거해 Intellisense에 표시되지 않도록 하는 부분입니다.

하지만 이로 인해 심각한 성능 문제가 발생합니다. TypeScript가 모든 타입의 값을 평가하여 never인지 확인하도록 강제하게 됩니다.

이를 어떻게 해결할까요? 타입이 덜 작업하도록 변경해야 합니다.

지연 평가 적용하기

v10 API가 레거시 v9 라우터를 더 우아하게 처리할 방법을 찾아야 합니다. 새로운 tRPC 프로젝트는 interop 모드의 TypeScript 성능 저하를 겪어서는 안 됩니다.

핵심 아이디어는 내부 타입 구조를 재조정하는 것입니다. v9 프로시저와 v10 프로시저는 서로 다른 엔티티이므로 라이브러리 코드에서 동일한 공간을 공유하지 않아야 합니다. tRPC 서버 측에서는 라우터의 단일 record 필드 대신 별도 필드에 타입을 저장하도록 변경해야 했습니다(앞서 언급한 DecoratedProcedureUtilsRecord 참조).

변경 사항: v9 라우터가 v10 라우터로 변환될 때 프로시저를 legacy 필드에 주입하도록 수정했습니다.

기존 타입:

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};
// convert a v9 interop router to a v10 router
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;

앞서 본 DecoratedProcedureUtilsRecord 타입에서 LegacyV9ProcedureTag를 첨부해 v9v10 프로시저를 타입 수준에서 구분하고, v9 프로시저가 v10 클라이언트에서 호출되지 않도록 강제했습니다.

새로운 타입:

ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;
ts
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// by default, no legacy procedures
legacy: {};
};
export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 routers inject their procedures into a `legacy` field
legacy: {
// v9 clients require that we filter queries, mutations, subscriptions at the top-level
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* empty object, v9 routers have no v10 procedures to pass */ {}>;

이제 OmitNeverKeys를 제거할 수 있습니다. 프로시저가 사전 분류되어 라우터의 record 프로퍼티 타입은 모든 v10 프로시저를 포함하고, legacy 프로퍼티 타입은 모든 v9 프로시저를 포함합니다. 더 이상 TypeScript가 거대한 DecoratedProcedureUtilsRecord 타입을 완전히 평가하도록 강제하지 않습니다. LegacyV9ProcedureTagv9 프로시저를 필터링하는 로직도 제거됩니다.

결과는?

새 추적 결과에서 병목 현상이 사라진 것을 확인할 수 있습니다: src/pages/index.ts 타입 검사 시간 136ms로 표시된 트레이스 바

상당한 개선입니다! 타입 검사 시간이 332ms에서 136ms로 줄었습니다 🤯! 전체적으로는 작은 차이로 보일 수 있지만, 이는 큰 성과입니다. 200ms는 한 번이라면 작은 시간이지만 다음을 고려해보세요:

  • 프로젝트 내 다른 TS 라이브러리 수

  • 현재 tRPC를 사용하는 개발자 수

  • 작업 세션에서 타입이 재평가되는 횟수

이 모든 요소가 누적되면 200ms는 매우 큰 숫자로 변환됩니다.

tRPC든 다른 TS 기반 프로젝트든, TypeScript 개발자 경험 개선 기회를 계속 모색하고 있습니다. TypeScript 관련 주제로 논의하고 싶다면 트위터에서 저를 멘션해주세요.

이 글을 작성하는 데 도움을 주신 Anthony Shew님과 검토해 주신 Alex님께 감사드립니다!