목록으로
BREEDING SYSTEM

ES Decorator 전환 실패기: 마이그레이션이 어려운 이유

ES Decorator 전환 실패기: 마이그레이션이 어려운 이유

들어가며

최근 내가 쓰는 MikroORM 메이저 버전이 7로 올라갔다. 가장 눈에 띈 변화는 데코레이터 패키지의 분리와 ES Decorator의 정식 채택이었다.

ES Decorator는 TC39 Stage 3에 올라와있는 표준 데코레이터로 Typescript 5.2부터 지원하기 시작했고, Bun에서도 1.3.10부터 본격적으로 지원하기 시작한 스펙이다.

나는 어차피 타입스크립트는 궁극적으로 ECMAScript 표준을 따를 것이고, 나중에 언젠가 바꿔야 할 거라면 이번 업그레이드 때 미리 써보자는 가벼운 마음으로 마이그레이션에 돌입했다. 하지만 결국 이 선택은 재앙이 되었다.

이 글에서는 그 과정을 정리해보려 한다.

ES Decorator

우선 우리가 그동안 tsconfig.json에서 experimentalDecorators: true를 켜고 사용하던 레거시 타입스크립트 데코레이터와, 새로운 ES 표준 데코레이터(Stage3)가 어떻게 다른지 짚고 넘어갈 필요가 있다.

겉보기에는 똑같이 @Decorator와 같이 사용하지만 내부 동작 방식, 인터페이스는 전혀 다르다.

함수 작성 방법

legacy

레거시 데코레이터는 클래스나 메서드를 조작할 때 대상이 되는 객체(target), 프로퍼티 이름(propertyKey), 그리고 descriptor를 직접 넘겨받는 방식이었다.

function LegacyLog() {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value; // 기존 메서드 백업
    
    // descriptor를 직접 조작하여 새로운 함수로 덮어씌움
    descriptor.value = function (...args: any[]) {
      console.log(`[Legacy] ${propertyKey} 메서드 호출됨. 인자:`, args);
      return originalMethod.apply(this, args);
    };
    
    return descriptor;
  };
}

class UserService {
  @LegacyLog()
  getUser(id: string) {
    return { id, name: "나비어리" };
  }
}

const service = new UserService();
service.getUser("123"); // [Legacy] getUser 메서드 호출됨. 인자: [ '123' ]

ES 표준 Decorator (Stage 3)

ES 표준은 객체를 직접 받거나 하기보다는, 데코레이팅 되는 원래의 함수(value)와 안전한 메타데이터가 담긴 context객체를 받아 새로운 함수를 반환하는 형태로 더 규격화되고 함수형에 가까운 구조를 보여준다.

또, 레거시 데코레이터처럼 객체를 직접 수정하는 것이 아닌 새로운 값을 반환하여 교체하거나 초기화 훅을 거친다.

function ESLog(){
  return function (value: Function, context: ClassMethodDecoratorContext) {
    
    // 기존 함수를 감싸는 새로운 함수를 반환하여 교체
    return function (this: any, ...args: any[]) {
      console.log(`[ES] ${String(context.name)} 실행됨`);
      return value.apply(this, args);
    };
  };
}

reflect-metadata

API 구조가 깔끔해진건 환영이다.

레거시 데코레이터는 emitDecoratorMetadata: true를 통해 컴파일러가 런타임에 클래스의 프로퍼티 타입이나 생성자 파라미터 타입을 알아서 주입해 주고 있었다. 하지만 ES Decorator는 이 기능을 지원하지 않는다.

메타데이터를 저장할 수 있는 context.metadata가 있긴 하지만 런타임에 이 객체가 어떤 타입인지 알려주던 마법은 사라졌다.

DI 툴의 한계와 navi-di의 탄생

나비어리에서는 NestJS를 사용하지 않고 있어 di library로 TypeDI를 사용해왔다. 하지만 이는 ES Decorator환경에선 전혀 동작하지 않았다.

이유는 간단하다. TypeDI를 포함하여 NestJS, tsyringe 등 현재 생태계의 거의 모든 DI 툴은 레거시 데코레이터에서 뱉어내는 메타데이터에 의존하여 주입해주고 있기 때문이다. 나 역시 기존에 di 툴을 따로 만들었을때도 reflect-metadata가 주는 마법같은 편안함에 기대어 만들었었다.

찾아봐도 마땅치 않아 내가 그냥 만들기로 했다. 타입스크립트의 메타데이터에 기대지 않는 순수한 ES Decorator로 구현한 di container library인 navi-di 를 말이다.

navi-di는 최대한 TypeDI의 인터페이스를 따라가려고 했다. 개념도 비슷하다. 메모리 상에 container 인스턴스를 띄우고 그곳에 주입할 class들을 넣어놓고 inject 시 class를 찾아 인젝션 해주는 것.

