[번역] tRPC v10 리팩토링 중 얻은 TypeScript 성능 교훈

Sachin Raja - 2023-01-14T00:00:00.000Z


라이브러리 작성자로서 우리의 목표는 동료 개발자들에게 가능한 최상의 개발자 경험(DX)을 제공하는 것입니다. 오류 확인 시간을 단축하고 직관적인 API를 제공하는 것은 개발자의 멘탈 모델(mental model)에서 부하를 제거하여, 개발자가 가장 중요한 것, 즉 훌륭한 최종 사용자 경험에 집중할 수 있도록 돕습니다.
tRPC가 놀라운 DX를 제공하는 원동력이 TypeScript라는 점은 비밀이 아닙니다. 오늘날 훌륭한 JavaScript 기반 경험을 제공하는 데 있어 TypeScript 도입은 현대적인 표준이 되었습니다. 하지만 타입에 대한 이러한 확신이 높아진 만큼 몇 가지 트레이드오프(tradeoff)도 존재합니다.
현재 TypeScript 타입 체커는 느려지기 쉬운 경향이 있습니다(TS 4.9와 같은 릴리스에서 성능 개선이 약속되고 있긴 하지만요!). 라이브러리는 거의 항상 코드베이스에서 가장 화려한 TypeScript 주문(incantations)을 포함하고 있으며, TS 컴파일러를 한계까지 밀어붙입니다. 이러한 이유로 우리와 같은 라이브러리 작성자는 이러한 부담에 기여하고 있음을 인지해야 하며, 여러분의 IDE가 가능한 한 빠르게 작동하도록 최선을 다해야 합니다.

라이브러리 성능 자동화

tRPC가 v9 버전일 때, 우리는 대규모 tRPC 라우터를 사용하는 개발자들로부터 타입 체커에 부정적인 영향을 미치기 시작했다는 보고를 받기 시작했습니다. 이는 tRPC 개발의 v9 단계 동안 엄청난 채택이 이루어지면서 겪게 된 새로운 경험이었습니다. 더 많은 개발자가 tRPC로 점점 더 큰 제품을 만들면서 몇 가지 균열이 나타나기 시작했습니다.
여러분의 라이브러리가 지금은 느리지 않을 수도 있지만, 라이브러리가 성장하고 변화함에 따라 성능을 주시하는 것이 중요합니다. 자동화된 테스트는 각 커밋마다 라이브러리 코드를 프로그래밍 방식으로 테스트함으로써 라이브러리 작성(및 애플리케이션 구축!)의 엄청난 부담을 덜어줄 수 있습니다.
tRPC의 경우, 3,500개의 프로시저(procedure)와 1,000개의 라우터를 가진 라우터를 생성하고 테스트함으로써 이를 보장하기 위해 최선을 다하고 있습니다. 하지만 이는 TS 컴파일러가 깨지기 전까지 얼마나 밀어붙일 수 있는지만 테스트할 뿐, 타입 체크에 걸리는 시간은 테스트하지 않습니다. 우리는 라이브러리의 세 가지 부분(서버, 바닐라 클라이언트, React 클라이언트)이 모두 서로 다른 코드 경로를 가지고 있기 때문에 이들 모두를 테스트합니다. 과거에 우리는 라이브러리의 한 섹션에만 국한된 회귀(regression) 현상을 본 적이 있으며, 이러한 예상치 못한 동작이 발생할 때 이를 알려주는 테스트에 의존합니다. (우리는 여전히 컴파일 시간을 측정하기 위해 더 많은 일을 하고 싶어 합니다.)
tRPC는 런타임 부하가 큰 라이브러리가 아니므로 우리의 성능 지표는 타입 체크에 집중되어 있습니다. 따라서 우리는 다음 사항을 유념합니다:
  • tsc를 사용한 타입 체크가 느린지 여부
  • 초기 로드 시간이 긴지 여부
  • TypeScript 언어 서버(language server)가 변경 사항에 응답하는 데 시간이 오래 걸리는지 여부
마지막 포인트는 tRPC가 가장 주의를 기울여야 하는 부분입니다. 개발자가 변경 후 언어 서버가 업데이트될 때까지 기다리게 만드는 상황은 절대로 원치 않을 것입니다. 여러분이 훌륭한 DX를 누릴 수 있도록 tRPC가 성능을 반드시 유지해야 하는 지점이 바로 여기입니다.

tRPC에서 성능 개선 기회를 찾은 방법

