안녕하세요! 정말 오랜만에 포스팅을 하네요..하핳 취업 준비하면서 과제도 있고, 면접도 계속 보러 다니느라 적을 시간이 많이 없었습니다ㅠㅠ 결과가 좋진 않았지만..어쨌든 다시 일주일에 두 번 정도씩은 꾸준하게 포스팅할 것 같습니다. 오늘 글은 이번에 새로 시작하는 프로젝트 환결 설정하는 과정을 기록하려고 합니다. 위의 기술 스택을 사용하는 분들에게 도움이 됐으면 좋겠네요:)


  

먼저 위의 기술들을 왜 사용하는지 정리하고 가겠습니다.

기술 스택 선정 이유

1. Vite

ES build를 사용해 종속성을 미리 묶으며 기존 번들링보다 훨씬 빠르게 빌드를 진행할 수 있도록 해줘서 사용합니다.

더 자세한 내용은 아래 글을 참고해주세요! 이데 대해 설명하는 글도 작성하겠습니다.

 

Vite

Vite, 차세대 프런트엔드 개발 툴

ko.vitejs.dev

 

2. Tailwind CSS

미리 설정된 유틸리티 클래스를 사용해 빠른 스타일링이 가능하고 사용되지 않는 CSS 클래스를 빌드 과정에서 제거하여 최종 파일 크기가 작아지는 이점이 있어 사용합니다.

 

3.  TanStack Query

효율적인 API 통신을 위해 사용합니다.

 

4. Zod

TypeScript는 런타임 단계에서는 타입 에러를 잡을 수 없는 한계가 있습니다. 이를 보완하는 Zod를 이용해 타입 안정성을 유지하고 API 응답 결과 체크를 위해 사용합니다.

 

5. Zustand

새로운 프로젝트는 기업과 협업하여 그들의 문제를 함께 해결하는 과정이기에 프로젝트의 규모가 작습니다. 그렇기에 패키지 설치 후 빠르게 Store를 만들어 상태 관리를 할 수 있는 Zustand를 사용합니다.

 

6. Jest, RTL, MSW

이번 프로젝트를 TDD 방식으로 진행하게 되어 테스트 코드를 작성하기 위해 Jest와 RTL을 사용합니다. MSW는 외부 API 모킹을 위해 사용합니다.

 

 

개발 환결 설정

1. 프로젝트 초기화

npm create vite@latest {이름} -- --template react-ts

 Vite를 이용해 Reat + TypeScript 기반 프로젝트 초기화

 

2. 필요 패키기 설치

Tailwind CSS 설치 및 초기화

 

설치

npm install tailwindcss@latest postcss@latest autoprefixer@latest

Tailwind CSS를 설치해줍니다. postcss와 autofixer는 Tailwind CSS를 사용하기 위해 설치하는 패키지입니다.

 

post CSS : TailwindCSS는 PostCSS 플러그인 형태로 작동하기 때문에 설치해야합니다. Tailwind가 제공하는 유틸리티 클래스들을 자동으로 생성하고, 필요한 경우 최적화합니다.

autoprefixer : postCSS 플러그인 중 하나로, CSS에 자동으로 공급업체 접두사(vendor prefixes)를 추가하여 여러 브라우저 간의 호환성을 보장해줍니다. 예를 들어, Flexbox, Grid와 같은 최신 CSS 기능은 일부 구형 브라우저에서 직접 지원하지 않고, -webkit-이나 -ms-와 같은 공급업체 접두사가 필요합니다. autoprefixer가 이를 자동으로 추가해줍니다.

 

 

초기화

npx tailwindcss init

 

 

postcss.config.cjs 파일을 만들어 아래의 설정 코드를 입력해 줍니다.

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  }
}

 

 

tailwind.config.js 파일을 만들어 아래의 설정 코드를 입력해 줍니다.

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

 

cjs 파일을 사용하는 이유 : Node.js는 package.json에 "type": "module"이 있는 경우, 모든 .js 파일을 ECMAScript 모듈로 해석합니다. 때문에 그냥 js파일로 만들 경우에는 module이라는 변수가 정의되지 않은 상태에서 읽게되며 ECMAScript 모듈로 해석됩니다. 하지만 PostCSS 설정 파일은 CommonJS 형식을 사용해야 하기 때문에 cjs 파일로 만들어줘 위와 같은 문제가 발생하지 않도록 방지합니다.

 

ts 파일로 설정하고 싶다면 Config를 import 해서 해주면 됩니다. 예시는 아래와 같습니다.

import { Config } from 'tailwindcss'

const config: Config = {
  content: ['./src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [],
}

export default config

 

tailwind.config.ts의 경우 파일을 인식하지 못할 경우를 대비해 postcss.config.js 파일에 다음 코드를 추가해 줘야합니다.

// postcss.config.js
module.exports = {
  plugins: {
    tailwindcss: { config: './tailwind.config.ts' },
    autoprefixer: {},
  },
}

 

마지막으로 글로벌 css 파일에 다음 코드를 추가하면 됩니다.

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

 


TanStack Query 설치 및 설정

 

설치

npm i @tanstack/react-query

 

설정

main.tsx에 기본 설정을 해 줍니다.

QueryCilent와 QueryCilentProvider를 불러와 App을 감싸줍니다.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";

const queryCilent = new QueryClient();

createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryCilent}>
    <StrictMode>
      <App />
    </StrictMode>
  </QueryClientProvider>
);

 


Zod 설치

npm i zod

 

 

▼ Zod 사용 방법 예시

import { z } from 'zod'

const UserSchema = z.object({
  name: z.string(), // 문자열인지 검사
  age: z.number().min(18), // 18 이하의 숫자인지 검사
})

type User = z.infer<typeof UserSchema>

 

 


Zustand 설치

npm i zustand

설치 후 바로 스토어를 만들어 사용해 주면 됩니다.

 

▼ 스토어 예시

// src/store.ts
import create from 'zustand'

interface BearState {
  bears: number
  increase: () => void
}

export const useBearStore = create<BearState>((set) => ({
  bears: 0,
  increase: () => set((state) => ({ bears: state.bears + 1 })),
}))

 

 


Jest & RTL 설치 및 설정

 

설치

