- Published on
tWIL 2022.07 3주차
- Authors
- Name
- eunchurn
- @eunchurn
TD;DR
이번 주는 API의 Custom Resolvers를 만들면서 CRUD를 사용하지 않는 비즈니스 로직작업을 하였다. 주로 외부 SDK와 API를 연결하는 작업이었고 이런 로직이 상당 수가 그룹화 되어있어 GraphQL에서도 구조화를 하면서 작업을 진행했다. 커스텀 리졸버들의 구조는 GraphQL의 상위에서 children으로 내려주면서 parent의 값을 이용해 SDK나 API fetching을 하는 방식이었다. 특정 API는 각 권한마다 토큰을 따로 발행하기 때문에 이러한 구조가 유용하게 작동하였다.
여기서는 주로 GraphQL의 Context를 사용하여 공용 함수와 인증 처리를 진행하였다.
그리고 이번주는 CRUD와 어드민 작업을 위해 그간 오래도록 써왔던 nexus-plugin-prisma의 experimentalCRUD
를 버리고(진작에 deprecated됨), 그리고 Prisma팀에서 더디게 개발중인 nexus-prisma도 버리기로 결심하였다. 그 이유는 pal.js가 어느정도 CRUD 리졸버들을 깔끔하게 만들어주고 게다가 PrismaTable
이라는 어드민 컴포넌트를 제공해주기 때문에...
Table of contents
GraphQL structure
Nexus GraphQL 프레임워크를 사용하며, 거의 모든 커스텀 쿼리를 Root에 노출했었다. 사용하는데엔 큰 문제는 없지만 엔드포인트가 많아지니 여러 개발자가 만들어낸 엔드포인트의 네이밍 컨벤션부터 각각 다르기도 하고 비슷한 이름들로 인해 헷갈리는 경우가 많이 있었다. 따라서 이러한 커스텀 Root Queries나 Mutations을 엔드포인트 특성에 맞게 그룹화하여 정리하는 것을 초반단계에서 준비를 잘해야겠다는 생각에 구조화를 진행하였다.
Root query
커스텀 리졸버를 위한 쿼리필드를 추가한다. 외부 SDK사용을 위한 엔드포인트는 이제 external
안으로 모은다.
export const externalQuery = queryField("external", {
type: ExternalQueryType,
description: "외부 SDK 사용을 위한 쿼리",
resolve: () => ({}),
});
resolve
함수는 없더라도 선언해야 한다. 그렇지 않으면 하위 타입에 아무값도 전달되지 않을 것이다.
Queries Group
하위 리졸버를 그룹화 해보자. 이 외부 SDK가 authentication
과 dataManagement
와 modelManagement
라는 3개의 그룹으로 나뉘어 있다고 생각하고 각각의 타입과 리졸버를 만들어준다.
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
를 반환하도록 한다.
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가 만들어진다.
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;
},
});
},
});
여기서 리턴 타입은 다음과 같다.
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를 잘 정리할 수 있다.
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/client
든 urql
이든 클라이언트 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 에러를 발생시키게 될 것이다.
매우 타입 안정적인 시스템은 그렇다. 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-prisma
와 nexus-prisma
를 조합하여 사용하는 워크플로는 아래 그림과 같다.
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-prisma
은 experimentalCRUD
옵션을 사용해 t.crud()
와 t.model()
을 사용할 수 있도록 해준다. 말그래도 사용할 수 있도록이라서, 결국 t.crud()
와 t.model()
은 개발자가 직접 만들어주어야 한다. 2년전에 이 플러그인을 접했을 땐 참 합리적이라고 생각했다. TypeGraphQL과 다르게 쓰고자하는 모델만 개발자가 입맛에 맞게 노출해주면 되니깐, 하지만 모델이 복잡해 질 수록 백엔드 개발자가 할일이 많아진다. Prisma 스키마가 변경되면 t.model()
도 같이 변경시켜주어야 한다.
과거에 nexus-plugin-prisma
를 사용하여 experimentalCRUD
를 아래와 같이 만들어 왔었다. fieldAuthorizePlugin
과 nexusShield
그리고 mocking을 위한 스키마 까지...
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 ORM도 Strapi도 어드민은 쉽게 지원하는데 Prisma도 있으면 좋겠다 싶어 찾아보고 놀랐다. CRUD가 여기있었네...
그렇다 Pal.js는 어드민을 위한 UI까지 제공하고 있었다. Pal.js 소개 문구는 다음과 같다.
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를 다음과 같이 정의하고
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
를 생성
/* 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 byprisma 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 theN + 1
issue.
그렇다 N + 1
문제까지 이것으로 해결이 가능하다는 것을...
Make schema
바로 현 프로젝트에 적용해보았다. 지금까지 만들었던 모든 models
와 crud
를 삭제하고...
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);
BigInt
와 DateTime
스칼라는 asNexusMethod
로 t.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 리졸버들은 넘어오는 select
를 info
정보를 가지고 context
에 담으며, 이 context
에서 해당 쿼리는 select
를 넣어 Prisma client로 쿼리를 한다. adminQuery
는 어드민 페이지의 설정을 json
파일 형태로 저장한다.
아마 배포를 하면 이 lowdb
의 json
파일이 날라갈 것으로 예상되는데. 이것도 어드민을 만들고, 고민해보고 커스터마이징이 필요할 듯 하다.