@Inject: 주입할 메타데이터 수집

@Inject는 당장 무언가를 주입하지 않고 context.metadata 배열에 어떤 토큰을 어떤 필드에 주입해야 하는지 기록해둔다.

export function Inject<T>(dependency: ServiceIdentifier<T>) {
  return function (_: undefined, context: ClassFieldDecoratorContext) {
    const injections = (context.metadata[INJECTION_KEY] ??= []) as InjectionMetadata[];

    injections.push({
      id: dependency,
      name: context.name,
    });
  };
}

@Service: 컨테이너에 서비스 등록

클래스 데코레이터인 @Service가 평가될 때, 앞서 @Inject들이 context.metadata에 쌓아둔 정보들을 읽는다. (property들이 모두 평가되어야 class를 평가할 수 있기 때문에 @Inject는 항상 @Service보다 먼저 평가된다.)

그리고 클래스 정보와 함께 DI container에 이를 등록한다.

export function Service<T>(idOrOptions?: ServiceIdentifier | ServiceOption) {
  return function (target: Constructable<T>, context: DecoratorContext) {
    const options = normalizeArguments(idOrOptions);

    // @Inject 가 모아둔 메타데이터를 여기서 읽어온다!
    const injections = (context.metadata[INJECTION_KEY] ?? []) as InjectionMetadata[];

    ContainerRegistry.defaultContainer.register({
      id: options?.id ?? target,
      Class: target,
      name: context.name,
      injections,
      scope: options?.scope ?? 'container',
      value: EMPTY_VALUE,
      multiple: options?.multiple,
    });
  };
}

이후 런타임에 컨테이너가 서비스를 생성할 때 이 injections 필드를 보고 알맞은 의존성을 스스로 조립해준다.

ES Decorator의 한계

직접 navi-di라는 di 툴을 만들때 ES Decorator에 스펙적인 한계가 있다고 생각이 들었다.

Parameter Decorator의 부재

Stage 3 명세에서는 현재 parameter decorator가 포함되어있지 않다. 이슈에서 건의는 했으나 v2에서 적용이 될 거라는 예정만 받았을 뿐이다.

이는 아래의 코드처럼 injection을 할 수 없다는 뜻이다.

@Service()
export class ParrotService {
  constructor(@Inject() private parrotRepository: ParrotRepository){
    ...
  }
}

위처럼 쓰지 못하고 무조건 프로퍼티 데코레이터로 사용해야한다.

@Service()
export class ParrotService {
  @Inject(ParrotRepository)
  parrotRepository!: ParrotRepository
}

이렇게 되면 유닛테스트가 매우 불편해진다. 기존에는 new ParrotService(mockedParrotRepository)와 같이 작성하면 됐다면, 필드 주입으로 변경되면서 Object.defineProperty 등을 이용해 강제로 쑤셔넣는 느낌으로 해야했다.

테스트 작성비용도 높아졌고 마이그레이션 비용도 늘어났다.

Token의 필수화

기존 TypeDI에서는 reflect metadata로 자동으로 요소의 타입을 추론할 수 있었다.

@Service()
export class ParrotService {
  constructor(@Inject() private parrotRepository: ParrotRepository){
    ...
  }
}

이렇게 작성했을때 @Inject 내부에서 parrotRepository가 ParrotRepository type인 것을 추론할 수 있었다. 다만, ES Decorator는 런타임에서 동작하기 때문에 저런 마법은 부릴 수가 없었다. 그래서 어떤 것을 주입해줘야하는지 Inject 함수에 알려줘야한다.

@Service()
export class Parrot {
  @Inject(ParrotRepository) // ParrotRepository를 inject하라고 명시해줘야함
  parrotRepository!: ParrotRepository
}

Bun, MikroORM 호환성 문제

위와 같은 억까를 이겨내고 그래도 표준이 좋겠지! 라는 생각으로 진행했다. 위의 문제점들을 해결했을 때 이미 100개의 file changed가 있었고, 컴파일 에러는 모두 해결했다. 드디어 실행이라는걸 할 수 있게 되었다.

하지만 TypeScript는 JavaScript로 돌아가는법. 런타임 에러가 났다.

