Published on

tWIL 2022.09 1주차

Authors

이번 주는 PoC 마무리가 있었고, 몸 컨디션이 좋지 않아서 라이딩을 좀 쉬었다가 토요일에 태풍이 오기전 여의도를 다녀왔다. 갈때는 역풍이 너무 심해서 체력이 고갈되는 듯 했으나, 여의도에서 넘어갈 수 밖에 없는 유혹 "한강에서 라이딩과 라면"에 넘어갔고, 돌아올 땐 순풍이여서 괜찮았다.

이번 주는 PoC를 마무리하고, 그간 미뤄놓은 개발 이슈에 손을 대기 시작했다. paljs에서 사용하는 adminSettings에 관련한 일이다. nexus 스키마에 includeAdmintrue로 설정한 경우 자동생성해주는 이 파일은 로컬 파일로 관리된다. 이 보관 위치에 의해 배포를 하거나 수동으로 로컬에서 수정된 파일을 업로드 시켜야한다. 내부는 lowdb 패키지를 사용하여 JSON 파일을 사용한다. 가이드는 아래와 같이 FileSync로 File DB를 사용한다.

Paljs admin prisma

import low from "lowdb";
import FileSync from "lowdb/adapters/FileSync";

const adapter = new FileSync<{
  [key: string]: { [key: string]: { [key: string]: any }[] }[];
}>("prisma/adminSettings.json");
const db = low(adapter);

export default {
  Query: {
    getSchema: () => {
      return db.value();
    },
  },
  Mutation: {
    updateModel: (_parent, { id, data }) => {
      return db.get("models").find({ id }).assign(data).write();
    },
    updateField: (_parent, { id, modelId, data }) => {
      return db.get("models").find({ id: modelId }).get("fields").find({ id }).assign(data).write();
    },
  },
};

GraphQL 스키마는 아래와 같이 정해져있다.

type Schema {
  enums: [Enum!]!
  models: [Model!]!
}

type Query {
  getSchema: Schema!
}

type Mutation {
  updateField(data: UpdateFieldInput, id: String!, modelId: String!): Field!
  updateModel(data: UpdateModelInput, id: String!): Model!
}

type Enum {
  fields: [String!]!
  name: String!
}

type Model {
  create: Boolean!
  delete: Boolean!
  displayFields: [String!]!
  fields: [Field!]!
  id: String!
  idField: String!
  name: String!
  update: Boolean!
}

type Field {
  create: Boolean!
  editor: Boolean!
  filter: Boolean!
  id: String!
  isId: Boolean!
  kind: KindEnum!
  list: Boolean!
  name: String!
  order: Int!
  read: Boolean!
  relationField: Boolean
  required: Boolean!
  sort: Boolean!
  title: String!
  type: String!
  unique: Boolean!
  update: Boolean!
}

input UpdateFieldInput {
  create: Boolean
  editor: Boolean
  filter: Boolean
  id: String
  isId: Boolean
  kind: KindEnum
  list: Boolean
  name: String
  order: Int
  read: Boolean
  relationField: Boolean
  required: Boolean
  sort: Boolean
  title: String
  type: String
  unique: Boolean
  update: Boolean
}

input UpdateModelInput {
  create: Boolean
  delete: Boolean
  displayFields: [String!]
  fields: [UpdateFieldInput!]
  idField: String
  name: String
  update: Boolean
}
enum KindEnum {
  enum
  object
  scalar
}

Query

배포된 어드민 셋팅은 서버에 저장된다. 즉 설정을 변경하면 서버에는 저장 로컬에선 알 수 없기 때문에 배포시점에 다시 리셋되기 마련이다. 그렇다고 DB에 설정값을 저장하면 추가되는 테이블이나 필드의 업데이트 상황에 다시 저장시켜야 하는 딜레마가 있다. 따라서 생각해낸 방식은 DB에 설정 정보를 저장하기로 하고, generate 될 때 DB에 있는 설정 상태를 파일로 저장. 특정 환경변수에서 includeAdmin을 true 상태로 생성을 시킨다. 이 때 설정파일은 기존 설정 파일을 삭제하지 않고 오버라이드한다. 그리고 이 설정 파일을 다시 DB로 저장하는 방식을 취하도록 설정하였다.