npm i jest @testing-library/react @testing-library/jest-dom @types/jest ts-jest jest-environment-jsdom

 

  • jest: JavaScript 테스트 프레임워크
  • @testing-library/react: React 컴포넌트 테스트 라이브러리
  • @testing-library/jest-dom: Jest용 DOM 사용자 정의 매처
  • @types/jest: TypeScript 환경에서 Jest의 타입 정의
  • ts-jest: TypeScript 코드를 Jest에서 사용할 수 있도록 하는 프리셋
  • jest-environment-jsdom: Jest 환경을 JSDOM으로 설정 (브라우저 환경 시뮬레이션)

 

 

설정

 

jest.config.ts 파일 생성

export default {
  testEnvironment: "jsdom",
  transform: {
    "^.+\\.tsx?$": "ts-jest",
  },
  moduleNameMapper: {
    "^.+\\.svg$": "jest-svg-transformer",
    "\\.(css|less|sass|scss)$": "identity-obj-proxy",
  },
  setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
};

 

 

jest.setup.ts

import "@testing-library/jest-dom";

 

 

다음으로 ts 설정 파일을 변경 해줘야합니다. vite로 React + Ts 프로젝트를 생성할 경우 tsconfig.app.json과 tsconfig.node.json 파일이 생성되기 때문에 기존 방식과 조금 다르게 설정을 해줘야 합니다.

 

위의 방식은 tsconfig.json 파일이 공통적인 설정을 공유하고 참조를 관리하는 용도로 사용합니다. 각 하위 파일은 각 프로젝트에 ts 설정을 해주기 위해 사용됩니다. 공통 설정은  tsconfig.base.json을 통해 관리해줍니다.  설정 과정을 작성해보겠습니다.

 

tsconfig.app.json에 Jest와 RTL 관련 타입 정의를 추가하여 Jest와 RTL을 프로젝트 전반에서 사용할 수 있도록 해주면 됩니다.

 

 

tsconfig.app.json

{
  "compilerOptions": {
    ...

    /* Bundler mode */
    ...

    /* Linting */
    ...

    /* Jest and RTL types */
    "types": ["jest", "@testing-library/jest-dom"]
  },
  "include": ["src"]
}

 

이렇게 설정한 후 script에 test를 추가하여 test 코드를 작성한 후 npm run test로 테스트를 진행할 수 있습니다.

 


MSW 설치 및 설정

 

설치

npm i msw

 

설정

msw가 2.x로 업그레이드 하면서 에러 방지를 위해 설치해줘야 하는 것들이 생겼습니다.

 

Next.js 환경에서 MSW로 API를 Mocking하여 테스트 구현하기

프로젝트를 완료한 후 Jest로 주요 기능에 대한 테스트 코드를 작성하여 테스트 커버리지를 높이고자 노력하고 있습니다. 이 과정에서 OpenWeatherMap API와 Kakao 지도 API를 이용해 날씨 데이터와 위치

pestudent.tistory.com

 

 

1.x → 2.x

Migration guidelines for version 2.0.

mswjs.io

 

제가 작성한 위의 포스팅을 보면 왜 해당 에러가 발생하고 어떻게 해결했는지 나와있습니다. 아래의 글은 발생하는 에러들을 해결할 때 참고한 공식문서입니다. 자세한 내용은 위의 글을 통해 확인해 주시면 감사하겠습니다. 지금은 설정해야 하는 것들만 작성하고 넘어가겠습니다.

 

jest.config.ts에 아래 코드 추가

export default {
  testEnvironmentOptions: {
    customExportConditions: [''],
  },
}

 

jest.polyfills.js 파일을 만들어 아래 코드 작성

// jest.polyfills.js
/**
 * @note The block below contains polyfills for Node.js globals
 * required for Jest to function when running JSDOM tests.
 * These HAVE to be require's and HAVE to be in this exact
 * order, since "undici" depends on the "TextEncoder" global API.
 *
 * Consider migrating to a more modern test runner if
 * you don't want to deal with this.
 */
 
const { TextDecoder, TextEncoder } = require('node:util')
 
Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
})
 
const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')
 
Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

 

이렇게 한 후 핸들러 파일과 서버 파일을 만들어 사용하면 됩니다.


React Router 설치와 설정

마지막으로 React Router를 설치하고 라우팅 해준 후 마무리 하겠습니다.

 

설치

npm install react-router-dom

 

설정

먼저 router.tsx 파일을 따로 만들고 createBrowserRouter를 이용해 라우터 설정을 해 준니다.

import { createBrowserRouter } from "react-router-dom";
import App from "../App";

export const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
  },
]);

 

 

이후 main.tsx에서 라우터 파일과 RouterProvider를 불러와 아래와 같이 설정해 주면 됩니다.

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "react-router-dom";
import { router } from "./routes/router.tsx";
import "./index.css";

const queryCilent = new QueryClient();

createRoot(document.getElementById("root")!).render(
  <QueryClientProvider client={queryCilent}>
    <StrictMode>
      <RouterProvider router={router} />
    </StrictMode>
  </QueryClientProvider>
);

 

 

이상입니다.

 