TypeScript의 정확성과 컴파일러 성능 사이에는 항상 트레이드오프가 존재합니다. 둘 다 다른 개발자들에게 중요한 관심사이므로 우리는 타입을 작성하는 방식에 대해 매우 신중해야 합니다. 특정 타입이 "너무 느슨해서" 애플리케이션에 심각한 오류가 발생할 가능성이 있는가? 성능 이득이 그만한 가치가 있는가?
애초에 의미 있는 성능 이득이 있기는 할까요? 좋은 질문입니다.
TypeScript 코드에서 성능 개선의 순간을 찾는 방법을 살펴보겠습니다. PR #2716을 생성하여 TS 컴파일 시간을 59% 단축한 과정을 살펴보겠습니다.


TypeScript에는 타입의 병목 현상을 찾는 데 도움이 되는 내장 트레이싱 도구(tracing tool)가 있습니다. 완벽하지는 않지만 사용 가능한 최선의 도구입니다.
라이브러리가 실제 개발자들에게 어떻게 작동하는지 시뮬레이션하기 위해 실제 앱에서 라이브러리를 테스트하는 것이 이상적입니다. tRPC의 경우, 많은 사용자가 작업하는 환경과 유사한 기본적인 T3 앱을 만들었습니다.
tRPC를 트레이싱하기 위해 제가 따른 단계는 다음과 같습니다:
  1. 라이브러리를 예제 앱에 로컬로 링크(locally link)합니다. 이렇게 하면 라이브러리 코드를 변경하고 로컬에서 즉시 변경 사항을 테스트할 수 있습니다.
  1. 예제 앱에서 다음 명령을 실행합니다:
tsc --generateTrace ./trace --incremental false
  1. 머신에 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()였습니다!
하지만 어떻게 그럴 수 있을까요? 우리는 그저 단순한 훅(hook)을 사용하고 있을 뿐입니다! 코드를 살펴보겠습니다:
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와 쿼리 무효화(invalidation)만 보입니다. 겉보기에는 TypeScript 부하가 클 만한 것이 없으므로, 문제는 스택 더 깊은 곳에 있음을 시사합니다. 이 변수 뒤에 있는 타입을 살펴보겠습니다:
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>;
// ... 그리고 다른 모든 React Query 유틸리티에 대해서도 마찬가지입니다.
};

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;
}>;
자, 이제 분석하고 배워야 할 것들이 생겼습니다. 먼저 이 코드가 무엇을 하는지 파악해 봅시다.
우리는 라우터의 모든 프로시저를 순회하며 invalidateQueries와 같은 React Query 유틸리티로 "장식(decorate)"(메서드 추가)하는 재귀 타입 DecoratedProcedureUtilsRecord를 가지고 있습니다.
tRPC v10에서는 여전히 이전 v9 라우터를 지원하지만, v10 클라이언트는 v9 라우터의 프로시저를 호출할 수 없습니다. 그래서 각 프로시저에 대해 v9 프로시저인지 확인(extends LegacyV9ProcedureTag)하고, 그렇다면 제거합니다. 이 모든 과정은 TypeScript가 수행하기에 많은 작업입니다... 지연 평가(lazy evaluation)되지 않는다면 말이죠.

지연 평가

여기서 문제는 TypeScript가 이 모든 코드를 타입 시스템에서 즉시 사용되지 않더라도 평가하고 있다는 점입니다. 우리 코드는 utils.r49.greeting.invalidate만 사용하고 있으므로, TypeScript는 r49 속성(라우터), greeting 속성(프로시저), 그리고 마지막으로 해당 프로시저의 invalidate 함수만 풀면(unwrap) 됩니다. 그 코드에는 다른 타입이 필요하지 않으며, 모든 tRPC 프로시저에 대한 모든 React Query 유틸리티 메서드의 타입을 즉시 찾는 것은 불필요하게 TypeScript를 느리게 만듭니다. TypeScript는 객체의 속성에 대한 타입 평가를 직접 사용될 때까지 미루므로, 이론적으로 위의 타입은 지연 평가를 받아야 합니다... 그렇죠?
글쎄요, 그것은 정확히 객체는 아닙니다. 사실 전체를 감싸고 있는 타입이 있습니다: OmitNeverKeys. 이 타입은 객체에서 값이 never인 키를 제거하는 유틸리티입니다. 이 부분이 v9 프로시저를 제거하여 인텔리센스(Intellisense)에 나타나지 않게 하는 부분입니다.
하지만 이것이 거대한 성능 문제를 야기합니다. 우리는 TypeScript가 모든 타입의 값이 never인지 확인하기 위해 모든 타입을 지금 평가하도록 강제했습니다.
이를 어떻게 해결할 수 있을까요? 타입을 일을 덜 하도록 변경해 봅시다.

