graphst프로젝트

graphql 프레임워크 개발기(Container)

덩실덩실 코드짜는 사람 2023. 8. 7. 21:27

1년 만에 포스팅입니다. 그동안은 회사를 다니느라(변명입니다.) 블로그를 방치했지만 최근에 만든 프레임워크를 공유하고 싶어 글을 씁니다.

 

만들게 된 계기?

이전에 nest.js를 주제로 스터디를 했었는데 회사에서 쓰는 건 아니지만 개인적으로 API를 만든다고 한다면 nest.js 쓰고 있어서 대강 사용방법은 알고 있었습니다.

스터디에선 nest를 직접 뜯어서 내부 구현을 탐구하는 방식으로 진행했는데 코드가 너무 복잡하고 왜 이렇게 구현해야 했을까... 싶은 생각이 들어 직접 구현해 보면 제작자의 의도를 알 수 있을 듯했습니다.

 

Nestjs는 Restfull, Graphql둘다 지원하지만 Yoga, TypeGraphql같은 Graphql Server Framework를 목표로 만들었습니다.

 

해당 프로젝트는 아래와 같이 진행되었습니다.

  1. graphst 제작
  2. graphst를 사용한 API 개발(graphst-api)
  3. graphst-api를 사용한 front구현(graphst-app)
  4. 배포 및 배포 자동화

자세한 코드 및 사용방법은 github페이지에 모두 공개해 놨습니다. 참고 부탁드려요

 

구현 목표

graphql은 크게 schema first, code first로 schema작성 방식이 나뉘는데 해당 글에선 schema first, code first에 대해 자세하게 설명하지 않을 겁니다.

graphst는 code first로 만들건데 이유는 code first가 schema first보다 만들기 어렵고 또한 두 방식의 개발 시 차이점은 code first에서 schema 자동 생성 부분만 외부 schema.gql을 사용하게만 바꿔주면 거의 똑같기 때문입니다.

 

graphst는 결국 사용자가 호출한 데코레이터를 통해 자동으로 schema를 생성하고 그에 맞는 resolver를 작성해 graphql서버를 실행시켜 주는 server builder가 될 것 입니다.

먼저 인스턴스 관리를 도와줄 Container부터 만들겠습니다.

 

Container

제가 만든 컨테이너는 크게 두 가지 이유가 필요해 만들어졌습니다.

  • Singleton: 객체가 단 하나만 생성됨을 보장
  • Auto Resolving: 싱글톤 패턴으로 인스턴스를 생성할 것이기 때문에 인스턴스가 생성되는 시점에 의존성을 주입해 줄 필요가 있습니다. 이때 주입되는 인스턴스는 똑같은 객체라는 것을 보장해 언제나 동일한 값을 공유할 수 있습니다.
export function Injectable(): ClassDecorator {
  const storage = MetadataStorage.getStorage();
  return (target: Function) => {
    storage.setProvider(target, { target: target as any });
  };
}


@Injectable()
  class Test {
    doTest(num: number) {
      return //
    }
  }

위 와 같은 방식으로 Injectable데코레이터를 호출한 class가 인스턴스화의 대상이 됩니다.

MetadataStorage는 해당 패키지 전역에서 공유할 자원을 담아 놓을 객체입니다.

 

export class Container {
  ...
  private factory() {
  // 데코레이터로 수집한 객체가 있다면  인스턴스화 해서 모아놓음
    this.storage.getProviderAll().forEach(({ target }) => {
      providerInstances.set(target, new target());
    });
  }
  ...
}

...
const providerInstances = new WeakMap<Function, InstanceType<ConstructType>>();

생성한 객체는 WeekMap에 저장해야 gc가 메모리에서 제거했을 때 같이 제거되어 메모리 낭비를 막을 수 있습니다.

 

...
export function Inject(prop: () => ConstructType): PropertyDecorator {
  return (target: object, propertyKey: string | symbol) => {
    const _target = target.constructor;
    const storage = MetadataStorage.getStorage();
    const value: MetadataInjectProp = {
      target: _target,
      prop,
      name: propertyKey,
    };
    storage.setInjectProps(_target, value);
  };
}
...

@Injectable()
class Circular1 {
  @Inject(() => Circular2)
  circular2!: Circular2;

  doTest1(num: number) {
    return this.circular2.doTest2(num);
  }

  doTestNew(num: number) {
    return this.circular2.doTestNew(num);
  }
}

@Injectable()
class Circular3 {
  @Inject(() => Circular1)
  circular1!: Circular1;

  doTest3(num: number) {
    return this.circular1.doTestNew(num);
  }
}

@Injectable()
class Circular2 {
  @Inject(() => Circular3)
  circular3!: Circular3;

  doTest2(num: number) {
    return this.circular3.doTest3(num);
  }

  doTestNew(num: number) {
    return num;
  }
}

위처럼 @Inject 데코레이터를 사용해 어떤 class에 어떤  프로퍼티를 주입할 것인지 저장합니다.

 