옵저버빌리티란 무엇인가?

 옵저버빌리티란 외부 출력만을 이용해 내부 상태를 측정할 수 있는 기능을 말합니다. 기존 모니터링이 '무엇인 언제 일어났는가'에 초점을 둔다면, 옵저버빌리티는 '왜 어떻게 일어났는가'까지 파악할 수 있도록 도움을 주는 것입니다. 즉 예상하지 못했던 숨겨진 이슈까지 발견해 주는게 모니터링과 가장 큰 차이점이며, 이러한 옵저버빌리티는 복잡한 IT 인프라에 대한 포괄적이고 통합된 가시정을 제공해 주고 심층적인 분석까지 가능하게 해주는 것이죠.

 

 좀 더 쉽게 말하자면 기업의 IT 인프라와 그 인프라 위에서 운영되는 어플리케이션, 그리고 이 어플리케이션에 접속하는 사용자들을 전방위적으로 모니터링하고 장애가 발생했을 때, 원인은 무엇이며, 어떻게 대처해야하는지 알려주는 기술이 옵저버빌리티입니다. 현재의 복잡해진 클라우드 기반의 어플리케이션의 장애 원인을 파악하는 데 있어서 모니터링만으로는 어려움이 있기에 어플리케이션의 구성 요소뿐만 아니라 구동하는 인프라의 상태, 최종 사용자 기기, 사용자 행동애 대한 로그를 분석해서 정확한 문제를 파악하고 개선하기 위한 해결책을 알려주는 옵저버빌리티가 나온 것이죠.

 

 이러한 옵저버빌리티는 위에서 언급한 것처럼 로그(Log) , 상대 정보(Metrics), 이벤트(Event), 추적 정보(Traces) 등의 여러 관점에서 인프라를 파악하고 분석하게 되는데, 이를 위해서는 곳곳에 흩어진 다른 정보들을 단순 데이터가 아닌 자산의 형태로 한 곳에 모으는 작업을 먼저 해야합니다. 이를 바탕으로 그 정보들의 맥락과 연관성을 파악할 수 있어야 하며, 이렇게 시스템을 한 눈에 파악할 수 있는 능력을 갖추었을 떄 자산에 대한 가시성을 확보했다고 할 수 있습니다.

 

 옵저버빌리티를 구축한다면, 클라우드 환경에서의 자신 및 취약점 관리 또한 한층 효과적으로 수행 가능해집니다. 운영하고 있는 자산 대상 및 범위를 정확하게 파악할 수 있기에 관리가 수월해지고, 이후 위협탐지시스템을 활용한 위협에 대한 정확도 평가 및 중요도 분류 작업도 수행할 수 있게 됩니다. 서비스 가용성, 사용 빈도, 편의성, 기존 공격에 유효성 이력 등 여러 요소를 종합적으로 평가해 대응 우선순위와 방법에 관한 선제적인 전략 수립이 가능해지기 때문에 효과적인 위협탐지 프로세스를 정립할 수 있습니다.

 

 이러한 옵저버빌리티의 가치를 제대로 파악하기 위해선 단순히 복잡해진 오늘날의 IT 인프라 환경을 너머 그간 IT 시스템이 어떻게 변화했는지에 대한 흐름을 이해하는 것이 좋습니다. IT 서비스는 매우 빠르고 크게 확장되어 왔으며, 이러한 규모는 고정된 환경으로는 감당할 수 없게 되었습니다. 즉 처리해야하는 트랜잭션 사용자 수가 과거와 비교가 안될 정도로 증가했기 때문에 이를 감당하기 위해서 새로운 시스템을 도입해야했던 것이죠. 이러한 문제의 해결책으로 등장한 것이 바로 클라우드입니다. 클라우드는 컴퓨터 리소스를 필요에 따라 사용할 만큼만 할당 받아 쓸 수 있는 방식을 이용하여 달라지는수요에 유연하게 대응할 수 있는 구조를 가졌기에 위의 문제를 해결할 수 있었습니다.

 하지만 기존 온 프레미스 시스템들을 클라우드로 이전하는 과정에 어려움이 있었습니다. 모놀리식 아키텍처(단일 코드 베이스 어플리케이션)를 통째로 클라우드로 전환하는 것은 구조적으로 쉽지 않았기에 하나의 큰 어플리케이션을 작게 분리할 필요가 생겼습니다. 이런 목적으로 생겨난 것이 바로 하나의 시스템을 작은 마이크로 단위로 나눠 개발하는 마이크로ㅅ비스 아키텍터(MSA)입니다. 이렇게 클라우드 기반의 서비스로 기존 서비스들이 전환되면서 모니터링해야 하는 구성이 자연스럽게 변화된 것입니다. 

 이렇게 가속화되는 클라우드 전환과 MSA의 확산으로 훨씬 복잡해진 IT 및 개발 환경 내에서 인프라 전체를 확인해야하는 것은 필수가 되었고, 동시에 옵저버빌리티가 각광받게 된것이죠.

 

옵저버빌리티와 모니터링

 하지만 옵저버빌리티가 있다고 모니터링이 필요하지 않은 것은 아닙니다. 모니터링은 옵저버빌리티를 달성하는 데 사용하는 기법 중 하나가 되었습니다. 옵저버빌리티는 복잡한 시스템을 얼마나 잘 이해할 수 있는지에 대한 접근 방식이고 모니터링은 이 접근 방식을 돕기위해 취하는 조치인 것이죠.

 

 모니터링에 대해 간단히 알아보자면 상태 정보(Metrics)를 기반으로 현재 측정된 위험요소를 기반으로 잘못될 가능성이 있나 없나 파악하여 알려주는 것입니다. 즉, 시스템 상의 상태 변화를 지속적으로 감시, 처리, 분석, 표현하여 어플리케이션 운영자가 해당 어플리케이션에 발생한 장애를 빠르게 파악하고 대처하는 데 도움을 주는 기능인 것입니다.

 

간단하게 옵저버빌리티에 대해 공부한 내용을 정리해 봤습니다. 글 읽어주셔서 감사합니다 :)

 

 

저번 글에 이어서 진행하겠습니다.

불러올 데이터를 넣어줬으니 이제 데이터 Fetching을 해보겠습니다. 보통 데이터를 Fetching하는 방법은 API Layer와 Database queries를 이용한 두 가지 방법이 있는데 이는 이 글을 작성한 이후에 설명하는 글을 작성하겠습니다.

계속해서 프로그램을 만들어 보겠습니다. 

 

    10. 데이터를 사용하기 위해 dashboard 페이지 수정

// /app/dashboard/page.tsx

import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
 
export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        {/* <Card title="Collected" value={totalPaidInvoices} type="collected" /> */}
        {/* <Card title="Pending" value={totalPendingInvoices} type="pending" /> */}
        {/* <Card title="Total Invoices" value={numberOfInvoices} type="invoices" /> */}
        {/* <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        /> */}
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        {/* <RevenueChart revenue={revenue}  /> */}
        {/* <LatestInvoices latestInvoices={latestInvoices} /> */}
      </div>
    </main>
  );
}

 

  • ReavenueChart 주석을 풀고 fetchRevenue에서 revenue를 가져와 선언한 후 ReavenueChart 컴포넌트 내부 주석 풀어주기
  • LatestInvoices 주석을 풀고 fetchLatestInvoices에서 latestInvoices를 가져와선언한 후 LatestInvoices 컴포넌트 내부 주석 풀어주기
  • Card 주석을 풀고 fetchCardData에서 totalPaidInvoices, totalPendingInvoices, numberOfInvoices, numberOfCustomers를 가져와 선언

