Published on

tWIL 2022.08 4주차

Authors

수요일 저녁에 라이딩을 했다. 18키로 라이딩 했다고 몸의 면역력이 떨어진 40대. 한강변 자전거 도로는 아직도 공사중인 곳이 있다. 한강변을 달릴 땐 기분이 매우 좋다. 경관이 좋기도 하고 뻥뚫려 있어서 시원하다. 주말엔 여의도까지 갈 수 있을지 tWIL쓰고 다녀올 기세.

지난주 tWIL에서 Notion API로 Vercel과 AWS Amplify 모두 배포해보고 얻은 결과는 Vercel은 컨텐츠가 즉각 변경되는 점에 비해 Amplify는 5-10분 정도 걸린다.(Update 2022.09.02 더 걸린다. 20분 이상. SSR에 문제가 있다.) API를 캐싱해두고 작동하는 것 같은데 이려러고 돈내고 쓰는게 아닐텐데 어디 설정도 찾기 어렵다. 그렇다고 Vercel이 전세계 CDN에 배포가 안되는 것도 아니고 너무 비교가 된다. 빨리 Amplify를 벗어나고 싶다.

Container components to Hooks

React 컴포넌트 Container, Presenter 패턴으로 개발하다 보면 Presenter 스타일이나 스타일드 컴포넌트드은 Styles.tsx를 만들어 사용한다. 그리고 Container에서 type validator나 커스텀 패턴을 만들기 위한 Types.tsx를 만들고 여기에 Presenter의 PropTypes를 만들어 낸다. 이런 컴포넌트들은 특정 폴더로 구분하고 Container는 index.tsx를 사용한다. 폴더 내 package.json을 만들어 main필드에 index.tsx를 두기도 한다. 이런 패턴으로 개발해온지 오래되었는데, graphql 오퍼레이션들도 같은 폴더에 위치하면 정리가 잘되는 편이였다. 그러나 Container 사이즈가 커지는 경우 대비가 잘 안되어있었는데 난 개발팀원들에게 코드라인은 120줄을 넘지 않도록, 넘더라도 160줄까진 봐주겠다고 했다. 이러한 가이드를 제시한 이유는 컨플릭이 발생했을 경우 resolve하기 어렵기 때문이다. 그리고 개발자들에겐 코드를 최적화 하기 마련이다. Container의 경우는 각종 useEffect, useCallback 그리고 핸들러들이 무수히 많아질 수 밖에 없다. 여기에 한 패턴을 더 추가하여 custom hook을 만드는 방법으로 Container 사이즈를 줄이려고 한다.

여러 state들이 혼재하고 핸들러들이 혼재할 때 대부분 핸들러들이나 함수들은 클로저로 state변수들을 담는다. 예를들어 파일업로드 하는 컨테이너가 있다고 하자. Presenter 컴포넌트는 input에 업로드 button이 달려있고, 여기에 파일 업로드 핸들러 및 파일 업로드 Mutation 함수 그리고 드래그앤드롭, 파일 업로드 진행상태 Progress bar 까지 구현한다고 가정하면, 아래와 같은 크나 큰 컨테이너가 만들어진다. 이보다 더 커지는 컨테이너 예제도 있을 수 있지만 여기를 리팩토링할 수 있다면 다른 곳도 정리가 쉬울 것 같다.

UploadContainer.tsx
import React from "react";
import { useDropzone } from "react-dropzone";
import {
  useAppSelector,
  useAppDispatch,
  uploadSingleComplete,
  designDocumentsUpload,
} from "store";
import {
  useUploadOneFileMutation,
  UploadStatus,
  DocumentType,
} from "generated/types";
import { useSnackbar } from "notistack";
import { getFileTitle } from "utils";
import { Presenter } from "./Presenter";

