Published on

tWIL 2022.07 3주차

Authors

TD;DR

이번 주는 API의 Custom Resolvers를 만들면서 CRUD를 사용하지 않는 비즈니스 로직작업을 하였다. 주로 외부 SDK와 API를 연결하는 작업이었고 이런 로직이 상당 수가 그룹화 되어있어 GraphQL에서도 구조화를 하면서 작업을 진행했다. 커스텀 리졸버들의 구조는 GraphQL의 상위에서 children으로 내려주면서 parent의 값을 이용해 SDK나 API fetching을 하는 방식이었다. 특정 API는 각 권한마다 토큰을 따로 발행하기 때문에 이러한 구조가 유용하게 작동하였다.

여기서는 주로 GraphQL의 Context를 사용하여 공용 함수와 인증 처리를 진행하였다.

그리고 이번주는 CRUD와 어드민 작업을 위해 그간 오래도록 써왔던 nexus-plugin-prismaexperimentalCRUD를 버리고(진작에 deprecated됨), 그리고 Prisma팀에서 더디게 개발중인 nexus-prisma도 버리기로 결심하였다. 그 이유는 pal.js가 어느정도 CRUD 리졸버들을 깔끔하게 만들어주고 게다가 PrismaTable이라는 어드민 컴포넌트를 제공해주기 때문에...

Table of contents

GraphQL structure

Nexus GraphQL 프레임워크를 사용하며, 거의 모든 커스텀 쿼리를 Root에 노출했었다. 사용하는데엔 큰 문제는 없지만 엔드포인트가 많아지니 여러 개발자가 만들어낸 엔드포인트의 네이밍 컨벤션부터 각각 다르기도 하고 비슷한 이름들로 인해 헷갈리는 경우가 많이 있었다. 따라서 이러한 커스텀 Root Queries나 Mutations을 엔드포인트 특성에 맞게 그룹화하여 정리하는 것을 초반단계에서 준비를 잘해야겠다는 생각에 구조화를 진행하였다.

graphql-structure

Root query

커스텀 리졸버를 위한 쿼리필드를 추가한다. 외부 SDK사용을 위한 엔드포인트는 이제 external안으로 모은다.

customQueries/index.ts
export const externalQuery = queryField("external", {
  type: ExternalQueryType,
  description: "외부 SDK 사용을 위한 쿼리",
  resolve: () => ({}),
});

resolve함수는 없더라도 선언해야 한다. 그렇지 않으면 하위 타입에 아무값도 전달되지 않을 것이다.

Queries Group

하위 리졸버를 그룹화 해보자. 이 외부 SDK가 authenticationdataManagementmodelManagement라는 3개의 그룹으로 나뉘어 있다고 생각하고 각각의 타입과 리졸버를 만들어준다.

customQueries/types.ts
export const ExternalQueryType = objectType({
  name: "ExternalQueryType",
  description: "External Query API",
  definition(t) {
    t.field("authentication", {
      type: ExternalAuthenticationQueries,
      description: "Authentication (OAuth) API v1: https://.../",
      resolve: () => ({}),
    });
    t.field("dataManagement", {
      type: ExternalDataManagementQueries,
      description: "Data Management API v2: https://.../",
      resolve: () => ({}),
    });
    t.field("modelManagement", {
      type: ExternalModelManagementQueries,
      description: "Model Management API v2: https://.../",
      resolve: () => ({}),
    });
  },
});

하위 리졸버들도 마찬가지로 children으로 값을 전달할 빈 resolve함수를 넣어준다.

Definition Group

이제 children으로 내려준 타입에서 커스텀 리졸버 엔드포인트들을 추가하기 위한 타입 정의를 해준다. 여기는 extendType메서드로 여기서 만든 타입들을 extend하여 타입을 만들 예정이기 때문에, 여기서는 definition(t)함수는 undefined를 반환하도록 한다.

customQueries/types.ts
export const ExternalAuthenticationQueries = objectType({
  name: "ExternalAuthenticationQueries",
  description: "Authentication (OAuth) API v1: https://.../",
  definition() {
    return;
  },
});
export const ExternalDataManagementQueries = objectType({
  name: "ExternalDataManagementQueries",
  description: "Data Management API v2: https://.../",
  definition() {
    return;
  },
});