첫번째로 Prisma 모델을 만든다. 불필요하게 네스팅된 모든 JSON 필드를 모두 DB화 하지 않고 Json타입을 사용한다. 요즘 Prisma는 Json 필드도 필터링 지원하기 때문에, Json 타입을 쓰려고 Document방식의 NoSQL을 따로 설정하여 사용할 필요는 없다. 물론 Document DB방식은 더 많은 막강한 기능을 지원하긴 하지만 여기서 쓸려고 하는건 하나의 Document에 대한 단순 CRUD이기 때문에 Json 필드만으로도 충분할 것 같다.

prisma/schema.prisma
// omited...

/// Aministrator Schema
model AdminSchema {
  /// ID
  id        Int      @id @default(autoincrement())
  /// createdAt
  createdAt DateTime @default(now())
  /// updatedAt
  updatedAt DateTime @updatedAt
  /// Schema
  schema    Json
}

GraphQL 스키마를 만들기 앞서 nexus 스키마를 조건별로 수행할 수 있도록 환경변수 조건을 만든다. GENERATE_ADMINtrue 일때만 이 어드민 쿼리들을 생성하도록 한다.

src/schema/index.ts
const option: SchemaConfig = {
  types,
  shouldGenerateArtifacts: true,
  plugins: [
    // omitted...
    paljs({
      excludeScalar: ["BigInt"],
      includeAdmin: process.env.GENERATE_ADMIN === "true",
    }),
    // omitted...
  ]
  // omitted...
}

이렇게 설정하면, GENERATE_ADMINtrue로 설정할 때, adminSettings.json 파일을 생성하고 그 외엔 DB에 이있는 데이터를 활용하도록 구성한다.

앞서 정해진 GraphQL 스키마를 만들도록 한다.

src/schema/admin.ts
import { enumType, objectType, queryField } from "nexus";
import { NexusGenObjects } from "src/generated/resolverTypes";
import adminSettings from "../../../../../adminSettings.json";
export const getSchema = queryField("getSchema", {
  type: "Schema",
  async resolve(_root, _args, { prisma }) {
    const recentSchema = await prisma.adminSchema.findFirst({
      orderBy: { createdAt: "desc" },
    });
    if (!recentSchema) return adminSettings as NexusGenObjects["Schema"];
    const { schema } = recentSchema;
    return schema as NexusGenObjects["Schema"];
  },
});

export const Schema = objectType({
  name: "Schema",
  definition(t) {
    t.nonNull.list.nonNull.field("enums", { type: SchemaEnum });
    t.nonNull.list.nonNull.field("models", { type: SchemaModel });
  },
});

export const SchemaEnum = objectType({
  name: "Enum",
  definition(t) {
    t.nonNull.list.nonNull.string("fields");
    t.nonNull.string("name");
  },
});

export const SchemaKindEnum = enumType({
  name: "KindEnum",
  members: ["enum", "object", "scalar"],
});

export const SchemaField = objectType({
  name: "Field",
  definition(t) {
    t.nonNull.boolean("create");
    t.nonNull.boolean("editor");
    t.nonNull.boolean("filter");
    t.nonNull.string("id");
    t.nonNull.boolean("isId");
    t.nonNull.field("kind", { type: SchemaKindEnum });
    t.nonNull.boolean("list");
    t.nonNull.string("name");
    t.nonNull.int("order");
    t.nonNull.boolean("read");
    t.boolean("relationField");
    t.nonNull.boolean("required");
    t.nonNull.boolean("sort");
    t.nonNull.string("title");
    t.nonNull.string("type");
    t.nonNull.boolean("unique");
    t.nonNull.boolean("update");
    t.nonNull.boolean("upload");
  },
});

export const SchemaModel = objectType({
  name: "Model",
  definition(t) {
    t.nonNull.boolean("create");
    t.nonNull.boolean("delete");
    t.nonNull.list.nonNull.string("displayFields");
    t.nonNull.list.nonNull.field("fields", { type: SchemaField });
    t.nonNull.string("id");
    t.nonNull.string("idField");
    t.nonNull.string("name");
    t.nonNull.boolean("update");
  },
});

