본문 바로가기
Clone Coding

Jira-clone - Appwrite로 워크스페이스 생성 기능 구현

by zzuny-code 2025. 9. 26.
반응형

https://www.youtube.com/watch?v=Av9C7xlV0fA

04:07:07

 

 

프로젝트 구조

src/
├── app/
│   ├── (dashboard)/
│   │   └── page.tsx                    # 메인 대시보드 페이지
│   └── api/
│       └── [[...route]]/
│           └── route.ts                # API 라우트 통합 관리
├── config.ts                          # 환경변수 중앙 관리
├── features/
│   └── workspaces/
│       ├── api/  ### 클라이언트 API 호출
│       │   └── use-create-workspace.ts # 클라이언트 API 호출 훅
│       ├── component/  ### UI 컴포넌트
│       │   └── create-workspace-form.tsx # Workspace 생성 폼 UI
│       ├── schemas.ts                  ### 데이터 검증 스키마
│       └── server/
│           └── route.ts               ### 서버사이드 API 라우트
└── lib/
    ├── rpc.ts                         # RPC 클라이언트 설정
    └── session-middleware.ts          # 세션 미들웨어



 

1. Appwrite 데이터베이스 설정 https://cloud.appwrite.io/

 

★ 데이터베이스 생성 과정

 

1. Appwrite 콘솔에서 jira-clone 데이터베이스 생성

 

.env.local 

NEXT_PUBLIC_APP_URL=***
NEXT_PUBLIC_APPWRITE_ENDPOINT=***
NEXT_PUBLIC_APPWRITE_PROJECT=***

NEXT_PUBLIC_APPWRITE_DATABASE_ID=여기추가

NEXT_APPWRITE_KEY=***

 

 

2. workspaces 테이블 생성 (기존: Collection)

 

 

3. jira-clone Databases 에서 Create table -> workspaces table을 생성

 

Appwrite 데이터베이스 업데이트로 인해 용어가 변경되었다.

기존의 Collection이 Table로,

Document는 Row,

Attribute는 Column으로 변경되었다. 

 

변경된 용어로 테이블(구: 컬렉션)을 만드는 현재 절차.

  1. 데이터베이스 페이지 이동: Appwrite 콘솔에서 Databases.
  2. 데이터베이스 선택: 테이블을 생성할 데이터베이스를 선택.
  3. 테이블 생성: Create table 버튼을 클릭.
  4. 컬럼(Columns) 생성: 테이블이 만들어지면Columns 탭으로 이동하여 Create column 버튼을 클릭해 원하는 데이터 필드(: 속성) 추가.
  5. 권한(Permissions) 설정: Settings 탭의 Permissions에서 역할(Role) 추가하고 CREATE, READ 등의 권한을 설정

 

.env.local 

NEXT_PUBLIC_APP_URL=***
NEXT_PUBLIC_APPWRITE_ENDPOINT=***
NEXT_PUBLIC_APPWRITE_PROJECT=***

NEXT_PUBLIC_APPWRITE_DATABASE_ID=***

NEXT_PUBLIC_APPWRITE_WORKSPACES_ID=workspaces  // 이건뭐지 뭐가 바뀐거지 

NEXT_APPWRITE_KEY=***

 


4. 필수 컬럼 추가:

  • name (size: 256)
  • userId (size: 100)

 

 

5. 권한 설정: Settings → Permissions에서 CREATE, READ 권한 설정

 

 

2. src/config.ts - 중앙집중식 설정 관리

 

왜 별도의 config 파일을 만드나? 

  • 중앙집중화: 모든 설정을 한 곳에서 관리
  • 타입 안정성: TypeScript에서 환경변수의 존재를 보장
  • 재사용성: 여러 파일에서 동일한 설정을 쉽게 사용
export const DATABASE_ID = process.env.NEXT_PUBLIC_APPWRITE_DATABASE_ID!;
export const WORKSPACES_ID = process.env.NEXT_PUBLIC_APPWRITE_WORKSPACES_ID!;

 

3. 데이터 검증 스키마

src/features/workspaces/schemas.ts

  • Zod를 사용하여 클라이언트와 서버 양쪽에서 데이터 검증을 수행
import {z} from "zod"

export const createWorkspaceSchema = z.object({
    name: z.string().trim().min(1, "Required"), 
})

 

 

 

4. API 라우트 구조

API 라우트 통합관리 - src/app/api/[[...route]]/route.ts 

  • 모듈화: 기능별로 API 라우트를 분리하여 관리
  • 확장성: 새로운 기능 추가 시 쉽게 라우트 추가 가능
  • 타입 추론: AppType을 통해 클라이언트에서 API 타입 사용
import {Hono} from 'hono'
import {handle} from 'hono/vercel'

import auth from '@/features/auth/server/route'
import workspaces from '@/features/workspaces/server/route'
const app = new Hono().basePath("/api");

const routes = app
    .route("/auth", auth)
    .route("/workspaces", workspaces)

export const GET = handle(app)
export const POST = handle(app)
export const PUT = handle(app)
export const DELETE = handle(app)

export type AppType = typeof routes

 

 

Workspace API - src/features/workspaces/server/route.ts

Hono를 사용한 API 구조.

  • 경량화: Express보다 빠르고 가벼운 프레임워크
  • Edge Runtime 지원: Vercel Edge Functions에서 실행 가능
  • 타입 안전성: 엔드 투 엔드 타입 안전성 제공
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { createWorkspaceSchema } from "../schemas";
import { sessionMiddleware } from "@/lib/session-middleware";
import { DATABASE_ID, WORKSPACES_ID } from "@/config";
import { ID } from "node-appwrite";