export const ExternalModelManagementQueries = objectType({
  name: "ExternalModelManagementQueries",
  description: "Model Management API v2: https://.../",
  definition() {
    return;
  },
});

물론 여기서 선언한 각 타입의 extendType을 하나라도 만들어야 에러가 발생하지 않는다. 현 상황에서는 에러가 발생할 예정이다.

Extended Object Type

적절한 폴더 구조를 만들어 extendType을 만들어 보면(여기서는 authentication만 고려) external > authentication > getExternalAuthToken 이러한 구조로 Query가 만들어진다.

customQueries/authentication.ts
import { extendType, objectType, arg } from "nexus";
import { externalAuthScope } from "../enum";

export const getExternalToken = extendType({
  type: "ExternalAuthenticationQueries",
  definition(t) {
    t.field("getExternalAuthToken", {
      type: ExternalAuthToken,
      args: {
        scopes: arg({
          type: externalAuthScope,
          description: "External Auth 토큰 권한 설정, default: `data:read`",
          list: true,
        }),
      },
      async authorize(_root, _args, { tokenPayload }) {
        if (tokenPayload) return true;
        return false;
      },
      async resolve(_root, { scopes }, { externalClient: { authenticator } }) {
        const targetScope =
          isEmpty(scopes) || isUndefined(scopes) || isNull(scopes)
            ? ["data:read" as ExternalAuthScope]
            : compact(scopes);
        const credentials = await authenticator(targetScope).authenticate();
        return credentials;
      },
    });
  },
});

여기서 리턴 타입은 다음과 같다.

types.ts
export const ExternalAuthToken = objectType({
  name: "ExternalAuthToken",
  description: "External Auth 토큰 타입",
  definition(t) {
    t.nonNull.string("access_token");
    t.nonNull.string("token_type");
    t.nonNull.int("expires_in");
    t.nullable.string("refresh_token");
  },
});

GraphQL 쿼리는 다음과 같이 사용한다. 이렇게 하면 루트쿼리에 모든 쿼리를 노출하지않고 API를 잘 정리할 수 있다.

externalToken.graphql
query externalToken($scope: ExternalAuthScope) {
  external {
    authentication {
      getExternalAuthToken($scope) {
        access_token
        token_type
        expires_in
        refresh_token
      }
    }
  }
}

GraphQL CRUD API

단순 CRUD관점에서 API개발과 프론트엔드 개발에 Type-safe pipeline은 아래 그림과 같이 구성된다. 백엔드 개발자는 Prisma Schema 만 설계하면 된다. 이 스키마는 DB Migration을 통해 데이터베이스의 변경점을 관리하고, Generation을 통해 Type과 (툴을 이용해)GraphQL 스키마를 만들어낸다. 그리고 이 GraphQL 스키마는 rover를 통해 Apollo studio로 스키마를 보낸다. 프론트엔드 개발자는 rover로 스키마의 변경점을 확인하고 프론트엔드 프로젝트로 스키마를 업데이트 한다. graphql-codegen은 이 스키마를 통해 스키마에 사용되는 모든 Type을 만들어내고, (Apollo studio에서 Query, Mutation문을 작성하고) 프론트엔드 개발자가 사용하고자 하는 Operations(.graphql파일)을 모두 스캔해서 읽어 @apollo/clienturql이든 클라이언트 Operation에 해당하는 타입과 Document를 생성시킨다. 여기서는 @apollo/client hooks를 만든다고 가정하면 프론트 개발자는 apollo hooks를 이용해 컨테이너 컴포넌트를 만들면 된다.

import { usePostQuery } from "generated/types";

export function Post() {
  const { data, loading, error } = usePostQuery();
  return <PostPresenter data={data} />;
}