Query에서 쓰일 타입들을 모두 만들었다. 이제 제너레이트를 위해 스키마를 실행시켜 json파일을 만들도록 한다. 이 스키마를 실행하기 전에 로컬 테스트를 위해 만든 seedcommand로 구조화 하였다. 전체 seed가 필요한 경우(migration을 모두 날리고 다시 migration 하는 경우에 대비) 전체 seed를 수행하며, 특정 테이블의 seed가 필요한 경우 --name="{tableName}" 방식으로 seed 하도록 구성하였다.

prisma/seed/index.ts
import { Command } from "commander";

const program = new Command();

program
  .version("0.0.1")
  .description("Prisma seed local DB, default: seeding all data")
  .option("-n, --name <name>", "Seed selected table name")
  .option("-l, --list", "List seed table name")
  .option("-a, --all", "Seed all")
  .parse();

const opts = program.opts();

type SeedProps = { name?: string; list?: boolean; all?: boolean };

async function seeding({ name, list, all }: SeedProps) {
  const seed = await import("./data");
  const seedTableKeys = Object.keys(seed);
  type SeedKeys = keyof typeof seed;
  if (list) {
    console.log("== list of seed table ==");
    console.log(seedTableKeys);
    return;
  }
  if (name) {
    const { PrismaClient } = await import("@prisma/client");
    const prisma = new PrismaClient({ log: ["query", "error"] });
    if (!seedTableKeys.includes(name)) {
      throw new Error(
        `There is no seed table name such as '${name}'. Check seed name by using "seed --list"`,
      );
    }
    console.log(`== seeding ${name} table ==`);
    const result = await seed[name as SeedKeys](prisma).finally(async () => {
      await prisma.$disconnect();
    });
    console.log(result);
    return;
  }
  console.log("== seeding all data ==");
  const { PrismaClient } = await import("@prisma/client");
  const prisma = new PrismaClient({ log: ["query", "error"] });
  for await (const tableKey of seedTableKeys) {
    const result = await seed[tableKey as SeedKeys](prisma);
    console.log(result);
  }
  await prisma.$disconnect();
  return;
}

seeding(opts);

이렇게 seed할 커맨드를 만들면, 서브 폴더 data에 named export한 함수들의 이름이 정확해야한다. 함수의 형태는 prisma 클라이언트를 인자로 받고, 리턴값은 무엇이든 상관없다. 여기서 추가된 AdminSchema라는 테이블도 동일하게 seed하도록 한다. paljs가 생성한 adminSettings를 DB에 인서트하기 위함이다.

prisma/seed/data/AdminSchema.ts
import { PrismaClient } from "@prisma/client";
import schema from "../../../adminSettings.json";

export async function AdminSchema(prisma: PrismaClient) {
  const result = await prisma.adminSchema.create({
    data: { schema },
  });
  return result;
}

이제 DB에서 FileSync로 옮기기 위한 작업은 아래와 같다.

src/libs/getCurrentAdminSettings.ts
import { PrismaClient } from "@prisma/client";
import prisma from "./prisma/client";
import fs from "fs";
import path from "path";

async function getCurrentAdminSettings(prisma: PrismaClient) {
  const adminSettings = await prisma.adminSchema.findFirst({
    orderBy: { createdAt: "desc" },
  });
  if (!adminSettings) {
    console.log("No adminSettings in DB. skipped.");
    return;
  }
  fs.writeFileSync(
    path.resolve(__dirname, "../../adminSettings.json"),
    JSON.stringify(adminSettings.schema, null, 2),
  );
}

getCurrentAdminSettings(prisma);

그리고 스키마 제너레이트를 할 때 환경변수값을 넣어주도록 package.json 설정을 한다.

package.json
{
  "scripts": {
    // omitted...
    "get:current:schema:admin": "dotenv -e .env -- ts-node --transpile-only src/libs/getCurrentAdminSettings.ts",
    "generate:schema:admin": "GENERATE_ADMIN=true dotenv -e .env -- ts-node --transpile-only src/schema/index.ts && yarn seed --name=AdminSchema"
    // omitted...
  }
}

