레지스트리(Registry)라는 디자인 패턴을 사용하여 100만 줄 규모의 TypeScript 코드베이스를 확장합니다.
저자:
Jason Jiang
현재 우리 모노레포에는 커밋된 TypeScript 코드가 약 110만 줄에 달합니다. 이를 확장하는 과정에서 느린 타입 검사, 불필요한 임포트, 점점 악화되는 코드 추적성 등 다양한 문제가 발생했습니다. 지난 몇 년간 우리는 코드베이스 확장을 위해 다양한 디자인 패턴을 탐구하고 반복해 왔습니다. 그중 우연히 발견한 레지스트리(Registry) 패턴은 점점 커져가는 코드 조직화 문제를 상당 부분 완화시켜 주었습니다. 이 패턴은 단순하면서도 우리가 도입한 가장 확장성 높은 패턴 중 하나입니다.
이 패턴이 큰 차이를 만들어낸 대표적인 사례가 바로 당사의 초기 내부 이벤트 처리 코드입니다. 단순하게 시작했지만 시간이 지남에 따라 거대한 타입 유니온으로 인해 타입 검사가 느려지고, 지나치게 많은 코드를 가져오는 배럴 파일, 그리고 과도한 문자열 연결로 인해 추적하기 어려운 코드가 발생했습니다. 마지막으로, 새로운 이벤트 핸들러를 유지 관리하고 추가하는 데 개발자 간 마찰을 야기했습니다.
// ./app-event-handlers/card.ts import type { SpecificEventHandler } from './base.ts';export const cardCreatedEventHandler: SpecificEventHandler<'card_created'> = async (ev) => { await sendNotificationToUsers();}// ./app-event-handlers/wire.tsimport type { SpecificEventHandler } from './base.ts';export const wireSentEventHandler: SpecificEventHandler<'wire_sent'> = async (ev) => { await sendNotificationToUsers();}
4. 롤업 파일 정의하기
./app-event-handlers/rollup.ts
export * from './card.ts';export * from './wire.ts';
5. 이벤트 핸들러 정의하기
./app-event-handlers/index.ts
import type { AppEventBase } from './base.ts';import * as AppEventHandlers from './rollup.ts';export type AppEvent = { [Key in keyof typeof EventHandlers]: (typeof EventHandlers)[Key] extends AppEventHandler<infer Ev extends AppEventBase> ? Ev : never;}[keyof typeof EventHandlers];export { AppEventHandlers };
6. 이벤트를 기록하기 위한 공통 함수를 정의하십시오
// ./record-app-event.tsimport { mqClient } from '@server/message-queue/client.ts';import type { AppEvent } from './app-event-handlers'export async function recordEvent<Ev extends AppEvent>(ev: Ev) { await mqClient.push('events', ev);}// ./some-random-service-file.tsimport { recordEvent } from './record-app-event.ts';await recordEvent({ type: 'card_created', data: { cardName: 'John Doe' } });
7. 작업자에서 큐의 이벤트 핸들러를 처리하는 함수를 내보내기
./process-app-event.ts
import { AppEventHandlers, type AppEvent } from './app-event-handlers/index.ts';const appEventHandlerMap = new Map( Object.values(AppEventHandlers).map(item => [item.type, item.handler]));export async function processAppEventHandler(ev: AppEvent) { switch (ev.type) { case 'card_created': await AppEventHandlers.cardCreatedEventHandler(ev); break; case 'wire_sent': await AppEventHandlers.wireSentEventHandler(ev); break; default: // Compile-time & runtime check to ensure this case is unreachable (aka never) assertUnreachable(ev.type); }}
한눈에 보기에는 이 디자인이 꽤 괜찮아 보입니다. 타입 안전성이 보장되고, 이해하기 쉬우며, 작업하기 간편하고, 새 이벤트 생성 방법이 명확합니다. 그러나 몇 가지 문제가 있었습니다:
타입 검사 복잡도: 각 새로운 이벤트마다, 앱 이벤트 유니온 타입이 커집니다. 개별적으로 단일 유니온은 문제가 되지 않지만, 코드베이스 전반에 걸쳐 이 패턴의 사용이 증가함에 따라(다중 타입 유니온), 타입 검사 성능이 급격히 저하됩니다.
열성적인 모듈 로딩: 롤업 파일 구조로 인해 거의 모든 모듈이 시작 시 전체 코드베이스를 불러왔으며, 이로 인해 지연 로딩이 불가능했습니다. 또한 서버를 서로 다른 배포 환경에 맞게 별도의 번들로 분할하는 것도 어려웠습니다. 전체 코드베이스의 즉시 로딩은 운영 환경에서는 수용 가능했지만, 개발 및 테스트 환경에서는 심각한 영향을 미쳤습니다. 개발 서버를 시작하는 데만 20~30초 이상 소요되었습니다.
추적성 부족: "이 이벤트 구현은 어디에 있나요?" 또는 "이 핸들러는 어디에서 호출되나요?" 같은 간단한 질문에 답하는 것도 어려웠습니다. 우리는 문자열 리터럴에 대한 전체 텍스트 검색에 의존해야 했습니다. 예를 들어, 엔지니어가 다음과 같은 작업을 하기로 결정했다면:
const eventName = `wire_${type}`; // Valid because type is a union between 'created' | 'sent', which are both defined eventsawait recordEvent({ type: eventName, data });
그것을 추적하는 것은 매우 어려울 것이다. 전송된 전선 그리고 선 생성됨 이곳에서 이벤트가 트리거됩니다. 코드베이스에서 "wire_sent" 문자열을 검색해도 이 사용법을 발견할 수 없습니다. 이름이 동적으로 생성되기 때문입니다. 결과적으로 이 정보는 소수의 엔지니어들 머릿속에만 존재하는 모호한 "부족적" 지식으로 남게 됩니다.
명확한 영역 경계의 부재: 동질적인 인터페이스(이벤트 핸들러, DB 엔터티, 헬스 체크)를 더 많이 도입함에 따라, 도메인 로직별로 그룹화하기보다는 유사한 인터페이스를 동일한 폴더에 함께 배치하는 경향이 생겼습니다. 이로 인해 비즈니스 로직이 분산되어 도메인별 컨텍스트 전환이 더 빈번하고 복잡해졌습니다.
위 설정에서 우리는 모든 이벤트 핸들러를 ./app-event-handlers/<event_type>.ts</event_type> 파일들. 모든 파일을 하나의 폴더에 모아두면 찾기는 쉬웠지만, 실제 작업 방식을 반영하지는 못했습니다. 실제로는 이벤트 핸들러를 다른 핸들러들과 그룹화하는 것보다 관련 애플리케이션 로직과 함께 배치하는 것이 훨씬 유용했습니다.
파일의 하위 확장자를 추가하는 아이디어가 바로 거기서 비롯되었습니다..event-handler.ts도메인별로 공동 호스팅을 허용하면서도 확장자를 조회하여 쉽게 발견할 수 있도록 했습니다. 파일 확장자를 통해 런타임에 저장소에서 해당 확장자와 일치하는 모든 파일을 스캔할 수 있게 되어 수동으로 관리하던 롤업 파일을 제거할 수 있었습니다.
다음은 기본 레지스트리 코드의 요약 버전과 그 작동 방식입니다. 모듈 로드 모든 파일을 스캔하고 내보낸 모든 객체를 $분별자 전달된 것과 동일한 심볼을 가진 속성 레지스트리 생성.
import { createRegistry } from '@/registry';const appEventHandlerDiscriminator = Symbol('app-event-handler');export const appEventHandlerRegistry = createRegistry<AppEventHandler<AppEventBase>>({ // When importing a module, a discriminator symbol to identify that an import is relevant to the registry discriminator: appEventHandlerDiscriminator, // The subextension to search for registryExtension: '.app-event-handler.ts', // Allows a lookup key to be derived from a module getKey: (mod) => mod.type,});export interface AppEventBase { type: string; data: unknown;}/** * Define an interface that exposes a `$discriminator` prop */export interface AppEventHandler<Ev extends AppEventBase> { $discriminator: typeof appEventHandlerDiscriminator; type: Ev['type']; handler: (data: Ev['data']) => Promise<void>;}/** * Define the method we'll actually use to create each event handler*/export function createAppEventHandler<Ev extends AppEventBase>( event: Ev['type'], handler: (data: Ev['data']) => Promise<void>): AppEventHandler<Ev> { return { $discriminator: appEventHandlerDiscriminator, type: event, handler }}
import { mqClient } from '@server/message-queue/client.ts';import type { AppEvent } from './app-event-handlers'export async function recordEvent<Ev extends AppEvent>( ev: AppEventHandler<Ev>, data: Ev) { await mqClient.push('events', { type: ev.type, data });}
4. 작업자에서 큐의 이벤트 핸들러를 처리하는 함수를 내보내기
./process-app-event.ts
import { type AppEventBase, appEventHandlerRegistry } from './app-event-handler.registry';export async function processAppEventHandler(ev: AppEventBase) { // Here, the registry allows us to look up by the serialized type name, and then call the corresponding handler. const item = appEventHandlerRegistry.get(ev.type, { throws: true }); await item.handler(ev);}
이는 모든 위치에서 쉽게 추적할 수 있음을 의미합니다. 카드생성이벤트핸들러 AST 도구(예: VS Code의 "모든 참조 찾기")를 사용하여 사용됩니다. 반대로, 이벤트 기록 호출 시, 한 번의 클릭으로 "구현으로 이동"하여 이벤트 정의와 해당 핸들러를 찾을 수 있습니다.
더 이상 타입 유니온 없음오히려 우리는 기본 유형을 사용하고 있는데, 이는 타입스크립트 유니온의 대규모 사용으로 발생하는 타입 검사 성능 문제를 피하도록 권장합니다.
이벤트 핸들러는 도메인 특정 로직과 함께 배치됩니다앱 이벤트 핸들러는 더 이상 단일 폴더에 저장되지 않습니다. 대신 관련 비즈니스 로직과 함께 배치됩니다. 예를 들어 도메인별 서비스는 다음과 같이 구성될 수 있습니다: