본문 바로가기

NodeJS

풀스택 틱톡 클론 따라하기 - 2. 프론트엔드 셋업

반응형

https://youtu.be/Xy15S0nQxBQ

 

영상의 40분 30초부터 1시간 12분까지의 내용을 다룬다.

 

목차.

  1. TailwindCSS 셋업
  2. react-router-dom 셋업
  3. 상태관리 Zustand
  4. GraphQL, Apollo 셋업
  5. graphql 코드 제너레이터 셋업

TailwindCSS 셋업

vite로 생성한 프로젝트는 node_modules이 없으므로 npm install을 먼저 해주어야한다.

App.css 삭제, index.css 내용 비우기.

https://tailwindcss.com/docs/guides/vite

Vite에서 Tailwind CSS 사용하기.

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

 

// tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};

 

/* src/index.css */

@tailwind base;
@tailwind components;
@tailwind utilities;

 

vscode 사용 시 @tailwind에 에러가 발생한다면 익스텐션에서 PostCSS Language Support를 설치하면 된다.

참고 - https://velog.io/@jinsunkimdev/React에서-TailwindCSS-사용-시-warning-Unknown-at-rule-tailwind-css

import { useState } from "react";

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <div className="bg-red-500">hello</div>
    </>
  );
}

export default App;

 

vscode에서 리액트에 tailwindcss 자동완성이 안되어서 되도록 설정해려면

익스텐션에서 Tailwind CSS IntelliSense를 설치하고,

ctrl + shift + p로 커맨드 팔레드를 열어서 Open User Setting(Json)을 찾은 후에,

{
    ...

    "files.associations": {
        ...
        "*.css": "tailwindcss"
    },
    "editor.quickSuggestions": {
        "strings": true
    }
    ...
}

 

이렇게 추가하면 된다. 나의 경우 files.associations가 이미 존재하여 ctrl + f로 찾기 후에 css를 추가하고, editor.quickSuggestions는 존재하지않았다.

npm run dev로 로컬서버 실행 후, 글자에 배경색으로 빨갛게 되어있다면 tailwindcss가 잘 적용되었음을 알 수 있다.

src 폴더에 pages 폴더를 생성 후 Feed.tsx 파일을 만든 뒤에, vscode에 리액트 관련 익스텐션이 잘 세팅되어있다면, rfce를 입력하면 리액트 자동완성이 뜬다.

강의 영상에선 Simple React Snippets을 쓰는데, 난 안쓰고 있다.

나는 ES7+ React/Redux/React-Native snippets 이걸 쓰고 있는데 여기서 자동완성 기능을 제공한다.

// src/pages/Feed.tsx

import React from "react";

function Feed() {
  return <div>Feed</div>;
}

export default Feed;

 

우선 기본뼈대로 Feed, Post, Profile, Upload의 tsx 파일을 pages 폴더에 만든다.

react-router-dom 셋업

npm i react-router-dom

 

// src/main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import {
  RouterProvider,
  createBrowserRouter,
} from "react-router-dom";
import Feed from "./pages/Feed.tsx";
import Upload from "./pages/Upload";
import Profile from "./pages/Profile.tsx";
import Post from "./pages/Post.tsx";

const router = createBrowserRouter([
  {
    path: "/feed",
    element: <Feed />,
  },
  {
    path: "/upload",
    element: <Upload />,
  },
  {
    path: "/profile/:id",
    element: <Profile />,
  },
  {
    path: "/post/:id",
    element: <Post />,
  },
]);

ReactDOM.createRoot(
  document.getElementById("root")!
).render(
  <React.StrictMode>
    <RouterProvider router={router} />
    <App />
  </React.StrictMode>
);

 

로컬 서비 실행 후 /upload, /profile/1, /post/1에 들어가 react-router-dom이 잘 세팅되었는지 확인.

상태관리 Zustand

zustand를 잠깐 사용해보았는데, 개인적으론 redux와 recoil보다 훨씬 간단하였다. apollo client의 상태관리, svelte의 상태관리와 함께 사용하기 편하다.

zustand는 리액트의 컨텍스트 api를 기반으로 한다는데, 이게 리덕스와 리코일과 다르게 가지는 장점이라 한다.

npm i zustand

 

// src/stores/generalStore.ts

import { create } from "zustand";
import { persist, devtools } from "zustand/middleware";

// 스토어 상태를 정의하는 인터페이스.
export interface GeneralStore {
  isLoginOpen: boolean;
  isEditProfileOpen: boolean;
  selectedPosts: null;
  ids: null;
  posts: null;
}