만약 DB 스키마의 변경점이 생기면(Prisma migration을 통한) GraphQL 스키마가 변경되고, 자동 생성된 프론트 클라이언트 코드 또한 타입이 변경되게 된다. 예를 들면 Prisma schema 중 nonNull 필드가 nullable로 변경되면, 이 필드를 사용하는 프론트 프로젝트는 (받은 데이터의 값이 null인경우 대비하지 않았다면) TypeScript 에러를 발생시키게 될 것이다.

schema-to-types

매우 타입 안정적인 시스템은 그렇다. GraphQL 자체가 Type-safe하게 만들긴 하지만, API 코드들 또한 Type-safe하게 만들어주기 때문에, Prisma ORM을 사용하는 것을 포기할 수 없다.

지금까지 이런 방식으로 안정적인 모놀리식 API를 만들어왔지만, CRUD를 만들어주는 nexus-plugin-prisma은 deprecated 되어 Prisma 버전 2를 사용하기엔 메이저 버전 2단계나 올라간 Prisma 4를 사용할 수 없다는 점이 아쉽고, 그렇다고 Prisma 팀이 개발중인 Prisma 플러그인인 nexus-prisma는 early-preview이기 때문에 Production에서 사용하기 매우 불안하다. 그리고 아직 CRUD는 지원하지 않는다. 하지만 어쩔 수 없이 CRUD를 사용해야하기 때문에 warning에도 불구하고 nexus-plugin-prisma을 사용해 왔었다.

Nexus-Prisma

nexus-plugin-prismanexus-prisma를 조합하여 사용하는 워크플로는 아래 그림과 같다.

nexus-prisma-workflow

official plugin인 nexus-prisma는 Prisma generate 될 때 Nexus GraphQL에서 사용할 수 있는 objectType들을 자동으로 만들어준다. 우리는 그 타입을 불러와서 사용하기만 하면 된다.

import { objectType } from "nexus";
import { Post } from "nexus-prisma";

export const post = objectType({
  type: Post.$name,
  description: Post.$description,
  /// ...omit
});

또한, nexus-plugin-prismaexperimentalCRUD 옵션을 사용해 t.crud()t.model()을 사용할 수 있도록 해준다. 말그래도 사용할 수 있도록이라서, 결국 t.crud()t.model()은 개발자가 직접 만들어주어야 한다. 2년전에 이 플러그인을 접했을 땐 참 합리적이라고 생각했다. TypeGraphQL과 다르게 쓰고자하는 모델만 개발자가 입맛에 맞게 노출해주면 되니깐, 하지만 모델이 복잡해 질 수록 백엔드 개발자가 할일이 많아진다. Prisma 스키마가 변경되면 t.model()도 같이 변경시켜주어야 한다.

과거에 nexus-plugin-prisma를 사용하여 experimentalCRUD를 아래와 같이 만들어 왔었다. fieldAuthorizePluginnexusShield 그리고 mocking을 위한 스키마 까지...

schema/index.ts
import { nexusPrisma } from "nexus-plugin-prisma";
import { makeSchema, fieldAuthorizePlugin, declarativeWrappingPlugin } from "nexus";
import { SchemaConfig } from "nexus/dist/builder";
import { addMocksToSchema } from "@graphql-tools/mock";
import * as types from "./types";
import { nexusShield, allow } from "nexus-shield";
import { ForbiddenError } from "apollo-server-core";

const option: SchemaConfig = {
  types,
  shouldGenerateArtifacts: true,
  plugins: [
    nexusPrisma({
      experimentalCRUD: true,
      paginationStrategy: "prisma",
      shouldGenerateArtifacts: true,
      outputs: {
        typegen: path.join(__dirname, "/generated/nexus-prisma.d.ts"),
      },
    }),
    fieldAuthorizePlugin({
      formatError: ({ error }) => {
        console.log(error);
        return error;
      },
    }),
    declarativeWrappingPlugin(),
    nexusShield({
      defaultError: new ForbiddenError("Not allowed"),
      defaultRule: allow,
    }),
  ],
  sourceTypes: {
    modules: [
      {
        module: "@prisma/client",
        alias: "prisma",
      },
    ],
  },
  contextType: {
    module: require.resolve("./context"),
    export: "Context",
  },
  outputs: {
    typegen: path.join(__dirname, "/generated/resolverTypes.ts"),
    schema: path.join(__dirname, "/generated/schema.graphql"),
  },
};
export const schema = makeSchema(option);

