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