const app = new Hono()
    .post(
        "/",
        zValidator("json", createWorkspaceSchema),
        sessionMiddleware,
        async (c) => {
            const databases = c.get("databases")
            const user = c.get("user")
            
            const {name} = c.req.valid("json")

            const workspace = await databases.createDocument(
                DATABASE_ID,
                WORKSPACES_ID,
                ID.unique(),
                {
                    name,
                    userId:user.$id
                }
            );

            return c.json({data:workspace})
        }
    )

export default app

 

 

5. 클라이언트 API 호출

src/features/workspaces/api/use-create-workspace.ts 

React Query(TanStack Query)를 사용하는 이유:

  • 캐싱: API 응답을 자동으로 캐시하여 성능 향상
  • 동기화: 데이터 변경 시 관련 쿼리를 자동으로 무효화
  • 로딩 상태: 로딩, 에러, 성공 상태를 쉽게 관리
import {useMutation, useQueryClient} from '@tanstack/react-query'
import { InferRequestType, InferResponseType } from 'hono';

import {client} from '@/lib/rpc'

type ResponseType = InferResponseType<typeof client.api.workspaces['$post']>
type RequestType = InferRequestType<typeof client.api.workspaces['$post']>

export const useCreateWorkspace = () => {

    const queryClient = useQueryClient()

    const mutation = useMutation<
        ResponseType,
        Error,
        RequestType
    >({
        mutationFn: async({json}) => {
            const response = await client.api.workspaces["$post"]({json});
            return response.json()
        },
        onSuccess: () => {
            queryClient.invalidateQueries({queryKey : ['workspaces']})
        }
    })
    return mutation
}

 

6. 사용자 인터페이스

create-workspace-form.tsx

'use client'

import { zodResolver } from "@hookform/resolvers/zod";
import { createWorkspaceSchema } from "../schemas";
import { useForm } from "react-hook-form";
import z from "zod";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import DottedSeparator from "@/components/dottedSeparator";

interface CreateWorkspaceFormProps{
    onCancel?: () => void;
}

export const CreateWorkspaceForm = ({onCancel} : CreateWorkspaceFormProps) =>{
    const form = useForm<z.infer<typeof createWorkspaceSchema>>({
        resolver: zodResolver(createWorkspaceSchema),
        defaultValues:{
            name: ''
        }
    })

    const onSubmit = (values: z.infer<typeof createWorkspaceSchema>) => {
        console.log({values})
    }

    return(
        <Card className="w-full h-full border-none shadow-none">
            <CardHeader className="flex p-7">
                <CardTitle className="text-xl font-bold">
                    Create a new Workspace
                </CardTitle>
            </CardHeader>
            <div className="px=7">
                <DottedSeparator/>
            </div>
            <CardContent className="p-7">
                form 영역이 들어갈 자리
            </CardContent>
        </Card>
    )
}

 

src/app/(dashboard)/page.tsx - 메인페이지

import { redirect } from "next/navigation";
import { getCurrent } from "@/features/auth/actions";
import { CreateWorkspaceForm } from "@/features/workspaces/component/create-workspace-form";

export default async function Home() {

	const user = await getCurrent();	

	if(!user) redirect("/sign-in")
  
	return (
		<div className="bg-neutral-500 p-4 h-full">
			<CreateWorkspaceForm/>
		</div>

	);	
}

 

 

 

7. React Hook Form과 Zod를 결합하여 타입 안전한 폼을 구현:

 

src/features/workspaces/component/create-workspace-form.tsx

'use client'

import { zodResolver } from "@hookform/resolvers/zod";
import { createWorkspaceSchema } from "../schemas";
import { useForm } from "react-hook-form";
import z from "zod";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useCreateWorkspace } from "../api/use-create-workspace";
import DottedSeparator from "@/components/dottedSeparator";

interface CreateWorkspaceFormProps{
    onCancel?: () => void;
}

export const CreateWorkspaceForm = ({onCancel} : CreateWorkspaceFormProps) =>{

    const {mutate, isPending} = useCreateWorkspace();

    const form = useForm<z.infer<typeof createWorkspaceSchema>>({
        resolver: zodResolver(createWorkspaceSchema),
        defaultValues:{
            name: ''
        }
    })

    const onSubmit = (values: z.infer<typeof createWorkspaceSchema>) => {
        mutate({json:values})
    }

    return(
        <Card className="w-full h-full border-none shadow-none">
            <CardHeader className="flex p-7">
                <CardTitle className="text-xl font-bold">
                    Create a new Workspace
                </CardTitle>
            </CardHeader>
            <div className="px=7">
                <DottedSeparator/>
            </div>
            <CardContent className="p-7">
                <Form {...form}>
                    <form onSubmit={form.handleSubmit(onSubmit)}>
                        <div className="flex flex-col gap-y-4">
                            <FormField
                                control={form.control}
                                name="name"
                                render={({field})=>(
                                    <FormItem>
                                        <FormLabel>Workspace Name</FormLabel>
                                        <FormControl>
                                            <Input
                                                {...field}
                                                placeholder="Enter Workstace name"
                                            />
                                        </FormControl>
                                        <FormMessage/>
                                    </FormItem>
                                )}
                            />
                        </div>
                        <DottedSeparator className="py-7"/>
                        <div className="flex items-center justify-between">
                            <Button
                                type="button"
                                size="lg"
                                variant='secondary'
                                onClick={onCancel}
                                disabled={isPending}
                            >
                                Cancel
                            </Button>
                            <Button
                                type="submit"
                                size="lg"
                                disabled={isPending}
                            >
                                Create Workstace
                            </Button>
                        </div>
                    </form>
                </Form>
            </CardContent>
        </Card>
    )
}

 

반응형