export const schemaWithMocks = addMocksToSchema({
  schema,
  preserveResolvers: false,
});

그리고, 수많은 날들을 Model들을 수정하며 수작업으로 만들어 왔었다.

Pal.js

Nexus 플러그인을 쓸 때는 Admin 클라이언트를 만드는 일은 전혀 고려하지 않았다. 분명 Django ORMStrapi도 어드민은 쉽게 지원하는데 Prisma도 있으면 좋겠다 싶어 찾아보고 놀랐다. CRUD가 여기있었네...

그렇다 Pal.js는 어드민을 위한 UI까지 제공하고 있었다. Pal.js 소개 문구는 다음과 같다.

Pal.js (Prisma tools)

We try to build Prisma db CRUD tables with ability to customize this tables with beautiful UI.

Generator Class에 Nexus가 있어서 바로 적용해 보았다. paljs/generator/nexus

Prisma schema를 다음과 같이 정의하고

prisma/schema.prisma
datasource postgresql {
  url      = env("DATABASE_URL")
  provider = "postgresql"
}
generator client {
  provider = "prisma-client-js"
}
model User {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  email     String   @unique
  name      String?
  role      Role     @default(USER)
  posts     Post[]
}
model Post {
  id         Int        @id @default(autoincrement())
  createdAt  DateTime   @default(now())
  updatedAt  DateTime   @updatedAt
  published  Boolean    @default(false)
  title      String
  author     User?      @relation(fields:  [authorId], references: [id])
  authorId   Int?
}
enum Role {
  USER
  ADMIN
}

프로젝트 루트에 pal.js를 생성

./pal.js
/* eslint-disable no-undef */
// @ts-check

/**
 * @type {import('@paljs/types').Config}
 **/

module.exports = {
  backend: {
    generator: "nexus",
    output: "src/schema/types/generated",
  },
};

Generation 스크립트는 pal g. 원하던 Plain nexus objectType들이 모두 생성되었다. 와우! 그리고 여기엔 select라는 컨텍스트 타입을 추가해야한다.(reserved) 그 이유는 이 select를 가지고 GraphQL selector를 사용하기 때문이다. 이것도 자동으로 해주기 때문에 objectType이 단순해진다. 이건 Prisma plugin, Prisma Select로 GraphQL resolver의 4번째 argument인 info:GraphQLResolveInfo를 읽어와서 prisma.{modle}.findMany({ ...select})로 붙여준다. Table을 모두 읽어와서 Network의 Throttle만 아껴주는 GraphQL이 아니라 Query Engine의 부하까지 아껴주는 GraphQL API인 것이다. 나름 신경써서 잘 만든 것 같다. 문서에서는 아래와 같이 소개한다.

Prisma Select takes the info: GraphQLResolveInfo object in general graphql arguments (parent, args, context, info) to select object accepted by prisma client. The approach allows a better performance since you will only be using one resolver to retrieve all your request. By doing so, it also eliminates the N + 1 issue.

그렇다 N + 1문제까지 이것으로 해결이 가능하다는 것을...

Make schema

바로 현 프로젝트에 적용해보았다. 지금까지 만들었던 모든 modelscrud를 삭제하고...

schema/index.ts
import { makeSchema, fieldAuthorizePlugin, declarativeWrappingPlugin } from "nexus";
import { SchemaConfig } from "nexus/dist/builder";
import { nexusShield, allow } from "nexus-shield";
import { ForbiddenError } from "apollo-server-core";
import * as types from "./types";
import { paljs } from "@paljs/nexus";
import path from "path";

