현재 우리 모노레포에는 커밋된 TypeScript 코드가 약 110만 줄에 달합니다. 이를 확장하는 과정에서 느린 타입 검사, 불필요한 임포트, 점점 악화되는 코드 추적성 등 다양한 문제가 발생했습니다. 지난 몇 년간 우리는 코드베이스 확장을 위해 다양한 디자인 패턴을 탐구하고 반복해 왔습니다. 그중 우연히 발견한 레지스트리(Registry) 패턴은 점점 커져가는 코드 조직화 문제를 상당 부분 완화시켜 주었습니다. 이 패턴은 단순하면서도 우리가 도입한 가장 확장성 높은 패턴 중 하나입니다.

이 패턴이 큰 차이를 만들어낸 대표적인 사례가 바로 당사의 초기 내부 이벤트 처리 코드입니다. 단순하게 시작했지만 시간이 지남에 따라 거대한 타입 유니온으로 인해 타입 검사가 느려지고, 지나치게 많은 코드를 가져오는 배럴 파일, 그리고 과도한 문자열 연결로 인해 추적하기 어려운 코드가 발생했습니다. 마지막으로, 새로운 이벤트 핸들러를 유지 관리하고 추가하는 데 개발자 간 마찰을 야기했습니다.

오리지널 디자인

대략적으로 설명하자면, 당사의 이벤트 서비스는 메시지 큐(MQ)를 기반으로 구축되었습니다. 이벤트는 백엔드의 어디에서나(API 포드, 워커 포드 등) 기록되어 MQ로 전송되며, 워커에 의해 정확히 한 번만 처리됩니다.

디자인 자체는 매우 단순합니다. 유일한 주요 제약 조건은 모든 이벤트가 네트워크를 통해 완전히 직렬화 가능해야 한다는 점입니다. 대부분의 복잡성과 유지보수 부담은 타입 안전성과 개발자 경험에서 비롯됩니다.


1. 기본 헬퍼 집합을 정의합니다:

./app-event-handler/base.ts
export interface AppEventBase {
  type: string;
}
 
export interface AppEventHandler<Ev extends AppEventBase> {
  type: Ev['type'];
  handler: (data: Ev) => Promise<void>
}
 
export function createAppEventHandler<Ev extends AppEventBase>(
  event: Ev['type'],
  handler: (data: Ev) => Promise<void>
): AppEventHandler<Ev> {
  return { type: event, handler }
}

2. 공유 파일에서 이벤트 유형 정의하기

./app-event-handlers/types.ts
import type { AppEventBase } from '../base.ts';
 
export interface CardCreatedEvent extends AppEventBase {
  type: 'card_created';
  cardName: string;
}
 
export interface WireSentEvent extends AppEventBase {
  type: 'wire_sent';
  data: {
    amount: number;
  }
}
 
export type AppEvent = CardCreatedEvent | WireSentEvent;

3. 이벤트 핸들러를 별도의 파일에서 정의하십시오

// ./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.ts
import 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.ts
import { 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.ts
import { 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 events
 
await recordEvent({ type: eventName, data });

그것을 추적하는 것은 매우 어려울 것이다. 전송된 전선 그리고 선 생성됨 이곳에서 이벤트가 트리거됩니다. 코드베이스에서 "wire_sent" 문자열을 검색해도 이 사용법을 발견할 수 없습니다. 이름이 동적으로 생성되기 때문입니다. 결과적으로 이 정보는 소수의 엔지니어들 머릿속에만 존재하는 모호한 "부족적" 지식으로 남게 됩니다.

명확한 영역 경계의 부재: 동질적인 인터페이스(이벤트 핸들러, DB 엔터티, 헬스 체크)를 더 많이 도입함에 따라, 도메인 로직별로 그룹화하기보다는 유사한 인터페이스를 동일한 폴더에 함께 배치하는 경향이 생겼습니다. 이로 인해 비즈니스 로직이 분산되어 도메인별 컨텍스트 전환이 더 빈번하고 복잡해졌습니다.

이 디자인을 반복적으로 개선 중입니다

위 설정에서 우리는 모든 이벤트 핸들러를 ./app-event-handlers/<event_type>.ts</event_type> 파일들. 모든 파일을 하나의 폴더에 모아두면 찾기는 쉬웠지만, 실제 작업 방식을 반영하지는 못했습니다. 실제로는 이벤트 핸들러를 다른 핸들러들과 그룹화하는 것보다 관련 애플리케이션 로직과 함께 배치하는 것이 훨씬 유용했습니다.

파일의 하위 확장자를 추가하는 아이디어가 바로 거기서 비롯되었습니다..event-handler.ts도메인별로 공동 호스팅을 허용하면서도 확장자를 조회하여 쉽게 발견할 수 있도록 했습니다. 파일 확장자를 통해 런타임에 저장소에서 해당 확장자와 일치하는 모든 파일을 스캔할 수 있게 되어 수동으로 관리하던 롤업 파일을 제거할 수 있었습니다.

다음은 기본 레지스트리 코드의 요약 버전과 그 작동 방식입니다. 모듈 로드 모든 파일을 스캔하고 내보낸 모든 객체를 $분별자 전달된 것과 동일한 심볼을 가진 속성 레지스트리 생성.

./registry.ts
interface Registry<T> {
  loadModules(): Promise<void>;
  get<Throws extends boolean>(key: string, options?: { throws?: Throws }): boolean extends Throws ? T | undefined : T;
}
export function createRegistry<T extends { $discriminator: symbol }>(options: {
  discriminator: T['$discriminator'];
  registryExtension: `.${string}.ts`;
  getKey: (value: T) => string;
}): Registry<T> {
  // implementation ...
}

이제 레지스트리를 사용하여 이벤트 핸들러를 구축하는 방법은 다음과 같습니다:

1. 레지스트리를 정의합니다. <name>.registry.ts</name> 파일:

./app-event-handler.registry.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 }
}