이제 인스턴스를 모아 놓은 객체를 돌며 저장된 타겟으로 필요한 프로퍼티를 주입해 주면 되는데 여기서 문제가 하나 발생합니다. "의존성 주입"하면 항상 따라다니는 문제인 circular dependency(의존성 순환)이 발목을 잡습니다.

위 코드를 자세히 보면 Circular1는 Circular2를 주입받고 Circular2는 Circular3을 또다시 Circular3은 Circular1을 주입받습니다.

물론 순환하며 끊기지 않는 메서드 호출은 막아야 하지만 의존성 주입 자체의 순환은 풀어주어야 할 필요가 있습니다.

 

위처럼 깔끔하게 순환한다면 문제 해결이 쉽지만 진짜 곤란한 점은 class안에 두게 이상의 의존이 필요할 경우입니다. 결국 아래 사진처럼 tree구조로 의존성은 복잡하게 꼬이게 될 것입니다.

왼쪽 에서 참조중인 5번은 다시 9번을 참조받아야한다.

저는 이 복잡한 순환을 재귀 함수를 사용해 해결해 보았습니다.

private resolve() {
    const dependencies = this.storage.getInjectPropAll().map(({ target }) => ({
      target,
      instance: this.getProvider(target),
    }));

    const circular = (
      cTarget: Function,
      cInstance: any,
      currentTargets = new Set<Function>()
    ) => {
      const props = this.storage.getInjectProps(cTarget);

      for (const { name, prop } of props ?? []) {
        if (cInstance[name]) {
          continue;
        }
        const targetProp = prop();
        const propsData = this.getProvider(targetProp);

        if (propsData) {
          // 순환종속이 일어난게 아니라면 재귀
          if (!currentTargets.has(targetProp)) {
            circular(targetProp, propsData, currentTargets.add(cTarget));
          }
          Object.defineProperty(cInstance, name, {
            value: propsData,
            configurable: true,
            writable: false,
            enumerable: false,
          });
        }
      }
    };

    for (const { target, instance } of dependencies) {
      circular(target, instance);
    }
  }

위 코드는 아직 리펙토링이 필요한 코드입니다. 감안해서 봐주세요

dependencies를 순환하며 circular를 호출, circular안에서도 아직 의존성 주입이 끝나지 않았다면  현재 주입중인 타겟을 저장 후 계속해서 함수를 재호출해 주입을 시도 합니다. 이때 순환 종속이 발생했을 경우(저장된 타겟에 다시 주입하려는 경우) 순환을 끊고 의존성 주입을 실행하는데 타겟을 확인하는 타이밍에 타겟 자체의 의존성 주입이 되어 있는건 아니지만 확인 후 바로 순서대로 주입될 것이기 때문에 주입완료를 보장할 수 있습니다.

저는 아직 재귀함수가 아닌경우의 해결법을 못 찾았습니다.(없는 것 같음) for, while같은 루프에선 주입된 인스턴스를 전달할 방법이 없는데

주입후 다음 주입을 하는건 의미가 없기 때문입니다. (A->B->C와 A->B, B->C의 의미는 매우 다릅니다.)

 

또한 트리가 분기하는 시점(for문이 시작되는 시점)에서 이웃된 분기간의 currentTargets는 독립적으로 저장될 것이기 때문에 다른 분기를 오염시킬 일이 없고, 한쪽 분기가 종료되면 다른 한쪽 분기가 참조할 타겟엔 이미 앞 분기의 인스턴스가 존재하기  때문에 다른 분기에서 같은 인스턴스를을 찾아 주입하려했을 때 즉 중복 주입을 시도할 때 instance[name]을 검사해 불필요한 코드가 실행되는 일이 없도록 작성했습니다.

예를들어 위 트리 사진에서 6번에 5번 주입이 완료 되었기 때문에 다음 루프인 11번 주입시에 이미 6번엔 5번이 주입되어 있는 상태

 

결국 아래 테스트는 통과합니다.

  const container = new Container();
  container.boot();

  // 순환종속성 주입 테스트
  it('Circular Dependency injection testing', () => {
    const circular1 = container.getProvider(Circular1);
    const circular2 = container.getProvider(Circular2);

    if (!circular1 || !circular2) {
      throw new Error('instance is undefined');
    }

    expect(circular1.doTest1(1)).toEqual(1);
  });

 

 

컨테이너는 이게 다입니다. 코드의 리펙토링이 필요하지만 추가적인 기능이나 빼야 될 기능은 없다고 생각합니다.

이로써 생성된 컨테이너를 토대로 나중에 entity를 주입받거나 service를 주입받아 활용할 수 있을 것입니다.

위에서 만든 컨테이너는 graphst내부에 작성된 기능입니다... 다른 패키지로 뺐어야 했는데... 나중에 graphql과는 독립적으로 뺄 생각입니다.

 

다음엔 graphql의 필수인 resolver와 schema를 생성하는 코드를 보겠습니다.

많이 부족한글 읽어주셔서 감사합니다!