const option: SchemaConfig = {
  types,
  shouldGenerateArtifacts: true,
  plugins: [
    paljs({
      excludeScalar: ["BigInt", "DateTime"],
      includeAdmin: true,
    }),
    declarativeWrappingPlugin(),
    fieldAuthorizePlugin({
      formatError: ({ error }) => {
        console.log(error);
        return error;
      },
    }),
    nexusShield({
      defaultError: new ForbiddenError("Not allowed"),
      defaultRule: allow,
    }),
  ],
  sourceTypes: {
    modules: [
      {
        module: "@prisma/client",
        alias: "prisma",
      },
    ],
  },
  contextType: {
    module: require.resolve("../context"),
    export: "Context",
  },
  outputs: {
    typegen: path.resolve(__dirname, "../generated/resolverTypes.ts"),
    schema: path.resolve(__dirname, "../generated/schema.graphql"),
  },
};

export const schema = makeSchema(option);

BigIntDateTime 스칼라는 asNexusMethodt.bigInt(), t.dateTime()으로 사용하기 때문에, 여기서 중복이 되어 excludeScalar에 설정하였고, includeAdmin은 밑에서 설명하려고 한다.

앞서 pal.js에 설정한 path에 모든 CRUD가 만들어진다. 테스트해보니 모든 CRUD가 제대로 동작한다. select도 제대로 동작하여 값을 읽어온다. 한가지 고려해야할 점은 relation field인데, relation 필드를 가지고 있는 uniqueId 필드를 select하지 않으면 relation 필드를 불러오지 못한다. 그 이유는 앞서 말한 Prisma Select 때문이다. 최적화를 했기에, 즉 relation 필드의 uniqueID가 없기 때문에 하위 관계형 필드에서는 이 값을 모르기 때문이다(아니 null이기 때문). 즉, 관계형 필드 속에 있는 비유니크한 값을 가져오려면 해당 관계형필드의 유니크한 id도 포함해서 select해야한다. 성능을 얻었으니 이정도 쯤은 괜찮다.

Include Admin

prisma-admin을 보면, 이 @paljs/cli는 어드민 페이지를 위한 특수 엔드포인트가 필요하다. Introspection 비슷한 현재 Prisma schema를 어드민으로 가져와야 한다. 이 방법론은 lowdb라는 파일(?)기반 DB를 별도로 사용함으로 어드민 페이지의 설정 등등을 저장하도록 한다. Prisma 타입이 생성된 경로에 prisma/adminSettings.json을 저장하도록 하는데 구성하는 방법은 prisma-admin#add-graphql-queries-and-mutation와 같이 한다. 하지만 위에서 본바와 같이 Nexus에서는 adminSettings: true옵션으로 넣어주고, nexus-paljs 플러그인을 설치하면 @paljs/nexus가 알아서 해준다. 만약 Nexus GraphQL을 쓰지않고 Plain GraphQL로 서버를 만든다면 위의 링크대로 설정해주어야 한다. (웬만하면 Nexus를 쓰는게...)

Workflow

이렇게 CRUD와 Admin을 한번에 잡을 수 있을까....

다음주까지 조금 더 해보고 결론을 내려야 할 것 같다. 가능성으로는 매우 긍정적이며, 어드민을 위한 개발기간도 매우 단축될 것으로 기대한다.

일단 Workflow는 아래처럼 백엔드 개발자는 Prisma schema를 신경쓴다. CRUD는 신경쓰지 않는다. 오로지 필요한 extendType이나 커스텀 리졸버를 만들기만 하면 된다.

  • Prisma schema를 만들고 db push 하고 로컬 테스트를 한다. 비즈니스 로직이 맞으면 migrate dev로 마이그레이션 파일을 생성
  • develop브랜치로 커밋 앤 푸시. CI/CD는 테스트하고 마이그레이션 하고 배포한다.

그림 처럼 CRUD에서는 거의 no-code 개발이다. CRUD 리졸버들은 넘어오는 selectinfo정보를 가지고 context에 담으며, 이 context에서 해당 쿼리는 select를 넣어 Prisma client로 쿼리를 한다. adminQuery는 어드민 페이지의 설정을 json파일 형태로 저장한다.

아마 배포를 하면 이 lowdbjson파일이 날라갈 것으로 예상되는데. 이것도 어드민을 만들고, 고민해보고 커스터마이징이 필요할 듯 하다.

paljs-workflow