2. 실제 이벤트 핸들러를 정의합니다. .app-event-handler.ts 파일들

// ./card-service/card.app-event-handler.ts
import { createAppEventHandler } from '@/app-event-handler.registry';
 
interface CardCreatedEvent extends AppEventBase {
  type: 'card_created';
  data: {
    cardName: string;
  }
}
 
export const cardCreatedEventHandler = createAppEventHandler<CardCreatedEvent>(
  'card_created',
  async (ev) => {
    /**
    * ev looks like:
    * { 
    *   type: 'card_created',
    *   data: {
    *     cardName: string
    *   }
    * }
    **/
    await sendNotificationToUsers();
  }
);
 
// ./transfers/wire.app-event-handler.ts
import { createAppEventHandler } from '@/app-event-handler.registry';
 
interface WireSentEvent extends AppEventBase {
  type: 'wire_sent';
  data: {
    amount: number;
  }
}
 
export const wireSentEventHandler = createAppEventHandler<WireSentEvent>(
  'wire_sent',
  async (ev) => {
    // ...
  }
)

3. 이벤트를 기록하기 위한 공통 함수를 정의한다

./record-app-event.ts
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);
}

주목해야 할 몇 가지 중요한 차이점:

코드 추적성이 훨씬 더 우수합니다이벤트를 기록할 때는 항상 다음과 같이 기록합니다:

await recordEvent(cardCreatedEventHandler, { cardName: 'John Doe' })

이는 모든 위치에서 쉽게 추적할 수 있음을 의미합니다. 카드생성이벤트핸들러 AST 도구(예: VS Code의 "모든 참조 찾기")를 사용하여 사용됩니다. 반대로, 이벤트 기록 호출 시, 한 번의 클릭으로 "구현으로 이동"하여 이벤트 정의와 해당 핸들러를 찾을 수 있습니다.

더 이상 타입 유니온 없음오히려 우리는 기본 유형을 사용하고 있는데, 이는 타입스크립트 유니온의 대규모 사용으로 발생하는 타입 검사 성능 문제를 피하도록 권장합니다.

이벤트 핸들러는 도메인 특정 로직과 함께 배치됩니다앱 이벤트 핸들러는 더 이상 단일 폴더에 저장되지 않습니다. 대신 관련 비즈니스 로직과 함께 배치됩니다. 예를 들어 도메인별 서비스는 다음과 같이 구성될 수 있습니다:

/ services
  / card-service
    - card-service.main.ts
    - card-lifecycle.app-event-handler.ts
    - card.db.ts
    ...

오늘날 레지스트리와 협력하기

현재 우리는 수십 개의 레지스트리와 협력하여 모든 코드가 애플리케이션 로직과 동일한 위치에 배치되도록 하고 있습니다. 주요 레지스트리로는 다음과 같습니다:

  • .db.ts 데이터베이스 엔터티 등록을 위해
  • .워크플로우.ts 그리고 .activities.ts 등록을 위해 시간적 워크플로우
  • .checks.ts 건강 검진 등록을 위해 (블로그 게시물)
  • .main.ts 도메인별 비즈니스 로직을 그룹화하는 서비스 등록을 위한
  • .permission-role.ts 그리고 .permission-key.ts 제품에서 RBAC 권한을 정의하기 위한
  • .email-box.ts Gmail 계정에서 이메일을 파싱하는 핸들러를 등록하기 위한
  • .cron.ts cron 작업 등록을 위해
  • .ledger-balance.ts 내부 재무 "원장" 기본 요소를 정의하기 위한
  • .metrics.ts Datadog 메트릭 정의용

그리고 여러 다른 도메인별 확장 기능들.

현재 시점에서 이 패턴은 오픈소스로 공개하지 않았지만, 이 글이 다른 코드베이스에서 이를 구현하는 방법을 명확히 이해하는 데 도움이 되길 바랍니다. 유용하다고 생각되신다면, 여러분의 프로젝트에 직접 적용해 보시고 결과가 어떠했는지 알려주세요!

Read more from us