export function UploadContainer() {
  const [progress, setProgress] = React.useState(0);
  const [uploadedFilename, setUploadedFilename] = React.useState("");
  const [uploadedAt, setUploadedAt] = React.useState<string | null>(null);
  const {
    designDocuments: { [designKey]: designFile },
    id,
  } = useAppSelector((state) => state.project);
  const [fileCount, setFileCount] = React.useState(0);
  const authState = useAppSelector((state) => state.auth);
  const dispatch = useAppDispatch();
  const ref = React.useRef<HTMLInputElement>(null);
  const { getRootProps, isDragActive } = useDropzone({
    onDrop: (files) => {
      if (!files) return;
      const file = files[0];
      setFileCount(files.length);
      if (designKey === DocumentType.Etc && files.length > 1) {
        return;
      }
      uploadFile(file, designKey);
    },
  });
  const { enqueueSnackbar } = useSnackbar();
  const uploadFile = (file: File, designKey: DocumentType) => {
    const uploadUrl = `https://some-bucket.s3.amazonaws.com/myfile/${id}`;
    dispatch(
      designDocumentsUpload({
        file,
        designKey,
        progressCallback(progress) {
          setProgress((progress.loaded / progress.total) * 100);
        },
        projectId: id,
      }),
    ).then(() => {
      uploadSingleFileUpdate(
        id,
        designKey,
        `${uploadUrl}/${file.name}`,
        file.name,
      );
    });
  };
  const handleUpload =
    (key: DocumentType) =>
    async (event: React.ChangeEvent<HTMLInputElement>) => {
      const {
        target: { files },
      } = event;
      if (!files) return;
      const file = files[0];
      uploadFile(file, key);
    };
  const handleClick = React.useCallback(() => {
    ref.current?.click();
  }, [ref]);
  const [uploadOne] = useUploadOneFileMutation();
  const uploadSingleFileUpdate = React.useCallback(
    (
      projectId: number,
      documentType: DocumentType,
      url: string,
      filename: string,
    ) => {
      uploadOne({
        variables: {
          data: {
            url,
            filename,
            documentType,
            status: UploadStatus.Success,
            project: { connect: { id: projectId } },
          },
          updateManyFileData: { status: { set: UploadStatus.Failed } },
          updateManyFileWhere: {
            projectId: { equals: projectId },
            documentType: { equals: documentType },
          },
        },
      }).then((data) => {
        dispatch(uploadSingleComplete(documentType));
        const filename = data.data?.createOneFile.filename;
        const createdAt = data.data?.createOneFile.createdAt;
        enqueueSnackbar(
          `${getFileTitle(designKey)} 파일이 "업로드" 되었습니다.`,
          { variant: "success" },
        );
        setUploadedFilename(filename || "");
        setUploadedAt(createdAt);
      });
    },
    [designKey, dispatch, enqueueSnackbar, replacement, uploadOne],
  );
  return (
    <Presenter
        {...{
          handleUpload,
          handleClick,
          isDragActive,
          getRootProps,
          progress,
          ref,
          file: designFile,
          fileCount,
        }}
      />
  );
}

Presenter는 위에서 내려받은 props로 잘 만들면 되고, 여기서는 컨테이너 정리로 리팩토링 한다고 하면, 일단 하나의 큰 hook을 뽑아낸다. 말이 어렵지 Presenter return을 제외한 모든 코드를 useUpload.ts라는 파일을 만들어 빼낸다.

useUpload.ts
import React from "react";
import { useDropzone } from "react-dropzone";
import {
useAppSelector,
useAppDispatch,
uploadSingleComplete,
designDocumentsUpload,
} from "store";
import {
useUploadOneFileMutation,
UploadStatus,
DocumentType,
} from "generated/types";
import { useSnackbar } from "notistack";
import { getFileTitle } from "utils";
import { Presenter } from "./Presenter";