adminSettings.json 파일은 git에서도 관리하기 때문에 개발자들은 이 버전 상태는 계속 유지 된다. 신규로 설정될 파일들은 DB에서 가져와야 한다. 즉, remote DB에서 가져오는 절차도 추가해야할 것이지만, 배포시점에 리모트의 상태는 업데이트 될 것이라 나중에 이 변경된 json 파일을 CD에서 커밋할 수 있도록 설정해야한다.

이제 이 파일은 로컬에 존재하며 로컬 DB에 없다면 업데이트가 없을 것이고, generate 할 때 확인하고 DB로부터 업데이트 되도록 스크립트를 설정한다.

package.json
{
  "scripts": {
    // omitted...
    "pregenerate": "npm -s run get:current:schema:admin",
    // omitted...
  }
}

generate를 마치면 이제 QuerygetSchema는 DB에 저장된 데이터로 응답이 갈 것이다.

Mutation

이제 어드민 설정이 바뀔 때 DB값이 업데이트 되도록 Mutation을 만들기 전에 데이터를 쓰고 업데이트할 lowdb adapter를 만들어 준다. lowdbCustomAsyncAdapter 클래스를 만드는데 이 클래스를 low adapter에 적용하면, read(), write() 메서드만 사용할 수 밖에 없다. 기본적으로 FileSyncJsonSync등 각종 싱크는 lowdb에서 제공해주기 때문에 get(), find() 메서드가 담기지만 여기서는 그렇지 않다. 따라서 lodashchain 메서드를 쓸 수 있도록 Low 클래스를 extends하여 chain 메서드를 추가해준다.

schema/admin/adapter.ts
import { Low } from "lowdb";
import { NexusGenObjects } from "src/generated/resolverTypes";
import { PrismaClient } from "@prisma/client";
import { chain, ExpChain } from "lodash";

export class CustomAsyncAdapter {
  constructor(prisma: PrismaClient) {
    this.prisma = prisma;
  }
  prisma: PrismaClient;
  async read() {
    const currentSchema = await this.prisma.adminSchema.findFirst({
      orderBy: { createdAt: "desc" },
    });
    if (!currentSchema) throw new Error("No schema found in DB");
    return currentSchema.schema as NexusGenObjects["Schema"];
  }
  async write(schema: NexusGenObjects["Schema"]) {
    await this.prisma.adminSchema.create({ data: { schema } });
  }
}

export class LowWithLodash<T> extends Low<T> {
  chain: ExpChain<this["data"]> = chain(this).get("data");
}

TypeScript에서 위의 코드는 오류가 발생한다. Error [ERR_REQUIRE_ESM]: require() of ES Module 그 이유는 lowdb가 ESM 패키지이기 때문에 발생한다. commonjs의 require()를 더이상 지원하지 않기 때문이다. TypeScript는 transpile한 결과를 보면, require()방식으로 변환하기 때문에 오류가 발생한다. TypeScript 버전이 올라가면 이런 ESM 모듈이 앞으로는 추세이기 때문에 알아서 변환해주면 좋겠지만, 아직은 안된다. 따라서 ESM 모듈을 쓰기위해 babel을 이래저래 설정하면 되겠지만, TypeScript에서는 불편하지만 dynamicImport와 같은 패키지를 사용해서 import해야 한다. 다음과 같이 async 함수에서 사용하고 promise로 반환해야한다.

const { Low } = (await dynamicImport("lowdb", module)) as typeof import("lowdb");

그래서 수정된 코드는 아래와 같다.

schema/admin/adapter.ts
import { dynamicImport } from "tsimportlib";
import { NexusGenObjects } from "src/generated/resolverTypes";
import { PrismaClient } from "@prisma/client";
import { chain, ExpChain } from "lodash";

export class CustomAsyncAdapter<T> {
  constructor(prisma: PrismaClient) {
    this.prisma = prisma;
  }
  prisma: PrismaClient;
  async read() {
    const currentSchema = await this.prisma.adminSchema.findFirst({
      orderBy: { createdAt: "desc" },
    });
    if (!currentSchema) throw new Error("No schema found in DB");
    return currentSchema.schema as T;
  }
  async write(schema: T) {
    await this.prisma.adminSchema.create({ data: { schema } });
  }
}