Next.js fetch는 기본적으로 캐싱을 해주는데 캐싱하지 않고 최신데이터를 불러오고 싶다면

import { unstable_noStore as noStore } from 'next/cache'

 

위와 같이 import 해 준 후에 fetching 함수 내부의 최상단에 noStore()를 선언해 주면 됩니다.

만약에 API로 가져오는 거라면 fetch(url, {cache: 'no-store' })를 해주면 됩니다.

 

수정된 최종 dashboard/page.tsx 코드

// /app/dashboard/page.tsx

import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchCardData, fetchLatestInvoices, fetchRevenue } from '../lib/data';

export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();
  const {
    totalPaidInvoices,
    totalPendingInvoices,
    numberOfInvoices,
    numberOfCustomers,
  } = await fetchCardData();

  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Collected" value={totalPaidInvoices} type="collected" />
        <Card title="Pending" value={totalPendingInvoices} type="pending" />
        <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
        <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <RevenueChart revenue={revenue} />
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

 

 

    11. dashboard 폴더에 loding.tsx 만들고 그룹화하기

(overview) : Route Groups

loding.tsx 파일을 특정 폴더에 만들 경우 하위의 모든 폴더(페이지)에 적용이 됩니다. 이를 (overview) 폴더를 만들고 해당 폴더에 넣어서 하위 폴더까지 적용하지 않고 loding.tsx 파일을 만든 폴더엔만 적용할 수 있습니다.

 

dashboard 폴더에 (overview) 폴더를 만들고 page.tsx와 loding.tsx 파일을 넣어줬습니다.

 

 

    12. Suspense 태그를 이용해 dashboard에서 데이터를 보여주는 컴포넌트들을 동적으로 로딩해주기

위의 작업을 하기 위해서 원래 dashborad(대시보드)의 page.tsx에서 데이터를 fetching하고 데이터를 가시화하는 컴포넌트에 넣어주던 방식을 각 데이터 가시화 컴포넌트 자체에서 데이터를 fetching 하는 방식으로 바꿔줍니다.

 

데이터를 가시화 하는 컴포넌트(RevenueChart, LatestInvoices, Card)가 props를 받는 구조를 없애고 각자의 내부에서 데이터를 fetching하는 방식으로 수정합니다.

여기서 Card 컴포넌트들은 card.tsx로 들어가면 CardWrapper이 만들어져 있습니다. 여기서 아래 주석들을 해제하고 필요한 데이터를 fetchCardData에서 가져와 선언하여 연결해주면 됩니다. 그리고 CardWrapper를 Suspense로 덮어줍니다.

fallback에 대한 컴포넌트들은 ui 폴더의 skeletons.tsx 파일에 만들어져 있습니다. 각 컴포넌트에 맞게 fallback에 넣어주면 됩니다.

 

완성된 대시보드의 페이지 컴포넌트 코드입니다.

// /app/dashboard/page.tsx

import CardWrapper, { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { Suspense } from 'react';
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
  CardsSkeleton,
} from '@/app/ui/skeletons';

export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<CardsSkeleton />}>
          <CardWrapper />
        </Suspense>
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <Suspense fallback={<LatestInvoicesSkeleton />}>
          <LatestInvoices />
        </Suspense>
      </div>
    </main>
  );
}

 

    13. invoices 페이지 만들기 검색했을때 url의 쿼리스트링에 연결될 수 있도록 설정 

먼저 invoices 페이지를 컴포넌트 코드를 작성해 줍니다.

// /app/dashboard/invoices/page.tsx

import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';

export default function Invoices({
  searchParams,
}: {
  searchParams: {
    query?: string;
  };
}) {
  console.log(searchParams?.query);

  return (
    <div>
      <div className="w-full">
        <h1 className="flex w-full items-center justify-between">Invoices</h1>
      </div>
      <div className=" mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>

      {/* <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense> */}

      <div className=" mt-5 w-full justify-center">
        {/* <Pagination totalPages={totalPages}/> */}
      </div>
    </div>
  );
}

그리고 Search를 수정하여 input 창에 값을 입력했을 때 url에 쿼리스트링에 넣어주는 코드를 작성합니다.

 

<원래 코드 ▼>

// /app/ui/search.tsx

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';

export default function Search({ placeholder }: { placeholder: string }) {
  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}

 

< 수정한 코드 ▼ >

// /app/ui/search.tsx

'use client';

import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';

export default function Search({ placeholder }: { placeholder: string }) {
  const searchParams = useSearchParams();
  const pathname = usePathname():
  const {replace} = useRouter();
  
  const handleSearch = (value: string) => {
  	const params = new URLSearchParams(searchParams);
    if (value) {
    	params.set('query', value);
    }
    else {
    	params.delete('query');
    }
    replace(`${pathname}`?${params.toString()});
  }
  
  return (
    <div className="relative flex flex-1 flex-shrink-0">
      <label htmlFor="search" className="sr-only">
        Search
      </label>
      <input
        className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
        placeholder={placeholder}
        onChange={ (e) => {
        	handleSearch(e.target.value)
        }}
        defaultValue={searchParams.get('query')?.toString()}
      />
      <MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
    </div>
  );
}

수정한 코드를 살펴보면 onChange를 이용해 input 값을 가져오고 그 값을 params에 넣어서 useRouter replace를 이용해 url에 추가하는 방식을 사용하고 있습니다.

 

    14. Table과 Suspense 주석을 풀고 검색되는지 확인

// /app/dashboard/invoices/page.tsx

import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';

export default function Invoices({
  searchParams,
}: {
  searchParams: {
    query?: string;
    page?: string;
  };
}) {
  console.log(searchParams?.query);
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;

  return (
    <div>
      <div className="w-full">
        <h1 className="flex w-full items-center justify-between">Invoices</h1>
      </div>
      <div className=" mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>

      <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense>

      <div className=" mt-5 w-full justify-center">
        {/* <Pagination totalPages={totalPages}/> */}
      </div>
    </div>
  );
}

