React

 

 

 

 

프로젝트 소개: 잡 트래킹 앱 (JOBIFY)

이 프로젝트의 목표는 지원한 채용 정보를 관리하고, 인터뷰 요청거절된 지원서를 추적하는 애플리케이션을 만드는 것입니다. 또한 지난 6개월간 매달 보낸 지원서 수를 시각화한 통계 차트를 제공해 사용자가 지원 기록의 일관성을 확인할 수 있도록 돕습니다.

프로젝트 주요 특징

  1. TypeScript 사용

    • 이 프로젝트는 TypeScript로 작성되었습니다.
    • TypeScript의 기본 개념에 대한 설명은 없으며, 데이터 타입 설정과 관련된 부분에 시간을 할애합니다.
    • TypeScript가 낯설어도 코드를 따라 작성하면 문제없습니다.
  2. CSS 라이브러리: shadcn UI

    • 빠르고 인기 있는 CSS 라이브러리 shadcn UI를 사용했습니다.
    • 프로젝트 개발 과정에서 이 라이브러리의 효율성을 확인했으며, 앞으로도 다른 프로젝트에서 사용할 계획입니다.
  3. Next.js 기반

    • 프로젝트는 Next.js 15를 사용해 전체 풀스택 앱을 하나의 프로젝트로 구축했습니다.
    • 백엔드와 프런트엔드 전환 없이, Next.js 환경에서 smoother한 작업 경험을 제공했습니다.

 

  1. Clark 인증 시스템

    • 사용자 인증 및 로그인 관리는 Clark 라이브러리를 통해 구현했습니다.

주요 기능 설명

1. 대시보드

  • 좌측 사이드바
    • 사용자 메뉴: 로그아웃 및 테마 변경 가능.
    • 통계 보기:
      • 현재 대기 중인 채용, 인터뷰 진행, 거절된 채용 정보의 개수.
      • 이번 달에 보낸 지원서 수.
    • 반응형 디자인: 작은 화면에서는 드롭다운 메뉴로 사이드바 제공.

2. 잡 리스트 및 검색

  • 잡 리스트

    • 10개씩 페이지네이션으로 채용 정보 표시.
    • 검색 옵션으로 특정 조건(예: 거절된 지원서) 필터링 가능.
    • 페이지네이션과 검색 결과가 실시간 반영됩니다.
  • 잡 추가

    • 신규 채용 정보를 추가하는 폼 제공.
    • 예) 직무, 회사명, 도시 정보 입력 후 저장.
  • 잡 수정

    • 지원 상태를 실시간으로 변경 가능.
    • 예) "대기" 상태를 "인터뷰"로 변경.
  • 잡 삭제

    • 버튼 클릭으로 해당 채용 정보를 리스트에서 제거.

 

 

개발 경험 및 참고 사항

  • Next.js 15 사용 소감
    • 동일한 프로젝트 내에서 백엔드와 프런트엔드를 모두 처리할 수 있어 작업 효율이 높았습니다.
    • MERN 스택으로 개발했던 유사 프로젝트보다 더 부드럽고 빠르게 완성할 수 있었습니다.
  • 테마 및 반응형 디자인
    • shadcn UI로 테마 변경 및 반응형 UI 구현.
    • 작은 화면에서는 드롭다운 메뉴를 사용하여 접근성을 강화.

테스트 및 접근 링크

이 프로젝트는 Next.js와 shadcn UI, TypeScript의 강점을 최대한 활용해 개발된 풀스택 애플리케이션입니다. 다소 규모가 크지만, 프로젝트를 따라가며 실무 수준의 기능 구현을 익힐 수 있습니다.

 

소스: https://github.com/braverokmc79/GeniusGPT-jobify

 

 

 

★ 핵심 학습내용 : shadcn  과 zod 를 이용한 유효성 체크 및 전송 방법

1.  input 및 select, texarea 등을  shadcn 을 이용한 커스텀 컴포넌트를  만든다.

2. 타이스크립트 타입 을 만든다. ex ) CreateAndEditJobType

3. prisma  설정 및 Actions  파일을 만든다.

 

 

 

 

 

 

1.프로젝트 생성

>>>>npx create-next-app@latest jobify
Need to install the following packages:
create-next-app@15.1.5
Ok to proceed? (y) y
 