export function useUpload(designKey: DocumentType, replacement?: boolean) {
  const [progress, setProgress] = React.useState(0);
  const [uploadedFilename, setUploadedFilename] = React.useState("");
  const [uploadedAt, setUploadedAt] = React.useState<string | null>(null);
  const {
    designDocuments: { [designKey]: designFile },
    id,
  } = useAppSelector((state) => state.project);
  const [fileCount, setFileCount] = React.useState(0);
  const authState = useAppSelector((state) => state.auth);
  const dispatch = useAppDispatch();
  const ref = React.useRef<HTMLInputElement>(null);
  const { getRootProps, isDragActive } = useDropzone({
    onDrop: (files) => {
      if (!files) return;
      const file = files[0];
      setFileCount(files.length);
      if (designKey === DocumentType.Etc && files.length > 1) {
        return;
      }
      uploadFile(file, designKey);
    },
  });
  const { enqueueSnackbar } = useSnackbar();
  const uploadFile = (file: File, designKey: DocumentType) => {
    const uploadUrl = `https://some-bucket.s3.amazonaws.com/myfile/${id}`;
    dispatch(
      designDocumentsUpload({
        file,
        designKey,
        progressCallback(progress) {
          setProgress((progress.loaded / progress.total) * 100);
        },
        projectId: id,
      }),
    ).then(() => {
      uploadSingleFileUpdate(
        id,
        designKey,
        `${uploadUrl}/${file.name}`,
        file.name,
      );
    });
  };
  const handleUpload =
    (key: DocumentType) =>
    async (event: React.ChangeEvent<HTMLInputElement>) => {
      const {
        target: { files },
      } = event;
      if (!files) return;
      const file = files[0];
      uploadFile(file, key);
    };
  const handleClick = React.useCallback(() => {
    ref.current?.click();
  }, [ref]);
  const [uploadOne] = useUploadOneFileMutation();
  const uploadSingleFileUpdate = React.useCallback(
    (
      projectId: number,
      documentType: DocumentType,
      url: string,
      filename: string,
    ) => {
      uploadOne({
        variables: {
          data: {
            url,
            filename,
            documentType,
            status: UploadStatus.Success,
            project: { connect: { id: projectId } },
          },
          updateManyFileData: { status: { set: UploadStatus.Failed } },
          updateManyFileWhere: {
            projectId: { equals: projectId },
            documentType: { equals: documentType },
          },
        },
      }).then((data) => {
        dispatch(uploadSingleComplete(documentType));
        const filename = data.data?.createOneFile.filename;
        const createdAt = data.data?.createOneFile.createdAt;
        enqueueSnackbar(
          `${getFileTitle(designKey)} 파일이 "업로드" 되었습니다.`,
          { variant: "success" },
        );
        setUploadedFilename(filename || "");
        setUploadedAt(createdAt);
      });
    },
    [designKey, dispatch, enqueueSnackbar, replacement, uploadOne],
  );
  return {
    uploadSingleFileUpdate,
    handleUpload,
    handleClick,
    uploadFile,
    isDragActive,
    getRootProps,
    progress,
    ref,
    file: designFile,
    uploadedFilename,
    uploadedAt,
    fileCount,
  };

이렇게 hooks을 빼어놓으면 컨테이너는 너무 간단해진다.

UploadContainer.tsx
import React from "react";
import { useUpload } from "./useUpload";

export function UploadContainer() {
  const {
    handleUpload,
    handleClick,
    isDragActive,
    getRootProps,
    progress,
    ref,
    file: designFile,
    fileCount,
  } = useUpload();
  return (
    <Presenter
        {...{
          handleUpload,
          handleClick,
          isDragActive,
          getRootProps,
          progress,
          ref,
          file: designFile,
          fileCount,
        }}
      />
  );
}

이제 이 hook을 맥락에 맞게 여러개의 hook으로 수정한다. 커플링된 함수들은 디커플링을 하고, state변경에 의해 함수가 변경이 예정되어 있는 함수들은 arguments로 이 state를 받도록 한다. state를 변경시키는 함수는 별도로 함수로 떼어낸다. 여기서는 handleUploadhandleClick은 다른 맥락으로 동작하기 때문에 분리한다. handleClickisDragActive 그리고 ref는 같은 맥락으로 동작하기 때문에 합쳐서 다른 hook으로 만들어 준다. 여기서는 hooks 안에서 사용하도록 한다.

useUploadDnD.ts
import React from "react";
import { useDropzone } from "react-dropzone";
import { DocumentType } from "generated/types";

interface UploadDnDHookProps {
  uploadFile: (file: File, designKey: DocumentType) => void;
  designKey: DocumentType;
}

export function useUploadDnD(hookProps: UploadDnDHookProps) {
  const { uploadFile, designKey } = hookProps;
  const [fileCount, setFileCount] = React.useState(0);
  const ref = React.useRef<HTMLInputElement>(null);
  const { getRootProps, isDragActive } = useDropzone({
    onDrop: (files) => {
      if (!files) return;
      const file = files[0];
      setFileCount(files.length);
      if (designKey === DocumentType.Etc && files.length > 1) {
        return;
      }
      uploadFile(file, designKey);
    },
  });

  const handleClick = React.useCallback(() => {
    ref.current?.click();
  }, [ref]);

  return {
    handleClick,
    isDragActive,
    getRootProps,
    ref,
    fileCount,
  };
}

useUploadDnD() hook을 분리하고 useUpload() hook을 정리하면

useUpload.ts
import React from "react";
import {
  useAppSelector,
  useAppDispatch,
  uploadSingleComplete,
  designDocumentsUpload,
} from "Store";
import {
  useUploadOneBlueprintMutation,
  UploadStatus,
  DocumentType,
} from "generated/types";
import { useSnackbar } from "notistack";
import { getBlueprintTitle } from "utils";
import { useUploadDnD } from "./useUploadDnD";

export function useUpload(designKey: DocumentType, replacement?: boolean) {
  const [progress, setProgress] = React.useState(0);
  const [uploadedFilename, setUploadedFilename] = React.useState("");
  const [uploadedAt, setUploadedAt] = React.useState<string | null>(null);
  const {
    designDocuments: { [designKey]: designFile },
    id,
  } = useAppSelector((state) => state.project);
  const authState = useAppSelector((state) => state.auth);
  const dispatch = useAppDispatch();
  const { enqueueSnackbar } = useSnackbar();
  const uploadFile = (file: File, designKey: DocumentType) => {
    const uploadUrl = `https://some-bucket.s3.amazonaws.com/myfile/${id}`;
    dispatch(
      designDocumentsUpload({
        file,
        designKey,
        progressCallback(progress) {
          setProgress((progress.loaded / progress.total) * 100);
        },
        projectId: id,
      }),
    ).then(() => {
      uploadSingleFileUpdate(
        id,
        designKey,
        `${uploadUrl}/${file.name}`,
        file.name,
      );
    });
  };
  const { handleClick, isDragActive, getRootProps, ref, fileCount } =
    useUploadDnD({ uploadFile, designKey });
  const handleUpload =
    (key: DocumentType) =>
    async (event: React.ChangeEvent<HTMLInputElement>) => {
      const {
        target: { files },
      } = event;
      if (!files) return;
      const file = files[0];
      uploadFile(file, key);
    };
  const [uploadOne] = useUploadOneBlueprintMutation();
  const uploadSingleFileUpdate = React.useCallback(
    (
      projectId: number,
      documentType: DocumentType,
      url: string,
      filename: string,
    ) => {
      uploadOne({
        variables: {
          data: {
            url,
            filename,
            documentType,
            status: UploadStatus.Success,
            project: { connect: { id: projectId } },
          },
          updateManyBlueprintData: { status: { set: UploadStatus.Failed } },
          updateManyBlueprintWhere: {
            projectId: { equals: projectId },
            documentType: { equals: documentType },
          },
        },
      }).then((data) => {
        dispatch(uploadSingleComplete(documentType));
        const filename = data.data?.createOneBlueprint.filename;
        const createdAt = data.data?.createOneBlueprint.createdAt;
        enqueueSnackbar(
          `${getBlueprintTitle(designKey)} 파일이 ${
            replacement ? "변경" : "업로드"
          }되었습니다.`,
          { variant: "success" },
        );

        setUploadedFilename(filename || "");
        setUploadedAt(createdAt);
      });
    },
    [designKey, dispatch, enqueueSnackbar, replacement, uploadOne],
  );
  return {
    uploadSingleFileUpdate,
    handleUpload,
    handleClick,
    uploadFile,
    isDragActive,
    getRootProps,
    progress,
    ref,
    file: designFile,
    uploadedFilename,
    uploadedAt,
    fileCount,
  };
}

아직도 길다. uploadFile이란 함수는 uploadSingleFileUpdate이란 함수를 인자로 받아서 수행하도록 하여 함수를 분리시켜서 다른 hook을 만들어주도록 한다.

useUploadFile.ts
import React from "react";
import { useAppSelector, useAppDispatch, designDocumentsUpload } from "Store";
import { DocumentType } from "generated/types";

interface UploadFileHookProps {
  uploadSingleFileUpdate: (
    projectId: number,
    documentType: DocumentType,
    url: string,
    filename: string,
  ) => void;
  progressCallback: (progress: any) => void;
}

export function useUploadFile(hookProps: UploadFileHookProps) {
  const { uploadSingleFileUpdate, progressCallback } = hookProps;
  const authState = useAppSelector((state) => state.auth);
  const { id } = useAppSelector((state) => state.project);
  const dispatch = useAppDispatch();
  const uploadFile = (file: File, designKey: DocumentType) => {
    const uploadUrl = `https://some-bucket.s3.amazonaws.com/myfile/${id}`;
    dispatch(
      designDocumentsUpload({
        file,
        designKey,
        progressCallback,
        projectId: id,
      }),
    ).then(() => {
      uploadSingleFileUpdate(
        id,
        designKey,
        `${uploadUrl}/${file.name}`,
        file.name,
      );
    });
  };
  return { uploadFile };
}

이제 2종류의 hook을 사용하여 useUpload.ts가 정리가 되었다.

useUpload.ts
import React from "react";
import {
  useAppSelector,
  useAppDispatch,
  uploadSingleComplete,
} from "Store";
import {
  useUploadOneBlueprintMutation,
  UploadStatus,
  DocumentType,
} from "generated/types";
import { useSnackbar } from "notistack";
import { getBlueprintTitle } from "utils";
import { useUploadDnD } from "./useUploadDnD";
import { useUploadFile } from "./useUploadFile";

export function useUpload(designKey: DocumentType, replacement?: boolean) {
  const [progress, setProgress] = React.useState(0);
  const [uploadedFilename, setUploadedFilename] = React.useState("");
  const [uploadedAt, setUploadedAt] = React.useState<string | null>(null);
  const {
    designDocuments: { [designKey]: designFile },
  } = useAppSelector((state) => state.project);
  const dispatch = useAppDispatch();
  const { enqueueSnackbar } = useSnackbar();
  const progressCallback = (progress: any) => {
    setProgress((progress.loaded / progress.total) * 100);
  };
  const [uploadOne] = useUploadOneBlueprintMutation();
  const uploadSingleFileUpdate = React.useCallback(
    (
      projectId: number,
      documentType: DocumentType,
      url: string,
      filename: string,
    ) => {
      uploadOne({
        variables: {
          data: {
            url,
            filename,
            documentType,
            status: UploadStatus.Success,
            project: { connect: { id: projectId } },
          },
          updateManyBlueprintData: { status: { set: UploadStatus.Failed } },
          updateManyBlueprintWhere: {
            projectId: { equals: projectId },
            documentType: { equals: documentType },
          },
        },
      }).then((data) => {
        dispatch(uploadSingleComplete(documentType));
        const filename = data.data?.createOneBlueprint.filename;
        const createdAt = data.data?.createOneBlueprint.createdAt;
        enqueueSnackbar(
          `${getBlueprintTitle(designKey)} 파일이 ${
            replacement ? "변경" : "업로드"
          }되었습니다.`,
          { variant: "success" },
        );

        setUploadedFilename(filename || "");
        setUploadedAt(createdAt);
      });
    },
    [designKey, dispatch, enqueueSnackbar, replacement, uploadOne],
  );
    const { uploadFile } = useUploadFile({
      uploadSingleFileUpdate,
      progressCallback,
    });
  const { handleClick, isDragActive, getRootProps, ref, fileCount } =
    useUploadDnD({ uploadFile, designKey });
  const handleUpload =
    (key: DocumentType) =>
    async (event: React.ChangeEvent<HTMLInputElement>) => {
      const {
        target: { files },
      } = event;
      if (!files) return;
      const file = files[0];
      uploadFile(file, key);
    };
  return {
    uploadSingleFileUpdate,
    handleUpload,
    handleClick,
    uploadFile,
    isDragActive,
    getRootProps,
    progress,
    ref,
    file: designFile,
    uploadedFilename,
    uploadedAt,
    fileCount,
  };
}

개발자 생각에 여전히 길다면, 더 스플릿하면 된다. 더 쪼갤 수 없을 때까지 쪼갤 필요는 없을 것 같다. 디버그가 가능해야하고 아주 잘게 쪼개어도 import은 공통적으로 계속 사용하기 때문에, 전체 코드량은 늘어난다.

반면에 Container는 간단해진다. 이제 업로드를 제외한 다른 비즈니스 로직을 추가하면 된다. 복잡도는 hook으로 옮겨갔고, 나중에 이부분을 구조화 하면 될 것이다. 그리고 Container에 담긴 코드를 재사용이 매우 어려운데 비해 hooks로 분리해 두면 다른 곳에 업로드 관련 컨테이너가 필요하다면, 바로 사용이 가능해진다.

Nextjs SSR with GraphQL-codegen

지난 paljs-frontend 프로젝트에서 이어서 진행하려고 한다. paljs/admin은 멋진 PrismaTable 컴포넌트를 제공한다. 하지만 커스터마이징의 어려움이 있다. 오픈소스이기 때문에 이 프로젝트를 붙여서 커스터마이징이 가능하지만, 간편하게 쓸 수 있는 이 테이블은 그대로 두고 별도로 테이블을 만들어 커스터마이징 하는 편이 좋을 것 같다. 여기의 디자인 패턴에 따라 query를 활용하여, SSR 페이지를 만들어보자. 우선 GraphQL-Codegen 셋팅을 한다.

GraphQL-codegen

Nextjs SSR page generator를 코드제너레이터에 추가할 생각인데, hook을 함께 사용하려고 한다. Pagination은 SSR 페이지로 쿼리하고, 옵션 및 데이터 수정은 hook을 쓰도록 하려면 2곳의 자동 생성된 코드가 필요하다. hook 이 있는 위치인 generated/types.ts 그리고 SSR페이지가 있는 generated/pages.tsx을 생성하도록 codegen.yml파일을 만든다.

codegen.yml
overwrite: true
schema: ${SCHEMA_PATH}
documents:
  - "src/**/*.graphql"
  - "!src/graphql/**/*.graphql"
  src/generated/types.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo
    config:
      reactApolloVersion: 3
      withHooks: true
      apolloReactComponentsImportFrom: "@apollo/client/react/components"
      apolloReactHocImportFrom: "@apollo/client/react/hoc"
      apolloReactHooksImportFrom: "@apollo/client"
      declarationKind:
        type: interface
        input: interface
        maybeValue: T
  src/generated/page.tsx:
    config:
      documentMode: external
      importDocumentNodeExternallyFrom: ./types
      reactApolloVersion: 3
      withHooks: true
      contextType: ApolloClientContext
      contextTypeRequired: true
      apolloClientInstanceImport: ../withApollo
    preset: import-types
    presetConfig:
      typesPath: ./types
    plugins:
      - graphql-codegen-apollo-next-ssr

GraphQL-codegen과 플러그인들을 설치

yarn add -D @graphql-codegen/cli \
  @graphql-codegen/fragment-matcher \
  @graphql-codegen/import-types-preset \
  @graphql-codegen/introspection \
  @graphql-codegen/typescript \
  @graphql-codegen/typescript-document-nodes \
  @graphql-codegen/typescript-operations \
  @graphql-codegen/typescript-react-apollo \
  graphql-codegen-apollo-next-ssr

package.json에는 생성 스크립트를 넣어준다.

package.json
{
  "scripts": {
    "generate:schema": "rover graph introspect http://localhost:8000 | awk 1 > ./src/generated/schema@dev.graphql",
    "generate:types": "SCHEMA_PATH=src/generated/schema@dev.graphql graphql-codegen --config codegen.yml",
    "postgenerate:types": "prettier --write src/generated/**/*.*",
    "generate": "npm -s run generate:schema && npm -s run generate:types"
  }
}
  • generate:schema는 API에서 GraphQL schema를 introspect 한다.
  • generate:types는 introspect한 schema로 GraphQL-codegen 을 수행한다.
  • postgenerate:types는 생성된 타입과 페이지를 prettier write 한다.
  • generate는 이 모두를 실행한다.

이제 첫번째 GraphQL operation을 만든다. 맨 처음으로는 Pagination을 하는 Table을 생성할 것이라. findAllPostsPagination.graphql파일을 만들어준다. paljs로 생성한 CRUD는 findManyPostaggregatePost가 있을 것이다.

findAllPostsPagination.graphql
query findAllPostsPagination($take: Int, $skip: Int) {
  findManyPost(take: $take, skip: $skip) {
    id
    title
    content
    author {
      name
      _count {
        posts
      }
    }
    authorId
    referredBlogs
  }
  aggregatePost {
    _count {
      id
    }
  }
}

이제 yarn generate를 실행하면 위의 GraphQL operation을 grab하고 apollo hooks와 각종 타입들은 generated/types.ts에 생성되고, SSR 페이지는 generated/pages.tsx에 생성될 것이다.

다음으로는 withApollo를 만들어준다.

withApollo

withApollowithPage를 제공하며, 이 withPage에는 NextRouter를 넘겨 받는다. 이 query를 가지고 해당 GraphQL query를 수행할 수 있도록 도와준다.

src/withApollo.tsx
import React from "react";
import { NextPage } from "next";
import {
  ApolloClient,
  NormalizedCacheObject,
  InMemoryCache,
  ApolloProvider,
  createHttpLink,
  from,
} from "@apollo/client";
import {
  NextApiRequestCookies,
  // @ts-ignore This path is generated at build time and conflicts otherwise
} from "next-server/server/api-utils";
import { IncomingMessage } from "http";

const uri = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";

export type ApolloClientContext = {
  req?: IncomingMessage & {
    cookies: NextApiRequestCookies;
  };
};

export const withApollo = (Comp: NextPage) => (props: any) => {
  return (
    <ApolloProvider client={getApolloClient(undefined, props.apolloState)}>
      <Comp />
    </ApolloProvider>
  );
};

export const getApolloClient = (
  ctx?: ApolloClientContext,
  initialState?: NormalizedCacheObject,
) => {
  if (ctx && ctx.req) {
    const { req } = ctx;
    req.cookies;
  }

  const httpLink = createHttpLink({
    uri,
    fetch,
  });
  const cache = new InMemoryCache().restore(initialState || {});
  return new ApolloClient({
    link: from([httpLink]),
    cache,
  });
};

여기서는 withApollo를 유심히 보면 된다. generated/pages.tsx에 해당 컴포넌트에 담기는 방식은 아래와 같다.

generated/pages.tsx
export const withPageFindAllPostsPagination =
  (
    optionsFunc?: (
      router: NextRouter,
    ) => QueryHookOptions<
      Types.FindAllPostsPaginationQuery,
      Types.FindAllPostsPaginationQueryVariables
    >,
  ) =>
  (WrappedComponent: PageFindAllPostsPaginationComp): NextPage =>
  (props) => {
    const router = useRouter();
    const options = optionsFunc ? optionsFunc(router) : {};
    const { data, error } = useQuery(
      Operations.FindAllPostsPaginationDocument,
      options,
    );
    return <WrappedComponent {...props} data={data} error={error} />;
  };
export const ssrFindAllPostsPagination = {
  getServerPage: getServerPageFindAllPostsPagination,
  withPage: withPageFindAllPostsPagination,
  usePage: useFindAllPostsPagination,
};

즉, withPageFindAllPostsPagination는 컴포넌트에 useQuery를 자동생성 해주어, 편하게 dataerror를 Props으로 내려 받을 수 있도록 제공한다.

getServerPage를 사용하는 방식은

generated/pages.tsx
export async function getServerPageFindAllPostsPagination(
  options: Omit<
    Apollo.QueryOptions<Types.FindAllPostsPaginationQueryVariables>,
    "query"
  >,
  ctx: ApolloClientContext,
) {
  const apolloClient = getApolloClient(ctx);

  const data = await apolloClient.query<Types.FindAllPostsPaginationQuery>({
    ...options,
    query: Operations.FindAllPostsPaginationDocument,
  });

  const apolloState = apolloClient.cache.extract();

  return {
    props: {
      apolloState: apolloState,
      data: data?.data,
      error: data?.error ?? data?.errors ?? null,
    },
  };
}

여기서 SSR을 제공한다. getApolloClient로 query를 하며, 쿼리 완료된 상태로 서버 Props를 넘겨준다. withPageFindAllPostsPagination 방식에서는 (props) => {} 형태로 함수를 넘겨주기 때문에 렌더링 시점에 데이터 fetching이 수행될 것으로 보이며, getServerPageFindAllPostsPagination방식은 Props를 서버 프랍으로 넘겨주기 때문에, 서버 렌더링 시점에 수행될 것으로 보인다.

withPage방식으로 리스트 컴포넌트를 만들면, 아래처럼 단순하다.

src/pages/listPosts/index.tsx
import React from "react";
import { withApollo } from "withApollo";
import {
  ssrFindAllPostsPagination,
  PageFindAllPostsPaginationComp,
} from "generated/page";
import { HeadlessTable } from "components";
const ListPosts: PageFindAllPostsPaginationComp = (props) => {
  return (
    <HeadlessTable {...props} />
  );
};

export default withApollo(
  ssrFindAllPostsPagination.withPage((arg) => ({
    variables: {
      take: 10,
      skip:
        (arg?.query?.page as unknown as number) == 1
          ? 0
          : ((arg?.query?.page as unknown as number) - 1) * 10,
    },
  }))(ListPosts),
);

이후, getServerPageFindAllPostsPagination는 다음 주에 조금 더 진행해보고 Page 렌더링도 체크해 볼 예정이다.