본 프로젝트는 https://www.codewithantonio.com/ 의 강의를 바탕으로 진행하였습니다.
온라인 쇼핑몰 웹사이트를 구현하는 사이드 프로젝트를 진행하였습니다.
해당 개발은 Admin 사이트와 Store 사이트로 구분하여 개발됩니다.
기본 구현은 해당 강의를 바탕으로 진행하며, 강의를 따라가면서 몰랐던 개념을 정리합니다.
그 밖에 에러 및 주요 서비스 문제 해결 과정은 개인 개발 블로그에 남겼습니다.
Admin Page
초기 환경 설정
npx create-next-app@latest ecommerce-app --typescript --tailwind --eslint
npx shadcn-ui@latest init
Next.JS 13
Next.JS 공식 문서 : https://nextjs.org/docs
v13 App route 방식으로 구현
기존에 pages/ 디렉토리에서 라우팅 되던 방식과 다르게 Next 13 버전은 app/ 디렉토리에서 라우팅힙니다.
app/ 디렉토리에 기존 파일 시스템 라우팅을 구현할 수 있으며, page.tsx가 해당 경로의 페이지 컴포넌트입니다.
app 디렉토리 내부에서는 모든 컴포넌트가 기본적으로 서버 컴포넌트로 동작합니다.
만약 app directory 내부에서 클라이언트 컴포넌트를 사용하고 싶다면 파일 최상단에 아래의 directive를 명시합니다.
"use client";
Dashboard 구현
Shadcn/ui 공식 문서 : https://ui.shadcn.com/docs/components/button
shadcn-ui 의 button, dialog, form, input 기능을 추가하면
components/ui 디렉토리에 각각의 해당 컴포넌트 파일이 생성된 것을 확인할 수 있습니다.
npx shadcn-ui@latest add button
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add form
npx shadcn-ui@latest add input
1. Modal Components
components/ui 디렉토리에 Modal.tsx 를 생성합니다.
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
interface ModalProps {
title: string;
description: string;
isOpen: boolean;
onClose: () => void;
children?: React.ReactNode;
};
export const Modal: React.FC<ModalProps> = ({
title,
description,
isOpen,
onClose,
children
}) => {
const onChange = (open: boolean) => {
if (!open) {
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={onChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<div>
{children}
</div>
</DialogContent>
</Dialog>
)
}
상태 관리 라이브러리인 Zustand를 설치합니다.
npm install zustand
hooks/use-store-modal.tsx 파일을 다음과 같이 생성합니다.
import { create } from "zustand";
interface useStoreModalStore {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
};
export const useStoreModal = create<useStoreModalStore>((set) => ({
isOpen: false,
onOpen: () => set({ isOpen: true}),
onClose: () => set({ isOpen: false }),
}));
components/modals/store-modal.tsx 파일을 다음과 같이 생성합니다.
"use client";
import { useStoreModal } from "@/hooks/use-store-modal";
import { Modal } from "@/components/ui/modal";
export const StoreModal = () => {
const storeModal = useStoreModal();
return(
<Modal
title = "가게 생성하기"
description="제품과 카테고리를 관리하기 위한 새로운 스토어 추가하기"
isOpen= {storeModal.isOpen}
onClose={storeModal.onClose}
>
Future Create Store Form
</Modal>
);
}
providers/modal-provider.tsx 파일을 다음과 같이 생성합니다.
"use client";
import { StoreModal } from "@/components/modals/store-modal";
import { useEffect, useState } from "react";
export const ModalProvider = () => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return null;
}
return (
<>
<StoreModal />
</>
);
};
마지막으로 app 디렉토리의 layout.tsx에 다음과 같이 ModalProvider를 렌더링합니다.
...
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ClerkProvider>
<html lang="en">
<body className={inter.className}>
<ModalProvider />
{children}
</body>
</html>
</ClerkProvider>
);
}
app/(root)/page.tsx 를 다음과 같이 수정합니다.
"use client";
import { useEffect } from "react";
import { useStoreModal } from "@/hooks/use-store-modal";
const SetupPage = () => {
const onOpen = useStoreModal((state) => state.onOpen);
const isOpen = useStoreModal((state) => state.isOpen);
useEffect(() => {
if(!isOpen) {
onOpen();
}
}, [onOpen, isOpen]);
return (
<div className="p-4">
Root Page
</div>
);
}
export default SetupPage;
2. Form Components
modals/store-modal.tsx 파일을 다음과 같이 수정합니다.
"use client";
import * as z from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useStoreModal } from "@/hooks/use-store-modal";
import { Modal } from "@/components/ui/modal";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const formSchema = z.object({
name: z.string().min(1),
});
export const StoreModal = () => {
const storeModal = useStoreModal();
const form = useForm<z.infer<typeof formSchema>> ({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
console.log(values);
// TODO: Create Store
}
return(
<Modal
title = "가게 생성하기"
description="제품과 카테고리를 관리하기 위한 새로운 스토어 추가하기"
isOpen= {storeModal.isOpen}
onClose={storeModal.onClose}
>
<div>
<div className="space-y-4 py-2 pb-4">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>이름</FormLabel>
<FormControl>
<Input placeholder="E-Commerce" {...field}/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="pt-6 space-x-2 flex items-center justify-end w-full">
<Button
variant="outline"
onClick={storeModal.onClose}>
취소하기
</Button>
<Button type="submit">
계속하기
</Button>
</div>
</form>
</Form>
</div>
</div>
</Modal>
);
}
성공적으로 작성했을 경우 다음과 같은 Form Components를 확인할 수 있습니다.
계속하기를 눌렀을 때 디버깅으로 통해 서버가 성공적으로 전달받고 있음을 확인하였습니다.
간편 로그인 구현
Clerk 공식 문서 : https://clerk.com/docs
.env 파일을 생성하고 Clerk Dashboard에서 제공하는 API Keys 데이터를 넣어줍니다.
(private key 이므로 gitignore에 추가하고 보안에 유의하기!)
npm install @clerk/nextjs
Clerk의 편리한 점 중 하나는 공식 문서에 App Router와 Pages Router 두 가지 예시를 제공한다는 것입니다.
아래 코드와 같이 app 디렉토리의 RootLayout을 ClerkProvider로 감싸줍니다.
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Admin Dashboard",
description: "Admin Dashboard",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ClerkProvider>
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
</ClerkProvider>
);
}
루트 디렉토리에 middleware.ts 파일을 추가합니다.
middleware.ts 파일은 클라이언트를 sign up 페이지로 리다이렉트해줍니다.
import { authMiddleware } from "@clerk/nextjs";
export default authMiddleware({});
export const config = {
matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};
아래와 같은 파일 구조로 (auth) 디렉토리를 생성하여 Sign up, Sign-in 페이지를 만들어 줍니다.
.env 파일에 signIn, signUp, afterSignup, afterSignIn 경로를 추가해 줍니다.
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
정상적으로 설정이 완료되었다면 아래와 같은 화면을 확인할 수 있습니다.
URI를 통해 올바르게 redirect 되었다는 것을 확인합니다.
(auth) 디렉토리의 Authlayout을 생성해 로그인 화면이 중앙에 위치할 수 있도록 수정했습니다.
마지막으로 아래와 같이 (root) 디렉토리의 page.tsx 파일을 수정해 로그인 이후 유저 버튼을 만들어 줍니다.
import { UserButton } from "@clerk/nextjs";
const SetupPage = () => {
return (
<div className="p-4">
<UserButton afterSignOutUrl="/" />
</div>
);
}
export default SetupPage;
저는 간편 로그인을 통해 구글로 Sign in 하였습니다. 아래와 같이 메인 화면에서 유저 버튼이 생성된 것을 확인할 수 있습니다.
Database 생성
planet scale 공식 문서 : https://planetscale.com/docs
먼저 터미널을 통해 다음 명령어로 prisma 를 설치합니다.
npm i -D prisma
npm install @prisma/client
아래 명령어를 통해 prisma 초기 설정을 진행하고 다음 순서에 따라 환경설정을 진행합니다.
$ npx prisma init
✔ Your Prisma schema was created at prisma/schema.prisma
You can now open it in your favorite editor.
warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.
Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver, mongodb or cockroachdb.
3. Run prisma db pull to turn your database schema into a Prisma schema.
4. Run prisma generate to generate the Prisma Client. You can then start querying your database. More information in our documentation: https://pris.ly/d/getting-started
.env 파일에 다음과 같은 DATABASE_URL이 추가된 것을 확인할 수 있습니다.
(이 URL은 이후 PlanetScale로 제공받은 DB 링크로 수정할 것입니다.)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY={NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}
CLERK_SECRET_KEY={CLERK_SECRET_KEY}
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
# This was inserted by `prisma init`:
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL="{DATABASE_URL}"
libs/prismadb.ts 파일을 다음과 같이 생성합니다.
이 코드의 목적은 전역으로 PrismaClient 인스턴스를 유지하고,
개발환경에서만 해당 인스턴스를 전역변수에 할당하는 것입니다.
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined
};
const prismadb = globalThis.prisma || new PrismaClient();
if(process.env.NODE_ENV !== 'production') globalThis.prisma = prismadb;
export default prismadb;
이제 PlanetScale 홈페이지에서 로그인 이후 'Create a new database'를 통해 DB 설정을 진행합니다.
PlanetScale 홈페이지에서 DB가 Initialize 되는동안 제공하는 .env 파일의 DATABASE_URL을
PlanetScale에서 제공받은 DB 링크로 수정합니다. 그리고 prisma/schema.prisma 파일을 다음과 같이 수정합니다.
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model Store {
id String @id @default(uuid())
name String
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
이후 다음 명령어를 통해 prisma를 generate 후 db push를 진행합니다.
정상적으로 진행되었다면 PlanetScale에 다음과 같은 Store 테이블이 생성됩니다.
npx prisma generate
npx prisma db push
app/api/stores/route.ts 파일을 다음과 같이 생성합니다.
import { NextResponse } from "next/server";
import { auth } from '@clerk/nextjs';
import prismadb from "@/lib/prismadb";
export async function POST (
req: Request,
) {
try {
const { userId } = auth();
const body = await req.json();
const { name } = body;
if (!userId) {
return new NextResponse("Unauthorized", { status: 401});
}
if (!name) {
return new NextResponse("Name is required", { status: 400 });
}
const store = await prismadb.store.create({
data: {
name,
userId
}
});
return NextResponse.json(store);
} catch (error) {
console.log('[STORES_POST]', error);
return new NextResponse("Internal error", { status: 500 });
}
}
components/modals/store-modal.tsx 파일을 다음과 같이 수정합니다.
...
export const StoreModal = () => {
...
const [loading, setLoading] = useState(false);
...
return (
...
<Input
disabled={loading}
...
/>
...
<Button
disabled={loading}
...>
취소하기
</Button>
<Button
disabled={loading}
...>
계속하기
</Button>
)
}
npm i axios
components/modals/store-modal.tsx 파일에서 '계속하기' 버튼을 눌렀을 때 onSubmit 를 다음과 같이 수정합니다.
axios로 서버에 데이터가 올바르게 POST 되는지 확인합니다.
...
export const StoreModal = () => {
...
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
setLoading(true);
const response = await axios.post('/api/stores', values);
console.log(response.data);
} catch (error) {
console.log(error);
} finally {
setLoading(false);
}
}
...
}
다음과 같이 '계속하기'를 누르면 서버에 데이터가 전송되어 console 창에 올바르게 출력되는 것을 알 수 있습니다.
npm i react-hot-toast
providers/toast-provider.tsx 파일을 생성합니다.
"use client";
import { Toaster } from "react-hot-toast";
export const ToastProvider = () => {
return <Toaster />;
};
app/layout.tsx 파일에 다음과 같이 추가합니다.
...
export default function RootLayout({
...
return (
<ClerkProvider>
...
<ToastProvider />
<ModalProvider />
{children}
...
</ClerkProvider>
);
}
components/modals/store-modal의 toast 코드를 수정합니다.
...
export const StoreModal = () => {
...
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
setLoading(true);
const response = await axios.post('/api/stores', values);
toast.success("Store created.");
} catch (error) {
toast.error("Something went wrong.");
} finally {
setLoading(false);
}
}
...
}