// 스토어에서 사용할 동작을 정의하는 인터페이스.
export interface GeneralActions {
  setLoginIsOpen: (isLoginOpen: boolean) => void;
  setIsEditProfileOpen: () => void;
}

// Zustand 스토어를 생성하고 설정.
export const useGeneralStore = create<
  GeneralStore & GeneralActions
>()(
  // 개발자 도구를 사용하기 위한 미들웨어.
  devtools(
    // 로컬 스토리지에 상태를 저장하고 복원하기 위한 미들웨어.
    persist(
      (set) => ({
        isLoginOpen: false,
        isEditProfileOpen: false,
        selectedPosts: null,
        ids: null,
        posts: null,
        setLoginIsOpen: (isLoginOpen) =>
          set({ isLoginOpen }),
        setIsEditProfileOpen: () => {
          return set((state) => ({
            isEditProfileOpen: !state.isEditProfileOpen,
          }));
        },
      }),
      {
        // 로컬 스토리지에 저장될 키 이름.
        name: "general-store",
      }
    )
  )
);

 

// src/stores/userStore.ts

import { create } from "zustand";
import { persist, devtools } from "zustand/middleware";

export interface User {
  id?: string;
  fullname: string;
  email?: string;
  bio?: string;
  image?: string;
}

export interface UserActions {
  setUser: (user: User) => void;
  logout: () => void;
}

export const useUserStore = create<User & UserActions>()(
  devtools(
    persist(
      (set) => ({
        id: "",
        fullname: "",
        email: "",
        bio: "",
        image: "",

        setUser: (user) => set(user),
        logout: () => {
          set({
            id: "",
            fullname: "",
            email: "",
            bio: "",
            image: "",
          });
        },
      }),
      {
        name: "user-storage",
      }
    )
  )
);

 

GraphQL, Apollo 셋업

npm i graphql @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo apollo-upload-client @types/apollo-upload-client @apollo/client

 

@graphql-codegen/cli: GraphQL 코드 생성 도구로, GraphQL 스키마를 기반으로 TypeScript 코드를 자동으로 생성할 수 있게 해줍니다.

@graphql-codegen/typescript: GraphQL 코드 생성 도구를 TypeScript용으로 사용할 수 있게 해주는 플러그인입니다.

@graphql-codegen/typescript-operations: GraphQL 연산과 쿼리를 TypeScript 코드로 생성하는 데 사용되는 플러그인입니다.

@graphql-codegen/typescript-react-apollo: Apollo Client와 함께 사용하는 React 애플리케이션에서 TypeScript 코드를 생성하는 플러그인입니다.

apollo-upload-client: 파일 업로드와 관련된 GraphQL 요청을 처리하는 Apollo Client용 라이브러리입니다.

// src/utils/apolloClient.ts

import {
  ApolloClient,
  InMemoryCache,
  NormalizedCacheObject,
  gql,
  Observable,
  ApolloLink,
} from "@apollo/client";
// The Observable class in @apollo/client has the following functionality that we need:
// - subscribe() method to subscribe to updates
// - unsubscribe() method to unsubscribe from updates
// - next() method to send updates to subscribers
// - error() method to send errors to subscribers
// - complete() method to send completion messages to subscribers

import { createUploadLink } from "apollo-upload-client";
import { onError } from "@apollo/client/link/error";

// 새로운 액세스 토큰을 얻는 비동기 함수
async function refreshToken(
  client: ApolloClient<NormalizedCacheObject>
) {
  try {
    // GraphQL 뮤테이션을 사용하여 새로운 액세스 토큰을 요청
    const { data } = await client.mutate({
      mutation: gql`
        mutation RefreshToken {
          refreshToken
        }
      `,
    });

    // 새로운 액세스 토큰을 받아옴
    const newAccessToken = data?.refreshToken;
    console.trace("newAccessToken", newAccessToken);

    if (!newAccessToken) {
      throw new Error("New access token not received.");
    }

    // 새로운 액세스 토큰을 로컬 스토리지에 저장
    localStorage.setItem("accessToken", newAccessToken);

    // 액세스 토큰을 Bearer 토큰 형식으로 반환
    return `Bearer ${newAccessToken}`;
  } catch (err) {
    console.trace(err);
    throw new Error("Error getting a new access token.");
  }
}

// 토큰 재시도 횟수 및 최대 재시도 횟수 설정
let retryCount = 0;
const maxRetry = 3;

