Neon Postgres로 Todo 앱 만들기

이 튜토리얼은 Drizzle ORM, Neon 데이터베이스Next.js를 사용하여 Todo 앱을 만드는 방법을 보여줍니다.

This guide assumes familiarity with:
  • 기존 Next.js 프로젝트가 있어야 하거나 다음 명령어를 사용하여 새 프로젝트를 생성해야 합니다:
npx create-next-app@latest --typescript
  • Drizzle ORM과 Drizzle kit를 설치해야 합니다. 다음 명령어를 실행하여 설치할 수 있습니다:
npm
yarn
pnpm
bun
npm i drizzle-orm
npm i -D drizzle-kit
npm
yarn
pnpm
bun
npm i @neondatabase/serverless
  • 환경 변수 관리를 위해 dotenv 패키지를 설치해야 합니다.
npm
yarn
pnpm
bun
npm i dotenv
IMPORTANT

설치 중 의존성 해결 문제가 발생하는 경우:

React Native를 사용하지 않는 경우 --force 또는 --legacy-peer-deps로 강제 설치하면 문제가 해결됩니다. React Native를 사용하는 경우 React Native 버전과 호환되는 정확한 버전의 React를 사용해야 합니다.

Neon과 Drizzle ORM 설정하기

새로운 Neon 프로젝트 생성하기

Neon 콘솔에 로그인하고 Projects 섹션으로 이동합니다. 프로젝트를 선택하거나 New Project 버튼을 클릭하여 새 프로젝트를 생성합니다.

Neon 프로젝트에는 neondb라는 이름의 바로 사용 가능한 Postgres 데이터베이스가 제공됩니다. 이 튜토리얼에서는 이것을 사용합니다.

연결 문자열 변수 설정하기

프로젝트 콘솔의 Connection Details 섹션으로 이동하여 데이터베이스 연결 문자열을 확인합니다. 다음과 유사하게 표시됩니다:

postgres://username:password@ep-cool-darkness-123456.us-east-2.aws.neon.tech/neondb

.env 또는 .env.local 파일에 DATABASE_URL 환경 변수를 추가합니다. 이를 사용하여 Neon 데이터베이스에 연결합니다.

DATABASE_URL=NEON_DATABASE_CONNECTION_STRING

데이터베이스에 Drizzle ORM 연결하기

src/db 폴더에 drizzle.ts 파일을 생성하고 데이터베이스 구성을 설정합니다:

src/db/drizzle.ts
import { config } from "dotenv";
import { drizzle } from 'drizzle-orm/neon-http';

config({ path: ".env" }); // or .env.local

export const db = drizzle(process.env.DATABASE_URL!);

todo 스키마 선언하기

src/db/schema.ts
import { integer, text, boolean, pgTable } from "drizzle-orm/pg-core";

export const todo = pgTable("todo", {
  id: integer("id").primaryKey(),
  text: text("text").notNull(),
  done: boolean("done").default(false).notNull(),
});

여기서는 Drizzle ORM의 데이터 타입을 사용하여 id, text, done 필드가 있는 todo 테이블을 정의합니다.

Drizzle 구성 파일 설정하기

Drizzle config - Drizzle Kit에서 사용되는 구성 파일로, 데이터베이스 연결, 마이그레이션 폴더 및 스키마 파일에 대한 모든 정보를 포함합니다.

프로젝트 루트에 drizzle.config.ts 파일을 생성하고 다음 내용을 추가합니다:

drizzle.config.ts
import { config } from 'dotenv';
import { defineConfig } from "drizzle-kit";

config({ path: '.env' });

export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./migrations",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

데이터베이스에 변경사항 적용하기

drizzle-kit generate 명령어를 사용하여 마이그레이션을 생성한 다음 drizzle-kit migrate 명령어로 실행할 수 있습니다.

마이그레이션 생성하기:

npx drizzle-kit generate

이러한 마이그레이션은 drizzle.config.ts에 지정된 대로 drizzle/migrations 디렉토리에 저장됩니다. 이 디렉토리에는 데이터베이스 스키마를 업데이트하는 데 필요한 SQL 파일과 다양한 마이그레이션 단계의 스키마 스냅샷을 저장하는 meta 폴더가 포함됩니다.

생성된 마이그레이션 예시:

CREATE TABLE IF NOT EXISTS "todo" (
	"id" integer PRIMARY KEY NOT NULL,
	"text" text NOT NULL,
	"done" boolean DEFAULT false NOT NULL
);