지연 처리하기 (Get lazy)

우리는 v10 API가 레거시 v9 라우터에 더 우아하게 적응할 수 있는 방법을 찾아야 합니다. 새로운 tRPC 프로젝트가 상호운용 모드(interop mode)의 저하된 TypeScript 성능으로 인해 고통받아서는 안 됩니다.
아이디어는 핵심 타입 자체를 재배치하는 것입니다. v9 프로시저는 v10 프로시저와 다른 엔티티이므로 라이브러리 코드에서 동일한 공간을 공유해서는 안 됩니다. tRPC 서버 측에서 이는 단일 record 필드 대신 라우터의 다른 필드에 타입을 저장하도록 작업해야 함을 의미합니다(위의 DecoratedProcedureUtilsRecord 참조).
우리는 v9 라우터가 v10 라우터로 변환될 때 자신의 프로시저를 legacy 필드에 주입하도록 변경했습니다.
이전 타입:
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
};

// v9 상호운용 라우터를 v10 라우터로 변환
export type MigrateV9Router<TV9Router extends V9Router> = V10Router<{
[TKey in keyof TV9Router['procedures']]: MigrateProcedure<
TV9Router['procedures'][TKey]
> &
LegacyV9ProcedureTag;
}>;
위의 DecoratedProcedureUtilsRecord 타입을 기억하신다면, 여기서 LegacyV9ProcedureTag를 붙여 타입 수준에서 v9v10 프로시저를 구분하고 v9 프로시저가 v10 클라이언트에서 호출되지 않도록 강제했음을 알 수 있습니다.
새로운 타입:
export type V10Router<TProcedureRecord> = {
record: TProcedureRecord;
// 기본적으로 레거시 프로시저 없음
legacy: {};
};

export type MigrateV9Router<TV9Router extends V9Router> = {
// v9 라우터는 자신의 프로시저를 `legacy` 필드에 주입
legacy: {
// v9 클라이언트는 최상위 수준에서 쿼리, 뮤테이션, 서브스크립션을 필터링해야 함
queries: MigrateProcedureRecord<TV9Router['queries']>;
mutations: MigrateProcedureRecord<TV9Router['mutations']>;
subscriptions: MigrateProcedureRecord<TV9Router['subscriptions']>;
};
} & V10Router</* 빈 객체, v9 라우터는 전달할 v10 프로시저가 없음 */ {}>;
이제 프로시저가 미리 정렬되어 있으므로 OmitNeverKeys를 제거할 수 있습니다. 라우터의 record 속성 타입은 모든 v10 프로시저를 포함하고, legacy 속성 타입은 모든 v9 프로시저를 포함하게 됩니다. 우리는 더 이상 TypeScript가 거대한 DecoratedProcedureUtilsRecord 타입을 완전히 평가하도록 강제하지 않습니다. 또한 LegacyV9ProcedureTag를 사용한 v9 프로시저 필터링도 제거할 수 있습니다.

효과가 있었나요?

새로운 트레이스는 병목 현상이 제거되었음을 보여줍니다:
src/pages/index.ts의 타입 체크에 136ms가 걸렸음을 보여주는 트레이스 바

상당한 개선입니다! 타입 체크 시간이 332ms에서 136ms로 줄었습니다 🤯! 큰 그림에서 보면 그리 많지 않아 보일 수 있지만, 이는 큰 승리입니다. 200ms는 한 번만 보면 적은 시간이지만, 다음을 생각해 보세요:
  • 프로젝트에 얼마나 많은 다른 TS 라이브러리가 있는지
  • 얼마나 많은 개발자가 현재 tRPC를 사용하고 있는지
  • 작업 세션 동안 타입이 얼마나 많이 재평가되는지
그 많은 200ms가 모여 매우 큰 숫자가 됩니다.
우리는 tRPC에서든, 다른 프로젝트에서 해결해야 할 TS 기반 문제에서든 TypeScript 개발자의 경험을 개선할 더 많은 기회를 항상 찾고 있습니다. TypeScript에 대해 이야기하고 싶다면 Twitter에서 저를 태그해 주세요.
이 포스트 작성을 도와준 Anthony Shew와 리뷰를 해준 Alex에게 감사를 표합니다!
0
10

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글