Search 컴포넌트에서 useRouter를 이용해 searchParams를 보내줬기 때문에 invoices 페이지에서 props로 받아와 currentPage와 query를 정의하고 Suspense로 감싼 Table 컴포넌트의 주석을 풀어줍니다. 이렇게 하면 검색창에 입력된 값이 사용자 정보와 일치한다면 데이터를 가지고 옵니다. 

정보를 불러오는 함수를 보면 입력된 query를 사용자의 모든 정보와 비교하여 일치하는 데이터가 하나라도 있으면 불러오는 것을 알 수 있습니다.

 

검색 과정을 파악해 보자면, 검색창에 입력된 값을 쿼리스트링으로 보내고 각 컴포넌트에서 해당 쿼리스트링의 값을 받아와 일치하는 데이터를 보여주는 방식입니다. 강사님도 말씀해 주시긴 했는데 이런 방식보다 그냥 입력창에 입력된 값으로 데이터를 바로 찾아와서 화면에 보여주는게 더 낫지 않을까 싶네요. 하지만 useSearchParams와 usePathname을 이용해 자식 컴포넌트에서 부모 컴포넌트로 데이터를 보낼 수 있는 방법론적인 면에서는 알아두면 좋다고 하셨습니다.

 

    15. Invoice 데이터에 대한 생성, 수정, 삭제 기능 만들기

 

a. 데이터 생성

먼저 CreateInvoice 컴포넌트를 클릭했을 때 이동하는 /dashboard/invoices/create 페이지를 만들어 줍니다.

// /app/dashboard/create/page.tsx

import Form from '@/app/ui/invoices/create-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';

export default async function Page() {
  const customers = await fetchCustomers();

  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'invoices', href: 'dashboard/invoices' },
          {
            label: 'Create Invoice',
            href: 'dashboard/invoices/create',
            active: true,
          },
        ]}
      />
      <Form customers={customers} />
    </main>
  );
}

이렇게 완성해주고 Create invoice 버튼을 클릭하면 다음과 같은 화면이 표시됩니다.

그리고 Create Invoice 버튼을 클릭했을 때 데이터가 추가될 수 있도록 //app/lib 폴더에 actions.ts 파일을 만들어 데이터를 보내주는 함수를 만들어 줍니다. 

// /app/lib/actions.ts

'use server';

import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

const FormSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});

const CreateInvoice = FormSchema.omit({ id: true, date: true });

export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];

  await sql`
  INSERT INTO invoices (customer_id, amount, status, date)
  VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
  `;

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

여기서 zod 라이브러리는 스키마 선언 및 데이터 검증을 위해 사용하는 라이브러리입니다. zod를 사용해 FormSchema를 선언하고 이를 통해 폼 데이터를 검증하기 위해 zod를 사용하고 있습니다.

z를 이용해 폼 데이터의 구조와 유형을 정의합니다.

 

그리고 CreateInvoice를 선언하며 인보이스 생성 시 필요한 필드만 포함한 스키마를 만들어줍니다.

 

이후 폼 데이터에서 필요한 필드를 검증하고 추출합니다. 

여기서 z.coerce.number()는 문자열을 숫자로 변환해 줍니다.

 

그리고 Form 컴포넌트에 action 속성이 지정되어 있지 않기 때문에 action 속성에 crateInvoice 함수를 연결해 주면 Create Invoice 버튼을 클릭했을 때, 입력한 데이터가 DB로 들어간 것을 확인할 수 있습니다.

 

 

b. 데이터 수정

// app/dashboard/invoices/page.tsx 의 Table 컴포넌트에서 UpdataInvoice 함수 찾아서 이동 주소 올바르게 바꾸기

 

edit 페이지 만들기

// /app/dashboard/invoices/[id]/edit/page.tsx

import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers, fetchInvoiceById } from '@/app/lib/data';

export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);

  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Edit Invoice',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      <Form invoice={invoice} customers={customers} />
    </main>
  );
}

 

데이터를 업데이트 해주는 함수 actions.ts 파일에 추가하기

// /app/lib/actions.ts

const UpdateInvoice = FormSchema.omit({ id: true, date: true });