npm notice Beginning October 4, 2021, all connections to the npm registry - including for package installation - must use TLS 1.2 or higher. You are currently using plaintext http to connect. Please visit the GitHub blog for more information: https://github.blog/2021-08-23-npm-registry-deprecating-tls-1-0-tls-1-1/
npm notice Beginning October 4, 2021, all connections to the npm registry - including for package installation - must use TLS 1.2 or higher. You are currently using plaintext http to connect. Please visit the GitHub blog for more information: https://github.blog/2021-08-23-npm-registry-deprecating-tls-1-0-tls-1-1/
√ Would you like to use TypeScript? ... No / Yes
√ Would you like to use ESLint? ... No / Yes
√ Would you like to use Tailwind CSS? ... No / Yes
√ Would you like your code inside a `src/` directory? ... No / Yes
√ Would you like to use App Router? (recommended) ... No / Yes
√ Would you like to use Turbopack for `next dev`? ... No / Yes
√ Would you like to customize the import alias (`@/*` by default)? ... No / Yes
√ What import alias would you like configured? ... @/*
Creating a new Next.js app in >>>>jobify.
 
Using npm.
 
Initializing project with template: app-tw
 
 
Installing dependencies:
- react
- react-dom
- next
 
Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next
- @eslint/eslintrc
 
npm notice Beginning October 4, 2021, all connections to the npm registry - including for package installation - must use TLS 1.2 or higher. You are currently using plaintext http to connect. Please visit the GitHub blog for more information: https://github.blog/2021-08-23-npm-registry-deprecating-tls-1-0-tls-1-1/
npm notice Beginning October 4, 2021, all connections to the npm registry - including for package installation - must use TLS 1.2 or higher. You are currently using plaintext http to connect. Please visit the GitHub blog for more information: https://github.blog/2021-08-23-npm-registry-deprecating-tls-1-0-tls-1-1/
 
added 373 packages in 51s
 
141 packages are looking for funding
  run `npm fund` for details
Initialized a git repository.
 
Success! Created jobify >>>>jobify
 

 

추가 설치 라이브러리

npm install \
  @clerk/nextjs \ # 사용자 인증 및 관리 (Clerk 라이브러리)  
  @hookform/resolvers \ # react-hook-form과 Zod 같은 유효성 검증 라이브러리 연결  
  @prisma/client \ # Prisma ORM 클라이언트  
  @radix-ui/react-dropdown-menu \ # Radix UI 드롭다운 메뉴 컴포넌트  
  @radix-ui/react-label \ # Radix UI 라벨 컴포넌트  
  @radix-ui/react-select \ # Radix UI 셀렉트 박스 컴포넌트  
  @radix-ui/react-separator \ # Radix UI 구분선 컴포넌트  
  @radix-ui/react-slot \ # Radix UI 슬롯 컴포넌트  
  @radix-ui/react-toast \ # Radix UI 토스트 알림 컴포넌트  
  @tanstack/react-query \ # 데이터 페칭 및 캐싱 라이브러리  
  @tanstack/react-query-devtools \ # React Query 디버깅 도구  
  axios \ # HTTP 요청 라이브러리  
  class-variance-authority \ # 조건부 Tailwind CSS 클래스 병합  
  clsx \ # 조건부 클래스 문자열 관리 유틸리티  
  dayjs \ # 날짜 및 시간 처리 라이브러리  
  lucide-react \ # React용 오픈 소스 아이콘 라이브러리  
  react-hook-form \ # React에서 폼 상태를 관리하는 라이브러리  
  recharts \ # 데이터 시각화를 위한 차트 라이브러리  
  tailwind-merge \ # Tailwind CSS 클래스 병합 도구  
  tailwindcss-animate \ # Tailwind CSS 애니메이션 플러그인  
  zod # 스키마 기반 데이터 유효성 검증 라이브러리

 

 

개발 환경 전용 라이브러리

npm install -D \
  @types/node \ # Node.js 타입 정의  
  @types/react \ # React 타입 정의  
  @types/react-dom \ # React DOM 타입 정의  
  autoprefixer \ # CSS 벤더 프리픽스를 자동으로 추가  
  eslint \ # 코드 품질 및 스타일 검사 도구  
  eslint-config-next \ # Next.js용 ESLint 설정  
  postcss \ # CSS 후처리기  
  prisma \ # Prisma CLI 및 설정 도구  
  tailwindcss \ # Tailwind CSS 프레임워크  
  typescript # TypeScript

 

 

 

추가 설치 라이브러리 전체 설치 명령어

npm install @clerk/nextjs @hookform/resolvers @prisma/client @radix-ui/react-dropdown-menu @radix-ui/react-label @radix-ui/react-select @radix-ui/react-separator @radix-ui/react-slot @radix-ui/react-toast @tanstack/react-query @tanstack/react-query-devtools axios class-variance-authority clsx dayjs lucide-react react-hook-form recharts tailwind-merge tailwindcss-animate zod

 

개발 환경 전용 라이브러리 설치 명령어

npm install -D @types/node @types/react @types/react-dom autoprefixer eslint eslint-config-next postcss prisma tailwindcss typescript

 

 

 

 

 

 

1.React 19 및 Next.js 15 환경에서 shadcn과 shadcn button 컴포넌트,

그리고 lucide-react를 설치하는 방법

 

https://lucide.dev/

 

$ npx shadcn@latest init
Need to install the following packages:
shadcn@2.1.8
Ok to proceed? (y) y

npm notice Beginning October 4, 2021, all connections to the npm registry - including for package installation - must use TLS 1.2 or higher. You are currently using plaintext http to connect. Please visit the GitHub blog for more information: https://github.blog/2021-08-23-npm-registry-deprecating-tls-1-0-tls-1-1/
npm notice Beginning October 4, 2021, all connections to the npm registry - including for package installation - must use TLS 1.2 or higher. You are currently using plaintext http to connect. Please visit the GitHub blog for more information: https://github.blog/2021-08-23-npm-registry-deprecating-tls-1-0-tls-1-1/
✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Verifying framework. Found Next.js.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS.
✔ Validating import alias.
√ Which style would you like to use? » Default
√ Which color would you like to use as the base color? » Zinc
√ Would you like to use CSS variables for theming? ... no / yes
✔ Writing components.json.
✔ Checking registry.
✔ Updating tailwind.config.ts
✔ Updating src\app\globals.css
  Installing dependencies.

It looks like you are using React 19.
Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).

? How would you like to proceed? » - Use arrow-keys. Return to submit.
>   Use --force
    Use --legacy-peer-deps


 

 

1. shadcn 설치 방법

(1) shadcn 초기화 명령 실행

shadcn을 설치하고 초기화하려면 아래 명령을 실행합니다


 

npx shadcn@latest init

 

  1. 명령 실행 후 설치 여부를 묻는 메시지가 나타나면 y를 입력하여 설치를 승인합니다.
  2. 설치 중에 아래와 같은 몇 가지 설정을 묻습니다:
    • 스타일 선택: Default 선택
    • 기본 색상 선택: Zinc 선택
    • CSS 변수 사용 여부: yes 선택 (권장)
  3. 설치 과정에서 Tailwind CSS 설정 파일과 globals.css가 업데이트됩니다.

 

 

(2) React 19로 인한 Peer Dependency 문제 해결

설치 중 React 19를 사용 중인 경우 Peer Dependency 관련 경고가 나타납니다. 두 가지 옵션 중 하나를 선택할 수 있습니다:

  • --legacy-peer-deps (권장): 더 안정적인 설치를 위해 사용합니다.
Use --legacy-peer-deps

 

    • NPM이 이전 방식의 peer dependency 해결 방식을 사용하여 설치가 진행됩니다.

 

  1. 설치 결과:
    • tailwind.config.ts 및 globals.css 파일 업데이트
    • components.json 파일 생성 (사용자 정의 컴포넌트 관리)
    • 의존성 설치 완료.

 

 

 

2. shadcn Button 컴포넌트 설치 (npx shadcn@latest add button)

npx shadcn@latest add button

 

    결과:

    • src/components/ui/button.tsx 생성
    • 버튼 컴포넌트의 기본 스타일과 기능 포함.

    button 컴포넌트를 이제 프로젝트에서 사용할 수 있음.

     

     

     

     

    3. lucide-react 설치 및 설정

    lucide-react는 shadcn에서 아이콘을 제공하기 위해 사용하는 라이브러리입니다.

    1. lucide-react 설치

    사용 방법

    import { Button } from "@/components/ui/button";
    import { Camera } from "lucide-react";
    
    export default function Home() {
      return (
        <div className="flex h-screen justify-center  items-center">
          <Button> default Button</Button>
          <Button variant="outline" size="icon" >
            <Camera />
          </Button>
        </div>
      );
    }
    

     

     

    최종 설치 명령 요약

    # 1. shadcn 초기화
    npx shadcn@latest init
    
    # 2. Button 컴포넌트 추가
    npx shadcn@latest add button
    
    # 3. lucide-react 설치
    npm install lucide-react
    

     

     

     

     

     

     

     

     

     

    2. shadcn의 Dropdown Menu 컴포넌트 설치 사용법

     

    https://ui.shadcn.com/docs/components/dropdown-menu

     

     

    import {
      DropdownMenu,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuLabel,
      DropdownMenuSeparator,
      DropdownMenuTrigger,
    } from "@/components/ui/dropdown-menu"
    

     

    <DropdownMenu>
      <DropdownMenuTrigger>Open</DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuLabel>My Account</DropdownMenuLabel>
        <DropdownMenuSeparator />
        <DropdownMenuItem>Profile</DropdownMenuItem>
        <DropdownMenuItem>Billing</DropdownMenuItem>
        <DropdownMenuItem>Team</DropdownMenuItem>
        <DropdownMenuItem>Subscription</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
    

     

    "use client"
    
    import * as React from "react"
    import { DropdownMenuCheckboxItemProps } from "@radix-ui/react-dropdown-menu"
    
    import { Button } from "@/components/ui/button"
    import {
      DropdownMenu,
      DropdownMenuCheckboxItem,
      DropdownMenuContent,
      DropdownMenuLabel,
      DropdownMenuSeparator,
      DropdownMenuTrigger,
    } from "@/components/ui/dropdown-menu"
    
    type Checked = DropdownMenuCheckboxItemProps["checked"]
    
    export function DropdownMenuCheckboxes() {
      const [showStatusBar, setShowStatusBar] = React.useState<Checked>(true)
      const [showActivityBar, setShowActivityBar] = React.useState<Checked>(false)
      const [showPanel, setShowPanel] = React.useState<Checked>(false)
    
      return (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant="outline">Open</Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent className="w-56">
            <DropdownMenuLabel>Appearance</DropdownMenuLabel>
            <DropdownMenuSeparator />
            <DropdownMenuCheckboxItem
              checked={showStatusBar}
              onCheckedChange={setShowStatusBar}
            >
              Status Bar
            </DropdownMenuCheckboxItem>
            <DropdownMenuCheckboxItem
              checked={showActivityBar}
              onCheckedChange={setShowActivityBar}
              disabled
            >
              Activity Bar
            </DropdownMenuCheckboxItem>
            <DropdownMenuCheckboxItem
              checked={showPanel}
              onCheckedChange={setShowPanel}
            >
              Panel
            </DropdownMenuCheckboxItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )
    }
    

     

     

     

    서 활용법

    1. shadcn 공식 문서:
      각 컴포넌트의 설치 및 사용법은 shadcn 문서에서 확인할 수 있습니다.

     

     

    1. TypeScript 도움:
      TypeScript 환경에서 잘못된 props를 전달하면 경고가 발생하므로 이를 활용해 오류를 예방할 수 있습니다.

     

     

     

     

     

     

     

     

     

    3. Layout and Homepage

     

     

    src/app/page.tsx

    import Image from "next/image";
    import Logo from "../assets/logo.svg";
    import LandignImg from '../assets/main.svg';
    import { Button } from "@/components/ui/button";
    import Link from "next/link";
    
    
    export default function Home() {
    
      return (
        <main>
          <header className="max-w-6xl mx-auto px-4 sm:px-8 py-6">
            <Image src={Logo} alt="logo"  /> 
          </header>
     
        
          <section className="max-w-6xl mx-auto px-4 sm:px-8 h-screen -mt-20 grid lg:grid-cols-[1fr,1fr,400px] items-center">
            <div>
              <h1 className="capitalize text-4xl md:text-7xl font-bold">
                job <span className="text-primary">tracking</span> app
              </h1>
    
              <p className="leading-loose max-w-md mt-4">
                Lorem ipsum dolor sit amet consectetur, adipisicing elit. Illo voluptatem
                temporibus dolorem ex alias sunt quibusdam numquam suscipit, unde, eveniet fuga.
                Nulla voluptate libero similique numquam, saepe ipsa magnam optio!
              </p>
    
              
              <Button asChild className="mt-4" >
                 <Link href="/add-job">Get Started</Link>
              </Button>
            </div>
    
            
             <Image src={LandignImg} alt="landing" className="hidden lg:block" />        
          </section>
         
    
    
        </main>
      );
    }
    

     

     

    타이틀, 설명, 파비콘 설정

    • layout.tsx에서 metadata 값을 수정하여 타이틀과 설명(title, description) 변경
    • 파비콘(favicon) 추가 (파일을 프로젝트에 드래그 앤 드롭)

     

     

    1. 홈 페이지 설정

    1. page.tsx 생성 후 기존 내용 제거

    2. main 태그 내부에 header 추가하고, next/image를 사용해 로고 삽입

      • max-w-6xl, text-center, px-4, py-6 등의 Tailwind CSS 클래스 적용
      • Next.js의 next/image 컴포넌트 사용하여 로고 이미지 추가

     

    1. 메인 섹션 추가 (section 태그)

      • 최대 너비 max-w-6xl, 화면 가운데 정렬
      • h-screen(100vh) 및 -mt-20(상단 마진) 적용
      • grid 레이아웃 사용하여 텍스트 영역이미지 영역 배치
      • lg:grid-cols-[1fr,400px](큰 화면에서 텍스트와 이미지 컬럼 크기 조정
      •  
    2. 텍스트 및 버튼 추가

      • h1 태그: text-4xl, md:text-5xl, font-bold, text-primary(추후 테마 색상 적용 예정)
      • p 태그: leading-loose, max-w-md, mt-4(줄 간격 및 너비 조정)
      • 버튼(button 태그) 내부에 Next.js의 Link 컴포넌트 사용
      • 버튼 클릭 시 /add-job 페이지로 이동 (아직 없는 페이지지만 나중에 추가할 예정)
    3.  
    4. 이미지 추가

      • next/image 사용하여 main.svg 이미지 삽입
      • 기본적으로 hidden, lg:block을 추가하여 큰 화면에서만 표시

     

     

    2.리소스(Assets) 정보

    • 파비콘: favicon.io에서 생성
    • 일러스트 이미지: undraw.co에서 색상 변경 가능
    • 로고 디자인: Figma에서 직접 제작하여 Figma Community에서 공유

     

     

     

     

     

     

     

     

    4. Next.js의 App Router를 사용하여 대시보드 관련 페이지를 구성

     

    • app/ 폴더 내에서 **대시보드(dashboard)**를 위한 페이지들을 구성
    • dashboard 폴더를 URL 경로에 포함시키지 않도록 설정
    • layout.tsx를 만들어 대시보드 내부 페이지들의 레이아웃을 관리
    • AddJobPage, JobsPage, StatsPage 페이지를 각각 생성

     

     

    dashboard 폴더 생성 및 URL에 포함되지 않게 설정

    • app/ 폴더에서 대시보드 관련 페이지를 그룹화하기 위해 dashboard 폴더를 생성
    • 폴더 이름을 ()(괄호)로 감싸면 URL 경로에 영향을 주지 않음
      • 예: app/(dashboard)/ → 실제 URL에는 dashboard가 나타나지 않음

     

     

    페이지 파일 생성 (AddJobPage, JobsPage, StatsPage)

    각 페이지의 page.tsx 파일을 생성하고, 간단한 제목을 표시하는 컴포넌트를 작성

    • app/(dashboard)/add-job/page.tsx
    • app/(dashboard)/jobs/page.tsx
    • app/(dashboard)/stats/page.tsx

     

    app
     ├── (dashboard)
     │   ├── add-job
     │   │   ├── page.tsx
     │   ├── jobs
     │   │   ├── page.tsx
     │   ├── stats
     │   │   ├── page.tsx
     │   ├── layout.tsx
    

     

    layout.tsx 생성 및 적용

    • 대시보드 내부 페이지들의 공통 레이아웃을 정의
    • layout.tsx 내부에서 children을 받아서 모든 하위 페이지를 감싸도록 구현

     layout.tsx 코드 개요

     

    import React from 'react'
    
    interface LayoutProps {
      children: React.ReactNode
    }
    
    const Layout: React.FC<LayoutProps> = ({ children }) => {
      return <div>{children}</div>
    }
    
    export default Layout
    

     

     

     

     

     

     

     

     

     

    5. Clerk Auth 적용

     

    다음 참조

    https://macaronics.net/m04/react/view/2363

     

    https://clerk.com/docs/references/nextjs/overview

     

     

     

     

     

    6. 대시보드 네비게이션 및 레이아웃 설정

     

    1. 네비게이션 링크 배열 만들기

    (1) utils/links.tsx 파일 생성

    • 프로젝트의 여러 곳에서 재사용할 수 있도록 네비게이션 링크를 배열로 관리합니다.
    • href, label, icon 속성을 포함하는 객체 배열을 정의합니다.
    • TypeScript의 타입을 활용하여 코드의 안전성을 높입니다.
    import { AreaChart, Layers, AppWindow } from "lucide-react";
    
    type NavLink = {
      href: string;
      label: string;
      icon: React.ReactNode;
    };
    
    const links: NavLink[] = [
      {
        href: "/add-job",
        label: "Add Job",
        icon: <Layers />,
      },
      {
        href: "/jobs",
        label: "Jobs",
        icon: <AppWindow />,
      },
      {
        href: "/stats",
        label: "Stats",
        icon: <AreaChart />,
      },
    ];
    
    export default links;
    

     

    2. 대시보드 레이아웃 설정

    (1) layout.tsx 파일 생성

    • Navbar와 Sidebar 컴포넌트를 포함하는 메인 레이아웃을 설정합니다.
    • 작은 화면에서는 Sidebar를 숨기고, 드롭다운 메뉴를 활용합니다.
    • 큰 화면에서는 Sidebar를 표시하고, 전체 UI를 grid 레이아웃으로 구성합니다.
    import Navbar from '@/components/Navbar';
    import Sidebar from '@/components/Sidebar';
    import React from 'react';
    
    interface LayoutProps {
        children: React.ReactNode;  
    }
    
    const Layout: React.FC<LayoutProps> = ({ children }) => {
      return (
        <main className='grid lg:grid-cols-5'>
          {/* 사이드바 (작은 화면에서는 숨김) */}
          <div className='hidden lg:block lg:col-span-1 lg:min-h-screen'>
            <Sidebar />
          </div>
    
          {/* 네비게이션 바 및 페이지 콘텐츠 */}
          <div className='lg:col-span-4'>
              <Navbar />
              <div className='py-16 px-4 sm:px-8 lg:px-16'>
                  {children}
              </div>
          </div>
        </main>
      );
    };
    
    export default Layout;
    

     

     

    3. 대시보드 관련 컴포넌트 생성

    (1) Sidebar.tsx 파일 생성

    const Sidebar = () => {
      return (
        <aside className='p-4 bg-gray-900 text-white min-h-screen'>
          <h1 className='text-2xl font-bold'>Sidebar</h1>
          {/* 네비게이션 링크 추가 예정 */}
        </aside>
      );
    };
    
    export default Sidebar;
    

    (2) Navbar.tsx 파일 생성

    const Navbar = () => {
      return (
        <nav className='p-4 bg-white shadow-md'>
          <h1 className='text-xl font-bold'>Navbar</h1>
          {/* 드롭다운 메뉴 추가 예정 */}
        </nav>
      );
    };
    
    export default Navbar;
    

    4. 대시보드 레이아웃 동작 방식

    • 작은 화면에서는 Sidebar를 숨기고 Navbar만 보이도록 설정합니다.
    • 큰 화면에서는 Sidebar가 나타나며 Navbar는 계속 유지됩니다.
    • children을 활용하여 페이지별 콘텐츠가 동적으로 삽입됩니다.

    (1) 작은 화면에서의 UI

    • Navbar 표시
    • Sidebar 숨김
    • 드롭다운 메뉴 활용 가능

    (2) 큰 화면에서의 UI

    • Sidebar 표시
    • Navbar 유지
    • 페이지 콘텐츠가 메인 영역에 표시됨

     

     

     

     

     

     

     

     

    7. 앱 라우터 방식 Sidebar 정리

     

     

    이번 Sidebar 컴포넌트는 Next.js 15의 App Router 방식을 사용하며, usePathname 훅을 활용하여 현재 경로를 확인하고, 활성화된 링크를 구분하는 방식으로 구현됩니다.

    구현 목표

    1. 로고 표시: Sidebar 최상단에 로고 배치
    2. 링크 목록 반복 렌더링: links 배열을 기반으로 링크 버튼 생성
    3. 현재 경로 확인: usePathname 훅을 사용하여 현재 URL과 비교
    4. 스타일 적용: 현재 경로와 일치하면 활성화 스타일(variant: 'default') 적용

     코드 설명

     

    1️⃣ 필요한 모듈 및 컴포넌트 불러오기

    "use client";
    import { usePathname } from 'next/navigation';
    import Image from "next/image";
    import Logo from "../assets/logo.svg";
    import { Button } from "@/components/ui/button";
    import Link from "next/link";
    import { links } from '@/utils/links';
    
    • usePathname: 현재 페이지의 경로를 가져오는 Next.js 훅
    • Image: Next.js의 이미지 최적화 컴포넌트
    • Button: UI 스타일을 적용할 버튼
    • links: 네비게이션 링크 배열

    2️⃣ Sidebar 컴포넌트 정의 및 경로 확인

    const Sidebar = () => {
      const pathname = usePathname(); // 현재 경로 확인
    
    • usePathname을 사용해 현재 URL을 가져옴

    3️⃣ Sidebar 레이아웃 구성

      return (
        <aside className='py-4 px-8 bg-muted h-full'>
          <Image src={Logo} alt="logo" className='mx-auto' />
    
    •  
    •  
    • aside 태그 사용 (사이드바 UI 구현)
    • bg-muted 배경 색상 적용
    • h-full을 추가하여 부모 요소의 높이를 따라가도록 설정

    4️⃣ 링크 리스트 반복 렌더링

          <div className='flex flex-col mt-20 gap-y-4'>
            {links.map((link) => {
              return (
                <Button
                  asChild
                  key={link.href}
                  variant={pathname === link.href ? 'default' : 'link'}
                >
                  <Link href={link.href} className='flex items-center gap-x-2'>
                    {link.icon} <span className='capitalize'>{link.label}</span>
                  </Link>
                </Button>
              );
            })}
          </div>
        </aside>
      );
    }
    
    • links.map()을 사용하여 네비게이션 링크 생성
    • variant를 현재 pathname과 비교하여 default (활성화) 또는 link (비활성화) 스타일 적용
    • asChild 속성을 사용해 <Button> 내부에서 <Link>가 렌더링될 수 있도록 설정

    ???? 결과

    • Sidebar 상단에 로고 표시
    • 현재 페이지와 일치하는 네비게이션 링크만 활성화 스타일 적용

     

     

     

     

     

     

     

     

     

    8.LinksDropdown 컴포넌트 정리

    1. 개요

    Next.js 15의 App Router 방식 프로젝트에서 LinksDropdown 컴포넌트는 모바일 화면에서 네비게이션 링크를 드롭다운 메뉴 형태로 제공하는 기능을 담당한다. shadcn/ui 라이브러리의 DropdownMenu 컴포넌트를 활용하여 구현되었으며, lucide-react 아이콘 라이브러리를 사용한다.

    2. 주요 구현 내용

    (1) 컴포넌트 구조

    • DropdownMenu: 드롭다운의 루트 컴포넌트
    • DropdownMenuTrigger: 버튼 트리거 (햄버거 아이콘)
    • DropdownMenuContent: 드롭다운 내부 컨텐츠 (링크 목록)
    • DropdownMenuItem: 개별 링크 항목
    • Link: Next.js의 next/link를 활용한 내비게이션

    (2) 주요 기능

    • 반응형 처리: lg:hidden 클래스를 사용하여 모바일에서만 보이도록 설정
    • 아이콘과 텍스트 표시: 링크 리스트에 아이콘과 텍스트를 함께 배치
    • 올바른 정렬 처리: 네비게이션에서의 정렬 문제 해결을 위해 div로 감싸 배치 조정

    3. 코드 구현

    import React from "react";
    import {
      DropdownMenu,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuTrigger,
    } from "@/components/ui/dropdown-menu";
    import { Button } from "./ui/button";
    import { AlignLeft } from "lucide-react";
    import { links } from "@/utils/links";
    import Link from "next/link";
    
    const LinksDropdown: React.FC = () => {
      return (
        <DropdownMenu>
          <DropdownMenuTrigger asChild className="lg:hidden">
             <Button variant={"outline"} size={"icon"}>
              <AlignLeft />
              <span className="sr-only">Toggle links</span>
             </Button>
          </DropdownMenuTrigger>
    
          <DropdownMenuContent className="w-52 lg:hidden" align="start" sideOffset={25}>
               {links.map((link) => (
                  <DropdownMenuItem key={link.href}>
                      <Link href={link.href} className="flex items-center gap-x-2">
                         {link.icon} <span className="capitalize">{link.label}</span>
                       </Link>   
                    </DropdownMenuItem>
               ))}    
          </DropdownMenuContent>
        </DropdownMenu>
      );
    };
    
    export default LinksDropdown;
    

    4. 핵심 구현 포인트

    (1) 드롭다운 트리거 설정

    • DropdownMenuTrigger를 Button 컴포넌트로 감싸서 버튼 클릭 시 메뉴가 열리도록 설정
    • lg:hidden 클래스를 적용하여 큰 화면에서는 숨김 처리

    (2) 드롭다운 컨텐츠 설정

    • DropdownMenuContent 내부에서 links 배열을 map 함수로 순회하여 DropdownMenuItem을 생성
    • flex items-center gap-x-2 클래스를 적용하여 아이콘과 텍스트 간 간격 유지
    • w-52 (208px) 크기로 드롭다운 너비 지정
    • align="start" 및 sideOffset={25} 적용으로 위치 조정

    (3) 네비게이션 정렬 이슈 해결

    • lg:hidden 속성으로 인해 큰 화면에서 드롭다운이 숨겨질 때 정렬이 틀어지는 문제 발생
    • 이를 해결하기 위해 LinksDropdown을 <div>로 감싸 공간을 유지하도록 조정

     

     

     

     

     

     

    9. shadcn을 이용한 다크모드 토글 기능 구현

    1. 프로젝트 설정

    Next.js 15의 앱 라우터 방식과 shadcn 라이브러리를 사용하여 테마 토글 기능을 구현하는 방법을 정리한다. 이번 구현에서는 next-themes 패키지를 사용하여 다크 모드를 손쉽게 적용할 것이다.

     

    2. 필요한 패키지 설치

    먼저, next-themes 패키지를 설치한다.

    npm install next-themes
    npm install --save-dev @types/next-themes
    

    이 패키지는 Next.js 환경에서 다크모드를 쉽게 적용할 수 있도록 도와준다.

     

    3. ThemeProvider 컴포넌트 생성

    next-themes에서 제공하는 ThemeProvider를 감싸는 커스텀 프로바이더를 생성한다.

    "use client";
    import React from 'react';
    import { ThemeProvider as NextThemesProvider } from 'next-themes';
    
    type ThemeProviderProps = React.ComponentProps<typeof NextThemesProvider>;
    
    const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, ...props }) => {
      return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
    };
    
    export default ThemeProvider;
    

    이 컴포넌트는 next-themes에서 제공하는 ThemeProvider를 감싸면서, children을 내부에서 렌더링하는 역할을 한다.

     

     

    4. Providers 컴포넌트 생성

    애플리케이션의 최상위 레벨에서 ThemeProvider를 감싸는 Providers 컴포넌트를 만든다.

    "use client";
    import ThemeProvider from "@/components/theme-provider";
    import React from "react";
    
    interface ProvidersProps {
      children: React.ReactNode;
    }
    
    const Providers: React.FC<ProvidersProps> = ({ children }) => {
      return (
        <ThemeProvider
          attribute='class'
          defaultTheme='system'
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      );
    };
    
    export default Providers;
    
    • attribute="class" → 테마 변경 시 HTML의 class 속성을 업데이트함.
    • defaultTheme="system" → 기본적으로 시스템 테마를 따름.
    • enableSystem → 시스템 테마 감지를 활성화함.
    • disableTransitionOnChange → 테마 변경 시 부드러운 전환 효과를 비활성화함.

     

    5. ModeToggle 컴포넌트 생성 (테마 토글 버튼)

    테마를 변경할 수 있는 버튼을 만들고, DropdownMenu를 활용해 사용자에게 옵션을 제공한다.

    'use client';
    
    import * as React from 'react';
    import { Moon, Sun } from 'lucide-react';
    import { useTheme } from 'next-themes';
    
    import { Button } from '@/components/ui/button';
    import {
      DropdownMenu,
      DropdownMenuContent,
      DropdownMenuItem,
      DropdownMenuTrigger,
    } from '@/components/ui/dropdown-menu';
    
    export default function ModeToggle() {
      const { setTheme } = useTheme();
    
      return (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button variant='outline' size='icon'>
              <Sun className='h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0' />
              <Moon className='absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100' />
              <span className='sr-only'>Toggle theme</span>
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align='end'>
            <DropdownMenuItem onClick={() => setTheme('light')}>
              Light
            </DropdownMenuItem>
            <DropdownMenuItem onClick={() => setTheme('dark')}>
              Dark
            </DropdownMenuItem>
            <DropdownMenuItem onClick={() => setTheme('system')}>
              System
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      );
    }
    
    • useTheme() → next-themes에서 제공하는 훅으로 현재 테마를 가져오고 변경할 수 있음.
    • setTheme('light') → 라이트 모드로 변경.
    • setTheme('dark') → 다크 모드로 변경.
    • setTheme('system') → 시스템 기본 설정을 따름.
    • Tailwind를 이용하여 아이콘 변경 애니메이션을 CSS로 구현.

     

    6. layout.tsx에서 Providers 적용

    최상위 layout.tsx에서 Providers를 적용하여 전역에서 사용할 수 있도록 한다.

    import Providers from '@/components/providers';
    
    export default function RootLayout({ children }: { children: React.ReactNode }) {
      return (
        <html lang='ko' suppressHydrationWarning>
          <body>
            <Providers>
              {children}
            </Providers>
          </body>
        </html>
      );
    }
    
    • <html> 태그에 suppressHydrationWarning 속성을 추가하여 Next.js에서 발생할 수 있는 경고를 방지함.
    • Providers를 감싸 모든 페이지에서 테마 기능을 사용할 수 있도록 설정.

     

    7. 결과 및 테스트

    위 설정이 완료되면, 브라우저에서 실행하여 다크 모드와 라이트 모드를 전환할 수 있다.

    • ModeToggle 버튼을 클릭하면 테마 변경이 정상적으로 이루어지는지 확인한다.
    • localStorage에 테마 설정이 저장되는지도 확인한다.
    • system 옵션을 선택했을 때, 운영 체제의 설정을 따르는지 테스트한다.

    이제 Next.js 15의 앱 라우터 방식과 shadcn을 이용하여 다크모드 토글 기능을 완벽하게 구현할 수 있다!

     

     

     

     

     

     

    10. ShadCN Form  사용 및  zod 를 사용한 유효성 체크

     

     

    1. 프로젝트 개요

    Next.js 15의 App Router 방식을 기반으로 ShadCN UI 라이브러리를 활용하여 폼(form) 컴포넌트를 구성하는 방법을 정리합니다. ShadCN은 직관적인 UI 컴포넌트와 React Hook Form, Zod 등의 라이브러리를 활용한 강력한 폼 기능을 제공합니다. 이를 통해 사용자 입력을 보다 쉽게 검증하고 관리할 수 있습니다.

     

     

    2. ShadCN Form 컴포넌트 설치

    ShadCN에서 제공하는 form 및 input 컴포넌트를 설치합니다.

     

     

    npx shadcn@latest add form input
    

    설치 후, package.json에 여러 패키지가 추가될 수 있습니다.

     

     

    3. 폼(Form) 컴포넌트 작성

    ???? CreateJobForm.tsx 파일 생성 및 기본 설정

    아래와 같이 폼 컴포넌트를 생성합니다.

    "use client";
    import * as z from "zod";
    import { zodResolver } from "@hookform/resolvers/zod";
    import { useForm } from "react-hook-form";
    import { Button } from "./ui/button";
    import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "./ui/form";
    import { Input } from '@/components/ui/input';
    
    // ???? 입력값 검증을 위한 Zod 스키마 정의
    const formSchema = z.object({
        username: z.string().min(2, {
            message: "사용자 아이디는 2자 이상이어야 합니다.",
        })
    });
    
    function CreateJobForm() {
      // ???? useForm 훅을 사용하여 폼 설정
      const form = useForm<z.infer<typeof formSchema>>({
        resolver: zodResolver(formSchema),
        defaultValues: {
          username: "",
        }
      });
    
      // ???? 폼 제출 핸들러
      function onSubmit(value: z.infer<typeof formSchema>) {        
        console.log("onSubmit 호출됨 :", value);
      }
    
      return (
        <Form {...form}>
          <form className="space-y-8" onSubmit={form.handleSubmit(onSubmit)}>
            <FormField
              control={form.control}
              name="username"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>사용자 아이디</FormLabel>
                  <FormControl>
                    <Input placeholder="사용자 아이디" {...field} />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            <Button type="submit">등록하기</Button>
          </form>
        </Form>
      );
    }
    export default CreateJobForm;
    

     

     

    4. AddJob.tsx에서 CreateJobForm 사용

    이제 CreateJobForm을 AddJob.tsx에서 렌더링하여 활용할 수 있습니다.

    import CreateJobForm from "@/components/CreateJobForm";
    
    export default function AddJob() {
      return (
        <div>
          <h2 className="text-xl font-bold">채용 공고 등록</h2>
          <CreateJobForm />
        </div>
      );
    }
    

    5. ShadCN Form 컴포넌트의 주요 특징

    ShadCN의 Form 컴포넌트는 여러 라이브러리를 활용하여 다양한 기능을 제공합니다.

     

    Zod와 함께 유효성 검사

    • Zod를 사용하면 입력값의 유효성을 쉽게 검사할 수 있습니다.
    • 예제에서는 username이 최소 2자 이상이어야 한다는 규칙을 적용했습니다.

     

    React Hook Form을 통한 상태 관리

    • useForm 훅을 사용하여 폼의 상태를 관리합니다.
    • handleSubmit 함수를 통해 유효성 검사를 거친 데이터를 처리할 수 있습니다.

     

    ShadCN UI 컴포넌트 활용

    • FormField, FormItem, FormLabel, FormControl, FormMessage 등의 컴포넌트를 사용하여 일관된 스타일을 유지할 수 있습니다.

     

    6. 코드 리팩토링 (재사용성 향상)

    현재 코드에서는 하나의 파일에서 모든 로직을 처리하고 있습니다. 이후 프로젝트가 확장되면서 입력 필드가 늘어나면 코드가 복잡해질 수 있습니다. 따라서 폼 스키마 및 폼 컴포넌트를 분리하여 재사용성을 높이는 것이 좋습니다.

     

    ???? formSchema.ts 파일 생성 (유효성 검사 로직 분리)

    import * as z from "zod";
    
    export const formSchema = z.object({
        username: z.string().min(2, {
            message: "사용자 아이디는 2자 이상이어야 합니다.",
        })
    });
    

    ???? CreateJobForm.tsx에서 스키마 분리하여 사용

    import { formSchema } from "@/lib/formSchema";
    

    이와 같은 방식으로 폼 로직을 모듈화하면 유지보수와 확장성이 크게 향상됩니다.

     

     

    7. 결론

    이번 정리에서는 Next.js 15 + App Router 방식을 활용하여 ShadCN UI 컴포넌트로 폼을 구성하는 방법을 다루었습니다. 또한, Zod를 통한 유효성 검사React Hook Form을 활용한 상태 관리 방법을 설명했습니다. 프로젝트가 확장될 경우 폼 스키마와 입력 필드를 분리하여 코드의 유지보수성을 높이는 것이 중요합니다.

    ShadCN을 사용하면 복잡한 폼을 쉽게 구현할 수 있으며, 보다 직관적인 유저 경험을 제공할 수 있습니다.

     

     

     

     

     

     

     

    11.타입 및 스키마 파일 분리

     

    1. 타입 및 스키마 파일 분리

    기존 코드 구조

    기존 코드에서는 타입과 Zod 스키마가 함께 정의되어 있었습니다. 하지만, 프로젝트 유지보수성과 가독성을 높이기 위해 이를 분리하는 것이 좋습니다. 따라서, 타입과 스키마를 별도의 파일로 관리합니다.

     

    2. types.ts 파일 생성 (유틸 폴더 내 저장)

    이 파일에는 JobType, JobStatus, JobMode 등의 타입과 Enum을 정의합니다.

    import * as z from 'zod';
    
    export type JobType = {
      id: string;
      createdAt: Date;
      updatedAt: Date;
      clerkId: string;
      position: string;
      company: string;
      location: string;
      status: string;
      mode: string;
    };
    
    export enum JobStatus {
      Pending = 'pending',
      Interview = 'interview',
      Declined = 'declined',
    }
    
    export enum JobMode {
      FullTime = 'full-time',
      PartTime = 'part-time',
      Internship = 'internship',
    }
    

     

     

    3. schemas.ts 파일 생성

    이제 Zod를 활용한 스키마를 별도의 파일로 분리합니다. 이를 통해 데이터 유효성 검사를 보다 체계적으로 관리할 수 있습니다.

    export const createAndEditJobSchema = z.object({
      position: z.string().min(2, {
        message: '직책은 2자 이상이어야 합니다.',
      }),
      company: z.string().min(2, {
        message: '회사는 2자 이상이어야 합니다.',
      }),
      location: z.string().min(2, {
        message: '위치는 2자 이상이어야 합니다.',
      }),
    
      status: z.nativeEnum(JobStatus,{
        message: '직업상태를 선택하세요.',
      }),
      mode: z.nativeEnum(JobMode,{
        message: '고용형태를 선택하세요.',
      }),
    
    });

     

    4. 타입과 스키마의 활용

    이제 프로젝트 내에서 types.ts와 schemas.ts를 필요에 따라 불러와 사용할 수 있습니다.

    예제: create-job.tsx

    import { createAndEditJobSchema } from '@/utils/schemas';
    import { CreateAndEditJobType } from '@/utils/types';
    
    const jobFormSchema: CreateAndEditJobType = {
      position: '',
      company: '',
      location: '',
      status: JobStatus.Pending,
      mode: JobMode.FullTime,
    };
    

     

     

    5. 분리의 장점

    1. 유지보수성 향상: 타입과 스키마를 한 곳에서 관리하므로 수정이 용이함.
    2. 가독성 증가: 코드가 깔끔해지고, 각 파일의 역할이 명확해짐.
    3. 재사용성 증가: 여러 곳에서 동일한 타입과 스키마를 활용할 수 있음.
    4. 타입스크립트 지원 강화: IDE에서 자동 완성 및 타입 추론이 원활해짐.

    이제 Next.js 15와 shadcn을 활용한 프로젝트에서 타입과 스키마를 분리하여 보다 효율적인 개발을 진행할 수 있습니다.

     

     

     

     

     

     

     

    12.shadcn을 이용한 재사용   폼  컴포넌트 만들기

    eslint.config.mjs 파일에서 any 타입 사용허용

    import { dirname } from "path";
    import { fileURLToPath } from "url";
    import { FlatCompat } from "@eslint/eslintrc";
    
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = dirname(__filename);
    
    const compat = new FlatCompat({
      baseDirectory: __dirname,
    });
    
    const eslintConfig = [
      ...compat.extends("next/core-web-vitals", "next/typescript"),
      {
        rules: {
          "@typescript-eslint/no-explicit-any": "off", // any 사용 허용
        },
      },
    ];
    
    export default eslintConfig;
    

     

     

     

    1. shadcn Select 컴포넌트 설치

    npx shadcn@latest add select
    

    이 명령어를 실행하여 shadcn의 Select 컴포넌트를 설치합니다.

     

     

    2. 폼 필드 및 셀렉트 컴포넌트 생성

    CustomFormField 컴포넌트

    react-hook-form과 shadcn의 UI 컴포넌트를 활용하여 재사용 가능한 폼 필드 컴포넌트를 만듭니다.

    import { Control} from 'react-hook-form';
    import {
      FormControl,
      FormField,
      FormItem,
      FormLabel,
      FormMessage,
    } from '@/components/ui/form';
    import { Input } from './ui/input';
    
    // 커스텀 폼 필드 프로퍼티 타입 정의
    type CustomFormFieldProps = {
      name: string;
      control: Control<any>;
    };
    
    export function CustomFormField({ name, control }: CustomFormFieldProps) {
      return (
        <FormField
          control={control}
          name={name}
          render={({ field }) => (
            <FormItem>
              <FormLabel className='capitalize'>{name}</FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
      );
    }
    

     

     

    CustomFormSelect 컴포넌트

    shadcn의 Select 컴포넌트를 활용하여 선택형 입력을 위한 재사용 가능한 컴포넌트를 생성합니다.

    import { Control} from 'react-hook-form';
    import {
      Select,
      SelectContent,
      SelectItem,
      SelectTrigger,
      SelectValue,
    } from '@/components/ui/select';
    import {
      FormControl,
      FormField,
      FormItem,
      FormLabel,
      FormMessage,
    } from '@/components/ui/form';
    
    // 커스텀 폼 셀렉트 프로퍼티 타입 정의
    type CustomFormSelectProps = {
      name: string;
      control: Control<any>;
      items: string[];
      labelText?: string;
    };
    
    export function CustomFormSelect({
      name,
      control,
      items,
      labelText,
    }: CustomFormSelectProps) {
      return (
        <FormField
          control={control}
          name={name}
          render={({ field }) => (
            <FormItem>
              <FormLabel className='capitalize'>{labelText || name}</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  {items.map((item) => (
                    <SelectItem key={item} value={item}>
                      {item}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
      );
    }
    
    export default CustomFormSelect;
    

     

     

    3. 재사용 가능한 폼 필드 컴포넌트로 분리하는 이유

    • 재사용성 증가: 여러 곳에서 반복되는 코드 없이 컴포넌트를 재사용할 수 있습니다.
    • 코드 가독성 향상: CreateJobForm과 같은 폼을 만들 때, 코드의 양을 줄여 유지보수가 용이해집니다.
    • 모듈화: CustomFormField와 CustomFormSelect를 별도의 파일에서 관리하여 코드가 명확해집니다.

    이제, CreateJobForm을 구성할 때 위에서 만든 CustomFormField와 CustomFormSelect를 사용하면 더욱 효율적인 폼을 작성할 수 있습니다.

     

     

     

     


     

     

     

     

     

    13.★ zod와  shadcn 을 이용한  유효성 체크 react-hook-form과 zod를 이용하여 폼을 검증하고, 커스텀 컴포넌트를 만들어 일관된 UI를 유지

     

     

    1. 개요

     react-hook-form과 zod를 이용하여 폼을 검증하고, 커스텀 컴포넌트를 만들어 일관된 UI를 유지합니다.

     

     

    2. 주요 라이브러리 및 패키지

    • Next.js 15: 최신 앱 라우터 방식 적용
    • shadcn: UI 컴포넌트 활용
    • react-hook-form: 폼 상태 및 검증
    • zod: 스키마 기반 검증

    3. 코드 구성

     

     

    3.1. 폼 컴포넌트 구현

    "use client";
    import { zodResolver } from "@hookform/resolvers/zod";
    import { useForm } from "react-hook-form";
    import { Button } from "./ui/button";
    import { Form } from "./ui/form";
    import { createAndEditJobSchema, CreateAndEditJobType, JobMode, JobStatus } from "@/dto/JobDTO";
    import CustomFormSelect, { CustomFormField } from "./FormComponents";
    
    function CreateJobForm() {
      const form = useForm<CreateAndEditJobType>({
        resolver: zodResolver(createAndEditJobSchema),
        defaultValues: {
          position: "",
          company: "",
          location: "",
          status: JobStatus.Pending,
          mode: JobMode.FullTime,
        },
      });
    
      function onSubmit(values: CreateAndEditJobType) {
        console.log(values);
      }
    
      return (
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="bg-muted p-8 rounded">
            <h2 className="capitalize font-semibold text-4xl mb-6">직업 추가</h2>
            <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 items-start">
              <CustomFormField name="position" lable="직책" control={form.control} />
              <CustomFormField name="company" lable="회사" control={form.control} />
              <CustomFormField name="location" lable="지역" control={form.control} />
              <CustomFormSelect name="status" control={form.control} labelText="직업상태" items={Object.values(JobStatus)} />
              <CustomFormSelect name="mode" control={form.control} labelText="고용형태" items={Object.values(JobMode)} />
              <Button type="submit" className="self-end capitalize">직업 생성</Button>
            </div>
          </form>
        </Form>
      );
    }
    
    export default CreateJobForm;
    

     

     

    3.2. 코드 설명

    1. useForm 설정: react-hook-form을 이용하여 폼을 관리하며, zodResolver를 사용하여 검증을 수행합니다.
    2. 기본값 설정: defaultValues를 설정하여 폼이 초기화될 때 사용할 값을 지정합니다.
    3. onSubmit 함수: 폼이 제출될 때 console.log를 통해 데이터를 확인할 수 있습니다.
    4. 커스텀 폼 필드 적용:
      • CustomFormField: 텍스트 입력 필드 (직책, 회사, 지역)
      • CustomFormSelect: 드롭다운 선택 필드 (직업 상태, 고용 형태)
    5. 버튼: Button 컴포넌트를 이용하여 폼을 제출할 수 있도록 구현합니다.

     

     

    4. 폼 검증 및 기능 테스트

    • 입력값이 zod의 조건을 충족하지 않으면 자동으로 오류 메시지가 표시됩니다.
    • 입력한 값이 올바르면 콘솔에 데이터가 출력됩니다.

     

     

    5. 결론

    이 폼은 Next.js 15의 최신 앱 라우터 방식과 shadcn UI 라이브러리를 활용하여 직관적이고 효율적인 방식으로 설계되었습니다. react-hook-form과 zod를 적용하여 코드량을 줄이고 유지보수를 쉽게 할 수 있도록 했습니다. 향후 이 폼에 React Query를 적용하여 서버와 연동하는 기능을 추가할 계획입니다.

     

     

     

     

     

     

     

    14.Prisma 적용 및 데이터베이스를 PostgreSQL에서 SQLite로 변경하기

     

    prisma 적용 방법 다음을 참조

    https://macaronics.net/m04/react/view/2359

     

     

     

    1. schema.prisma 파일 수정하기

    Prisma에서 데이터베이스를 변경하려면 schema.prisma 파일을 수정해야 합니다.

    변경 전 (PostgreSQL 설정)

     datasource db {
       provider = "postgresql"
       url      = env("DATABASE_URL")
     }
    

     

    변경 후 (SQLite 설정)

     datasource db {
       provider = "sqlite"
       url      = "file:./dev.db"
     }
    

     

    prisma  sqlite  설정

    npx prisma init --datasource-provider sqlite

     

     

     

    2. .env 파일 수정 (필요 시)

    PostgreSQL 환경 변수가 설정되어 있다면 이를 제거하거나 SQLite와 관련된 환경 변수를 설정합니다.

    # 기존 PostgreSQL 환경 변수 제거
    # DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
    

    SQLite는 파일 기반 데이터베이스이므로 별도 환경 변수 설정이 필요 없습니다.

     

     

    3. 기존 PostgreSQL 데이터 삭제 및 마이그레이션 초기화

    기존 데이터베이스 설정을 초기화해야 합니다.

    npx prisma migrate reset
    

    ⚠️ 주의: 위 명령어는 기존 데이터베이스의 모든 데이터를 삭제합니다.

     

     

    4. 마이그레이션 적용하기

    SQLite용 데이터베이스를 새로 생성하고 마이그레이션을 적용합니다.

    npx prisma migrate dev --name init
    

    이제 prisma/dev.db 파일이 생성됩니다.

     

     

    5. Prisma 클라이언트 다시 생성하기

    Prisma 클라이언트를 다시 생성해야 정상적으로 작동합니다.

    npx prisma generate
    

     

     

    6. 애플리케이션 실행하기

    이제 Next.js 애플리케이션을 다시 실행하여 변경된 데이터베이스 설정이 적용되었는지 확인합니다.

    npm run dev
    

    이제 프로젝트가 PostgreSQL이 아닌 SQLite를 사용하도록 설정되었습니다. ????

     

     

     

     

     

     

     

    15.  createJobAction  Action  작업

    1. 프로젝트 개요

    Next.js 15 앱 라우터 방식과 shadcn을 활용하여 직업 관리 시스템을 구축합니다. 이 프로젝트에서는 서버 액션을 이용하여 폼 데이터를 처리하고 Prisma를 활용해 데이터베이스와 연동합니다.

     

    2. 데이터 모델 정의

    데이터는 JobType 타입을 기준으로 관리됩니다. 직업 정보에는 ID, 생성일, 수정일, 사용자 ID(Clerk ID), 직책, 회사, 위치, 상태, 고용 형태 등의 정보가 포함됩니다.

    import * as z from 'zod';
    
    export type JobType = {
      id: string;
      createdAt: Date;
      updatedAt: Date;
      clerkId: string;
      position: string;
      company: string;
      location: string;
      status: string;
      mode: string;
    };
    
    export enum JobStatus {
      Pending = '보류중',
      Interview = '인터뷰',
      Declined = '거절됨',
    }
    
    export enum JobMode {
      FullTime = '풀타임',
      PartTime = '파트타임',
      Internship = '인턴십',
    }
    

    3. Zod를 활용한 유효성 검사

    폼에서 입력받은 데이터를 검증하기 위해 createAndEditJobSchema를 정의합니다.

    export const createAndEditJobSchema = z.object({
      position: z.string().min(2, {
        message: '직책은 2자 이상이어야 합니다.',
      }),
      company: z.string().min(2, {
        message: '회사는 2자 이상이어야 합니다.',
      }),
      location: z.string().min(2, {
        message: '위치는 2자 이상이어야 합니다.',
      }),
      status: z.nativeEnum(JobStatus, {
        message: '직업 상태를 선택하세요.',
      }),
      mode: z.nativeEnum(JobMode, {
        message: '고용 형태를 선택하세요.',
      }),
    });
    
    export type CreateAndEditJobType = z.infer<typeof createAndEditJobSchema>;
    

     

    4. 서버 액션 정의

    createJobAction 함수는 사용자 인증을 거친 후, 새로운 직업 정보를 데이터베이스에 저장하는 역할을 합니다.

    "use server";
    import { createAndEditJobSchema, CreateAndEditJobType, JobType } from "@/dto/JobDTO";
    import prisma from "@/utils/db";
    import { auth } from "@clerk/nextjs/server";
    import { redirect } from "next/navigation";
    
    async function authenticateAndRedirect(): Promise<string> {
      const { userId } = await auth();
      if (!userId) redirect("/");
      return userId;
    }
    
    export async function createJobAction(values: CreateAndEditJobType): Promise<JobType | null> {
      try {
        await new Promise((resolve) => setTimeout(resolve, 3000));
        
        const userId = await authenticateAndRedirect();
        createAndEditJobSchema.parse(values);
        
        const job: JobType = await prisma.job.create({
          data: {
            ...values,
            clerkId: userId,
          },
        });
    
        return job;
      } catch (error) {
        console.log("createJobAction Error: ", error);
        return null;
      }
    }
    

     

    5. 기능 설명

    1. authenticateAndRedirect() 함수:
      • Clerk를 이용한 사용자 인증을 수행하고, 인증되지 않은 사용자는 홈으로 리디렉션합니다.
    2. createJobAction(values: CreateAndEditJobType) 함수:
      • 프론트엔드에서 전달된 values를 받아 새로운 Job을 생성합니다.
      • Prisma를 이용하여 데이터베이스에 저장합니다.
      • 오류 발생 시 null을 반환하며 콘솔에 오류를 기록합니다.

     

     

    6. 프론트엔드 폼 처리 흐름

    • 사용자가 폼을 제출하면 onSubmit 이벤트에서 createJobAction을 호출합니다.
    • react-query를 사용해 상태를 관리하며, 요청이 성공하면 '직업 생성 완료' 메시지를 표시합니다.
    • 오류 발생 시 사용자에게 알림을 표시합니다.

     

    이와 같은 방식으로 Next.js 15과 Shadcn을 활용하여 직업 관리 기능을 구현할 수 있습니다. 추가적으로 UI 개선과 데이터 처리 로직을 보완하여 더욱 견고한 애플리케이션을 구축할 수 있습니다.

     

     

     

     

     

    16. shadcn  toast 적용하기

     

    ===========================>

    Sonner 업데이트 됨

    1. Sonner 패키지 설치
    
    pnpm dlx shadcn@latest add sonner
    

     

    2. 프로젝트에서 적용하기

    Toaster 컴포넌트를 루트 레벨(예: _app.tsx 또는 layout.tsx)에 추가해야 합니다.

     

    import { Toaster, toast } from 'sonner';
    
    export default function App() {
      return (
        <>
          <Toaster richColors position="top-right" />
          <button onClick={() => toast.success('성공 메시지')}>
            성공 토스트 띄우기
          </button>
        </>
      );
    }
    

     

    3. 다양한 토스트 스타일 사용하기

    Sonner는 success, error, info, warning 등 다양한 유형의 메시지를 지원합니다.

    toast.success('성공!', { duration: 3000 });
    toast.error('오류 발생!', { description: '자세한 오류 내용 표시 가능' });
    toast.info('정보 메시지', { description: '추가 설명이 가능합니다' });
    toast.warning('경고 메시지');
    

     

     

     

    import { FiTrash } from 'react-icons/fi';
    
    
     const handleDeleteTopic =async () => {
        toast.info("토픽을 삭제하시겠습니까?", {
            icon: <FiTrash size={20} />, // 삭제 아이콘 추가
            position: 'top-center',        
            action: {
                label: "확인",          
                onClick: async () => {
                    const response = await deleteTopic(topicId);
                    if (response === "success") {
                        router.push("/");
                    }
                },                      
            },
            actionButtonStyle: {
                color: "white",
                backgroundColor: "red",
                borderRadius: "5px",
                padding: "5px 10px",
                border: "none",
                cursor: "pointer",
            },
            cancel: {
                label: "취소",
                onClick: () => {
                    // 취소 시 아무 작업도 하지 않거나 특정 작업을 추가할 수 있습니다.
                    toast.dismiss(); // 토스트 메시지 닫기
                },                       
            },
            cancelButtonStyle: {
                color: "white",
                backgroundColor: "black", // 취소 버튼 배경색
                borderRadius: "5px",
                padding: "5px 10px",
                border: "none",
                cursor: "pointer",
            },
        });
     }
    

     

     

    =====================> 예전 방식 참조

     

    참고:

    https://ui.shadcn.com/docs/components/toast

     

    1. 설치

    pnpm dlx shadcn@latest add toast
     

    or

     

    npx shadcn@latest add toast

     

     

    2.설치하면 3개의 파일 생성된다.

    복사

    1)toast.tsx ->    custom-toast.tsx

    2)toaster.tsx->   custom-toaster.tsx

    3)use-toast.ts->  custom-use-toast.ts

     

    3. custom-use-toast.ts 파일  수정

    ~
    
    import type {
      ToastActionElement,
      ToastProps,
    } from "@/components/ui/custom-toast"
    
    
    ~
    

     

    4.custom-toaster.tsx   수정

    ~
    import {
      Toast,
      ToastClose,
      ToastDescription,
      ToastProvider,
      ToastTitle,
      ToastViewport,
    } from "@/components/ui/custom-toast"
    
    
    ~

     

    5.custom-toast.tsx 수정

    ~
    
    const toastVariants = cva(
      "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
      {
        variants: {
          variant: {
            default: "border bg-background text-foreground",
            destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
            primary: "primary group border-blue-600 bg-blue-500 text-white",
            secondary: "secondary group border-gray-500 bg-gray-400 text-white",
            success: "success group border-green-600 bg-green-500 text-white",
            warning: "warning group border-yellow-600 bg-yellow-500 text-black",
            dark: "warning group border-gray-600 bg-gray-500 text-white",
            
          },
          position: {       
            top: "fixed top-5 left-[calc(5%)] w-[calc(90%)] md:w-80 gap-2 md:left-[calc(50%-160px)] flex flex-col md:flex-row z-50 ",
            "top-left": "fixed top-5 left-5 z-50  w-auto",
            "top-right": "fixed top-5 right-5 z-50  w-auto ",
            bottom: "fixed bottom-5 left-[calc(5%)] w-[calc(90%)] md:w-80 gap-2 md:left-[calc(50%-160px)] flex flex-col md:flex-row z-50",
            "bottom-left": "fixed bottom-5 left-5 z-50  w-auto ",
            "bottom-right": "fixed bottom-5 right-5 z-50  w-auto ",
            center: "fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2   z-50  w-auto "
          }
        },
        defaultVariants: {
          variant: "default",
        },
      }
    )
    
    const Toast = React.forwardRef<
      React.ElementRef<typeof ToastPrimitives.Root>,
      React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
        VariantProps<typeof toastVariants>
    >(({ className, variant, position, ...props }, ref) => {
      return (
        <ToastPrimitives.Root
          ref={ref}
          className={cn(toastVariants({ variant, position }),  className)}
          {...props}
        />
      )
    })
    Toast.displayName = ToastPrimitives.Root.displayName
    
    
    ~

     

     

     

    6. src/app/provders.tsx 에 적용

    "use client";
    import ThemeProvider from "@/components/theme-provider";
    import React from "react";
    import { Toaster } from "@/components/ui/custom-toaster";
    
    interface ProvidersProps {
      children: React.ReactNode;
    }
    
    const Providers: React.FC<ProvidersProps> = ({ children }) => {
      return (
        <ThemeProvider
          attribute="data-theme"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          {children}
          <Toaster />
        </ThemeProvider>
      );
    };
    
    export default Providers;
    

     

     

    7.사용방 예

    import { useToast } from "@/hooks/custom-use-toast";
    import { ToastAction } from "@/components/ui/custom-toast";
    
    
    ~
    
    
    const { toast } = useToast();
    
    ~
    
      // 회원가입 핸들러
      const handleSubmit = (data: z.infer<typeof formSchema>) => {
        console.log("회원가입 확인이 통과되었습니다.", data);
        
        toast({
          variant: "default",
          position: "top",
          description: "???? 회원 가입을 축하 합니다.",
          duration: 3000, // ✅ 3초 후 토스트가 사라짐
          action: (
            <ToastAction
              altText="확인"
              onClick={() => router.push("/dashboard")} // ✅ 확인 버튼 클릭 시 이동
            >
              확인
            </ToastAction>
          ),
        });
    
        // ✅ 토스트가 사라진 후 3.2초 후 이동 (여유 시간 추가)
        setTimeout(() => {
          router.push("/dashboard");
        }, 3200);
    
      };
    
    
    
    ~

     

    "use client"
    import { Button } from '@/components/ui/button';
    import { ToastAction } from "@/components/ui/custom-toast";
    import { useToast } from "@/hooks/custom-use-toast";
    import React from 'react'
    
    const JobsPage:React.FC = () => {
      const { toast } = useToast()
     
      return (   
          <h1 className='text-4xl'>
              JobsPage
    
    
              <Button
               variant="outline"
                onClick={() => {
                  toast({
                    variant: "default",
                    //position: "top",
                    title: "Uh oh! Something went wrong.",
                    description: "There was a problem with your request.",
                    action: <ToastAction altText="Try again">Try again</ToastAction>,
                  })
                }}
              >
                Show Toast
              </Button>
          </h1>
      )
    }
    
    export default JobsPage;

     

     

     

     

     

     

     

     

     

     

    17.React Query - 설정

    넥스트 프레임워크는 서버 액션에서  캐시 처리를  강력히  지원 하지만,

    클라이언트에서는  캐시 처리는 tanstasck  react query  라이브러리를 이용한  캐시 처리를 사용한다

     

    1)  React Query 패키지 설치

    React Query와 개발 도구(선택 사항)를 설치합니다.

     

    pnpm install @tanstack/react-query
    
    개발 중 React Query Devtools를 사용하려면 추가로 설치합니다.
    
    
    pnpm install @tanstack/react-query-devtools
    
    
    

     

     

    2) QueryClient 설정 및 Provider 적용

     layout.tsx 또는 layout.ts 파일에서 QueryClientProvider를 설정합니다.

    app/providers.tsx (Provider 설정 파일)

     

    "use client";
    
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import { ReactNode, useState } from "react";
    
    //QueryClient를 Providers 컴포넌트 바깥에서 선언 (최적화)
    const queryClient = new QueryClient();
    
    export function Providers({ children }: { children: ReactNode }) {
    
      return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
    }
    

     

    "use client";
    
    import { HeroUIProvider } from "@heroui/system";
    import React from "react";
    import { SessionProvider } from "next-auth/react";
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    
    interface ProvidersProps {
      children: React.ReactNode;
    }
    
    // QueryClient를 Providers 컴포넌트 바깥에서 선언 (최적화)
    const queryClient = new QueryClient();
    
    const Providers: React.FC<ProvidersProps> = ({ children }) => {
      return (
        <SessionProvider>
          <HeroUIProvider>
            <QueryClientProvider client={queryClient}>
              <main className="text-foreground bg-background">{children}</main>
            </QueryClientProvider>
          </HeroUIProvider>
        </SessionProvider>
      );
    };
    
    export default Providers;
    

    ⚠️ 주의:
    다만, Next.js에서는 각 요청마다 새로운 QueryClient 인스턴스를 생성해야 합니다.
    위 방법을 사용하면 한 번 생성된 QueryClient가 여러 요청에 공유될 위험이 있으므로,
    요청마다 새로운 인스턴스를 생성하는 것이 바람직합니다.

     

    import React from "react";
    import { getChartsDataAction, getStatsAction } from "@/actions/jobService";
    import { HydrationBoundary, dehydrate, QueryClient } from "@tanstack/react-query";
    import StatsContainer from "@/components/StatsContainer";
    import ChartsContainer from "@/components/ChartsContainer";
    
    const StatsPage: React.FC = async () => {
      // 요청마다 새로운 QueryClient 생성 (각 요청마다 독립적인 캐시 유지)
      const queryClient = new QueryClient();
    
      await queryClient.prefetchQuery({
        queryKey: ["stats"],
        queryFn: getStatsAction,
      });
    
      await queryClient.prefetchQuery({
        queryKey: ["charts"],
        queryFn: getChartsDataAction,
      });
    
      return (
        <HydrationBoundary state={dehydrate(queryClient)}>
          <StatsContainer />
          <ChartsContainer />
        </HydrationBoundary>
      );
    };
    
    export default StatsPage;
    

     

    또는  Providers 에서 다음과 같이 설정

    const [queryClient] = useState(() => new QueryClient());

     

    "use client";
    import React, { useState } from "react";
    import { Toaster } from "@/components/ui/custom-toaster";
    import { ThemeProvider } from "@/components/theme-provider";
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
    
    interface ProvidersProps {
      children: React.ReactNode;
    }
    
    const Providers: React.FC<ProvidersProps> = ({ children }) => {
      const [queryClient] = useState(() => new QueryClient());
    
      return (
        <ThemeProvider
          attribute="data-theme"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          <QueryClientProvider client={queryClient}>        
              {children}
              <Toaster />       
    
            <ReactQueryDevtools initialIsOpen={false} />
          </QueryClientProvider>
        </ThemeProvider>
      );
    };
    
    export default Providers;
    

     

     

    ★★★★★  정리하면: ★★★★★

    1️⃣  Providers에서 사용하는 QueryClient

    • 한 번만 생성하여 앱 전체에서 재사용

    • layout.tsx 등 전역 컨텍스트에서 유지

    • 최적화를 위해 컴포넌트 바깥에서 선언

    2️⃣  페이지 컴포넌트(SSR)에서 사용하는 QueryClient

    • 각 요청마다 새로운 인스턴스를 생성

    • SSR 시 서버 요청별로 독립적인 캐시 유지

    • 함수 내부에서 new QueryClient()를 사용하여 생성

     

     

     

     

     

     

     

    ✅ layout.tsx에서 적용

    이제 app/layout.tsx에서 Providers를 감싸줍니다.

    import Providers from "./providers";
    
    export default function RootLayout({ children }: { children: React.ReactNode }) {
      return (
        <html lang="ko">
          <body>
            <Providers>{children}</Providers>
          </body>
        </html>
      );
    }
    

     

     

    3. React Query로 데이터 가져오기 (useQuery 사용)

    API 데이터를 가져오는 예제입니다.

     

    ✅ app/page.tsx에서 사용 예시

    "use client";
    
    import { useQuery } from "@tanstack/react-query";
    
    async function fetchData() {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts");
      if (!res.ok) throw new Error("데이터 로드 실패");
      return res.json();
    }
    
    export default function HomePage() {
      const { data, error, isLoading } = useQuery({
        queryKey: ["posts"],
        queryFn: fetchData,
      });
    
      if (isLoading) return <p>로딩 중...</p>;
      if (error) return <p>에러 발생: {error.message}</p>;
    
      return (
        <div>
          <h1>게시글 목록</h1>
          <ul>
            {data.map((post: { id: number; title: string }) => (
              <li key={post.id}>{post.title}</li>
            ))}
          </ul>
        </div>
      );
    }
    

     

    4. DevTools 추가 (선택 사항)

    개발 중 React Query Devtools를 사용하려면 Providers에서 추가합니다.

    app/providers.tsx

    "use client";
    
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import { ReactNode, useState } from "react";
    import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
    
    // ???? QueryClient를 Providers 컴포넌트 바깥에서 선언 (최적화)
    const queryClient = new QueryClient();
    
    export function Providers({ children }: { children: ReactNode }) {
    
    
      return (
        <QueryClientProvider client={queryClient}>
          {children}
          <ReactQueryDevtools initialIsOpen={false} />
        </QueryClientProvider>
      );
    }
    

     

     

    5. 서버 사이드 데이터 패칭 (React Query + Next.js Server Components)

    Next.js 15에서 서버 컴포넌트와 React Query를 함께 사용하려면 클라이언트에서만 React Query를 실행하도록 해야 합니다.

    ✅ app/page.tsx에서 서버 사이드 데이터를 프리패칭 후 React Query에 전달

    import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query";
    import HomePage from "./HomePage"; // 클라이언트 컴포넌트
    
    async function getPrefetchedQueryClient() {
      const queryClient = new QueryClient();
    
      await queryClient.prefetchQuery({
        queryKey: ["posts"],
        queryFn: async () => {
          const res = await fetch("https://jsonplaceholder.typicode.com/posts");
          if (!res.ok) throw new Error("데이터 로드 실패");
          return res.json();
        },
      });
    
      return queryClient;
    }
    
    export default async function Page() {
      const queryClient = await getPrefetchedQueryClient();
      const dehydratedState = dehydrate(queryClient);
    
      return (
        <HydrationBoundary state={dehydratedState}>
          <HomePage />
        </HydrationBoundary>
      );
    }
    

     

    ✅ 클라이언트 컴포넌트에서 React Query 사용 (HomePage.tsx)

    "use client";
    
    import { useQuery } from "@tanstack/react-query";
    
    async function fetchData() {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts");
      if (!res.ok) throw new Error("데이터 로드 실패");
      return res.json();
    }
    
    export default function HomePage() {
      const { data, error, isLoading } = useQuery({
        queryKey: ["posts"],
        queryFn: fetchData,
      });
    
      if (isLoading) return <p>로딩 중...</p>;
      if (error) return <p>에러 발생: {error.message}</p>;
    
      return (
        <div>
          <h1>게시글 목록</h1>
          <ul>
            {data.map((post: { id: number; title: string }) => (
              <li key={post.id}>{post.title}</li>
            ))}
          </ul>
        </div>
      );
    }
    

     

    6. Mutation 적용 (데이터 생성, 수정, 삭제)

    데이터를 생성하거나 수정할 때는 useMutation을 사용합니다.

    "use client";
    
    import { useMutation, useQueryClient } from "@tanstack/react-query";
    
    async function createPost(newPost: { title: string }) {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
        method: "POST",
        body: JSON.stringify(newPost),
        headers: {
          "Content-Type": "application/json",
        },
      });
    
      if (!res.ok) throw new Error("게시글 생성 실패");
      return res.json();
    }
    
    export default function CreatePost() {
      const queryClient = useQueryClient();
      const mutation = useMutation({
        mutationFn: createPost,
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: ["posts"] }); // 데이터 갱신
        },
      });
    
      return (
        <button onClick={() => mutation.mutate({ title: "새 게시글" })}>
          게시글 생성
        </button>
      );
    }
    

    정리

    ✅ QueryClientProvider를 providers.tsx에 추가
    ✅ useQuery로 데이터 패칭
    ✅ useMutation으로 데이터 생성 및 수정
    ✅ 서버 사이드 프리패칭을 위해 dehydrate, HydrationBoundary 활용

    이제 Next.js 15에서도 React Query를 활용하여 상태 관리 및 비동기 데이터 패칭을 효율적으로 처리할 수 있습니다!

     

     

     

     

     

    ✅ Next.js 15 App Router + React Query 최적화 방법

    Next.js 15의 App Router 방식에서 react-query를 사용할 때, RootLayout에 QueryClientProvider를 적용하는 것은 올바른 방법입니다.

    하지만, HydrationBoundary는 페이지별로 적용하는 것이 더 적절합니다.

     

    현재 코드의 문제점

    1. QueryClientProvider와 HydrationBoundary의 역할이 중복됨

      • QueryClientProvider는 클라이언트 상태 관리 역할
      • HydrationBoundary는 서버에서 미리 가져온 데이터를 클라이언트에서 재활용하는 역할
      • 현재는 QueryClientProvider 내부에 HydrationBoundary를 두었지만, 모든 페이지에서 SSR을 할 필요는 없음
      • 모든 페이지에서 SSR을 강제하는 구조가 되어 불필요한 서버 요청이 발생할 가능성이 큼
      •  
    2. QueryClient가 Providers.tsx와 RootLayout.tsx에서 중복 생성됨

      • Providers.tsx에서 useState를 이용해 QueryClient를 생성
      • RootLayout.tsx에서 QueryClient를 생성 후 dehydrate() 호출
      • 불필요하게 두 개의 QueryClient가 만들어지고 관리됨

    올바른 구조

    1. Providers.tsx에서 QueryClientProvider를 유지
      • 클라이언트 전체에서 react-query를 사용할 수 있도록 함
    2. SSR이 필요한 페이지에서만 HydrationBoundary 적용
      • layout.tsx에서는 제거
      • HydrationBoundary는 SSR을 수행하는 페이지(server components에서 데이터를 미리 불러오는 경우)에만 적용

    수정된 코드

     

     

    1️⃣ Providers.tsx (변경 없음)

    "use client";
    import ThemeProvider from "@/components/theme-provider";
    import React, { useState } from "react";
    import { Toaster } from "@/components/ui/toaster";
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
    
    interface ProvidersProps {
      children: React.ReactNode;
    }
    
    const Providers: React.FC<ProvidersProps> = ({ children }) => {
      const [queryClient] = useState(() =>
        new QueryClient({
          defaultOptions: {
            queries: {
              staleTime: 60 * 1000 * 5, // 5분 동안 캐시 유지
            },
          },
        })
      );
    
      return (
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          <Toaster />
          <QueryClientProvider client={queryClient}>
            {children}
            <ReactQueryDevtools initialIsOpen={false} />
          </QueryClientProvider>
        </ThemeProvider>
      );
    };
    
    export default Providers;
    
    • QueryClientProvider는 여기에 유지
    • 모든 페이지에서 react-query 사용 가능

     

    2️⃣ RootLayout.tsx (SSR 관련 코드 제거)

    import type { Metadata } from "next";
    import { Inter } from "next/font/google";
    import "./globals.css";
    import { koKR } from "@clerk/localizations";
    import { ClerkProvider } from "@clerk/nextjs";
    import Providers from "./providers"; // ✅ 오타 수정
    
    const inter = Inter({ subsets: ["latin"] });
    
    export const metadata: Metadata = {
      title: "Jobify Dev",
      description: "Job application tracking system for job hunters",
    };
    
    export default function RootLayout({
      children,
    }: Readonly<{ children: React.ReactNode }>) {
      return (
        <html lang="ko" suppressHydrationWarning>
          <body className={inter.className}>
            <ClerkProvider localization={koKR}>
              <Providers>{children}</Providers>
            </ClerkProvider>
          </body>
        </html>
      );
    }
    
    • HydrationBoundary를 제거
      • RootLayout에서는 모든 페이지에서 SSR을 강제할 필요 없음
      • SSR을 적용해야 하는 특정 페이지에서 개별적으로 추가
      •  

    3️⃣ SSR이 필요한 페이지에서만 HydrationBoundary 추가

    예를 들어, page.tsx에서 서버에서 데이터를 가져오는 경우:

    import { HydrationBoundary, QueryClient, dehydrate } from "@tanstack/react-query";
    import getData from "@/lib/getData"; // 서버에서 데이터를 가져오는 함수
    import SomeComponent from "@/components/SomeComponent";
    
    export default async function Page() {
      const queryClient = new QueryClient();
      await queryClient.prefetchQuery(["data"], getData); // 서버에서 데이터 미리 가져오기
      const dehydratedState = dehydrate(queryClient);
    
      return (
        <HydrationBoundary state={dehydratedState}>
          <SomeComponent />
        </HydrationBoundary>
      );
    }
    
    • HydrationBoundary를 SSR이 필요한 개별 페이지에서만 적용
    • getData 같은 API 요청을 미리 수행하여 클라이언트에서 재활용

     

    사용하기

    ==>아래설명

     

    최종 정리

    RootLayout (layout.tsx)에서는 HydrationBoundary 제거
    SSR이 필요한 페이지에서만 HydrationBoundary 적용
    Providers.tsx에서 QueryClientProvider 유지하여 전역적으로 react-query 사용 가능

    이렇게 수정하면 불필요한 SSR 요청을 줄이고, 필요한 곳에서만 HydrationBoundary를 사용할 수 있어 최적화된 Next.js 15 + React Query 구조가 됩니다! ????

     

     

     

     

     

     

    17.React Query - 적용하기

     

    1.useMutation 훅 사용하기

    useMutation 훅은 데이터를 생성하거나 수정하는 등 서버에 데이터를 변경하는 요청을 보낼 때 사용됩니다. 이 훅은 mutate 함수를 반환하여 이를 통해 실제 API 호출을 할 수 있습니다. 또한 isPending 상태를 반환하여 요청 중인지 여부를 확인할 수 있습니다.

     

    const { mutate, isPending } = useMutation({
      mutationFn: (values: CreateAndEditJobType) => createJobAction(values),
      onSuccess: (data) => {
        if (!data) {
          toast({ description: "에러가 발생 했습니다.", variant: "destructive" });
          return;
        }
    
        toast({ description: "직업이 추가되었습니다." });
        queryClient.invalidateQueries({ queryKey: ["jobs"] });
        queryClient.invalidateQueries({ queryKey: ["stats"] });
        queryClient.invalidateQueries({ queryKey: ["charts"] });
    
        router.push("/jobs");
      }
    });
    

     

     

    이 코드에서는 mutationFn으로 createJobAction 함수를 호출하고, 성공 시에는 toast 알림을 띄운 후 쿼리를 무효화하고 직업 목록 페이지로 리디렉션합니다.

     

    2. onSubmit에서 mutate 호출하기

    onSubmit 함수에서 mutate를 호출하여 폼 데이터를 서버로 전송합니다. 이때 isPending 값을 사용하여 요청 중일 때 버튼의 텍스트를 변경하거나 버튼을 비활성화할 수 있습니다.

     

    function onSubmit(values: CreateAndEditJobType) {
      console.log(values);
      mutate(values); // 서버에 요청 보내기
    }
    

     

     

    3. Query Client 설정 및 데이터 무효화

    React Query는 서버에서 데이터를 가져온 후 캐싱하여 성능을 최적화합니다. 하지만 데이터 변경 후, 새로운 데이터가 반영되도록 하기 위해 기존 쿼리를 무효화해야 합니다. useQueryClient 훅을 사용하여 쿼리 클라이언트를 가져온 후 invalidateQueries로 쿼리를 무효화합니다.

     

    const queryClient = useQueryClient();
    
    queryClient.invalidateQueries({ queryKey: ["jobs"] });
    queryClient.invalidateQueries({ queryKey: ["stats"] });
    queryClient.invalidateQueries({ queryKey: ["charts"] });
    

     

    전체 코드 예시

    "use client";
    import { zodResolver } from "@hookform/resolvers/zod";
    import { useForm } from "react-hook-form";
    import { Button } from "./ui/button";
    import { Form } from "./ui/form";
    import { createAndEditJobSchema, CreateAndEditJobType, JobMode, JobStatus } from "@/dto/JobDTO";
    import CustomFormSelect, { CustomFormField } from "./FormComponents";
    import { useMutation, useQueryClient } from "@tanstack/react-query";
    import { useToast } from "@/hooks/use-toast";
    import { createJobAction } from "@/actions/add-job/jobActions";
    import { useRouter } from 'next/navigation';
    
    
    function CreateJobForm() {
      
      const form =useForm<CreateAndEditJobType>({
          resolver: zodResolver(createAndEditJobSchema), 
          defaultValues: {
            position: "",
            company: "",
            location: "",
            status: JobStatus.Pending,
            mode: JobMode.FullTime
          }
      });
    
      const queryClient=  useQueryClient();
      const {toast}  =useToast();
      const router = useRouter();
      const {mutate, isPending} = useMutation({
        mutationFn: (values:CreateAndEditJobType) => createJobAction(values),
        onSuccess: (data) => {
          if(!data){
            toast({description:"에러가 발생 했습니다.", variant:"destructive"}); 
            return;       
          }
    
          toast({description:"직업가 추가 되었습니다."});
          queryClient.invalidateQueries({queryKey:["jobs"]});
          queryClient.invalidateQueries({queryKey:["stats"]});
          queryClient.invalidateQueries({queryKey:["charts"]});
    
          router.push("/jobs");
        }
    
        
      });
    
      function onSubmit(values:CreateAndEditJobType) {
        console.log(values);
        mutate(values);
    
      }
    
      return (
        <Form  {...form}>
          <form 
            onSubmit={form.handleSubmit(onSubmit)}
            className="bg-muted p-8 rounded"
          >
            <h2 className="capitalize font-semibold text-4xl mb-6">직업 추가</h2>
            <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 items-start">
    
                {/* position */}
                <CustomFormField name="position" lable="직책" control={form.control} />
    
                <CustomFormField name="company" lable="회사" control={form.control} />
    
                <CustomFormField name="location" lable="지역" control={form.control} />
    
                <CustomFormSelect 
                    name="status" 
                    control={form.control}
                    labelText="직업상태"
                    items={Object.values(JobStatus)}            
                />
    
              <CustomFormSelect 
                    name="mode" 
                    control={form.control}
                    labelText="고용형태"
                    items={Object.values(JobMode)}            
                />
    
    
                <Button
                  type='submit'
                  className='self-end capitalize'            
                  >
                     {isPending ? "직업 생성중..." : "직업 생성"}
                 </Button>
                 
            </div>
          </form>
        </Form>
        
      );
    }
    
    export default CreateJobForm;
    

     

     

     

     

     

    백엔드 spring boot , prisma 병행 처리를 위한 추상화 레이어 만들기

     

    ✅ 1️⃣공통 인터페이스 (jobService.ts) 생성

    먼저, src/actions/jobService.ts 파일을 생성하여 Prisma와 Spring Boot 두 가지 방식을 쉽게 전환할 수 있도록 합니다.

    // src/actions/jobService.ts
    import { CreateAndEditJobType, JobType } from "@/types/JobDTO";
    import { createJobAction as prismaCreateJob } from "./prisma/add-job/jobActions";
    import { createJobAction as springBootCreateJob } from "./spring-boot/add-job/jobActions";
    
    // 사용할 백엔드 타입을 설정 (환경 변수나 설정 파일로 관리 가능)
    const BACKEND_TYPE = process.env.NEXT_PUBLIC_BACKEND_TYPE || "prisma"; // 기본값: Prisma
    
    export async function createJobAction(values: CreateAndEditJobType): Promise<JobType | null> {
      if (BACKEND_TYPE === "spring-boot") {
        return await springBootCreateJob(values);
      } else {
        return await prismaCreateJob(values);
      }
    }
    

    ✅ 장점:

    • createJobAction을 호출할 때마다 Prisma/Spring Boot를 자동으로 선택
    • process.env.NEXT_PUBLIC_BACKEND_TYPE 값을 "prisma" 또는 "spring-boot"로 바꾸면 프로젝트 전체 변경 없이 즉시 적용

    ✅ 2️⃣ 기존 코드에서 jobService.ts 사용

    이제 기존의 createJobAction 호출 부분을 jobService.ts를 참조하도록 변경하면 됩니다.

    기존 코드 (변경 전)

    import { createJobAction } from "@/actions/prisma/add-job/jobActions";
    

    변경 후

    import { createJobAction } from "@/actions/jobService"; // 공통 서비스 사용
    

    이제 jobService.ts를 사용하면 백엔드를 쉽게 변경 가능!

    ✅ 3️⃣ 환경 변수 설정으로 동적으로 변경

    이제 .env 파일에서 백엔드 타입을 설정하면 코드 수정 없이 변경할 수 있습니다.

    .env.local

    NEXT_PUBLIC_BACKEND_TYPE=prisma  # Prisma 사용
    # NEXT_PUBLIC_BACKEND_TYPE=spring-boot  # Spring Boot 사용
    

    ✅ 서버 재시작 없이 즉시 반영되도록 NEXT_PUBLIC_BACKEND_TYPE 사용!

    • 환경 변수 변경 후, Next.js에서 process.env.NEXT_PUBLIC_BACKEND_TYPE 값을 읽어 백엔드를 선택

    최종 정리

    공통 인터페이스 (jobService.ts) 생성
    각 백엔드의 createJobAction을 jobService.ts에서 관리
    환경 변수 (NEXT_PUBLIC_BACKEND_TYPE)로 백엔드 변경 가능
    프로젝트 전체에서 jobService.ts만 사용하면 백엔드 변경이 간편

    이제 백엔드를 Prisma ↔ Spring Boot로 변경해도 한 줄도 수정할 필요 없이 자동 적용됩니다! ????

     

     

     

     

     

     

     

    18.getAllJobsAction 함수

     

    (1) getAllJobsAction 함수 개요

    이 함수는 데이터베이스에서 사용자의 구인 정보를 조회하는 역할을 합니다. 검색 필터, 상태 필터, 페이지네이션을 적용할 수 있으며, 반환값은 jobs, count, page, totalPages로 구성됩니다.

    export type GetAllJobsActionTypes = {
      search?: string;
      jobStatus?: string;
      page?: number;
      limit?: number;
    };
    
    export async function getAllJobsAction({ search, jobStatus, page = 1, limit = 10 }: GetAllJobsActionTypes): Promise<{ jobs: JobType[]; count: number; page: number; totalPages: number }> {
      const userId = await authenticateAndRedirect();
    
      try {
        let whereClause: Prisma.JobWhereInput = {
          clerkId: userId,
        };
    
        if (search) {
          whereClause = {
            ...whereClause,
            OR: [
              { position: { contains: search } },
              { company: { contains: search } },
              { location: { contains: search } },
            ],
          };
        }
    
        if (jobStatus && jobStatus !== 'all') {
          whereClause = {
            ...whereClause,
            status: jobStatus,
          };
        }
    
        const jobs: JobType[] = await prisma.job.findMany({
          where: whereClause,
          orderBy: { createdAt: 'desc' },
          take: limit,
          skip: (page - 1) * limit,
        });
    
        return { jobs, count: 0, page, totalPages: 0 };
      } catch (error) {
        console.log('getAllJobsAction Error: ', error);
        return { jobs: [], count: 0, page: 1, totalPages: 0 };
      }
    }
    

     

    (2) getAllJobsAction 상세 설명

    1. 입력 타입 정의 (GetAllJobsActionTypes)

      • search: 검색어 (선택 사항)
      • jobStatus: 채용 상태 필터 (선택 사항)
      • page: 현재 페이지 (기본값 1)
      • limit: 한 페이지당 표시할 데이터 개수 (기본값 10)
    2. 유저 인증 및 리디렉션 (authenticateAndRedirect())

      • 사용자의 userId를 가져와서 데이터 필터링에 활용
    3. 검색 필터 적용 (search 포함 여부 체크)

      • position, company, location 필드에서 검색어가 포함된 데이터 조회
    4. 상태 필터 적용 (jobStatus)

      • jobStatus가 all이 아닐 경우 특정 상태의 데이터만 조회
    5. 데이터베이스 조회 (prisma.job.findMany)

      • whereClause를 기반으로 데이터 검색
      • 최신순 정렬 (createdAt: 'desc')
      • 페이지네이션 적용 (take, skip 사용)
    6. 에러 처리 및 기본 반환 값 설정

      • 에러 발생 시 빈 배열과 기본 페이지네이션 값 반환

     

    4. 프로젝트 활용 방법

    (1) Prisma와 Spring Boot 전환 방법

    • Prisma를 사용할 경우:

      • Next.js 내부에서 직접 데이터베이스와 연결하여 사용
      • prisma.job.findMany() 등의 Prisma ORM 메서드를 활용
    •  
    • Spring Boot를 사용할 경우:

      • API 엔드포인트를 호출하여 데이터를 가져옴
      • fetch('/api/jobs') 형태의 API 요청 방식 적용
    • 전환을 쉽게 하기 위해 공통 인터페이스를 활용

      • process.env.BACKEND_TYPE 값에 따라 Prisma 또는 API 호출 방식 선택 가능
    •  

    (2) UI 구성 및 ShadCN 활용

    • ShadCN의 Button, Input, Table 컴포넌트를 적극 활용
    • 다크 모드 지원 가능
    • Tailwind CSS와 조합하여 스타일링 최적화

     

    (3) API 연동 예제 (Spring Boot 연동 시)

    export async function fetchJobsFromAPI(page: number, limit: number) {
      const response = await fetch(`/api/jobs?page=${page}&limit=${limit}`);
      const data = await response.json();
      return data;
    }
    

     

     

    5. 결론

    이 프로젝트는 Next.js 15 App Router 방식ShadCN을 활용하여, Prisma 또는 Spring Boot 백엔드를 자유롭게 전환할 수 있도록 설계되었습니다. 이를 통해 검색, 필터, 페이지네이션 기능을 포함한 강력한 데이터 조회 기능을 구현할 수 있습니다.

    또한, UI 라이브러리로 ShadCN을 사용하여 깔끔하고 일관된 스타일의 인터페이스를 제공하며, 필요에 따라 다크 모드 및 Tailwind CSS를 조합하여 확장성을 극대화할 수 있습니다.

    이 문서를 참고하여 프로젝트를 구축하고, 원하는 백엔드 방식에 맞게 쉽게 전환하여 사용할 수 있습니다!

     

     

     

     

     

    19.Jobs page 설정

     

    1. 프로젝트 구조 설정

    이번 섹션에서는 넥스트 15 앱라우터 방식과 shadcn을 이용한 프로젝트의 구조를 설정합니다. 주요 컴포넌트인 `SearchForm`, `JobsList`, `JobCard`, `JobInfo`, `DeleteJobButton` 등을 생성하고, 페이지 로딩 시 `react-query`를 사용하여 데이터를 미리 가져오는 작업을 진행합니다.

     

    2. 컴포넌트 생성

    먼저, `SearchForm`과 `JobsList` 컴포넌트를 생성합니다. 이 컴포넌트들은 각각 검색 폼과 작업 목록을 표시하는 역할을 합니다. 초기에는 간단한 텍스트를 표시하도록 설정합니다.

    import React from 'react';
    
          const SearchForm = () => {
            return (
              <div>SearchForm</div>
            );
          };
    
          export default SearchForm;
        
    
      
        
          import React from 'react';
    
          const JobsList = () => {
            return (
              <div>JobsList</div>
            );
          };
    
          export default JobsList;

     

     

     

    3. JobCard 및 기타 컴포넌트 생성

    `JobCard`, `JobInfo`, `DeleteJobButton` 컴포넌트를 생성합니다. `JobCard`는 작업 카드를 표시하고, `JobInfo`는 작업 정보를 표시하며, `DeleteJobButton`은 작업 삭제 버튼을 담당합니다.

       import React from 'react';
          import { JobType } from '@/types/job';
    
          const JobCard = ({ job }: { job: JobType }) => {
            return (
              <div>JobCard</div>
            );
          };
    
          export default JobCard;

     

    4. 페이지 로딩 설정

    `jobs` 페이지에서 `react-query`를 사용하여 데이터를 미리 가져오는 작업을 설정합니다. `QueryClient`를 생성하고, `prefetchQuery`를 사용하여 모든 작업 데이터를 미리 가져옵니다.

     
          import { getAllJobsAction } from '@/actions/jobService';
          import JobsList from '@/components/JobsList';
          import SearchForm from '@/components/SearchForm';
          import { HydrationBoundary, dehydrate, QueryClient } from '@tanstack/react-query';
          import React from 'react';
    
          const JobsPage: React.FC = async () => {
            const queryClient = new QueryClient();
    
            await queryClient.prefetchQuery({
              queryKey: ['jobs', '', 'all', 1],
              queryFn: () => getAllJobsAction({}),
            });
    
            return (
              <HydrationBoundary state={dehydrate(queryClient)}>
                <SearchForm />
                <JobsList />
              </HydrationBoundary>
            );
          };
    
          export default JobsPage;

     

     

    5. 로딩 컴포넌트 추가

    페이지 로딩 시 표시할 `Loading` 컴포넌트를 추가합니다. 이 컴포넌트는 데이터가 로딩 중일 때 사용자에게 표시됩니다.

          import React from 'react';
    
          const Loading = () => {
            return (
              <div>Loading...</div>
            );
          };
    
          export default Loading;
        

     

    6. 브라우저에서 확인

    모든 설정이 완료되면 브라우저에서 페이지를 확인합니다. `SearchForm`과 `JobsList`가 정상적으로 표시되고, `react-query`를 통해 데이터가 미리 가져와지는지 확인합니다.

     

     

     

     

     

     

     

    20.SearchForm Component - Setup

    1. 개요 (Introduction)

    이번 가이드에서는 Next.js 15 App Router 방식ShadCN을 이용하여  검색 폼(Search Form)을 구현하는 방법을 설명합니다. 검색 폼에서는  텍스트 입력 필드(input)와

    선택 필드(select)를 사용하며,  검색 버튼(Button)을 포함합니다. 또한, FormData를 활용하여 입력된 데이터를 가져오고 콘솔에 출력하는 기본 기능을 구현합니다.

     

    2. 코드 구현

    (1) 기본 코드 구조

    "use client";
    import React from "react";
    import { Input } from "./ui/input";
    import { Button } from "./ui/button";
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
    import { JobStatus } from "@/types/JobDTO";
    
    const SearchForm: React.FC = () => {
      const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
    
        const search = formData.get("search") as string;
        const jobStatus = formData.get("jobStatus") as string;
        console.log("search:", search, ":::: jobStatus:", jobStatus);
      };
    
      return (
        <form
          className="bg-muted mb-16 p-8 grid sm:grid-cols-2 md:grid-cols-3 gap-4 rounded-lg"
          onSubmit={handleSubmit}
        >
          <Input type="search" placeholder="직업 검색" name="search" required />
          <Select name="jobStatus" defaultValue="all">
            <SelectTrigger>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {["all", ...Object.values(JobStatus)].map((jobStatus) => {
                return (
                  <SelectItem key={jobStatus} value={jobStatus}>
                    {jobStatus}
                  </SelectItem>
                );
              })}
            </SelectContent>
          </Select>
          <Button type="submit">검색</Button>
        </form>
      );
    };
    
    export default SearchForm;

     

    3. 코드 설명

    (1) use client 지시어 사용

    "use client";는 클라이언트 컴포넌트임을 명시하는 역할을 합니다. 폼 이벤트 처리 및 상태 관리를 위해 클라이언트 컴포넌트로 설정해야 합니다.

     

    (2) 폼(form) 태그 구조

    • 배경색(bg-muted), 마진(mb-16), 패딩(p-8), 그리드 레이아웃(grid) 등을 활용하여 스타일을 적용했습니다.
    • 반응형 디자인: sm:grid-cols-2 (작은 화면에서는 2컬럼), md:grid-cols-3 (중간 크기 화면에서는 3컬럼)를 적용했습니다.
    • gap-4: 입력 필드 사이의 간격을 지정했습니다.
    • rounded-lg: 폼의 모서리를 둥글게 만들었습니다.

     

    (3) Input 컴포넌트

    • type="search": 검색 입력 필드로 설정
    • placeholder="직업 검색": 사용자에게 검색 입력을 유도
    • name="search": FormData에서 값 가져올 때 사용할 키
    • required: 필수 입력 필드 지정

     

    (4) Select 컴포넌트 (직업 상태 선택)

    • name="jobStatus": FormData에서 값 가져올 때 사용할 키
    • defaultValue="all": 기본값을 "all"로 설정
    • SelectContent 내부에서 JobStatus Enum을 이용해 옵션을 생성

     

    (5) Button 컴포넌트 (검색 버튼)

    • type="submit": 폼 제출 기능 수행

     

    (6) handleSubmit 함수 (폼 제출 처리)

    • e.preventDefault(): 기본 제출 이벤트 방지
    • new FormData(e.currentTarget): 폼 데이터를 가져옴
    • formData.get("search") as string: 검색어 값 가져오기
    • formData.get("jobStatus") as string: 선택한 직업 상태 값 가져오기
    • console.log를 통해 콘솔에 값 출력

     

     

     

     

     

     

     

    21. SearchForm Component - Functionality

     

    Next.js 15 + App Router + ShadCN을 활용한 검색 폼 구현

    1. 개요

    이번 문서에서는 Next.js 15의 App Router와 ShadCN을 이용하여 검색 폼을 구현하는 방법을 설명합니다. 검색 폼은 사용자가 입력한 검색어와 선택한 상태 값을 URL의 쿼리 매개변수로 추가하여 유지할 수 있도록 구성됩니다. 이를 통해 사용자는 검색을 수행한 후에도 입력값이 유지되며, 페이지를 새로고침해도 이전 검색 기록이 반영됩니다.

    2. 주요 기능

    • 검색어 입력 및 상태 선택: 사용자가 검색어를 입력하고, 특정 상태를 선택할 수 있도록 함.
    • URL 쿼리 매개변수 적용: 입력된 검색어와 선택한 상태가 URL에 반영됨.
    • 초기값 유지: URL에서 쿼리 매개변수를 가져와 검색 필드와 드롭다운의 기본값으로 설정.
    • 검색 실행 시 페이지 이동: 검색 실행 시 동일한 페이지로 이동하면서 URL 쿼리 값이 업데이트됨.

    3. 코드 구현

    "use client"
    import React from 'react'
    import { Input } from './ui/input';
    import { Button } from './ui/button';
    import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
    import { JobStatus } from '@/types/JobDTO';
    import { useRouter, usePathname, useSearchParams } from 'next/navigation';
    
    const SearchForm: React.FC = () => {
      const searchParams = useSearchParams();
    
      const search = searchParams.get('search') || '';
      const jobStatus = searchParams.get('jobStatus') || '전체';
      console.log("searchParams search :", search, "  :::: searchParams jobStatus:", jobStatus);
    
      const router = useRouter();
      const pathname = usePathname();
    
      const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
    
        const search = formData.get('search') as string;
        const jobStatus = formData.get('jobStatus') as string;
        console.log("search :", search, "  :::: jobStatus:", jobStatus);
        
        const params = new URLSearchParams();
        params.set('search', search);
        params.set('jobStatus', jobStatus);
    
        router.push(`${pathname}?${params.toString()}`);
      }
    
      return (
        <form className='bg-muted mb-16 p-8 grid sm:grid-cols-2 md:grid-cols-3 gap-4 rounded-lg' onSubmit={handleSubmit}>
          <Input type="search" placeholder='직업 검색' name="search" required defaultValue={search} />
          <Select name="jobStatus" defaultValue={jobStatus}>
            <SelectTrigger>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {
                ['전체', ...Object.values(JobStatus)].map((jobStatus) => (
                  <SelectItem key={jobStatus} value={jobStatus}>
                    {jobStatus}
                  </SelectItem>
                ))
              }
            </SelectContent>
          </Select>
          <Button type='submit'>검색</Button>
        </form>
      )
    };
    
    export default SearchForm;

     

    4. 코드 설명

    4.1. useSearchParams 활용

    • useSearchParams() 훅을 사용하여 현재 URL의 쿼리 매개변수를 가져옵니다.
    • search와 jobStatus의 기본값을 설정하여, URL에 값이 없는 경우에도 초기값을 제공합니다.
    •  

    4.2. 폼 제출 처리 (handleSubmit 함수)

    • 폼 제출 시 handleSubmit 함수가 실행됩니다.
    • FormData를 이용하여 입력값을 가져온 후, URLSearchParams를 생성하여 검색어와 상태를 설정합니다.
    • router.push를 사용하여 동일한 페이지(pathname)로 이동하면서 쿼리 매개변수를 추가합니다.
    •  

    4.3. 기본값 유지

    • <Input>과 <Select> 컴포넌트에서 defaultValue 속성을 사용하여 URL의 기존 검색어 및 상태 값을 유지하도록 설정합니다.
    •  

    5. 실행 과정

    1. 사용자가 검색어를 입력하고, 드롭다운에서 상태를 선택한 후 검색 버튼을 클릭합니다.
    2. 검색 폼에서 handleSubmit이 실행되며, 입력한 값이 URL의 쿼리 매개변수로 추가됩니다.
    3. useSearchParams를 통해 URL에서 검색어와 상태를 읽어와 폼의 기본값으로 설정합니다.
    4. 검색 결과를 반영할 컴포넌트에서 URL의 쿼리 값을 활용하여 데이터를 필터링할 수 있습니다.
    5.  

    6. 정리

    이 코드에서는 Next.js 15의 App Router와 ShadCN을 활용하여 검색 기능을 구현했습니다. URL 쿼리 매개변수를 활용하여 사용자 입력을 유지하고, 이를 통해 검색 결과를 필터링할 수 있도록 했습니다.

    향후에는 검색 결과를 React Query와 결합하여 서버 데이터와 연동하는 방식도 고려할 수 있습니다.

     

     

     

     

     

     

    22. JobsList Component

    1. Next.js 15 App Router + ShadCN을 이용한 JobsList 컴포넌트 구현

     

    2. 프로젝트 개요

    이번 프로젝트에서는 Next.js 15의 App Router 방식과 ShadCN을 활용하여 Job 목록을 표시하는 JobsList 컴포넌트를 구현합니다. React Query를 사용하여 서버에서 데이터를 가져오고, 검색 및 필터링 기능도 추가합니다.

     

    3. 코드 분석

    "use client";
    import { getAllJobsAction } from '@/actions/jobService';
    import { useQuery } from '@tanstack/react-query';
    import { useSearchParams } from 'next/navigation';
    import React from 'react';
    import JobCard from './JobCard';
    import { JobType } from '@/types/JobDTO';
    
    const JobsList = () => {
      const searchParams = useSearchParams();
      const search = searchParams.get('search') || '';
      const jobStatus = searchParams.get('jobStatus') || '전체';
      const pageNumber = Number(searchParams.get('page')) || 1;
    
      const { data, isPending } = useQuery({
        queryKey: ['jobs', search, jobStatus, pageNumber],
        queryFn: () => getAllJobsAction({ search, jobStatus, page: pageNumber, limit: 10 }),
      });
    
      const jobs = data?.jobs as JobType[] || [];
    
      if (isPending) return <h2 className='text-xl'>Please Wait...</h2>;
      if (jobs.length < 1) return <h2 className='text-xl'>No Jobs Found...</h2>;
    
      return (
        <>
          <div className='grid md:grid-cols-2 gap-8'>
            {jobs.map((job) => (
              <JobCard key={job.id} job={job} />
            ))}
          </div>
        </>
      );
    };
    
    export default JobsList;

     

    4. 코드 상세 설명

    (1) useSearchParams 훅 활용

    • searchParams.get('search')를 사용해 검색어를 가져옴
    • jobStatus, page 값도 동일한 방식으로 가져와 기본값을 설정

    (2) React Query 활용

    • useQuery를 사용해 getAllJobsAction API 호출
    • queryKey에 검색어, 상태, 페이지 번호를 포함해 캐싱 및 리패칭을 효율적으로 수행

    (3) 조건부 렌더링

    • 데이터가 로딩 중이면 "Please Wait..." 메시지 표시
    • 검색 결과가 없으면 "No Jobs Found..." 메시지 표시

    (4) Job 목록 출력

    • jobs.map()을 활용해 JobCard 컴포넌트를 동적으로 생성
    • key 값으로 job.id를 사용하여 React의 리스트 렌더링 최적화

    5. 향후 개선 방향

    • 페이지네이션 추가: 현재 페이지 정보를 기반으로 이전/다음 페이지 버튼 구현
    • 검색 및 필터링 강화: 입력창과 드롭다운을 활용하여 사용자 경험 개선
    • 스타일링 개선: ShadCN의 UI 컴포넌트를 활용해 더욱 세련된 디자인 적용

    이와 같이 Next.js 15의 App Router 방식을 활용하여 Job 목록을 관리할 수 있습니다. 앞으로 추가 기능을 개선하면서 더욱 완성도 높은 UI를 만들어갈 수 있습니다!

     

     

     

     

     

    23. Badge, Separator, Card 컴포넌트를 추가

     

    1. ShadCN 컴포넌트 설치

    ShadCN을 활용하여 Badge, Separator, Card 컴포넌트를 추가하려면 아래 명령어를 실행합니다.

    npx shadcn@latest add badge separator card
    

    이 명령어를 실행하면 프로젝트에 필요한 ShadCN 컴포넌트가 설치됩니다. 설치가 완료되면 해당 컴포넌트를 import하여 사용할 수 있습니다.

    2. Badge 컴포넌트 사용

    Badge 컴포넌트는 간단한 스타일 요소를 추가하는 데 유용합니다. Variant(변형) 옵션을 제공하여 다양한 스타일을 적용할 수 있으며, 링크 형태로도 사용 가능합니다.

    import { Badge } from "@/components/ui/badge";
    
    function Example() {
      return <Badge variant="outline">New</Badge>;
    }
    

    3. Separator 컴포넌트 사용

    Separator는 구분선을 추가하는 데 사용됩니다. 기본적으로 상하 마진을 설정하여 구분할 수 있으며, 추가적인 옵션도 제공됩니다.

    import { Separator } from "@/components/ui/separator";
    
    function Example() {
      return (
        <div>
          <p>위 내용</p>
          <Separator className="my-4" />
          <p>아래 내용</p>
        </div>
      );
    }
    

    4. Card 컴포넌트 사용

    Card 컴포넌트는 UI에서 컨텐츠를 정리하여 표시할 때 유용합니다. 카드 내부에는 제목, 설명, 컨텐츠, 푸터 등의 구성 요소를 포함할 수 있습니다.

    import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
    
    function Example() {
      return (
        <Card>
          <CardHeader>
            <CardTitle>Job Title</CardTitle>
            <CardDescription>Job description goes here.</CardDescription>
          </CardHeader>
          <CardContent>
            <p>Job details and other information.</p>
          </CardContent>
        </Card>
      );
    }
    

    5. 다음 작업: Job Card 컴포넌트 구성

    이제 기본적인 Badge, Separator, Card 컴포넌트를 설치하고 활용하는 방법을 익혔습니다. 이를 바탕으로 Job Card 컴포넌트를 구현할 준비가 되었습니다.

     

     

     

     

     

    24. JobCard Component

     

    2. JobCard 컴포넌트 구현

    아래는 JobCard 컴포넌트 코드입니다.

    import { JobType } from "@/types/JobDTO";
    import React from "react";
    import {
      Card,
      CardContent,
      CardDescription,
      CardFooter,
      CardHeader,
      CardTitle,
    } from "@/components/ui/card";
    import { Separator } from "./ui/separator";
    import { Button } from "./ui/button";
    import Link from "next/link";
    import DeleteJobButton from "./DeleteJobButton";
    
    interface JobCardProps {
      key: string;
      job: JobType;
    }
    
    const JobCard: React.FC<JobCardProps> = ({ job }) => {
      return (
        <Card className="bg-muted">
          <CardHeader>
            <CardTitle>{job.position}</CardTitle>
            <CardDescription>{job.company}</CardDescription>
          </CardHeader>
          <Separator />
          <CardContent></CardContent>
          <CardFooter className="flex gap-4">
            <Button asChild size="sm">
              <Link href={`/jobs/${job.id}`}>수정하기</Link>
            </Button>
            <DeleteJobButton />
          </CardFooter>      
        </Card>
      );
    };
    
    export default JobCard;
    

    3. JobCard 설명

    • JobCard는 특정 직무 정보를 표시하는 컴포넌트입니다.
    • CardHeader에는 직무명과 회사명을 표시합니다.
    • Separator를 추가하여 콘텐츠 구분을 명확히 합니다.
    • CardFooter에 수정하기 버튼과 삭제하기 버튼을 배치합니다.
    • 수정하기 버튼 클릭 시 해당 Job ID 페이지로 이동합니다.
    • 삭제하기 버튼 클릭 시 해당 직무 정보를 삭제하는 기능을 수행합니다.

    4. 수정 및 삭제 기능 구현

    4.1 수정 기능

    • 수정하기 버튼을 클릭하면, 동적으로 Job ID를 포함하는 페이지(/jobs/:id)로 이동합니다.
    • 해당 페이지에서 기존 데이터를 불러와 기본값을 설정한 후, 수정된 내용을 제출하면 업데이트됩니다.
    • 새 직무를 추가하는 기능과 거의 동일하나, 기존 데이터를 가져와 수정하는 점이 다릅니다.

    4.2 삭제 기능

    • DeleteJobButton 컴포넌트에서 삭제 기능을 구현합니다.
    • 삭제 버튼을 클릭하면 해당 Job ID를 기반으로 삭제 요청을 보냅니다.
    • 삭제 후에는 리스트에서 해당 항목이 제거됩니다.

    5. 정리

    • JobCard 컴포넌트는 직무명, 회사명, 수정, 삭제 버튼을 포함합니다.
    • 수정 버튼을 통해 개별 직무 정보를 수정할 수 있으며, 삭제 버튼을 통해 삭제가 가능합니다.
    • ShadCN UI 컴포넌트를 활용하여 일관된 스타일을 유지하면서도 쉽게 UI를 구성할 수 있습니다.

     

     

     

     

     

     

    25. JobInfo 컴포넌트

     

    1. JobCard 컴포넌트 구현

    개요

    JobCard는 채용 정보를 카드 형태로 표시하는 UI 컴포넌트입니다. 주요 요소는 다음과 같습니다:

    • 회사명, 포지션, 생성일 등의 정보 표시
    • JobInfo 컴포넌트를 사용해 아이콘과 텍스트를 함께 렌더링
    • 수정 및 삭제 버튼 추가

    코드 구현

    import { JobType } from "@/types/JobDTO";
    import React from "react";
    import {
      Card,
      CardContent,
      CardDescription,
      CardFooter,
      CardHeader,
      CardTitle,
    } from "@/components/ui/card";
    import { Separator } from "./ui/separator";
    import { Button } from "./ui/button";
    import Link from "next/link";
    import DeleteJobButton from "./DeleteJobButton";
    import JobInfo from "./JobInfo";
    import { Briefcase, CalendarDays, MapPin, RadioTower } from "lucide-react";
    import { Badge } from "./ui/badge";
    
    interface JobCardProps {
      key: string;
      job: JobType;
    }
    
    const JobCard: React.FC<JobCardProps> = ({ job }) => {
      const date = new Date(job.createdAt).toLocaleDateString();
    
      return (
        <Card className="bg-muted">
          <CardHeader>
            <CardTitle>{job.position}</CardTitle>
            <CardDescription>{job.company}</CardDescription>
          </CardHeader>
          <Separator />
          
          <CardContent className="mt-4 grid grid-cols-2 gap-4">
            <JobInfo icon={<Briefcase />} text={job.mode} />
            <JobInfo icon={<MapPin />} text={job.location} />
            <JobInfo icon={<CalendarDays />} text={date} />
            <Badge className="w-32 justify-center">
              <JobInfo icon={<RadioTower className="w-4 h-4" />} text={job.status} />
            </Badge>
          </CardContent>
    
          <CardFooter className="flex gap-4">
            <Button asChild size="sm">
              <Link href={`/jobs/${job.id}`}>수정하기</Link>
            </Button>
            <DeleteJobButton />
          </CardFooter>      
        </Card>
      );
    };
    
    export default JobCard;
    

     

    2. JobInfo 컴포넌트 구현

    개요

    JobInfo는 아이콘과 텍스트를 나란히 표시하는 컴포넌트로, JobCard 내에서 재사용됩니다.

    코드 구현

    import React from 'react';
    
    interface JobInfoProps {
      icon: React.ReactNode;
      text: string;
    }
    
    const JobInfo: React.FC<JobInfoProps> = ({ icon, text }) => {
      return (
        <div className='flex gap-x-2 items-center'>
          {icon} {text}
        </div>
      );
    };
    
    export default JobInfo;
    

     

    3. JobCard 기능 설명

    3.1. JobInfo 컴포넌트 활용

    JobInfo는 아이콘과 텍스트를 함께 표시하는 역할을 합니다.

    • icon prop: lucide-react에서 제공하는 아이콘 컴포넌트
    • text prop: 해당 정보 (근무 형태, 위치, 생성일, 상태)
    • flex와 gap-x-2를 사용하여 아이콘과 텍스트 간격을 조정

    3.2. 날짜 포맷 처리

    날짜를 보기 좋게 변환하기 위해 toLocaleDateString()을 활용하여 YYYY-MM-DD 형태로 변환합니다.

    const date = new Date(job.createdAt).toLocaleDateString();
    

     

    3.3. 그리드 레이아웃 적용

    grid grid-cols-2 gap-4 클래스를 사용하여 2열 레이아웃을 설정하고, 정보 간 간격을 조정했습니다.

    <CardContent className="mt-4 grid grid-cols-2 gap-4">
      <JobInfo icon={<Briefcase />} text={job.mode} />
      <JobInfo icon={<MapPin />} text={job.location} />
      <JobInfo icon={<CalendarDays />} text={date} />
      <Badge className="w-32 justify-center">
        <JobInfo icon={<RadioTower className="w-4 h-4" />} text={job.status} />
      </Badge>
    </CardContent>
    

     

    3.4. 수정 및 삭제 버튼 추가

    • 수정 버튼: Link를 사용해 특정 Job ID의 상세 페이지로 이동
    • 삭제 버튼: DeleteJobButton 컴포넌트 활용
    <CardFooter className="flex gap-4">
      <Button asChild size="sm">
        <Link href={`/jobs/${job.id}`}>수정하기</Link>
      </Button>
      <DeleteJobButton />
    </CardFooter>
    

     

    4. 결론

    이 프로젝트에서는 Next.js 15 앱 라우터 방식shadcn UI 컴포넌트를 활용하여 JobCard를 구현하였습니다. JobInfo 컴포넌트를 분리하여 재사용성을 높였으며, 그리드 레이아웃을 적용하여 정보 배치를 최적화했습니다. 앞으로 삭제 기능 구현을 추가할 예정입니다.

     

     

     

     

     

     

    26. Delete Job

     

    1. 개요

    이 문서는 Next.js 15 앱 라우터 방식과 shadcn을 이용하여 Delete Job 기능을 구현하는 방법을 정리한 것입니다. React Query와 토스트 메시지를 활용하여 사용자 경험을 개선하고, 삭제 후 데이터를 갱신하는 방식도 설명합니다.

     

    2. Delete Job 버튼 컴포넌트 구현

    아래는 DeleteJobButton.tsx 파일의 코드입니다. 이 컴포넌트는 특정 id를 받아 해당 Job을 삭제하는 기능을 수행합니다.

    import { deleteJobAction } from '@/actions/jobService';
    import { useToast } from '@/hooks/use-toast';
    import { useMutation, useQueryClient } from '@tanstack/react-query';
    import React from 'react';
    import { Button } from './ui/button';
    
    interface DeleteJobButtonProps {
      id: string;
    }
    
    const DeleteJobButton: React.FC<DeleteJobButtonProps> = ({ id }) => {
      const { toast } = useToast();
      const queryClient = useQueryClient();
      
      const { mutate, isPending } = useMutation({
        mutationFn: () => deleteJobAction(id),
        onSuccess: (data) => {
          if (!data) {
            toast({ description: '에러가 발생 했습니다.', variant: 'destructive' });
            return;
          }
          queryClient.invalidateQueries({ queryKey: ['jobs'] });
          queryClient.invalidateQueries({ queryKey: ['stats'] });
          queryClient.invalidateQueries({ queryKey: ['charts'] });
          toast({ description: '직업을 삭제 했습니다.' });
        },
        onError: () => toast({ description: '에러가 발생 했습니다.', variant: 'destructive' }),
      });
    
      const handleDeleteClick = () => {
        toast({
          description: '정말 삭제하시겠습니까?',
          position:"top",
          action: (
            <Button
              variant="destructive"
              size="sm"          
              onClick={() => mutate()}
            >
              삭제
            </Button>
          ),
        });
      };
    
      return (
        <Button
          size="sm"
          disabled={isPending}
          onClick={handleDeleteClick}
          className="bg-red-500 hover:bg-red-600 text-white"
        >
          {isPending ? '직업 삭제중...' : '직업 삭제하기'}
        </Button>
      );
    };
    
    export default DeleteJobButton;
    

     

     

    3. Delete Job Action 구현

    다음은 deleteJobAction.ts의 코드입니다. 데이터베이스에서 Job을 삭제하는 기능을 수행합니다.

    //잡 삭제
    export async function deleteJobAction(id: string): Promise<JobType | null> {
    
      if (BACKEND_TYPE === "spring-boot") {
        return null ;
      } else {
       return await prisamDeleteJobAction(id);
     }
    
    }
    

    4. 삭제 후 데이터 갱신 처리

    삭제가 성공하면 React Query의 invalidateQueries 메서드를 사용하여 관련 데이터를 갱신합니다. 삭제된 Job이 화면에서 즉시 반영되도록 하기 위해 jobs, stats, charts 데이터를 무효화(invalidate)합니다.

     

    5. 기능 테스트

    1. Delete Job 버튼을 클릭하면 확인 메시지가 표시됩니다.
    2. 삭제 버튼을 누르면 삭제 요청이 전송됩니다.
    3. 삭제 성공 시, 삭제된 Job이 목록에서 제거되며, 관련 데이터가 자동으로 갱신됩니다.
    4. 삭제 실패 시, 에러 메시지가 표시됩니다.

     

    6. 결론

    이제 Next.js 15 앱 라우터와 shadcn을 활용하여 Delete Job 기능을 성공적으로 구현할 수 있습니다. 삭제 후 데이터를 최신 상태로 유지하는 React Query의 invalidateQueries 활용법을 숙지하면 다양한 CRUD 작업에도 쉽게 적용할 수 있습니다.

     

     

     

     

     

     

    27.Single Job Page

    1. 개요

    이 문서는 Next.js 15 앱 라우터 방식과 shadcn을 이용하여 Job 상세 조회 및 수정 기능을 구현하는 방법을 정리한 것입니다. React Query의 HydrationBoundary를 활용하여 서버에서 데이터를 미리 불러오고, EditJobForm을 이용해 데이터를 수정하는 과정을 설명합니다.

     

    2. Job 상세 페이지 구현

    아래는 JobDetailPage.tsx 파일의 코드입니다. 특정 jobId를 받아 해당 Job의 상세 정보를 조회하고 수정할 수 있도록 합니다.

    import { getSingleJobAction } from '@/actions/jobService';
    import EditJobForm from '@/components/EditJobForm';
    import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query';
    import React from 'react';
    
    interface JobDetailPageProps {
        params: { jobId: string };
    }
    
    const JobDetailPage: React.FC<JobDetailPageProps> = async ({ params }) => {
        const queryClient = new QueryClient();
    
        await queryClient.prefetchQuery({
            queryKey: ['job', params.jobId],
            queryFn: () => getSingleJobAction(params.jobId),
        });
    
        return (
            <HydrationBoundary state={dehydrate(queryClient)}>
                <EditJobForm jobId={params.jobId} />
            </HydrationBoundary>
        );
    };
    
    export default JobDetailPage;
    

     

    3. Job 데이터 가져오기

    다음은 getSingleJobAction.ts의 코드입니다. 데이터베이스에서 Job을 조회하는 기능을 수행합니다.

    export async function getSingleJobAction(id: string): Promise<JobType | null> {
      if (BACKEND_TYPE === "spring-boot") {
        return null;
      } else {
        return await prismaGetSingleJobAction(id);
      }
    }
    

     

    4. 서버 데이터 미리 불러오기 (Prefetching)

    prefetchQuery를 활용하여 서버에서 데이터를 미리 가져옵니다. 이렇게 하면 컴포넌트가 마운트될 때 이미 필요한 데이터가 준비되어 있어 성능이 향상됩니다.

     

    5. 기능 테스트

    1. Job 상세 페이지에 접속하면 jobId에 해당하는 데이터가 서버에서 미리 불러와집니다.
    2. EditJobForm을 이용해 Job 정보를 수정할 수 있습니다.
    3. 수정된 데이터는 서버에 저장되며, React Query를 이용해 변경 사항이 반영됩니다.
    4.  

    6. 결론

    이제 Next.js 15 앱 라우터와 shadcn을 활용하여 Job 상세 조회 및 수정 기능을 성공적으로 구현할 수 있습니다. 서버 데이터 미리 불러오기를 활용하면 성능을 최적화할 수 있으며, HydrationBoundary를 이용하면 SSR 환경에서도 데이터를 안정적으로 관리할 수 있습니다.

     

     

     

     

     

     

    28.직업 수정하기

     

    1. 프로젝트 개요

    Next.js 15의 앱 라우터 방식과 Shadcn UI를 활용한 프로젝트로, 직업 정보를 수정하는 기능을 구현합니다. React Query를 사용하여 서버와 데이터를 주고받으며, Zod 및 React Hook Form을 이용해 입력 값을 검증합니다.

    2. 주요 기능 및 개념

    2.1 데이터 가져오기 (React Query)

    • useQuery를 사용하여 서버에서 특정 직업 데이터를 가져옵니다.
    • queryKey를 ["job", jobId]로 설정하여 직업 정보를 캐싱하고 관리합니다.
    const {data} = useQuery({
      queryKey: ['job', jobId],
      queryFn: () => getSingleJobAction(jobId),
    });

     

    2.2 폼 설정 및 검증 (React Hook Form + Zod)

    • useForm을 사용하여 폼 상태를 관리합니다.
    • zodResolver를 사용해 Zod 스키마를 적용하여 입력 값을 검증합니다.
    • 기본 값(defaultValues)을 서버에서 받아온 데이터로 설정합니다.
    const form = useForm<CreateAndEditJobType>({
      resolver: zodResolver(createAndEditJobSchema),
      defaultValues: {
        position: data?.position || '',
        company: data?.company || '',
        location: data?.location || '',
        status: (data?.status as JobStatus) || JobStatus.Pending,
        mode: (data?.mode as JobMode) || JobMode.FullTime,
      }
    });

     

     

    2.3 데이터 업데이트 (React Query의 useMutation)

    • useMutation을 사용하여 직업 정보를 수정합니다.
    • 성공적으로 업데이트되면 queryClient.invalidateQueries를 호출하여 캐시를 무효화하고 최신 데이터를 가져옵니다.
    • 수정 완료 후, router.push를 이용해 해당 직업 상세 페이지로 이동합니다.
    const {mutate, isPending} = useMutation({
      mutationFn: (values:CreateAndEditJobType) => updateJobAction(jobId, values),
      onSuccess: (data) => {
        if (!data) {
          toast({ description: "에러가 발생 했습니다.", variant: "destructive" });
          return;
        }
        toast({ description: "직업이 성공적으로 수정되었습니다.", variant: "success" });
        queryClient.invalidateQueries({ queryKey: ["job", jobId] });
        queryClient.invalidateQueries({ queryKey: ["jobs"] });
        queryClient.invalidateQueries({ queryKey: ["stats"] });
        router.push(`/jobs/${jobId}`);
      }
    });

     

     

    3. UI 구성

    3.1 폼 UI

    • CustomFormField 및 CustomFormSelect를 사용하여 입력 필드를 구성합니다.
    • Button을 이용해 폼 제출 버튼을 추가합니다.
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="bg-muted p-8 rounded">
        <h2 className="text-[18px] font-semibold mb-6">직업 수정</h2>
        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 items-start">
          <CustomFormField name="position" label="직책" control={form.control} />
          <CustomFormField name="company" label="회사" control={form.control} />
          <CustomFormField name="location" label="지역" control={form.control} />
          <CustomFormSelect name="status" control={form.control} labelText="직업 상태" items={Object.values(JobStatus)} />
          <CustomFormSelect name="mode" control={form.control} labelText="고용 형태" items={Object.values(JobMode)} />
          <Button type='submit' className='self-end capitalize'>
            {isPending ? "직업 수정중..." : "직업 수정"}
          </Button>
        </div>
      </form>
    </Form>
    

     

    4. 서버 측 업데이트 함수

    4.1 updateJobAction

    • prisma.job.update를 사용하여 데이터베이스에서 직업 정보를 업데이트합니다.
    • try-catch 블록을 사용하여 오류 발생 시 null을 반환합니다.
    export async function updateJobAction(id: string, values: CreateAndEditJobType): Promise<JobType | null> {
      const userId = await authenticateAndRedirect();
      try {
        createAndEditJobSchema.parse(values);
        const job: JobType = await prisma.job.update({
          where: { id, clerkId: userId },
          data: { ...values }
        });
        return job;
      } catch (error) {
        console.log("updateJobAction Error:", error);
        return null;
      }
    }

     

     

     

    5. 마무리

    이 프로젝트는 React Query, React Hook Form, Zod 등의 라이브러리를 활용하여 Next.js 15의 앱 라우터 방식으로 직업 수정 기능을 구현한 사례입니다. useQuery, useMutation을 활용한 데이터 핸들링과 react-hook-form을 이용한 입력 폼 관리가 핵심입니다.

     

     

     

     

     

     

    29. Mockaroo 을 이용한 더미데이터 생성하기

    https://www.mockaroo.com/

     

     

    1. 프로젝트 개요

    Next.js 15의 앱 라우터(App Router) 방식과 UI 라이브러리인 shadcn을 활용하여 프로젝트를 진행합니다. 이 글에서는 코드와 강의 대본을 기반으로 프로젝트의 핵심 개념과 데이터 시딩(Seeding) 방법을 정리합니다.

     

    2. Mock 데이터 생성 및 데이터베이스 시딩

    백엔드에서 통계 및 차트 컴포넌트를 쉽게 구축하기 위해 약 100개의 Job 데이터를 생성하여 데이터베이스에 추가합니다.

    (1) Mockaroo를 이용한 Mock 데이터 생성

    1. Mockaroo 웹사이트 접속 (회원가입 불필요)
    2. 아래 필드 설정:
      • position: 직책 (Job title)
      • company: 회사명
      • location: 도시명
      • status: Custom List ("pending", "interview", "declined")
      • mode: Custom List ("full-time", "part-time", "internship")
      • createdAt: 날짜 (ISO 형식)
    3. 데이터 미리보기 후 JSON으로 다운로드

    (2) 데이터 파일 정리

    • 다운로드한 JSON 파일을 mock-data.json으로 변경
    • prisma 폴더에 저장 (혹은 utils 폴더에 저장 가능)

    3. 데이터베이스 시딩 코드 작성

    (1) Prisma 클라이언트 설정

    시딩을 위해 Prisma 클라이언트를 사용합니다. 일반적인 DB 인스턴스가 아닌 새 클라이언트를 생성합니다.

    const { PrismaClient } = require('@prisma/client');
    const mockData = require('./mock-data.json');
    
    const prisma = new PrismaClient();
    

    (2) 데이터베이스에 Job 데이터 추가

    • 각 Job 데이터에 특정 유저 ID를 연결해야 하므로, 인증을 통해 userId를 가져옵니다.
    • for...of 루프를 사용하여 데이터베이스에 Job을 추가합니다.
    const { PrismaClient } = require('@prisma/client');
    const mockData = require('./mock_data.json');
    
    const prisma = new PrismaClient();
    
    
    async function seedDatabase() {
        const clerkId = '클라이언트에서 가져온 사용자 ID';   
        const jobs= mockData.map((job) => {
          return {
            ...job,
             clerkId,
          };
        })
    
        for (const job of jobs) {
          await prisma.job.create({
            data: job        
          });
        }
    
        console.log('✅ 1000개의 Job 데이터가 추가되었습니다.');
    }
      
    seedDatabase()
        .then(async() =>await prisma.$disconnect())
        .catch(async(error) => {
        console.error(error);
           await prisma.$disconnect();
           process.exit(1);
    });

    4. 데이터 확인 및 테스트

    (1) 터미널에서 시딩 실행

    node prisma/seed
    
    • 데이터베이스에 정상적으로 100개의 Job이 추가되었는지 확인합니다.
    • Prisma Studio를 실행하여 데이터 확인:
    npx prisma studio
    

    5. 다음 작업: 통계 및 차트 기능 구현

    데이터베이스에 Job 데이터가 정상적으로 추가되었으므로, 이제 통계 및 차트 기능을 개발할 준비가 되었습니다.

    • 차트 시각화
    • 페이지네이션
    • 필터 및 정렬 기능

     

     

     

     

     

    30.통계액션

    1. 개요

    Next.js 15 앱 라우터 방식과 shadcn을 이용한 프로젝트에서 getStatsAction 함수를 구현하고, 해당 데이터를 StatsPage에서 활용하는 방법을 설명합니다.

     

    2. getStatsAction 함수 구현

    export async function getStatsAction(): Promise<{
      "보류중": number;
      "인터뷰": number;
      "거절됨": number;
    }> {
      const userId = await authenticateAndRedirect();
      
      try {
        const stats = await prisma.job.groupBy({    
          where: {
            clerkId: userId,
          },
          by: ['status'],
          _count: {
            status: true,
          },
        });
    
        console.log("getStatsAction stats: ", stats);
        
        const statsObject = stats.reduce((acc, curr) => {
          acc[curr.status] = curr._count.status;
          return acc;
        }, {} as Record<string, number>);
    
        const defaultStats = {
          "거절됨": 0,
          "보류중": 0,
          "인터뷰": 0,
          ...statsObject,
        };
    
        return defaultStats;
      } catch (error) {
        console.log("getStatsAction Error: ", error);
        redirect("/jobs");
      }
    }

     

     

    3. StatsPage 컴포넌트

    import { getStatsAction } from '@/actions/jobService';
    import React from 'react'
    
    const StatsPage: React.FC = async () => {
      const stats = await getStatsAction();
      console.log("*stats : ", stats);
      
      return (
        <div className='text-4xl'>
          StatsPage
        </div>
      );
    }
    
    export default StatsPage;

     

     

    4. getStatsAction 함수 설명

    getStatsAction 함수는 데이터베이스에서 특정 사용자의 지원 현황을 가져오는 역할을 합니다.

    1. 사용자 ID 가져오기: authenticateAndRedirect를 통해 현재 로그인한 사용자의 ID를 가져옵니다.
    2. 데이터베이스 조회: prisma.job.groupBy 메서드를 이용해 status(보류중, 인터뷰, 거절됨)별로 그룹화하고 각 상태별 개수를 계산합니다.
    3. 데이터 변환: reduce를 사용해 { "거절됨": 0, "보류중": 0, "인터뷰": 0 } 형태의 객체로 변환합니다.
    4. 예외 처리: 오류 발생 시 콘솔에 오류를 출력하고 /jobs 페이지로 리디렉션합니다.

     

    5. StatsPage에서 데이터 활용

    StatsPage 컴포넌트에서 getStatsAction을 호출하여 데이터를 가져오고, 콘솔에 출력합니다. 이후 데이터를 활용하여 UI를 구성할 수 있습니다.

    1. getStatsAction 호출: await getStatsAction();
    2. 콘솔 로그 출력: console.log("*stats : ", stats);
    3. 데이터 활용: UI에서 상태별 데이터를 표시할 수 있도록 확장 가능

     

    6. 결론

    • getStatsAction을 통해 데이터베이스에서 지원 현황 데이터를 효율적으로 가져올 수 있습니다.
    • StatsPage에서 해당 데이터를 불러와 활용할 수 있습니다.
    • Prisma의 groupBy 메서드를 활용하여 데이터를 그룹화하고 가공하는 방법을 익힐 수 있습니다.

     

     

     

     

     

     

     

    31.통계액션

    1. 프로젝트 개요

    Next.js 15의 앱 라우터 방식과 shadcn을 활용한 프로젝트에서 통계 및 차트 데이터를 가져오는 기능을 구현합니다. 이를 위해 getStatsAction과 getChartsDataAction 함수를 사용하여 데이터베이스에서 정보를 조회하고, 이를 프론트엔드에서 활용할 수 있도록 포맷팅합니다.

     

    2. 통계 데이터 가져오기 (getStatsAction)

    export async function getStatsAction(): Promise<{
      "보류중": number;
      "인터뷰": number;
      "거절됨": number;
    }> {
      const userId = await authenticateAndRedirect();
    
      try {
        const stats = await prisma.job.groupBy({
          where: {
            clerkId: userId,
          },
          by: ['status'],
          _count: {
            status: true,
          },
        });
    
        console.log("getStatsAction stats: ", stats);
        const statsObject = stats.reduce((acc, curr) => {
          acc[curr.status] = curr._count.status;
          return acc;
        }, {} as Record<string, number>);
    
        const defaultStats = {
          "거절됨": 0,
          "보류중": 0,
          "인터뷰": 0,
          ...statsObject,
        };
    
        return defaultStats;
      } catch (error) {
        console.log("getStatsAction Error: ", error);
        redirect("/jobs");
      }
    }

     

     

    이 함수는 사용자의 job 데이터를 status별로 그룹화하여 개수를 반환합니다. 기본적으로 거절됨, 보류중, 인터뷰 상태를 포함하며, 데이터가 없는 경우 0을 반환합니다.

     

    3. 차트 데이터 가져오기 (getChartsDataAction)

    export async function getChartsDataAction(): Promise<Array<{ date: string; count: number }>> {
      // 사용자 인증 후 userId 가져오기
      const userId = await authenticateAndRedirect();
      // 최근 6개월간의 데이터를 가져오기 위해 기준 날짜 설정
      const sixMonthsAgo = dayjs().subtract(6, 'month').toDate();
    
      try {
        // 데이터베이스에서 사용자 ID와 생성 날짜 기준으로 job 데이터 조회
        const jobs = await prisma.job.findMany({
          where: {
            clerkId: userId,
            createdAt: {
              gte: sixMonthsAgo,
            },
          },
          orderBy: {
            createdAt: "asc",
          },
        });
    
        console.log("* getChartsDataAction - jobs :", jobs);
    
        // 조회한 job 데이터를 월별로 집계하여 배열로 변환
        const applicationsPerMonth = jobs.reduce((acc, job) => {
          // job 생성 날짜를 'MMM YY' (예: Jan 24) 형식으로 변환
          const date = dayjs(job.createdAt).format('MMM YY');
          // 이미 존재하는 월 데이터가 있는지 확인
          const existingEntry = acc.find((entry) => entry.date === date);
    
          if (existingEntry) {
            // 기존 월 데이터가 있다면 count 증가
            existingEntry.count++;
          } else {
            // 새로운 월 데이터 추가
            acc.push({ date, count: 1 });
          }
    
          return acc;
        }, [] as Array<{ date: string; count: number }>);
    
        return applicationsPerMonth;
      } catch (error) {
        console.log("getChartsDataAction Error: ", error);
        return [];
      }
    }

     

     

    getChartsDataAction 함수 설명

    1. 사용자 인증 및 userId 가져오기: authenticateAndRedirect 함수를 호출하여 현재 로그인한 사용자의 ID를 가져옵니다.
    2. 최근 6개월 데이터 조회: dayjs().subtract(6, 'month').toDate()를 사용하여 6개월 전의 날짜를 계산하고, Prisma의 findMany를 활용하여 createdAt이 해당 날짜 이후인 데이터만 가져옵니다.
    3. 데이터 가공 및 정리:
      • 조회한 데이터를 reduce를 사용하여 MMM YY 형식(예: Jan 24, Feb 24)의 월별 집계 데이터로 변환합니다.
      • 같은 월의 데이터가 존재하면 count를 증가시키고, 없으면 새로운 항목을 추가합니다.
    4. 예외 처리: 오류 발생 시 콘솔에 로그를 출력하고 빈 배열을 반환합니다.

    이 함수는 차트 라이브러리에서 사용할 수 있도록 데이터 형식을 변환하여, 사용자에게 지난 6개월 동안의 지원 현황을 시각적으로 보여줄 수 있도록 합니다.

     

    4. 통계 및 차트 데이터 표시 (StatsPage)

    import { getChartsDataAction, getStatsAction } from '@/actions/jobService';
    import React from 'react';
    
    const StatsPage: React.FC = async () => {
      const stats = await getStatsAction();
      const charts = await getChartsDataAction();
    
      console.log("*stats :", stats);
      console.log("*charts :", charts);
    
      return (
        <div className='text-4xl'>
          StatsPage
        </div>
      );
    };
    
    export default StatsPage;

    이 컴포넌트는 getStatsAction과 getChartsDataAction을 호출하여 데이터를 가져오고, 로그로 출력합니다. 실제 UI 구현은 이후 단계에서 진행할 수 있습니다.

     

    5. 마무리

    이제 데이터를 가져오는 기본적인 기능이 완료되었습니다. 다음 단계에서는 프론트엔드에서 react-query를 활용하여 데이터를 관리하고, 차트 라이브러리를 활용하여 시각화하는 작업을 진행할 수 있습니다.

     

     

     

     

     

     

     

     

    32.통계수치  개발

    1. 프로젝트 개요

    이 프로젝트는 Next.js 15 앱 라우터 방식과 ShadCN을 활용하여 개발되었습니다. 주요 기능으로는 사용자 데이터 시각화를 위한 통계 페이지를 포함하며, React Query를 활용하여 서버에서 데이터를 가져와 화면에 표시하는 구조로 되어 있습니다.

    2. 주요 기능 및 코드 분석

    2.1. 통계 페이지 (StatsPage)

    import React from "react";
    import { getChartsDataAction, getStatsAction } from "@/actions/jobService";
    import { HydrationBoundary, dehydrate, QueryClient } from "@tanstack/react-query";
    import StatsContainer from "@/components/StatsContainer";
    import ChartsContainer from "@/components/ChartsContainer";
    
    
    
    const StatsPage: React.FC = async () => {
    const queryClient = new QueryClient();
    
      await queryClient.prefetchQuery({
        queryKey: ["stats"],
        queryFn: getStatsAction,
      });
    
      await queryClient.prefetchQuery({
        queryKey: ["charts"],
        queryFn: getChartsDataAction,
      });
    
      return (
        <HydrationBoundary state={dehydrate(queryClient)}>
          <StatsContainer />
          <ChartsContainer />
        </HydrationBoundary>
      );
    };
    
    export default StatsPage;
    

    설명

    • queryClient.prefetchQuery를 사용하여 getStatsAction과 getChartsDataAction을 미리 가져옵니다.
    • HydrationBoundary를 사용하여 서버에서 데이터를 미리 가져온 후 클라이언트에서 활용할 수 있도록 합니다.

     

    2.2. 통계 데이터 컨테이너 (StatsContainer)

    "use client";
    import React from "react";
    import StatsCard from "./StatsCard";
    import { getStatsAction } from "@/actions/jobService";
    import { useQuery } from "@tanstack/react-query";
    
    const StatsContainer: React.FC = () => {
      const { data } = useQuery({
        queryKey: ["stats"],
        queryFn: () => getStatsAction(),
      });
    
      return (
        <div className="grid md:grid-cols-2 gap-4 lg:grid-cols-3">           
          <StatsCard title="보류 처리" value={data?.["보류중"] || 0} />
          <StatsCard title="인터뷰 처리" value={data?.["인터뷰"] || 0} />
          <StatsCard title="거절 처리" value={data?.["거절됨"] || 0} />
        </div>
      );
    };
    
    export default StatsContainer;

     

     

    설명

    • useQuery를 사용하여 getStatsAction을 호출하고 데이터를 가져옵니다.
    • StatsCard 컴포넌트를 이용하여 보류, 인터뷰, 거절 데이터를 표시합니다.
    • data?.["보류중"] || 0와 같이 옵셔널 체이닝을 사용하여 데이터가 없을 경우 0을 반환하도록 설정했습니다.

    2.3. 통계 카드 컴포넌트 (StatsCard)

    import React from 'react'
    import { Card, CardDescription, CardHeader, CardTitle } from './ui/card';
    
    interface StatsCardProps {
        title: string;
        value: number;
    }
    
    const StatsCard: React.FC<StatsCardProps> = ({ title, value }) => {
      return (
        <Card className="bg-muted">
            <CardHeader className="flex flex-row justify-between items-center">
                <CardTitle className="capitalize">{title}</CardTitle>
                <CardDescription className='text-4xl font-semibold text-primary mt[0px!important]'>
                    {value}
                </CardDescription>
            </CardHeader>
        </Card>
      )
    }
    
    export default StatsCard;

     

     

    설명

    • Card 컴포넌트를 활용하여 통계 데이터를 카드 형식으로 표시합니다.
    • title과 value를 받아 적절한 UI 요소에 표시합니다.
    • Tailwind CSS를 활용하여 스타일을 지정하였습니다.

    3. 전체 동작 흐름

    1. StatsPage에서 getStatsAction과 getChartsDataAction을 사전 로드하여 HydrationBoundary에 전달합니다.
    2. StatsContainer에서 useQuery를 사용하여 데이터를 가져와 StatsCard에 전달합니다.
    3. StatsCard에서 각 통계 값을 카드 형식으로 표시합니다.

    이렇게 구현함으로써 서버에서 데이터를 미리 로드하고, 클라이언트에서 즉시 활용할 수 있도록 하여 UX를 향상시켰습니다.

     

     

     

    33.skeleton  로딩 처리

     

    1. shadcn 설치 및 구성

    Next.js 15 프로젝트에서 shadcn을 사용하려면 아래 명령어를 실행하여 필요한 컴포넌트를 추가합니다.

    npx shadcn@latest add skeleton
    

    이 명령어를 실행하면 Skeleton 컴포넌트가 프로젝트에 추가됩니다. 이는 로딩 상태를 나타내는 UI 컴포넌트로 활용할 수 있습니다.

     

    2. StatsLoadingCard 컴포넌트 생성

    아래와 같이 StatsLoadingCard.tsx 파일을 생성하여 로딩 상태를 표시하는 카드 컴포넌트를 만듭니다.

    import React from 'react'
    import { Card, CardHeader } from './ui/card'
    import { Skeleton } from './ui/skeleton'
    
    const StatsLoadingCard: React.FC = () => {
      return (
        <Card className="w-[330px] h-[88px]">
          <CardHeader className="flex flex-row justify-between items-center">
            <div className='flex items-center space-x-4'>
              <Skeleton className='h-12 w-12 rounded-full' />
              <div className='space-y-2'>
                <Skeleton className='h-4 w-[150px]' />
                <Skeleton className='h-4 w-[100px]' />
              </div>
            </div>
          </CardHeader>
        </Card>
      )
    }
    
    export default StatsLoadingCard
    

    이 컴포넌트는 Card와 Skeleton을 활용하여, 데이터가 로드되기 전의 UI 상태를 미리 보여주는 역할을 합니다.

     

    3. StatsLoading 컴포넌트 생성

    다음으로, 여러 개의 StatsLoadingCard를 렌더링하는 StatsLoading.tsx 파일을 생성합니다.

    import StatsLoadingCard from '@/components/StatsLoadingCard';
    import React from 'react'
    
    const StatsLoading = () => {
      return (
        <div className='grid md:grid-cols-2 gap-4 lg:grid-cols-3'>
          <StatsLoadingCard />
          <StatsLoadingCard />
          <StatsLoadingCard />
        </div>
      )
    }
    
    export default StatsLoading;
    

    이 컴포넌트는 grid 레이아웃을 활용하여 StatsLoadingCard를 2~3개씩 나열합니다. 이를 통해 여러 개의 데이터를 비동기 로딩할 때 사용자 경험을 개선할 수 있습니다.

     

    4. 결론

    위와 같은 방식으로 shadcn을 활용하여 Next.js 15 앱 라우터 환경에서 효율적인 로딩 UI를 구성할 수 있습니다. Skeleton 컴포넌트는 데이터를 로드하는 동안 사용자 경험을 개선하는 데 유용하게 사용됩니다.

     

     

     

     

     

    34. 차트  개발

    1. 개요

    Next.js 15의 앱 라우터 방식을 사용하고, ShadCN을 활용하여 차트 컴포넌트를 구현하는 방법을 정리합니다. 해당 프로젝트에서는 recharts 라이브러리를 사용하여 데이터를 시각화합니다.

    2. 라이브러리 설치

    다음 명령어를 실행하여 recharts 라이브러리를 설치합니다.

    npm install recharts
    

     

    차트 라이브러리 문서

    https://recharts.org/en-US/examples/SimpleBarChart

     

     

    3. 코드 구현

    아래는 ChartsContainer 컴포넌트의 전체 코드입니다.

    "use client";
    import { getChartsDataAction } from "@/actions/jobService";
    import { useQuery } from "@tanstack/react-query";
    import React from "react";
    import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
    
    const ChartsContainer: React.FC = () => {
      const { data } = useQuery({
        queryKey: ["charts"],
        queryFn: () => getChartsDataAction(),
      });
    
      if (!data || data.length < 1) return null;
    
      // 데이터 키 변경
      const formattedData = data.map(({ date, count }) => ({
        date,
        신청자수: count,
      }));
    
      return (
        <section className="mt-16">
          <h1 className="text-4xl font-semibold text-center mb-10">월간 신청</h1>
    
          <ResponsiveContainer width="100%" height={300}>
            <BarChart data={formattedData} margin={{ top: 5 }}>
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey="date" />
              <YAxis allowDecimals={false} />
              <Tooltip />
              <Bar dataKey="신청자수" fill="#2563eb" barSize={75} />
            </BarChart>
          </ResponsiveContainer>
        </section>
      );
    };
    
    export default ChartsContainer;

     

     

    4. 코드 설명

    4.1. use client 선언

    Next.js 15의 서버 컴포넌트와 클라이언트 컴포넌트를 분리하기 위해 "use client"를 선언합니다. 해당 컴포넌트는 클라이언트에서 실행됩니다.

     

     

    4.2. 데이터 불러오기

    useQuery 훅을 사용하여 서버에서 차트 데이터를 가져옵니다. getChartsDataAction 함수는 서버에서 데이터를 비동기적으로 가져오는 역할을 합니다.

    4.3. 데이터 가공

    서버에서 받아온 데이터의 키를 신청자수로 변경하여 formattedData 배열을 생성합니다. 이를 통해 한글 키를 가진 데이터를 recharts에 전달할 수 있습니다.

     

    4.4. BarChart 설정

    BarChart 내부에 ResponsiveContainer를 사용하여 반응형 차트를 구현하고, XAxis, YAxis, Tooltip 등을 추가하여 차트의 정보를 표시합니다. Bar 컴포넌트를 사용하여 신청자 수 데이터를 막대그래프로 나타냅니다.

     

    5. 실행 결과

    해당 코드를 실행하면, 월별 신청자 수를 시각화한 막대 그래프가 출력됩니다. Y축에는 소수점 없이 값이 표시되며, X축에는 날짜가 표시됩니다. Tooltip을 추가하여 마우스 오버 시 데이터를 확인할 수 있습니다.

     

     

     

     

     

     

    35. ★페이징 처리

     

    1. 프로젝트 개요

    이 프로젝트는 Next.js 15 앱 라우터(App Router) 방식과 shadcn UI 라이브러리를 활용하여 채용 정보 관리 시스템을 개발하는 것을 목표로 합니다.

    주요 기능

    • 채용 정보 검색 및 필터링: 회사명, 직무, 지역 등을 기준으로 검색 가능
    • 페이징 처리: 한 번에 많은 데이터를 불러오는 것을 방지하고 사용자 경험을 개선
    • React Query 적용: 서버 상태 관리를 최적화하여 데이터 fetching
    • shadcn 활용: UI 구성 및 버튼 스타일링에 활용
    •  

    2. 데이터 페이징 처리 개요

    페이징 처리는 대량의 데이터를 작은 단위로 나누어 효율적으로 사용자에게 제공하는 기법입니다. 이 프로젝트에서는 PrismaReact Query를 이용하여 서버와 클라이언트에서 페이징을 구현합니다.

     

    페이징 주요 개념

    1. 한 페이지당 보여줄 개수 (limit): 기본적으로 10개씩 데이터를 불러옴
    2. 현재 페이지 (page): 현재 사용자가 보고 있는 페이지 번호
    3. 전체 데이터 개수 (totalCount): 검색 조건에 맞는 전체 데이터 개수
    4. 총 페이지 수 (totalPages): 전체 데이터 개수를 기준으로 계산된 페이지 개수
    5. skip 사용: skip = (page - 1) * limit를 통해 특정 페이지 데이터를 건너뛰고 가져옴

     

    3. 서버 측 페이징 구현 (getAllJobsAction)

    export async function getAllJobsAction({
        search,
        jobStatus,
        page = 1,
        limit = 10,
      }: GetAllJobsActionTypes): Promise<{ jobs: JobType[]; count: number; page: number; totalPages: number }> {
        const userId = await authenticateAndRedirect();
        console.log("@getAllJobsAction :", userId, search, jobStatus, page, limit);
      
        try {
          let whereClause: Prisma.JobWhereInput = {
            clerkId: userId,
          };
      
          // ???? 검색어가 있을 경우 검색 조건 추가
          if (search && search.trim() !== "") {
            whereClause = {
              ...whereClause,
              OR: [
                { position: { contains: search } },
                { company: { contains: search } },
                { location: { contains: search } },
              ],
            };
          }
      
          // ???? 특정 상태(jobStatus) 필터링
          if (jobStatus && jobStatus !== "전체") {
            whereClause = {
              ...whereClause,
              status: { equals: jobStatus },
            };
          }
      
          // ???? 전체 데이터 개수 계산
          const totalCount = await prisma.job.count({ where: whereClause });
          const totalPages = Math.ceil(totalCount / limit);
      
          // ???? 데이터 가져오기
          const jobs: JobType[] = await prisma.job.findMany({
            where: whereClause,
            orderBy: { createdAt: "desc" },
            take: limit,
            skip: (page - 1) * limit,
          });
      
          return { jobs, count: totalCount, page, totalPages };
        } catch (error) {
          console.error("getAllJobsAction Error:", error);
          throw new Error("직업 데이터를 가져오는 중 오류 발생");
        }
    }
    

    서버 측 로직 요약

    • 검색 조건에 따라 whereClause를 설정
    • prisma.job.count()를 사용하여 총 개수(totalCount)를 가져옴
    • totalPages = Math.ceil(totalCount / limit)을 계산하여 총 페이지 수 설정
    • skip과 take를 사용하여 현재 페이지 데이터만 가져옴

     

    4. 클라이언트 측 페이징 처리 (JobsList)

    "use client";
    import { getAllJobsAction } from '@/actions/jobService';
    import { useQuery } from '@tanstack/react-query';
    import { useSearchParams } from 'next/navigation';
    import React from 'react';
    import JobCard from './JobCard';
    import { JobType } from '@/types/JobDTO';
    import ComplexButtonContainer from './ComplexButtonContainer';
    
    const JobsList = () => {
      const searchParams = useSearchParams();
      const search = searchParams.get('search') || '';
      const jobStatus = searchParams.get('jobStatus') || '전체';
      const pageNumber = Number(searchParams.get('page')) || 1;
    
      const {data, isPending} = useQuery({
        queryKey: ['jobs', search, jobStatus, pageNumber],
        queryFn: async () => {
          return await getAllJobsAction({ search, jobStatus, page: pageNumber, limit: 10 });
        },
        staleTime: 10 * 60 * 1,
      });
    
      const jobs = data?.jobs || [];
      const count = data?.count || 0;
      const totalPages = data?.totalPages || 0;
    
      return (
        <>
          <div className='flex items-center justify-between mb-8'>
            <h2 className='text-xl font-semibold capitalize'>{count} 건의 일자리</h2>
            {totalPages < 2 ? null : <ComplexButtonContainer currentPage={pageNumber} totalPages={totalPages} />}
          </div>
          <div className='grid md:grid-cols-2 gap-8'>
            {jobs.map((job) => <JobCard key={job.id} job={job} />)}
          </div>
        </>
      );
    };
    
    export default JobsList;
    

    클라이언트 측 로직 요약

    • useSearchParams를 사용하여 현재 URL에서 search, jobStatus, page 값을 가져옴
    • useQuery로 서버에서 데이터 가져오기
    • 데이터가 존재하면 JobCard 목록을 렌더링하고, 페이지네이션 버튼(ComplexButtonContainer) 표시

     

     

    5. 페이지네이션 버튼 (ComplexButtonContainer)

    페이지네이션 버튼을 생성하고 페이지 변경 시 router.push를 이용해 URL을 업데이트합니다.

    <Button
      className='flex items-center gap-x-2 '
      variant='outline'
      onClick={() => handlePageChange(currentPage - 1)}
    >
      <ChevronLeft /> 이전
    </Button>
    
    • handlePageChange(page): 클릭 시 URL을 변경하여 새 페이지를 로드
    • 첫 페이지, 마지막 페이지, 현재 페이지 버튼 동적 생성
    • 이전/다음 버튼을 통해 빠르게 이동 가능

     

     

     

     

    ComplexButtonContainer 페이지 버튼 

    ComplexButtonContainer.tsx

    'use client';
    import { usePathname, useRouter, useSearchParams } from 'next/navigation';
    import { ChevronLeft, ChevronRight } from 'lucide-react';
    
    // 페이지네이션 컨테이너의 Props 타입 정의
    type ButtonContainerProps = {
      currentPage: number;
      totalPages: number;
    };
    
    // 개별 페이지 버튼의 Props 타입 정의
    type ButtonProps = {
      page: number;
      activeClass: boolean;
    };
    
    import { Button } from './ui/button';
    
    function ComplexButtonContainer({ currentPage, totalPages }: ButtonContainerProps) {
      const searchParams = useSearchParams(); // 현재 URL의 검색 매개변수 가져오기
      const router = useRouter(); // 라우터 인스턴스 생성
      const pathname = usePathname(); // 현재 경로 가져오기 (쿼리 매개변수 제외)
    
      // 페이지 변경 처리 함수
      const handlePageChange = (page: number) => {
        const defaultParams = {
          search: searchParams.get('search') || '', // 기존 검색어 유지
          jobStatus: searchParams.get('jobStatus') || '', // 기존 jobStatus 필터 유지
          page: String(page), // 새로운 페이지 번호 설정
        };
    
        const params = new URLSearchParams(defaultParams);
        router.push(`${pathname}?${params.toString()}`); // 업데이트된 URL로 이동
      };
    
      // 개별 페이지 버튼 생성 함수
      const addPageButton = ({ page, activeClass }: ButtonProps) => {
        return (
          <Button
            key={page}
            size='icon'
            variant={activeClass ? 'default' : 'outline'} // 활성화된 페이지 강조
            onClick={() => handlePageChange(page)}
          >
            {page}
          </Button>
        );
      };
    
      // 페이지네이션 버튼 렌더링 함수
      const renderPageButtons = () => {
        const pageButtons = [];
        
        // 첫 번째 페이지 버튼 추가
        pageButtons.push(addPageButton({ page: 1, activeClass: currentPage === 1 }));
        
        // 현재 페이지가 3보다 크면 '...' 추가
        if (currentPage > 3) {
          pageButtons.push(
            <Button size='icon' variant='outline' key='dots-1'>
              ...
            </Button>
          );
        }
    
        // 현재 페이지 앞쪽 버튼 추가 (첫 번째와 두 번째 페이지가 아닌 경우)
        if (currentPage !== 1 && currentPage !== 2) {
          pageButtons.push(addPageButton({ page: currentPage - 1, activeClass: false }));
        }
    
        // 현재 페이지 버튼 추가 (첫 번째 및 마지막 페이지 제외)
        if (currentPage !== 1 && currentPage !== totalPages) {
          pageButtons.push(addPageButton({ page: currentPage, activeClass: true }));
        }
    
        // 현재 페이지 뒤쪽 버튼 추가 (마지막 및 마지막에서 두 번째 페이지가 아닌 경우)
        if (currentPage !== totalPages && currentPage !== totalPages - 1) {
          pageButtons.push(addPageButton({ page: currentPage + 1, activeClass: false }));
        }
    
        // 현재 페이지가 마지막에서 두 번째보다 작으면 '...' 추가
        if (currentPage < totalPages - 2) {
          pageButtons.push(
            <Button size='icon' variant='outline' key='dots-2'>
              ...
            </Button>
          );
        }
        
        // 마지막 페이지 버튼 추가
        pageButtons.push(addPageButton({ page: totalPages, activeClass: currentPage === totalPages }));
        
        return pageButtons;
      };
    
      return (
        <div className='flex gap-x-2'>
          {/* 이전 페이지 버튼 */}
          <Button
            className='flex items-center gap-x-2'
            variant='outline'
            onClick={() => {
              let prevPage = currentPage - 1;
              if (prevPage < 1) prevPage = totalPages; // 첫 번째 페이지에서 이전을 누르면 마지막 페이지로 이동
              handlePageChange(prevPage);
            }}
          >
            <ChevronLeft />
            이전
          </Button>
          
          {/* 페이지 번호 버튼 */}
          {renderPageButtons()}
          
          {/* 다음 페이지 버튼 */}
          <Button
            className='flex items-center gap-x-2'
            onClick={() => {
              let nextPage = currentPage + 1;
              if (nextPage > totalPages) nextPage = 1; // 마지막 페이지에서 다음을 누르면 첫 번째 페이지로 이동
              handlePageChange(nextPage);
            }}
            variant='outline'
          >
            다음
            <ChevronRight />
          </Button>
        </div>
      );
    }
    
    export default ComplexButtonContainer;
    

     

     

    1. 페이지 변경 함수 (handlePageChange)

    const handlePageChange = (page: number) => {
      const defaultParams = {
        search: searchParams.get('search') || '',
        jobStatus: searchParams.get('jobStatus') || '',
        page: String(page),
      };
    
      const params = new URLSearchParams(defaultParams);
      router.push(`${pathname}?${params.toString()}`);
    };
    
    • 현재 URL에서 기존 검색어(search)직업 상태(jobStatus) 값을 유지하면서, 새로운 **페이지 번호(page)**를 쿼리스트링에 적용하여 router.push()를 실행합니다.
    • 이 방식은 SSR (서버 사이드 렌더링) 또는 SSG (정적 페이지 생성) 환경에서도 유용하게 동작할 수 있습니다.

     

    2. 숫자 버튼 생성 (addPageButton)

    const addPageButton = ({ page, activeClass }: ButtonProps) => {
      return (
        <Button
          key={page}
          size='icon'
          variant={activeClass ? 'default' : 'outline'}
          onClick={() => handlePageChange(page)}
        >
          {page}
        </Button>
      );
    };
    
    • 해당 페이지가 현재 페이지(currentPage)인지 확인 후, activeClass 값을 적용하여 버튼 스타일을 변경합니다.
    • 현재 페이지인 경우 variant="default"로 설정해 강조.
    • 현재 페이지가 아닌 경우 variant="outline"으로 표시.

     

     

    3. 페이지 버튼 렌더링 로직 (renderPageButtons)

    const renderPageButtons = () => {
      const pageButtons = [];
      
      // 첫 번째 페이지 버튼 (1번 페이지는 항상 표시)
      pageButtons.push(
        addPageButton({ page: 1, activeClass: currentPage === 1 })
      );
    
      // 현재 페이지가 3보다 크다면 앞쪽에 "..." 추가
      if (currentPage > 3) {
        pageButtons.push(
          <Button size='icon' variant='outline' key='dots-1'>
            ...
          </Button>
        );
      }
    
      // 현재 페이지의 이전 페이지 (1, 2페이지가 아닌 경우)
      if (currentPage !== 1 && currentPage !== 2) {
        pageButtons.push(
          addPageButton({
            page: currentPage - 1,
            activeClass: false,
          })
        );
      }
    
      // 현재 페이지 버튼 (현재 페이지는 항상 강조)
      if (currentPage !== 1 && currentPage !== totalPages) {
        pageButtons.push(
          addPageButton({
            page: currentPage,
            activeClass: true,
          })
        );
      }
    
      // 현재 페이지 다음 페이지 (마지막 페이지 직전이 아닌 경우)
      if (currentPage !== totalPages && currentPage !== totalPages - 1) {
        pageButtons.push(
          addPageButton({
            page: currentPage + 1,
            activeClass: false,
          })
        );
      }
    
      // 마지막 페이지가 2개 이상 차이나면 "..." 추가
      if (currentPage < totalPages - 2) {
        pageButtons.push(
          <Button size='icon' variant='outline' key='dots-2'>
            ...
          </Button>
        );
      }
    
      // 마지막 페이지 버튼
      pageButtons.push(
        addPageButton({
          page: totalPages,
          activeClass: currentPage === totalPages,
        })
      );
    
      return pageButtons;
    };
    

    4. 전체 컴포넌트 구조

    return (
      <div className='flex gap-x-2'>
        {/* 이전 버튼 */}
        <Button
          className='flex items-center gap-x-2'
          variant='outline'
          onClick={() => handlePageChange(currentPage > 1 ? currentPage - 1 : totalPages)}
        >
          <ChevronLeft />
          이전
        </Button>
        
        {renderPageButtons()}
        
        {/* 다음 버튼 */}
        <Button
          className='flex items-center gap-x-2'
          variant='outline'
          onClick={() => handlePageChange(currentPage < totalPages ? currentPage + 1 : 1)}
        >
          다음
          <ChevronRight />
        </Button>
      </div>
    );
    

     

     

     

     

     

     

     

     

     

    about author

    PHRASE

    Level 60  라이트

    과오는 아무리 열심히 믿어도 결코 진리로 변하지 않는다. 많은 자유가 있는 곳에 많은 과오가 있기 쉽다. 과오를 범해도 대수롭지 않게 생각하는 사람만큼 자주 과오를 범하는 사람은 없다. 과오를 범하지 않는 쪽보다 과오를 고백하는 쪽이 더 훌륭한 경우가 많다. - R. 잉거솔

    댓글 ( 1)

    댓글 남기기

    작성