ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • schema, resolver 자동생성 (2) ...또 Circular
    graphst프로젝트 2023. 8. 9. 19:36

    저번 포스팅에 이어 schema를 만드는 코드를 살펴보겠습니다.

    🤚아래 코드는 이해를 위해 아주아주 간략화된 코드로 실제코드와 많이 다릅니다. 원리는 같아요. 실제코드

    // @ObjectType을 사용해 저장된 모든 값을 불러옵니다.
    const objects = this.storage.getObjectTypeAll();
    
    objects.forEach(({ target, name }) => {
      // @FieldResolver를 사용해 부모가 target인 모든 데이터를 가져옴
      const fieldResolverProps = this.storage.getFieldResolver(target)
      const fieldProps = this.storage.getResolver(target)
      
      // getSchema를 사용해 자식 요소의 graphqlType을 만들어 할당
      const fields = this.fieldFactory.getSchema([...fieldProps, ...fieldResolverProps]);
        if (fields) {
          graphqlEntity = new GraphQLObjectType({
            name,
            fields,
          });
        }
    })
    
    
    // fieldFactory
    getSchema(props: ...) {
      const filedArg = {}
      props.forEach(({ name, returnType, description }) => {
      	// () => GraphqlInt이런것들을 객체로 리턴시킴
        const argReturn = returnType();
        filedArg[name] = { type: argReturn, description };
      })
    }

    생각보다 별게 없습니다. 그냥 데코레이터에서 수집한 데이터를 GraphqlObject로 만들어주는 코드가 루프로 돌고 있을 뿐입니다.

    Query, Mutation 또한 비슷합니다. 전부 살펴보면 너무 포스팅이 길어지니... 궁금하면 깃헙을 봐주세요

     

    사실 위 같은 경우는 재귀함수로 만들어야 하는 경우입니다.

    Object하위 field의 args또는 returnType가 GraphqlType(GraphqlString 같은 거)이 아닌 아직 생성안된 Object을 바라보고 있을 수 있기 때문입니다.

    아직 생성안된 Object를 생성하기 위해 함수 본인을 호출해야겠죠

     

    처음엔  Containor의 의존성 주입에서 사용한 것처럼 재귀함수로 만들었습니다.

    getObjectSchema(target)
      const object = this.storage.getObjectType(target);
      const fieldResolverProps = this.storage.getFieldResolver(object.target)
      const fieldProps = this.storage.getResolver(object.target)
      
      const fields = {}
      
      // field들을 모조리 찾아서 
      [...fieldResolverProps, ...fieldProps].forEach(({ name, returnType, description }) => {
        let argReturn = returnType();
        if (interfaceof argReturn Function) {
          // Graphql타입이 아니고 저장된 Object가 없으면 본인을 다시 호출
          getObjectSchema = generatedObject.get(argReturn) && this.getObjectSchema(argReturn)
        }
        
        fields[name] = { type: argReturn, description };
      })
    
      const result = new GraphQLObjectType({
        name: object.name,
        description: object.description,
        fields,
      });
      
      generatedObject.add(target, result)
      
      return result
    }

    위 코드 또한 포스팅을 위해 급조한 코드입니다. 감안하고 봐주세요

    코드 진행 순서는 아래와 같습니다.

    • @ObjectType으로 수집한 class를 parent키로 가지고 있는 field들의 메타 데이터를 사용해 GraphqlObject를 생성
    • field의 returnType이 class고 GraphqlObjectType으로 완성되지 않았다면 먼저 만들고 할당
    • GraphqlObjectType가 완성될때까지 반복

    여기서 문제가 발생합니다.

     

    circular dependencies문제

    Contaior만들 때 발생한 문제가 또다시 불거졌습니다.

     

    Type User {

       post: Post

    }

    Type Post {

       user: User

    }

    위 같은 스키마를 만들어야 한다면 당시 만든 로직으론 아래와 같은 코드가 만들어져 무한 루프에 빠집니다.

    const graphqlPostType = new GraphQLObjectType({
      name: 'Post',
      fields: {
        user: { 
          type: new GraphqlObjectType({
            name: 'User',
            fields: {
              post: { 
                type: new GraphqlObjectType({
                  name: 'Post',
                  // 무한 반복
                })
              }
            }
          })
        }
      },
    });

    제작당시 저는 위에 순환참조 문제를 해결하기 위한 두 가지 아이디어가 있었습니다.

     

    1. Contraior의 circular를 해결한 것처럼 해결

    이경우도 Contraior의 의존성 순환 문제와 똑같이 tree형태로 뎁스가 깊어지는 상황이라 똑같이 해결할 수 있겠다 싶었습니다.

    순환을 감시해서 중복이 발생하면 이전까지 작성한 Object만 만들어주고 루프를 벗어나는 방식이었습니다. 

    하지만 해결 방식이 되지 못했습니다.

    만약 이전까지 작성한 Object까지만 만든다면 아래처럼 코드가 완성될 겁니다.

    const graphqlPostType = new GraphQLObjectType({
      name: 'Post',
      // 여긴 나중에 넣어줘야지 라는 안일한 생각
      fields: {},
    });
    
    const graphqlUserType = new GraphQLObjectType({
      name: 'User',
      fields: {
        post: { type: graphqlPostType },
      },
    });

    graphqlPostType는 비어있기 때문에 fields를 재할당 해야 하는데 GraphqlObjectType이 가지고 있는 fieldList에 접근할 수 있는 방법이 전혀 없습니다.

    게다가 수정할 수 있다고 해도 저 자리에 graphqlUserType을 넣으면 복사해서 넣는 게 아닌 이상 Maximum call stack size exceeded에러가 리턴 됐겠죠 애초에 안 되는 아이디어입니다.

     

    graphqlPostType을 수정하지 않으면 

    Type Post {

     

    }

    이런 식으로 schema가 만들어집니다.

    Contaior에선 인스턴스를 미리 만들어 놓고 Object.defineProperty로 프로퍼티를 직접 수정이 가능했기 때문에 사용할 수 있는 방식입니다.

     

    2. 이름이 같은 GraphqlObject를 만들자(복사하자)

    결론부터 말하자면 이건 이름 중복 에러가 발생해서 안됩니다.

    const graphqlUserType = new GraphQLObjectType({
      name: 'User',
      fields: {
        post: { type: graphqlPostType },
      },
    });
    
    const graphqlUserType2 = new GraphQLObjectType({
      name: 'User',
      fields: {},
    });
    
    const graphqlPostType = new GraphQLObjectType({
      name: 'Post',
      fields: {
        user: { type: graphqlUserType2 }
      },
    });

    이렇게 만들면 graphqlUserType2는 Post의  user: User만 만드는데 문제가 없다 판단했습니다. User라는 문자를 도출하는 데에 fields가 필요한 건 아니니까 GraphQLSchema 인스턴스를 만들 때 graphqlUserType2를 전달 안 하면 되겠다는 생각이었지만 그건 저의 바람일 뿐이었고  GraphQLSchema는 내부적으로 graphqlPostType의 graphqlUserType2를 바로 사용했습니다. 어찌 보면 당연하죠(너무 늦게 깨달았습니다.)

     

     

    TypeGraphql의 경우

    지난 포스팅에서 TypeGraphql이 리턴타입에 함수구문을 사용하는 이유에 대해 아직 남아있는 게 있다고 했습니다.

    다른 패키지에선 어떻게 해당 문제를 해결하고 있는지 궁금해졌습니다. 아래는 TypeGraphql의 공식 Docs에서 가져왔습니다.

    Why use function syntax and not a simple { type: Rate } config object? Because, by using function syntax we solve the problem of circular dependencies (e.g. Post <--> User)

    단순한 구성 개체가 아닌 함수 구문을 사용하는 이유는 무엇입니까 { type: Rate }? 함수 구문을 사용하여 순환 종속성 문제(예: Post <--> User)를 해결하므로 관례로 채택되었습니다.

    그냥 rate: Rate 이렇게 있는 그대로 사용하면 되기 때문에 아주 편해집니다.

    또한 GraphQLNonNull(GraphQLList(GraphQLNonNull(GraphqlString))) 같이 더러운 코드보단 [String!]! 이렇게 간단히 사용할 수도 있고 패키지 내부에서 만들어진 Graphql타입을 외부로 전달할 방법에 대해 고민할 필요가 없어 여러모로 합리적인 선택입니다.

     

    이쯤 되니 저도 지금이라도 function syntax를 사용해야겠다 생각이 들었지만 하나만 더 해보자 싶었습니다. function syntax는 진짜 쓰기 싫었거든요([String] 이건 배열이잖아...)

     

    해결

    graphqlSchema는 어떻게든 아래 같은 string을 전달해 주면 된다는 부분에 집중했습니다.

      type Post {
        id: ID!
        user: User
      }
      
      type User {
        id: ID!
        post: Post
      }
    
      type Query {
        posts: [Post]
      }

    해결방법은 임의의 GraphqlObject를 생성하는 겁니다.

    const graphqlPostType = new GraphQLObjectType({
      name: 'Post',
      fields: {
        post: { type: graphqlUserType },
      },
    });
    
    const graphqlPostType__copy = new GraphQLObjectType({
      name: 'Post__copy'
    });
    
    const graphqlUserType = new GraphQLObjectType({
      name: 'User',
      fields: {
        post: { type: graphqlPostType__copy },
      },
    });

    위처럼 작성하면 schema는 아래처럼 만들어집니다.

    type Post {
      user: User
    }
    
    type User {
      post: Post__copy
    }
    
    type Post__copy {}

    circular가 발생하면 그냥 "__copy"를 붙여 새로운 GraphqlObjectType을 생성해 할당합니다.

    GraphqlSchema에선 Post__copy를 사용해 type을 생성하고 이름을 이용해 프린트를 해줄 것이기 때문에 위 경우가 아닌 다른 case가 아예 없습니다.

    저 정도는 정규표현식만 잘 짜놓으면 사이드 이펙트는 없겠다 싶어 탄생한 코드가 아래입니다.

    import { GraphQLSchema, printSchema } from 'graphql';
    
    ...
    this.graphqlSchema = printSchema(schema)
      .split('\n')
      .filter((line) => {
        const trimmedLine = line.trim();
        return !(
          trimmedLine.startsWith('type') && trimmedLine.endsWith('__copy')
        );
      })
      .join('\n')
      .replace(/__copy/g, '');
    ...

     

    마치며...

    거창하게 "해결"이라고 써놨지만 사실은 편법이죠 그것도 굉장히 나쁜 냄새가 나는...

    graphst는 아직 갈길이 먼 프레임워크긴 합니다. subscription도 아직 지원하지 않고 Interface, Union, Scalar에 대한 테스트 또한 아직 완성되지 않아 좋은 퍼포먼스를 내기엔 부족합니다. 아마 위 기능들이 들어가야 마이너 버전을 올릴 수 있을거에요...지금은 간단한 graphql server를 만들정도는 되는 듯합니다. 점차 기능을 추가할예정이니 귀엽게 봐주시면 감사하겠습니다.

     

    다음 포스팅부턴 graphst-api, graphst-app과 배포과정을 포스팅하겠습니다.

     

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

Designed by Tistory.