// 오류 처리를 담당하는 Apollo Link
const errorLink = onError(
  ({ graphQLErrors, operation, forward }) => {
    if (graphQLErrors) {
      for (const err of graphQLErrors) {
        // "UNAUTHENTICATED" 오류와 재시도 횟수가 최대 재시도 횟수보다 적을 경우
        if (
          err.extensions.code === "UNAUTHENTICATED" &&
          retryCount < maxRetry
        ) {
          retryCount++;

          return new Observable((observer) => {
            // Promise를 사용하지 않고 Observable을 사용하는 이유는
            // Promise는 하나의 값으로 resolve되는 반면,
            // Observable은 하나의 값이 아니라 여러 값을 처리할 수 있는 데이터 스트림을
            // 나타낸다는 점 때문이다.
            // 하나의 값이 아니라 여러 값을 다루어야 하므로 Observable을 사용하고,
            // 여러 번 시도해야 할 수도 있기 때문에 단순한 단일 값이 아니라 여러 값을 처리할 수 있어야 한다.

            console.trace("observer", observer);

            // 새로운 액세스 토큰을 얻어오고, 헤더에 추가한 후 원래 요청 다시 보내기
            refreshToken(client)
              .then((token) => {
                console.trace("token", token);
                operation.setContext(
                  (previousContext: any) => ({
                    headers: {
                      ...previousContext.headers,
                      authorization: token,
                    },
                  })
                );
                const forward$ = forward(operation);
                forward$.subscribe(observer);
              })
              .catch((error) => observer.error(error));
          });
        }
      }
    }
  }
);

// 파일 업로드를 지원하는 Apollo Link
const uploadLink = createUploadLink({
  uri: "http://localhost:3000/graphql", // GraphQL 서버 URI
  credentials: "include",
  headers: {
    "apollo-require-preflight": "true",
  },
});

// Apollo Client 인스턴스 생성
export const client = new ApolloClient({
  uri: "http://localhost:3000/graphql", // GraphQL 서버 URI
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          getCommentsByPostId: {
            merge(existing, incoming) {
              return incoming;
            },
          },
        },
      },
    },
  }),
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
  },
  // Apollo Link를 사용하여 오류 처리 및 파일 업로드 기능 추가
  link: ApolloLink.from([errorLink, uploadLink]),
});

 

graphql 코드 제너레이터에 쓰일 쿼리 작성. 파일 이름은 소문자로 해야한다. 영상에선 대문자로 하는데, 후에 리액트 컴포넌트에도 로그인, 레지스터를 대문자로 만들고 쿼리 파일들을 소문자로 변경한다.

// src/graphql/mutations/login.ts

import { gql } from "@apollo/client";

export const LOGIN_USER = gql`
  mutation LoginUser($email: String!, $password: String!) {
    login(
      loginInput: { email: $email, password: $password }
    ) {
      user {
        email
        id
        fullname
      }
    }
  }
`;

 

// src/graphql/mutations/register.ts

import { gql } from "@apollo/client";

export const REGISTER_USER = gql`
  mutation RegisterUser(
    $fullname: String!
    $email: String!
    $password: String!
    $confirmPassword: String!
  ) {
    register(
      registerInput: {
        fullname: $fullname
        email: $email
        password: $password
        confirmPassword: $confirmPassword
      }
    ) {
      user {
        id
        fullname
        email
      }
    }
  }
`;

 

// src/graphql/mutations/logout.ts

import { gql } from "@apollo/client";

export const LOGOUT_USER = gql`
  mutation LogoutUser {
    logout
  }
`;

 

graphql 코드 제너레이터 셋업

// codegen.ts

import { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: "http://localhost:3000/graphql",
  documents: ["src/graphql/**/*.ts"],
  ignoreNoDocuments: true, // for better experience with the watcher
  generates: {
    "./src/gql/": {
      preset: "client",
      plugins: [
        "typescript",
        "typescript-operations",
        "typescript-react-apollo",
      ],
      config: {
        withHooks: true,
        withHOC: false,
        withComponent: false,
      },
    },
  },
};

export default config;

 

https://the-guild.dev/graphql/codegen/docs/guides/react-vue - graphql code generator의 react 가이드.

 

Guide: React and Vue – GraphQL Code Generator

GraphQL Code Generator is a tool that generates code from your GraphQL schema and operations. It can generate TypeScript, Flow, Swift, Kotlin, and more.

the-guild.dev

npx graphql-codegen --watch

 

에러가 발생해서 ts-node와 @graphql-codegen/client-preset을 설치하니 해결되었다.

npm i ts-node @graphql-codegen/client-preset

 

Failed to load schema from http://localhost:3000/graphql 에러가 떴다. 백엔드 서버를 실행해야한다.

백엔드 서버를 실행하려면 에러가 발생하는데, 다음 글에서 작성.

반응형