마이그레이션 실행하기:

npx drizzle-kit migrate

또는 Drizzle kit push 명령어를 사용하여 데이터베이스에 변경사항을 직접 푸시할 수 있습니다:

npx drizzle-kit push
IMPORTANT
Push 명령어는 로컬 개발 환경에서 새로운 스키마 디자인이나 변경사항을 빠르게 테스트해야 하는 상황에 적합하며, 마이그레이션 파일 관리의 오버헤드 없이 빠른 반복이 가능합니다.

서버 사이드 함수 설정하기

이 단계에서는 src/actions/todoAction.ts 파일에 todo 항목에 대한 중요한 작업을 처리하는 서버 사이드 함수를 설정합니다:

  1. getData:
    • 데이터베이스에서 모든 기존 todo 항목을 가져옵니다.
  2. addTodo:
    • 제공된 텍스트로 새 todo 항목을 데이터베이스에 추가합니다.
    • **revalidatePath("/")**를 사용하여 홈 페이지의 재검증을 시작합니다.
  3. deleteTodo:
    • 고유 ID를 기반으로 데이터베이스에서 todo 항목을 제거합니다.
    • 홈 페이지의 재검증을 트리거합니다.
  4. toggleTodo:
    • todo 항목의 완료 상태를 토글하고 데이터베이스를 업데이트합니다.
    • 작업 후 홈 페이지를 재검증합니다.
  5. editTodo:
    • 데이터베이스에서 ID로 식별되는 todo 항목의 텍스트를 수정합니다.
    • 홈 페이지의 재검증을 시작합니다.
src/actions/todoAction.ts
"use server";
import { eq, not } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db/drizzle";
import { todo } from "@/db/schema";

export const getData = async () => {
  const data = await db.select().from(todo);
  return data;
};

export const addTodo = async (id: number, text: string) => {
  await db.insert(todo).values({
    id: id,
    text: text,
  });
};

export const deleteTodo = async (id: number) => {
  await db.delete(todo).where(eq(todo.id, id));

  revalidatePath("/");
};

export const toggleTodo = async (id: number) => {
  await db
    .update(todo)
    .set({
      done: not(todo.done),
    })
    .where(eq(todo.id, id));

  revalidatePath("/");
};

export const editTodo = async (id: number, text: string) => {
  await db
    .update(todo)
    .set({
      text: text,
    })
    .where(eq(todo.id, id));

  revalidatePath("/");
};
Expand

Next.js로 홈 페이지 설정하기

TypeScript 타입 정의하기

src/types/todoType.ts에서 todo 항목에 대한 TypeScript 타입을 정의합니다. number 타입의 id, string 타입의 text, boolean 타입의 done 세 가지 속성을 가집니다. **todoType**이라는 이름의 이 타입은 애플리케이션 내에서 일반적인 todo 항목의 구조를 나타냅니다.

src/types/todoType.ts
export type todoType = {
  id: number;
  text: string;
  done: boolean;
};

todo 애플리케이션 홈 페이지 생성하기

  1. src/components/todo.tsx: 단일 todo 항목을 나타내는 Todo 컴포넌트를 생성합니다. todo 텍스트를 표시하고 편집하는 기능, 체크박스로 완료 표시하는 기능, todo를 편집, 저장, 취소 및 삭제하는 동작을 제공합니다.
  2. src/components/addTodo.tsx: AddTodo 컴포넌트는 Todo 앱에 새 todo 항목을 추가하기 위한 간단한 폼을 제공합니다. todo 텍스트를 입력하는 입력 필드와 새 todo 추가를 트리거하는 버튼이 포함됩니다.
  3. src/components/todos.tsx: Todo 앱의 메인 인터페이스를 나타내는 Todos 컴포넌트를 생성합니다. todo 항목의 상태를 관리하고, todo를 생성, 편집, 토글 및 삭제하는 함수를 제공하며, Todo 컴포넌트를 사용하여 개별 todo 항목을 렌더링합니다.
todo.tsx
addTodo.tsx
todos.tsx
"use client";
import { ChangeEvent, FC, useState } from "react";
import { todoType } from "@/types/todoType";

interface Props {
  todo: todoType;
  changeTodoText: (id: number, text: string) => void;
  toggleIsTodoDone: (id: number, done: boolean) => void;
  deleteTodoItem: (id: number) => void;
}