export async function updateInvoice(id: string, formData: FormData) {
  const { customerId, amount, status } = UpdateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;

  await sql`
  UPDATE invoices
  SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
  WHERE id = ${id}
  `;

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

 

위에서 create 페이지를 포면 각 페이지에 맞는 Form 컴포넌트가 준비되어 있다는 것을 알 수 있습니다. 그렇기에 edit 페이지에 대한 Form 컴포넌트가 updateInvoie 함수를 사용할 수 있도록 코드를 수정해 줘야 합니다. 그리고 updateInvoice 함수는 폼 데이터뿐만 아니라 id 값도 파라미터로 받고 있기 때문에 invoice.id를 바인드해준 후에 form 태그의 actiondp 넣어줍니다.

이렇게 하면 수정 기능도 완성입니다.

 

c. 데이터 삭제

삭제는 id를 가져와서 id에 일치하는 데이터를 삭제해 주면 되기 때문에 생성과 수정보다 간단합니다.

delete 버튼을 클릭했을 때는 따로 페이지를 이동하지 않기 때문에 

 여기서 바로 수정해 줍니다.

deleteInvoice 함수를 만들었다 가정하고 id를 함수에 반환해주고 button 태그를 form으로 감싼 후에 action에 바인드 처리한 함수를 넣어주면 됩니다.

 

그리고 actions.ts 파일에 deleteInvopice 함수를 만들어 주면 삭제 기능 완성입니다.

// /app/lib/actions.ts

export async function deleteInvoice(id: string) {
  await sql`
  DELETE FROM invoices
  WHERE id = ${id}
  `;

  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

 


여기까지 간단한 대시보드 프로그램을 함께 만들고 강의를 마무리 했습니다. 조금 아쉬웠던 점은 SPA 구현이 Suspense 사용해 Streaming 페이지를 구현하는 것을 얘기하는 것 같더라고요ㅠㅠ SPA 구현에 대해 자세히 배우지 못한 것 같아 아쉬움이 남았습니다. 그래서 Next.js 없이 React만 이용해 SPA를 구현하는 방법을 공부해서 구현한 후에 정리해서 올릴 예정입니다. 긴 글 읽어주셔서 감사합니다.

SPA 만드는 법을 배우기 위해 원티드에서 진행하는 프리온보딩 FE 챌린지를 신청하여 들었습니다. 그 과정을 정리한 글입니다. 강사님이 Next.js 튜토리얼를 기반으로 강의를 구성했다고 하네요. 그래서 SPA뿐만 아니라 CSS 스타일링 데이터베이스 연결 등 다양한 실습을 진행했습니다. 그 과정들을 간단하게 정리하고 제가 배우려고 했던 부분들과 중요하다고 생각하는 부분만 정리하겠습니다. SPA를 집중적으로 하지 않아서 살짝 아쉬움은 있네요ㅠㅠ

 

[ 주요 키워드 ]

  • clsx 사용법
  • Tailwind CSS의 반응형
  • usePathname을 이용해 active한 사이드바 만들기
  • vercel의 pastgres 데이터 베이스 사용해보기

 

git bash를 열어 프로젝트를 설치해줬습니다.

프로젝트 생성

npx create-next-app@latest nextjs-dashboard --example 
"https://github.com/vercel/next-learn/tree/main/dashboard/starter-example"

 

그리고 설치된 디렉토리로 들어가서 pnpm을 설치해 줍니다.

pnpm 설치

npm i pnpm

# 설치 후 패키지 설치와 실행
# pnpm i
# pnpm dev

 

여기서 pnpm에 대해 간단히 설명하자면 

npm, yarn과 유사하지만 더 빠르고 효율적인 패키지 매니저입니다. 하드 링크와 심볼릭 링크를 사용하여 중복된 패키지의 저장 공간을 줄이는 독특한 방식을 사용합니다. 또한 node_modules를 직접 설치하는 대신 전역 저상소에서 패키지를 공유하는 구조를 사용합니다. 이를 통해 pnpm이 패키지를 설치할 때는 package.json에 명시된 패키지를 읽은 후 node_modules에 심볼릭 링크를 생성하여 전역 저장소의 해당 패키지를 참조합니다. 이 방식을 통해 명시한 패키지만 사용할 수 있도록 합니다.

 

위에서 언급했듯이 어떤 작업을 했는지 간단하게 언급하면서 중요하다고 생각하는 부분들을 정리하겠습니다.

 

    1. /app/layout.tsx에 global.css import하기

 

    2. /app/page.tsx 10번째 줄 AcemLogo 주석 해제 후 /app/ui/acme-logo.tsx fonts 파일을 만들어 추가

// app/ui/font.ts

import { Inter, Lusitana } from 'next/font/google';

export const inter = Inter({ subsets: ['latin'] });
export const lusitana = Lusitana({ weight: '400', subsets: ['latin'] });

적용하는 방법은 acme-logo 파일을 보면 알 수 있다.

font.ts 파일을 import하고 원하는 요소 className에 적용할려고 설정한 font를 사용할 것이라고 명시해 주면 된다.

import {lusitana} from '@/app/ui/fonts.ts'

export default function Example () {
	return <div className = {` ${lusitana.className} `}>예시</div>
}

 

    3. /app/page.tsx 28번째 줄 div 내부에 Image 만들기 (웹페이지에서 볼 때랑 모바일에서 볼 때랑 구분

// app/page.tsx 일부

<div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
  {/* Add Hero Images Here */}
  <Image
      src="/hero-desktop.png"
      width={1000}
      height={760}
      className="hidden md:block"
      alt="tkwls"
  />
  <Image
      src="/hero-desktop.png"
      width={700}
      height={500}
      className="block md:hidden"
      alt="tkwls"
  />
</div>

Tailwind CSS에서 각 너비에 맞춰서 어떤 스타일을 적용할 것인지 정할 수 있습니다.

 

    4. dashboard 페이지 만들고 layout.tsx와 page.tsx 만들기

Next.js는 파일 시스템 기반 라우팅을 지원하기 때문에 매우 편리하게 라우팅을 설정할 수 있습니다. app 폴더 내에 새로 만든 폴더의 이름이 곧 웹에서 라우팅 주소가 됩니다.그리고 새로 만든 폴더 내부에 page.tsx(jsx) 파일을 만들면 해당 파일이 라우팅 주소로 이동했을 때의 페이지가 됩니다. 

app 폴더에 dashboard 폴더를 만들어 page.tsx와 layout.tsx를 만들어줍니다. 여기서 layout.tsx는 page.tsx 및 자식 요소에 공유 UI를 제공해주는 역할을 합니다. 즉 layout.tsx에서 설정한 화면이 하위로 만드는 화면들에 적용되는 것입니다.

// app/dashboard/layout.tsx

import SideNav from '../ui/dashboard/sidenav';

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className=" flex h-screen flex-col md:flex-row md:overflow-hidden">
      <div className="w-full flex-none md:w-64">
        <SideNav />
      </div>
      <div className=" flex-grow p-6 md:overflow-y-auto md:p-12">
        {children}
      </div>
    </div>
  );
}

이렇게 해서 dashboard 페이지 및 하위 페이지들의 UI를 구성해 줍니다.

// app/dashboard/page.tsx

export default function Page() {
	return <div>dashboard page</div>
}

 

    5. dashboard 하위 페이지 만들기

/app/dashboard/coustumers

/app/dashboard/invoices

를 만들고 각각 page.tsx도 만들어주기

 

    6. /app/ui/dashboard/sidenav,tsx의 a태그 next/Link로 바꾸어주기

a 태그로 놓았을 때는 사이드바에서 각 페이지로 이동하는 버튼을 누를때마다 리로드가 되지만 Next의 Link는 페이지에 대한 캐싱을 해주기 때문에 리로드되지 않고 페이지를 보여줍니다.

 

    7. usePathname 훅을 이용해 사이드 버튼을 active하게 만들기

usePathname은 이동한 현재 페이지의 주소를 가져오는 훅입니다. 이 usePathname과 clsx를 이용해 현재 이동한 페이지에 대한 버튼의 스타일을 바꿔보겠습니다. 아래 컴포넌트는 사이드바의 링크에 대한 컴포넌트입니다. 여기서 원래 a였던 태그를 Link로 바꿨고 usePathname을 이용해 현재 페이지와 link.href가 같다면 특정 스타일을 주는 코드로 바꿨습니다.

// app/ui/dashboard/nav-link.tsx

'use client';

import {
  UserGroupIcon,
  HomeIcon,
  DocumentDuplicateIcon,
} from '@heroicons/react/24/outline';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import clsx from 'clsx';

// Map of links to display in the side navigation.
// Depending on the size of the application, this would be stored in a database.
const links = [
  { name: 'Home', href: '/dashboard', icon: HomeIcon },
  {
    name: 'Invoices',
    href: '/dashboard/invoices',
    icon: DocumentDuplicateIcon,
  },
  { name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon },
];

export default function NavLinks() {
  const pathname = usePathname();
  console.log(pathname);
  return (
    <>
      {links.map((link) => {
        const LinkIcon = link.icon;
        return (
          <Link
            key={link.name}
            href={link.href}
            className={clsx(
              'flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3',
              {
                'bg-sky-100 text-blue-600': pathname === link.href,
              },
            )}
          >
            <LinkIcon className="w-6" />
            <p className="hidden md:block">{link.name}</p>
          </Link>
        );
      })}
    </>
  );
}
className={clsx(
              'flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3',
              {
                'bg-sky-100 text-blue-600': pathname === link.href,
              },
            )}

clsx를 이용해 특정 조건일 때, 스타일을 주는 코드입니다.

 

    8. github에 올리고 vercel 배포

    9. postgres database 연동 

배포 후에 배포한 프로젝트로 들어가서 Storage로 들어갑니다.

여기서 postgres create 버튼을 클릭합니다. 그럼 바로 생성이 됩니다. 그리고 .env.local로 들어가 해당 env들을 복사하여 프로젝트에 .env 파일을 만들어 넣어줍니다.

 

env는 환경 변수 파일이고 포트, DB관련 정보, API_KEY 등 개발자 혹은 팀만 알아야 하는 값을 저장해두는 파일입니다. 그렇기 때문에 .gitignore에 .env를 추가해 git 레포지토리로 올라가지 않게 합니다. 원래는 이렇게

파일로 관리하지 않고, install 해주는 파이프라인에서 변수를 입력하게끔 변경을 한다고 합니다.

 

마지막으로 package.json에

"seed": "node -r dotenv/config ./scripts/seed.js"

를 추가해 줍니다. 데이터를 넣어주는 명령어입니다.

 

원래는 @vercel/postgres 패키지를 설치하고 seed.js를 만들어 어떤 데이터를 넣어줄 것인지 정의하여 pnpm seed를 입력해 postgres에 데이터를 넣어줘야 합니다. 해당 대시보드 예제는 미리 seed.js를 구현해 줘서 명령어만 쳐주면 됩니다.

pnpm seed를 터미널에 입력한 후 다시 vercel의 storage로 돌아오면 데이터가 생긴 것을 확인할 수 있습니다.

 

seed.js 파일은 다음과 같습니다.

// scripts/seed.js

const { db } = require('@vercel/postgres');
const {
  invoices,
  customers,
  revenue,
  users,
} = require('../app/lib/placeholder-data.js');
const bcrypt = require('bcrypt');

async function seedUsers(client) {
  try {
    await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;
    // Create the "users" table if it doesn't exist
    const createTable = await client.sql`
      CREATE TABLE IF NOT EXISTS users (
        id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
        name VARCHAR(255) NOT NULL,
        email TEXT NOT NULL UNIQUE,
        password TEXT NOT NULL
      );
    `;

    console.log(`Created "users" table`);

    // Insert data into the "users" table
    const insertedUsers = await Promise.all(
      users.map(async (user) => {
        const hashedPassword = await bcrypt.hash(user.password, 10);
        return client.sql`
        INSERT INTO users (id, name, email, password)
        VALUES (${user.id}, ${user.name}, ${user.email}, ${hashedPassword})
        ON CONFLICT (id) DO NOTHING;
      `;
      }),
    );

    console.log(`Seeded ${insertedUsers.length} users`);

    return {
      createTable,
      users: insertedUsers,
    };
  } catch (error) {
    console.error('Error seeding users:', error);
    throw error;
  }
}

async function seedInvoices(client) {
  try {
    await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;

    // Create the "invoices" table if it doesn't exist
    const createTable = await client.sql`
    CREATE TABLE IF NOT EXISTS invoices (
    id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
    customer_id UUID NOT NULL,
    amount INT NOT NULL,
    status VARCHAR(255) NOT NULL,
    date DATE NOT NULL
  );
`;

    console.log(`Created "invoices" table`);

    // Insert data into the "invoices" table
    const insertedInvoices = await Promise.all(
      invoices.map(
        (invoice) => client.sql`
        INSERT INTO invoices (customer_id, amount, status, date)
        VALUES (${invoice.customer_id}, ${invoice.amount}, ${invoice.status}, ${invoice.date})
        ON CONFLICT (id) DO NOTHING;
      `,
      ),
    );

    console.log(`Seeded ${insertedInvoices.length} invoices`);

    return {
      createTable,
      invoices: insertedInvoices,
    };
  } catch (error) {
    console.error('Error seeding invoices:', error);
    throw error;
  }
}

async function seedCustomers(client) {
  try {
    await client.sql`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`;

    // Create the "customers" table if it doesn't exist
    const createTable = await client.sql`
      CREATE TABLE IF NOT EXISTS customers (
        id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
        name VARCHAR(255) NOT NULL,
        email VARCHAR(255) NOT NULL,
        image_url VARCHAR(255) NOT NULL
      );
    `;

    console.log(`Created "customers" table`);

    // Insert data into the "customers" table
    const insertedCustomers = await Promise.all(
      customers.map(
        (customer) => client.sql`
        INSERT INTO customers (id, name, email, image_url)
        VALUES (${customer.id}, ${customer.name}, ${customer.email}, ${customer.image_url})
        ON CONFLICT (id) DO NOTHING;
      `,
      ),
    );

    console.log(`Seeded ${insertedCustomers.length} customers`);

    return {
      createTable,
      customers: insertedCustomers,
    };
  } catch (error) {
    console.error('Error seeding customers:', error);
    throw error;
  }
}

async function seedRevenue(client) {
  try {
    // Create the "revenue" table if it doesn't exist
    const createTable = await client.sql`
      CREATE TABLE IF NOT EXISTS revenue (
        month VARCHAR(4) NOT NULL UNIQUE,
        revenue INT NOT NULL
      );
    `;

    console.log(`Created "revenue" table`);

    // Insert data into the "revenue" table
    const insertedRevenue = await Promise.all(
      revenue.map(
        (rev) => client.sql`
        INSERT INTO revenue (month, revenue)
        VALUES (${rev.month}, ${rev.revenue})
        ON CONFLICT (month) DO NOTHING;
      `,
      ),
    );

    console.log(`Seeded ${insertedRevenue.length} revenue`);

    return {
      createTable,
      revenue: insertedRevenue,
    };
  } catch (error) {
    console.error('Error seeding revenue:', error);
    throw error;
  }
}

async function main() {
  const client = await db.connect();

  await seedUsers(client);
  await seedCustomers(client);
  await seedInvoices(client);
  await seedRevenue(client);

  await client.end();
}

main().catch((err) => {
  console.error(
    'An error occurred while attempting to seed the database:',
    err,
  );
});

 

그리고 위에서 불러오는 데이터는 app/lib/placehorder-data.js에 정의되어 있습니다.

// This file contains placeholder data that you'll be replacing with real data in the Data Fetching chapter:
// https://nextjs.org/learn/dashboard-app/fetching-data
const users = [
  {
    id: '410544b2-4001-4271-9855-fec4b6a6442a',
    name: 'User',
    email: 'user@nextmail.com',
    password: '123456',
  },
];

const customers = [
  {
    id: '3958dc9e-712f-4377-85e9-fec4b6a6442a',
    name: 'Delba de Oliveira',
    email: 'delba@oliveira.com',
    image_url: '/customers/delba-de-oliveira.png',
  },
  {
    id: '3958dc9e-742f-4377-85e9-fec4b6a6442a',
    name: 'Lee Robinson',
    email: 'lee@robinson.com',
    image_url: '/customers/lee-robinson.png',
  },
  {
    id: '3958dc9e-737f-4377-85e9-fec4b6a6442a',
    name: 'Hector Simpson',
    email: 'hector@simpson.com',
    image_url: '/customers/hector-simpson.png',
  },
  {
    id: '50ca3e18-62cd-11ee-8c99-0242ac120002',
    name: 'Steven Tey',
    email: 'steven@tey.com',
    image_url: '/customers/steven-tey.png',
  },
  {
    id: '3958dc9e-787f-4377-85e9-fec4b6a6442a',
    name: 'Steph Dietz',
    email: 'steph@dietz.com',
    image_url: '/customers/steph-dietz.png',
  },
  {
    id: '76d65c26-f784-44a2-ac19-586678f7c2f2',
    name: 'Michael Novotny',
    email: 'michael@novotny.com',
    image_url: '/customers/michael-novotny.png',
  },
  {
    id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa',
    name: 'Evil Rabbit',
    email: 'evil@rabbit.com',
    image_url: '/customers/evil-rabbit.png',
  },
  {
    id: '126eed9c-c90c-4ef6-a4a8-fcf7408d3c66',
    name: 'Emil Kowalski',
    email: 'emil@kowalski.com',
    image_url: '/customers/emil-kowalski.png',
  },
  {
    id: 'CC27C14A-0ACF-4F4A-A6C9-D45682C144B9',
    name: 'Amy Burns',
    email: 'amy@burns.com',
    image_url: '/customers/amy-burns.png',
  },
  {
    id: '13D07535-C59E-4157-A011-F8D2EF4E0CBB',
    name: 'Balazs Orban',
    email: 'balazs@orban.com',
    image_url: '/customers/balazs-orban.png',
  },
];

const invoices = [
  {
    customer_id: customers[0].id,
    amount: 15795,
    status: 'pending',
    date: '2022-12-06',
  },
  {
    customer_id: customers[1].id,
    amount: 20348,
    status: 'pending',
    date: '2022-11-14',
  },
  {
    customer_id: customers[4].id,
    amount: 3040,
    status: 'paid',
    date: '2022-10-29',
  },
  {
    customer_id: customers[3].id,
    amount: 44800,
    status: 'paid',
    date: '2023-09-10',
  },
  {
    customer_id: customers[5].id,
    amount: 34577,
    status: 'pending',
    date: '2023-08-05',
  },
  {
    customer_id: customers[7].id,
    amount: 54246,
    status: 'pending',
    date: '2023-07-16',
  },
  {
    customer_id: customers[6].id,
    amount: 666,
    status: 'pending',
    date: '2023-06-27',
  },
  {
    customer_id: customers[3].id,
    amount: 32545,
    status: 'paid',
    date: '2023-06-09',
  },
  {
    customer_id: customers[4].id,
    amount: 1250,
    status: 'paid',
    date: '2023-06-17',
  },
  {
    customer_id: customers[5].id,
    amount: 8546,
    status: 'paid',
    date: '2023-06-07',
  },
  {
    customer_id: customers[1].id,
    amount: 500,
    status: 'paid',
    date: '2023-08-19',
  },
  {
    customer_id: customers[5].id,
    amount: 8945,
    status: 'paid',
    date: '2023-06-03',
  },
  {
    customer_id: customers[2].id,
    amount: 8945,
    status: 'paid',
    date: '2023-06-18',
  },
  {
    customer_id: customers[0].id,
    amount: 8945,
    status: 'paid',
    date: '2023-10-04',
  },
  {
    customer_id: customers[2].id,
    amount: 1000,
    status: 'paid',
    date: '2022-06-05',
  },
];

const revenue = [
  { month: 'Jan', revenue: 2000 },
  { month: 'Feb', revenue: 1800 },
  { month: 'Mar', revenue: 2200 },
  { month: 'Apr', revenue: 2500 },
  { month: 'May', revenue: 2300 },
  { month: 'Jun', revenue: 3200 },
  { month: 'Jul', revenue: 3500 },
  { month: 'Aug', revenue: 3700 },
  { month: 'Sep', revenue: 2500 },
  { month: 'Oct', revenue: 2800 },
  { month: 'Nov', revenue: 3000 },
  { month: 'Dec', revenue: 4800 },
];

module.exports = {
  users,
  customers,
  invoices,
  revenue,
};

 

 

+ Recent posts