Task PATH_SYMBOL = Task
[info] MikroORM version: 7.0.4
[discovery] ORM entity discovery started, using TsMorphMetadataProvider
[discovery] - processing entity Task (Task)
163 |       path = path.replace(new RegExp(`^${outDirRelative}`), '');
164 |     }
165 |     path = this.stripRelativePath(path);
166 |     const source = this.sources.find(s => s.getFilePath().endsWith(path));
167 |     if (!source && validate) {
168 |       throw new MetadataError(
                  ^
MetadataError: Source file './Task' not found. Check your 'entitiesTs' option and verify you have 'compilerOptions.declaration' enabled in your 'tsconfig.json'. If you are using webpack, see https://bit.ly/35pPDNn
 entity: undefined,

entity 정보를 가져오는 것을 경로로 지정해서 가져오고 있었는데 (본인의 엔티티 데이터는 모두 비슷한 패턴으로 되어있어 glob으로 처리했다.) 이 경로를 제대로 가져오지 못해서 entity를 인식하지 못하여 에러가 나는 것이었다.

이 문제는 bun이 1.3.10으로 업데이트 되면서 native decorator를 지원하면서 stack trace가 변경되면서 mikroORM의 lookup fallback이 제대로 동작하지 않아 생긴 에러였다.

그래서 이 문제를 해결하기 위해 MikroORM 코어에 PR 을 올려 기여하는 값진 경험(?)까지 하게 되었다.

멈추게 된 이유

내가 올린 PR로 런타임 에러는 해결되었다. (물론 그 이후에 parent class의 프로퍼티의 타입을 추론하지 못하는 에러가 나서 이슈를 올렸고 , 그건 메인테이너가 처리하였다.)

다만 그 이후엔 또다시 구조적인 문제들이 기다리고 있었다.

MikroORM 7버전으로 올라오면서 테이블 관계 설정 시 MikroORM의 Collection 객체 사용이 강제되었고, 이로 인해 기존 도메인 로직에서 이런저런 수정이 필요했다. API 변경점도 매우 많았다.

그래서 ES Decorator를 포기하고 다시 Legacy Decorator로라도(MikroORM 7버전에서는 es decorator와 legacy decorator를 모두 지원한다.) 마이그레이션을 하려고 했지만 이 역시도 쉽지 않았다.

7버전으로 올라오면서 여러가지 테이블 생성 추론 로직이나 엔티티 정의의 내부 로직이 변경되어 기존과 똑같이 동작하지 않았다. 그래서 이걸 다 손보려면 사실상 거의 다시만드는 느낌이 강했다. (엔티티와 관련된 모든 코드를 거의 다시 만져야함.)

결국 문제는 기술적 여부가 아니라 비용 대시 가치, 즉 가성비의 문제였다. 나는 이제 더이상 코딩만 하는 개발자가 아니라, 어느정도는 비즈니스 운영과 함께 가야하기 때문에 내부 동작이 크게 바뀌는 마이그레이션에 장기간 매달리기에는 큰 부담감이 있다. 물론 시간적 여유가 있더라도 이부분 외에도 해야할 것이 많았다.

마무리

결국 나비어리 시스템의 백엔드는 다시 MikroORM 6버전과 레거시 데코레이터 환경으로 롤백하기로 결정했다.

추후 여유가 생겼을 때 다시 한번 마이그레이션에 도전해 볼 생각이다. 단, 그때도 구조적인 문제로 시간을 많이 잡아먹는다면 미련 없이 MikroORM을 버리고 TypeORM으로 갈아탈 계획이다.

TypeORM과 MikroORM은 생각보다 인터페이스가 비슷한 것이 많아 내가 많은 리소스를 투여할 필요가 없고. 투여한다고 해도 @Transactional을 자체 구현해야한다는 점 정도만 투여하면 될 것이다.

표준 데코레이터로의 전환은 당장 실패로 끝났지만, 이를 위해 직접 만든 navi-di 는 계속 유지보수할 예정이다.

그리고 ES Decorator에 파라미터 데코레이터가 정식으로 추가되고 충분한 생태계가 지원하는 순간, 다시 한번 대대적인 마이그레이션을 할 예정이다. 이때는 유지보수를 더이상 하지 않는 TypeDI를 버리고 내가 만든 navi-di 를 사용할 예정이다. 실제로 지금 서버에서도 충분히 테스트가 잘 되었기도 해서 믿을만하다고 생각된다.

어느정도 최신 기술을 사용해보는 것을 좋아하고 표준을 따르는 것을 좋아하지만, 운영상에 있어 마이그레이션 하는 것이 지금 당장의 정답은 아니며, 생태계가 성숙할 때까지 기다리는 것도 훌륭한 전략이라는 것을 뼈저리게 깨닫는 과정이었다.

물론 야크털 깎기 처럼 파고들어가서 근본부터 다시 재구축해보는 경험도 재밌는 경험이었다. 오픈소스 개발, 오픈소스 기여 역시 재밌는 개발이었고 말이다.

#TypeScript #ES Decorator #MikroORM #Dependency Injection #Migration #나비어리