export async function getLowWithLodash() {
  const { Low } = (await dynamicImport(
    "lowdb",
    module,
  )) as typeof import("lowdb");
  return class LowWithLodash<T> extends Low<T> {
    chain: ExpChain<this["data"]> = chain(this).get("data");
  };
}

이제 Field를 업데이트 하는 Mutation을 만들면 다음과 같다. getLowWithLodash는 Promise 반환값이 class LowWithLodash이다.

schema/admin/index.ts
import {
  mutationField,
  stringArg,
  nonNull,
} from "nexus";
import { CustomAsyncAdapter, getLowWithLodash } from "./adapter";

export const updateField = mutationField("updateField", {
  type: "Field",
  args: {
    data: UpdateFieldInput,
    id: nonNull(stringArg()),
    modelId: nonNull(stringArg()),
  },
  async resolve(_root, { data, id, modelId }, { prisma }) {
    const adapter = new CustomAsyncAdapter<NexusGenObjects["Schema"]>(prisma);
    const LowWithLodash = await getLowWithLodash();
    const db = new LowWithLodash(adapter);
    await db.read();
    const result = db.chain
      .get("models")
      .find({ id: modelId })
      .get("fields")
      .find({ id })
      .assign(data)
      .value();
    await db.write();
    return result;
  },
});

코드 그대로 db.chain에는 prisma 클라이언트가 담겼고, modelId를 찾고 fieldsid를 찾아 데이터를 업데이트하고 CustomAsyncAdapter로 생성한 write 메서드를 호출하여 DB생성을 한다. 여기서는 변경내역을 남기기 위해 prisma.adminSchema.create() 메서드를 사용했다. 대신 최신의 설정값을 받아오기 위해 findFirst({ orderBy: { createdAt: "desc" }})로 데이터를 받아온다.

맞다 위의 코드에서 inputObjectType도 만들어주어야 한다.

schema/admin/inputType.ts
import { inputObjectType } from "nexus";

export const UpdateFieldInput = inputObjectType({
  name: "UpdateFieldInput",
  definition(t) {
    t.boolean("create");
    t.boolean("editor");
    t.boolean("filter");
    t.string("id");
    t.boolean("isId");
    t.field("kind", { type: "KindEnum" });
    t.boolean("list");
    t.string("name");
    t.int("order");
    t.boolean("read");
    t.boolean("relationField");
    t.boolean("required");
    t.boolean("sort");
    t.string("title");
    t.string("type");
    t.boolean("unique");
    t.boolean("update");
  },
});

이제 남은건 Model 업데이트하는 inputObjectTypeMutation 타입을 만들어준다.

schema/admin/inputType.ts
export const UpdateModelInput = inputObjectType({
  name: "UpdateModelInput",
  definition(t) {
    t.boolean("create");
    t.boolean("delete");
    t.list.string("displayFields");
    t.list.field("fields", { type: UpdateFieldInput });
    t.string("idField");
    t.string("name");
    t.boolean("update");
  },
});

Model 업데이트 MutationField와 거의 동일하다. 유의할 점은 CustomAsyncAdapter에서 만들어준 read(), write()async 함수를 꼭 실행해야 읽어오고 데이터쓰기가 가능하다.

schema/admin/index.ts
export const updateModel = mutationField("updateModel", {
  type: "Model",
  args: { data: UpdateModelInput, id: nonNull(stringArg()) },
  async resolve(_root, { data, id }, { prisma }) {
    const adapter = new CustomAsyncAdapter<NexusGenObjects["Schema"]>(prisma);
    const LowWithLodash = await getLowWithLodash();
    const db = new LowWithLodash(adapter);
    await db.read();
    const result = db.chain.get("models").find({ id }).assign(data).value();
    await db.write();
    return result;
  },
});

이제 어드민을 실행하고, 설정을 변경할 때 마다, 데이터가 생성되는 것을 볼 수 있다. 이 create 방식이 좀 그렇다면, 최근 데이터의 키를 받아 update 방식으로 수정해도 된다. 그리고 seed 할 땐 생성하도록하고...

다음 주는 미뤄놓은 SSR 방식을 다시 점검해서 정리하려고 한다.