const Todo: FC<Props> = ({
  todo,
  changeTodoText,
  toggleIsTodoDone,
  deleteTodoItem,
}) => {
  // State for handling editing mode
  const [editing, setEditing] = useState(false);

  // State for handling text input
  const [text, setText] = useState(todo.text);

  // State for handling "done" status
  const [isDone, setIsDone] = useState(todo.done);

  // Event handler for text input change
  const handleTextChange = (e: ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  // Event handler for toggling "done" status
  const handleIsDone = async () => {
    toggleIsTodoDone(todo.id, !isDone);
    setIsDone((prev) => !prev);
  };

  // Event handler for initiating the edit mode
  const handleEdit = () => {
    setEditing(true);
  };

  // Event handler for saving the edited text
  const handleSave = async () => {
    changeTodoText(todo.id, text);
    setEditing(false);
  };

  // Event handler for canceling the edit mode
  const handleCancel = () => {
    setEditing(false);
    setText(todo.text);
  };

  // Event handler for deleting a todo item
  const handleDelete = () => {
    if (confirm("Are you sure you want to delete this todo?")) {
      deleteTodoItem(todo.id);
    }
  };

  // Rendering the Todo component
  return (
    <div className="flex items-center gap-2 p-4 border-gray-200 border-solid border rounded-lg">
      {/* Checkbox for marking the todo as done */}
      <input
        type="checkbox"
        className="text-blue-200 rounded-sm h-4 w-4"
        checked={isDone}
        onChange={handleIsDone}
      />
      {/* Input field for todo text */}
      <input
        type="text"
        value={text}
        onChange={handleTextChange}
        readOnly={!editing}
        className={`${
          todo.done ? "line-through" : ""
        } outline-none read-only:border-transparent focus:border border-gray-200 rounded px-2 py-1 w-full`}
      />
      {/* Action buttons for editing, saving, canceling, and deleting */}
      <div className="flex gap-1 ml-auto">
        {editing ? (
          <button
            onClick={handleSave}
            className="bg-green-600 text-green-50 rounded px-2 w-14 py-1"
          >
            Save
          </button>
        ) : (
          <button
            onClick={handleEdit}
            className="bg-blue-400 text-blue-50 rounded w-14 px-2 py-1"
          >
            Edit
          </button>
        )}
        {editing ? (
          <button
            onClick={handleCancel}
            className="bg-red-400 w-16 text-red-50 rounded px-2 py-1"
          >
            Close
          </button>
        ) : (
          <button
            onClick={handleDelete}
            className="bg-red-400 w-16 text-red-50 rounded px-2 py-1"
          >
            Delete
          </button>
        )}
      </div>
    </div>
  );
};

export default Todo;
Expand

src/app 폴더의 page.tsx 파일을 업데이트하여 데이터베이스에서 todo 항목을 가져오고 Todos 컴포넌트를 렌더링합니다:

src/app/page.tsx
import { getData } from "@/actions/todoAction";
import Todos from "@/components/todos";

export default async function Home() {
  const data = await getData();
  return <Todos todos={data} />;
}

기본 파일 구조

이 가이드는 다음 파일 구조를 사용합니다:

📦 <project root>
 ├ 📂 migrations
 │  ├ 📂 meta
 │  └ 📜 0000_heavy_doctor_doom.sql
 ├ 📂 public
 ├ 📂 src
 │  ├ 📂 actions
 │  │  └ 📜 todoActions.ts
 │  ├ 📂 app
 │  │  ├ 📜 favicon.ico
 │  │  ├ 📜 globals.css
 │  │  ├ 📜 layout.tsx
 │  │  └ 📜 page.tsx
 │  ├ 📂 components
 │  │  ├ 📜 addTodo.tsx
 │  │  ├ 📜 todo.tsx
 │  │  └ 📜 todos.tsx
 │  └ 📂 db
 │  │  ├ 📜 drizzle.ts
 │  │  └ 📜 schema.ts
 │  └ 📂 types
 │     └ 📜 todoType.ts
 ├ 📜 .env
 ├ 📜 .eslintrc.json
 ├ 📜 .gitignore
 ├ 📜 drizzle.config.ts
 ├ 📜 next-env.d.ts
 ├ 📜 next.config.mjs
 ├ 📜 package-lock.json
 ├ 📜 package.json
 ├ 📜 postcss.config.mjs
 ├ 📜 README.md
 ├ 📜 tailwind.config.ts
 └ 📜 tsconfig.json