키모스토리

#37. Next.js 16 + Prisma 7 + SQLite 회원관리 예제 본문

Web Devlopment/NextJs

#37. Next.js 16 + Prisma 7 + SQLite 회원관리 예제

키모형 2026. 1. 2. 14:02
반응형

0) 프로젝트 생성

 
pnpm create next-app@latest member-demo --ts --src-dir --tailwind --eslint --app
cd member-demo

 

프로젝트 폴더구조

src/
  app/
    members/
      page.tsx                // 회원목록 (Server Component)
      actions.ts              // 서버액션모음 (등록/수정/삭제)
      register/
        page.tsx              // 회원가입 페이지
        SignupForm.tsx        // 회원가입 폼 (Client Component)
      [id]/
        edit/
          page.tsx            // 수정 페이지 (Server Component)
          EditForm.tsx        // 수정 폼 (Client Component)
  lib/
    db/
      prisma.ts               // PrismaClient singleton(Prisma7 adapter 포함)
    validators.ts             // 정규식/검증 함수 모음
  components/
    MembersRefresh.tsx        // 목록 새로고침(router.refresh)
    ConfirmSubmit.tsx         // submit confirm 유틸 컴포넌트
    
prisma/
  schema.prisma               // Prisma schema (SQLite용)
  seed.ts                     // 초기 데이터 seed

 

최종 결과 UI화면 

 


1) 패키지 설치 (Prisma 7 + SQLite)

 
pnpm add @prisma/client
pnpm add -D prisma

# Prisma 7 SQLite 드라이버 어댑터(공식: better-sqlite3)
pnpm add @prisma/adapter-better-sqlite3
pnpm add -D @types/better-sqlite3

# 비밀번호 해시
pnpm add bcryptjs

# seed 실행(ESM/TS 꼬임 방지)
pnpm add -D tsx

# (선택) 검증 라이브러리
pnpm add zod
 

SQLite를 “sqlite3 패키지”로 쓰는 게 아니라, Prisma 7의 SQLite 연결은 드라이버 어댑터(보통 better-sqlite3)를 사용하는 방식입니다. Prisma+1


2) .env (SQLite 파일 DB)

루트에 .env:

 
DATABASE_URL="file:./prisma/dev.db"

Windows에서 경로 이슈가 나면(상대경로 해석 문제) 절대경로로 바꾸면 안정적입니다. GitHub

 

3) prisma.config.ts (Prisma 7 권장: CLI 설정)

루트에 prisma.config.ts:

import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  datasource: {
    url: env("DATABASE_URL"),
  },
});

Prisma 7은 CLI 설정을 TypeScript config로 관리하는 흐름이 강화되었습니다. Prisma+2Prisma+2


4) prisma/schema.prisma (Generator output 필수)

prisma/schema.prisma:

generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "sqlite"
}

enum UserRole {
  USER
  ADMIN
}

model User {
  userNo    Int      @id @default(autoincrement())
  userId    String   @unique
  userNick  String   @unique
  userEmail String
  userPhone String
  userPwd   String   // bcrypt hash
  userJob   String?
  role      UserRole @default(USER)
  create_at DateTime @default(now())
}

Prisma 7에서는 기본 generator가 prisma-client로 바뀌고, Client 생성 위치도 기본이 node_modules가 아니라 output 기반입니다. Prisma+2Prisma+2

 

5) 마이그레이션 + Client Generate

 
npx prisma migrate dev --name init
npx prisma generate
 
import { PrismaClient, UserRole } from "@/generated/prisma/client";

6) PrismaClient 싱글톤 (Prisma 7 필수: adapter로 생성)

src/lib/db/prisma.ts:

import { PrismaClient } from "@/generated/prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";

const globalForPrisma = globalThis as unknown as {
  prisma?: PrismaClient;
};

function createPrismaClient() {
  const url = process.env.DATABASE_URL;
  if (!url) throw new Error("DATABASE_URL is missing");

  return new PrismaClient({
    // Prisma 7: adapter 또는 accelerateUrl 필수
    adapter: new PrismaBetterSqlite3({ url }),
  });
}

export const prisma = globalForPrisma.prisma ?? createPrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Prisma 7부터는 new PrismaClient()가 불가하고 adapter/accelerateUrl이 필수입니다. GitHub+2Prisma+2


7) 서버 검증 규칙(정규식) 모음

src/lib/validators.ts:

export const reUserId = /^[a-z][a-z0-9!@#$%^&*()_-]{3,19}$/;
// 4~20, 소문자로 시작, 이후 소문자/숫자/특수(!@#$%^&*()_-) 허용

export const reUserNick = /^(?=.{2,12}$)[A-Za-z가-힣][A-Za-z0-9가-힣_-]*$/;
// 2~12, 한글/영문/숫자/_- 가능, 시작은 한글/영문만

export const reEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export const rePhone = /^[0-9-]+$/;

export const rePwdAllowed = /^[A-Za-z0-9!@#$%^&*()_-]{4,20}$/;
export const rePwdHasAlpha = /[A-Za-z]/;
export const rePwdHasDigit = /[0-9]/;

export function validateSignup(input: {
  userId: string;
  userNick: string;
  userEmail: string;
  userPhone: string;
  userPwd: string;
  userJob?: string | null;
}) {
  const errors: Record<string, string> = {};

  if (!reUserId.test(input.userId)) {
    errors.userId =
      "아이디: 4~20자, 소문자로 시작, 소문자/숫자/특수(!@#$%^&*()_-)만 가능합니다.";
  }
  if (!reUserNick.test(input.userNick)) {
    errors.userNick =
      "닉네임: 2~12자, 한글/영문으로 시작, 한글/영문/숫자/_-만 가능합니다.";
  }
  if (
    !input.userEmail ||
    input.userEmail.length > 50 ||
    !reEmail.test(input.userEmail)
  ) {
    errors.userEmail = "이메일 형식이 올바르지 않습니다. (50자 이내)";
  }
  if (!input.userPhone || !rePhone.test(input.userPhone)) {
    errors.userPhone = "전화번호는 숫자와 - 만 입력 가능합니다.";
  }
  if (
    !rePwdAllowed.test(input.userPwd) ||
    !rePwdHasAlpha.test(input.userPwd) ||
    !rePwdHasDigit.test(input.userPwd)
  ) {
    errors.userPwd =
      "비밀번호: 4~20자, 영문+숫자 포함, 특수(!@#$%^&*()_-) 사용 가능합니다.";
  }
  if (
    input.userJob != null &&
    input.userJob !== "" &&
    input.userJob.length > 20
  ) {
    errors.userJob = "직업은 20자 이내로 입력해주세요.";
  }

  return { ok: Object.keys(errors).length === 0, errors };
}

 

8) Server Actions (회원가입/수정/삭제)

src/app/members/actions.ts: 

"use server";

import { prisma } from "@/lib/db/prisma";
import { validateSignup } from "@/lib/validators";
import bcrypt from "bcryptjs";
import { redirect } from "next/navigation";

export type SignupValues = {
  userId: string;
  userNick: string;
  userEmail: string;
  userPhone: string;
  userJob: string; // "" 허용(선택 입력)
};

export type SignupState =
  | { ok: true; msg: string }
  | {
      ok: false;
      msg: string;
      fieldErrors?: Record<string, string>;
      values?: SignupValues; // ✅ 비밀번호 제외 값만 유지용으로 반환
    };

export async function checkUserId(userId: string) {
  const id = (userId ?? "").trim();
  // 형식 검증은 서버에서도 1차로 해주는 편이 안전
  if (id.length < 4 || id.length > 20)
    return { ok: false, msg: "아이디 길이가 올바르지 않습니다." };

  const exists = await prisma.user.findUnique({ where: { userId: id } });
  return exists
    ? { ok: false, msg: "이미 등록된 아이디입니다." }
    : { ok: true, msg: "등록 가능한 아이디입니다." };
}

export async function checkUserNick(userNick: string) {
  const nick = (userNick ?? "").trim();
  if (nick.length < 2 || nick.length > 12)
    return { ok: false, msg: "닉네임 길이가 올바르지 않습니다." };

  const exists = await prisma.user.findUnique({ where: { userNick: nick } });
  return exists
    ? { ok: false, msg: "이미 등록된 닉네임입니다." }
    : { ok: true, msg: "등록 가능한 닉네임입니다." };
}

export async function signupAction(
  _prev: SignupState | null,
  formData: FormData
): Promise<SignupState> {
  const userId = String(formData.get("userId") ?? "").trim();
  const userNick = String(formData.get("userNick") ?? "").trim();
  const userEmail = String(formData.get("userEmail") ?? "").trim();
  const userPhone = String(formData.get("userPhone") ?? "").trim();
  const userJob = String(formData.get("userJob") ?? "").trim();

  const userPwd = String(formData.get("userPwd") ?? "");
  const userPwd2 = String(formData.get("userPwd2") ?? "");

  // 중복확인 상태
  const idCheckedOk = String(formData.get("idCheckedOk") ?? "") === "true";
  const idCheckedValue = String(formData.get("idCheckedValue") ?? "");
  const nickCheckedOk = String(formData.get("nickCheckedOk") ?? "") === "true";
  const nickCheckedValue = String(formData.get("nickCheckedValue") ?? "");

  const values: SignupValues = {
    userId,
    userNick,
    userEmail,
    userPhone,
    userJob,
  };

  // 서버 검증(기존 규칙)
  const v = validateSignup({
    userId,
    userNick,
    userEmail,
    userPhone,
    userPwd,
    userJob: userJob || null,
  });
  const errors: Record<string, string> = { ...(v.errors ?? {}) };

  // ✅ 비밀번호 재입력 검증
  if (userPwd !== userPwd2) {
    errors.userPwd2 = "비밀번호가 일치하지 않습니다.";
  }

  if (Object.keys(errors).length) {
    return {
      ok: false,
      msg: "입력값을 확인해주세요.",
      fieldErrors: errors,
      values,
    };
  }

  // ✅ 중복확인 강제(값 변경 방지)
  if (!idCheckedOk || idCheckedValue !== userId) {
    return {
      ok: false,
      msg: "아이디 중복확인을 다시 진행해주세요.",
      fieldErrors: { userId: "중복확인 필요" },
      values,
    };
  }
  if (!nickCheckedOk || nickCheckedValue !== userNick) {
    return {
      ok: false,
      msg: "닉네임 중복확인을 다시 진행해주세요.",
      fieldErrors: { userNick: "중복확인 필요" },
      values,
    };
  }

  // 최종 중복 체크(레이스/우회 방지)
  const [idExists, nickExists] = await Promise.all([
    prisma.user.findUnique({ where: { userId } }),
    prisma.user.findUnique({ where: { userNick } }),
  ]);
  if (idExists)
    return {
      ok: false,
      msg: "이미 등록된 아이디입니다.",
      fieldErrors: { userId: "중복" },
      values,
    };
  if (nickExists)
    return {
      ok: false,
      msg: "이미 등록된 닉네임입니다.",
      fieldErrors: { userNick: "중복" },
      values,
    };

  const hash = await bcrypt.hash(userPwd, 10);

  await prisma.user.create({
    data: {
      userId,
      userNick,
      userEmail,
      userPhone,
      userPwd: hash,
      userJob: userJob === "" ? null : userJob,
    },
  });

  // ✅ 성공 시 자동 이동
  redirect("/members");
}

import {
  reUserNick,
  reEmail,
  rePhone,
  rePwdAllowed,
  rePwdHasAlpha,
  rePwdHasDigit,
} from "@/lib/validators";

export async function checkUserNickForUpdate(userNo: number, userNick: string) {
  const nick = (userNick ?? "").trim();

  if (!reUserNick.test(nick)) {
    return { ok: false as const, msg: "닉네임 규칙이 올바르지 않습니다." };
  }

  const exists = await prisma.user.findUnique({ where: { userNick: nick } });
  if (exists && exists.userNo !== userNo) {
    return { ok: false as const, msg: "이미 등록된 닉네임입니다." };
  }

  return { ok: true as const, msg: "등록 가능한 닉네임입니다." };
}

export type UpdateState =
  | { ok: true; msg: string }
  | { ok: false; msg: string; fieldErrors?: Record<string, string> };

export async function updateUserAction(
  _prev: UpdateState | null,
  formData: FormData
): Promise<UpdateState> {
  const userNo = Number(formData.get("userNo"));
  if (!Number.isFinite(userNo) || userNo <= 0) {
    return { ok: false, msg: "잘못된 요청입니다." };
  }

  const userNick = String(formData.get("userNick") ?? "").trim();
  const userEmail = String(formData.get("userEmail") ?? "").trim();
  const userPhone = String(formData.get("userPhone") ?? "").trim();
  const userJobRaw = String(formData.get("userJob") ?? "").trim();
  const userJob = userJobRaw === "" ? null : userJobRaw;

  const userPwd = String(formData.get("userPwd") ?? ""); // optional

  // 원래 닉네임(변경 감지용)
  const originalNick = String(formData.get("originalNick") ?? "");

  // 닉네임 중복확인 상태
  const nickCheckedOk = String(formData.get("nickCheckedOk") ?? "") === "true";
  const nickCheckedValue = String(formData.get("nickCheckedValue") ?? "");

  const errors: Record<string, string> = {};

  // 필수
  if (!userNick) errors.userNick = "닉네임은 필수입니다.";
  if (!userEmail) errors.userEmail = "이메일은 필수입니다.";
  if (!userPhone) errors.userPhone = "전화번호는 필수입니다.";

  // 형식
  if (userNick && !reUserNick.test(userNick)) {
    errors.userNick = "닉네임 규칙이 올바르지 않습니다.";
  }
  if (userEmail && (userEmail.length > 50 || !reEmail.test(userEmail))) {
    errors.userEmail = "이메일 형식이 올바르지 않거나 50자를 초과했습니다.";
  }
  if (
    userPhone &&
    (!rePhone.test(userPhone) ||
      userPhone.startsWith("-") ||
      userPhone.endsWith("-") ||
      userPhone.includes("--"))
  ) {
    errors.userPhone = "전화번호는 숫자와 '-'만 입력 가능합니다.";
  }

  // 비번(입력 시에만 변경)
  if (userPwd) {
    if (
      !rePwdAllowed.test(userPwd) ||
      !rePwdHasAlpha.test(userPwd) ||
      !rePwdHasDigit.test(userPwd)
    ) {
      errors.userPwd =
        "비밀번호 규칙이 올바르지 않습니다. (4~20, 영문+숫자 포함, 특수 허용)";
    }
  }

  // 직업(선택)
  if (userJob && userJob.length > 20) {
    errors.userJob = "직업은 20자 이내로 입력해주세요.";
  }

  // ✅ 닉네임이 바뀐 경우만 중복확인 강제
  if (userNick !== originalNick) {
    if (!nickCheckedOk || nickCheckedValue !== userNick) {
      errors.userNick =
        errors.userNick ?? "닉네임 중복확인을 다시 진행해주세요.";
    }
  }

  if (Object.keys(errors).length) {
    return { ok: false, msg: "입력값을 확인해주세요.", fieldErrors: errors };
  }

  // 서버 최종 중복 체크(우회 방지)
  const exists = await prisma.user.findUnique({ where: { userNick } });
  if (exists && exists.userNo !== userNo) {
    return {
      ok: false,
      msg: "이미 등록된 닉네임입니다.",
      fieldErrors: { userNick: "중복" },
    };
  }

  await prisma.user.update({
    where: { userNo },
    data: {
      userNick,
      userEmail,
      userPhone,
      userJob,
      ...(userPwd ? { userPwd: await bcrypt.hash(userPwd, 10) } : {}),
    },
  });

  redirect("/members");
}

export async function deleteUserAction(formData: FormData) {
  const userNo = Number(formData.get("userNo"));
  if (!Number.isFinite(userNo) || userNo <= 0) return;

  await prisma.user.delete({ where: { userNo } });

  // 목록으로 새로고침
  redirect("/members");
}

 

9) 회원가입 UI (중복확인 버튼 UX)

src/app/members/register/SignupForm.tsx:

"use client";

import Link from "next/link";
import {
  useActionState,
  useEffect,
  useMemo,
  useState,
  useTransition,
} from "react";
import {
  checkUserId,
  checkUserNick,
  signupAction,
  type SignupState,
} from "@/app/members/actions";

const initialState: SignupState | null = null;

export default function SignupForm() {
  const [state, formAction, pending] = useActionState(
    signupAction,
    initialState
  );

  // ✅ 유지할 값들은 state로 관리
  const [userId, setUserId] = useState("");
  const [userNick, setUserNick] = useState("");
  const [userEmail, setUserEmail] = useState("");
  const [userPhone, setUserPhone] = useState("");
  const [userJob, setUserJob] = useState("");

  // ✅ 비밀번호는 “유지하지 않고”, 실패 시만 비우기(리셋)용 key
  const [pwdKey, setPwdKey] = useState(0);

  const [idCheck, setIdCheck] = useState<{
    ok: boolean;
    msg: string;
    value: string;
  } | null>(null);
  const [nickCheck, setNickCheck] = useState<{
    ok: boolean;
    msg: string;
    value: string;
  } | null>(null);

  const [isChecking, startTransition] = useTransition();

  // 입력값 바뀌면 중복확인 무효화
  const onChangeUserId = (v: string) => {
    setUserId(v);
    setIdCheck(null);
  };
  const onChangeUserNick = (v: string) => {
    setUserNick(v);
    setNickCheck(null);
  };

  // ✅ 실패 시: 비밀번호/재입력만 리셋 + (필요 시) values로 복구
  useEffect(() => {
    if (!state || state.ok) return;

    // 비밀번호/확인 입력은 항상 빈값 정책
    setPwdKey((k) => k + 1);

    // 혹시 값이 리셋되는 환경(리마운트 등) 대비: 서버가 준 values로 복구
    if (state.values) {
      setUserId(state.values.userId ?? "");
      setUserNick(state.values.userNick ?? "");
      setUserEmail(state.values.userEmail ?? "");
      setUserPhone(state.values.userPhone ?? "");
      setUserJob(state.values.userJob ?? "");
    }
  }, [state]);

  const fieldErrors = state && !state.ok ? state.fieldErrors ?? {} : {};
  const msg = state ? state.msg : "";

  // hidden: 중복확인 상태 전달
  const hidden = useMemo(() => {
    return {
      idCheckedOk: String(idCheck?.ok === true),
      idCheckedValue: idCheck?.value ?? "",
      nickCheckedOk: String(nickCheck?.ok === true),
      nickCheckedValue: nickCheck?.value ?? "",
    };
  }, [idCheck, nickCheck]);

  return (
    <form action={formAction} className="space-y-4 max-w-xl">
      <div>
        <label className="block text-sm font-medium">아이디</label>
        <div className="flex gap-2">
          <input
            name="userId"
            value={userId}
            onChange={(e) => onChangeUserId(e.target.value)}
            className="border px-3 py-2 rounded w-full"
          />
          <button
            type="button"
            onClick={() =>
              startTransition(async () => {
                const r = await checkUserId(userId);
                setIdCheck({ ...r, value: userId });
              })
            }
            className="border px-3 py-2 rounded whitespace-nowrap"
            disabled={isChecking}
          >
            중복확인
          </button>
        </div>
        <p className="text-sm mt-1">{idCheck?.msg}</p>
        {fieldErrors.userId && (
          <p className="text-sm text-red-600">{fieldErrors.userId}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium">닉네임</label>
        <div className="flex gap-2">
          <input
            name="userNick"
            value={userNick}
            onChange={(e) => onChangeUserNick(e.target.value)}
            className="border px-3 py-2 rounded w-full"
          />
          <button
            type="button"
            onClick={() =>
              startTransition(async () => {
                const r = await checkUserNick(userNick);
                setNickCheck({ ...r, value: userNick });
              })
            }
            className="border px-3 py-2 rounded whitespace-nowrap"
            disabled={isChecking}
          >
            중복확인
          </button>
        </div>
        <p className="text-sm mt-1">{nickCheck?.msg}</p>
        {fieldErrors.userNick && (
          <p className="text-sm text-red-600">{fieldErrors.userNick}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium">이메일</label>
        <input
          name="userEmail"
          value={userEmail}
          onChange={(e) => setUserEmail(e.target.value)}
          className="border px-3 py-2 rounded w-full"
        />
        {fieldErrors.userEmail && (
          <p className="text-sm text-red-600">{fieldErrors.userEmail}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium">전화번호</label>
        <input
          name="userPhone"
          value={userPhone}
          onChange={(e) => setUserPhone(e.target.value)}
          className="border px-3 py-2 rounded w-full"
          placeholder="010-1234-5678"
        />
        {fieldErrors.userPhone && (
          <p className="text-sm text-red-600">{fieldErrors.userPhone}</p>
        )}
      </div>

      {/* ✅ 비밀번호/재입력: 실패 시 key 변경으로만 리셋(값 유지 안 함) */}
      <div key={pwdKey} className="space-y-4">
        <div>
          <label className="block text-sm font-medium">비밀번호</label>
          <input
            name="userPwd"
            type="password"
            className="border px-3 py-2 rounded w-full"
            autoComplete="new-password"
          />
          {fieldErrors.userPwd && (
            <p className="text-sm text-red-600">{fieldErrors.userPwd}</p>
          )}
        </div>

        <div>
          <label className="block text-sm font-medium">비밀번호 재입력</label>
          <input
            name="userPwd2"
            type="password"
            className="border px-3 py-2 rounded w-full"
            autoComplete="new-password"
          />
          {fieldErrors.userPwd2 && (
            <p className="text-sm text-red-600">{fieldErrors.userPwd2}</p>
          )}
        </div>
      </div>

      <div>
        <label className="block text-sm font-medium">직업(선택)</label>
        <input
          name="userJob"
          value={userJob}
          onChange={(e) => setUserJob(e.target.value)}
          className="border px-3 py-2 rounded w-full"
        />
        {fieldErrors.userJob && (
          <p className="text-sm text-red-600">{fieldErrors.userJob}</p>
        )}
      </div>

      {/* hidden: 중복확인 상태 */}
      <input type="hidden" name="idCheckedOk" value={hidden.idCheckedOk} />
      <input
        type="hidden"
        name="idCheckedValue"
        value={hidden.idCheckedValue}
      />
      <input type="hidden" name="nickCheckedOk" value={hidden.nickCheckedOk} />
      <input
        type="hidden"
        name="nickCheckedValue"
        value={hidden.nickCheckedValue}
      />

      <div className="flex gap-2">
        <button
          className="bg-black text-white px-4 py-2 rounded flex-1"
          disabled={pending}
        >
          {pending ? "처리중..." : "회원가입"}
        </button>

        <Link
          href="/members"
          className="border px-4 py-2 rounded flex-1 text-center"
        >
          회원목록
        </Link>
      </div>

      {msg && (
        <p
          className={`text-sm mt-2 ${
            state?.ok ? "text-green-700" : "text-red-700"
          }`}
        >
          {msg}
        </p>
      )}
    </form>
  );
}

 

10) 회원목록 + 수정/삭제 

목록: src/app/members/page.tsx

import Link from "next/link";
import { prisma } from "@/lib/db/prisma";
import { deleteUserAction } from "./actions";
import ConfirmSubmit from "@/app/components/ConfirmSubmit";
import MembersRefresh from "@/app/components/MembersRefresh";

export default async function Page() {
  const users = await prisma.user.findMany({
    orderBy: { userNo: "desc" },
    select: {
      userNo: true,
      userId: true,
      userNick: true,
      userEmail: true,
      userPhone: true,
      userJob: true,
      create_at: true,
    },
  });

  return (
    <div className="p-6 space-y-4">
      <div className="flex items-center justify-between">
        <h1 className="text-xl font-semibold">회원목록</h1>
        <div className="flex gap-2 items-center">
          <MembersRefresh /> {/* ✅ 추가 */}
          <Link className="border px-3 py-2 rounded" href="/members/register">
            회원추가
          </Link>
        </div>
      </div>

      <div className="border rounded">
        <table className="w-full text-sm">
          <thead>
            <tr className="border-b">
              <th className="p-2 text-left">No</th>
              <th className="p-2 text-left">ID</th>
              <th className="p-2 text-left">Nick</th>
              <th className="p-2 text-left">Email</th>
              <th className="p-2 text-left">Phone</th>
              <th className="p-2 text-left">Job</th>
              <th className="p-2 text-left">Created</th>
              <th className="p-2 text-left">Actions</th>
            </tr>
          </thead>
          <tbody>
            {users.map((u) => (
              <tr key={u.userNo} className="border-b">
                <td className="p-2">{u.userNo}</td>
                <td className="p-2">{u.userId}</td>
                <td className="p-2">{u.userNick}</td>
                <td className="p-2">{u.userEmail}</td>
                <td className="p-2">{u.userPhone}</td>
                <td className="p-2">{u.userJob ?? "-"}</td>
                <td className="p-2">
                  {u.create_at.toISOString().slice(0, 19).replace("T", " ")}
                </td>
                <td className="p-2">
                  <div className="flex gap-2">
                    {/* ✅ 수정: confirm 후 이동 */}
                    <Link
                      href={`/members/${u.userNo}/edit`}
                      className="underline"
                    >
                      수정
                    </Link>

                    {/* ✅ 삭제: confirm 후 submit */}
                    <form action={deleteUserAction}>
                      <input type="hidden" name="userNo" value={u.userNo} />
                      <ConfirmSubmit
                        message="삭제하시겠습니까?"
                        className="underline text-red-600"
                      >
                        삭제
                      </ConfirmSubmit>
                    </form>
                  </div>
                </td>
              </tr>
            ))}
            {users.length === 0 && (
              <tr>
                <td className="p-4" colSpan={8}>
                  등록된 회원이 없습니다.
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}

 

11) Seed (Prisma 7 + tsx)

prisma/seed.ts (주의: tsx 실행은 @/ alias가 꼬일 수 있어 상대경로 import 권장)

import "dotenv/config";
import bcrypt from "bcryptjs";
import { PrismaClient, UserRole } from "../src/generated/prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";

const url = process.env.DATABASE_URL!;
const prisma = new PrismaClient({
  adapter: new PrismaBetterSqlite3({ url }),
});

async function main() {
  const hash = await bcrypt.hash("Test1234!", 10);

  await prisma.user.create({
    data: {
      userId: "admin1",
      userNick: "관리자",
      userEmail: "admin@example.com",
      userPhone: "010-0000-0000",
      userPwd: hash,
      userJob: "ADMIN",
      role: UserRole.ADMIN,
    },
  });
}

main()
  .then(() => console.log("seed done"))
  .finally(async () => prisma.$disconnect());

 

package.json:

{
  "scripts": {
    "db:migrate": "pnpm exec prisma migrate dev",
    "db:seed": "pnpm exec tsx prisma/seed.ts",
    "db:studio": "pnpm exec prisma studio"
  }
}

 

실행:

pnpm db:seed

 

 
12) 회원정보 수정 페이지 
 

✅  (A) src/app/members/[id]/edit/page.tsx

import { prisma } from "@/lib/db/prisma";
import { notFound } from "next/navigation";
import EditForm from "./EditForm";

export const runtime = "nodejs";

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const userNo = Number(id);

  if (!Number.isFinite(userNo) || userNo <= 0) notFound();

  const user = await prisma.user.findUnique({
    where: { userNo },
    select: {
      userNo: true,
      userId: true,
      userNick: true,
      userEmail: true,
      userPhone: true,
      userJob: true,
    },
  });

  if (!user) notFound();

  return (
    <div className="p-6">
      <h1 className="text-xl font-semibold mb-4">회원 수정</h1>
      <EditForm user={user} />
    </div>
  );
}
 

✅ (B) src/app/members/actions.ts 

  • 닉네임이 변경된 경우에만 “중복확인 버튼”을 강제
  • 이메일/전화/비번은 submit 시 검증
  • 비번은 입력 시에만 변경(옵션)
  • src/app/members/actions.ts  파일내 checkUserNickForUpdate , updateUserAction 내용 참조

✅ (C) src/app/members/[userNo]/edit/EditForm.tsx

"use client";

import Link from "next/link";
import ConfirmSubmit from "@/app/components/ConfirmSubmit";
import { useActionState, useMemo, useState, useTransition } from "react";
import {
  checkUserNickForUpdate,
  updateUserAction,
  type UpdateState,
} from "@/app/members/actions";

const initialState: UpdateState | null = null;

export default function EditForm({
  user,
}: {
  user: {
    userNo: number;
    userId: string;
    userNick: string;
    userEmail: string;
    userPhone: string;
    userJob: string | null;
  };
}) {
  const [state, formAction, pending] = useActionState(
    updateUserAction,
    initialState
  );

  const [userNick, setUserNick] = useState(user.userNick);
  const [nickCheck, setNickCheck] = useState<{
    ok: boolean;
    msg: string;
    value: string;
  } | null>(null);

  const [checking, startTransition] = useTransition();

  // 닉네임 변경 시 중복확인 무효화
  const onChangeNick = (v: string) => {
    setUserNick(v);
    setNickCheck(null);
  };

  const hidden = useMemo(() => {
    return {
      nickCheckedOk: String(nickCheck?.ok === true),
      nickCheckedValue: nickCheck?.value ?? "",
    };
  }, [nickCheck]);

  const fieldErrors = state && !state.ok ? state.fieldErrors ?? {} : {};
  const msg = state ? state.msg : "";

  const nickChanged = userNick.trim() !== user.userNick;

  return (
    <form action={formAction} className="space-y-4 max-w-xl">
      <input type="hidden" name="userNo" value={user.userNo} />
      <input type="hidden" name="originalNick" value={user.userNick} />

      {/* 중복확인 상태 전달 */}
      <input type="hidden" name="nickCheckedOk" value={hidden.nickCheckedOk} />
      <input
        type="hidden"
        name="nickCheckedValue"
        value={hidden.nickCheckedValue}
      />

      <div>
        <label className="block text-sm font-medium">회원번호</label>
        <input
          className="border px-3 py-2 rounded w-full bg-gray-50"
          value={user.userNo}
          readOnly
        />
      </div>

      <div>
        <label className="block text-sm font-medium">아이디(수정불가)</label>
        <input
          className="border px-3 py-2 rounded w-full bg-gray-50"
          value={user.userId}
          readOnly
        />
      </div>

      <div>
        <label className="block text-sm font-medium">닉네임</label>
        <div className="flex gap-2">
          <input
            name="userNick"
            value={userNick}
            onChange={(e) => onChangeNick(e.target.value)}
            className="border px-3 py-2 rounded w-full"
          />
          <button
            type="button"
            className="border px-3 py-2 rounded whitespace-nowrap"
            disabled={checking || !nickChanged}
            onClick={() =>
              startTransition(async () => {
                const r = await checkUserNickForUpdate(user.userNo, userNick);
                setNickCheck({ ...r, value: userNick.trim() });
              })
            }
            title={nickChanged ? "중복확인" : "변경된 닉네임이 없습니다"}
          >
            중복확인
          </button>
        </div>

        {nickChanged && (
          <p className="text-xs mt-1 opacity-70">
            ※ 닉네임 변경 시 중복확인이 필요합니다.
          </p>
        )}
        {nickCheck?.msg && <p className="text-sm mt-1">{nickCheck.msg}</p>}
        {fieldErrors.userNick && (
          <p className="text-sm text-red-600">{fieldErrors.userNick}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium">이메일</label>
        <input
          name="userEmail"
          defaultValue={user.userEmail}
          className="border px-3 py-2 rounded w-full"
        />
        {fieldErrors.userEmail && (
          <p className="text-sm text-red-600">{fieldErrors.userEmail}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium">전화번호</label>
        <input
          name="userPhone"
          defaultValue={user.userPhone}
          className="border px-3 py-2 rounded w-full"
        />
        {fieldErrors.userPhone && (
          <p className="text-sm text-red-600">{fieldErrors.userPhone}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium">
          비밀번호(변경 시 입력)
        </label>
        <input
          name="userPwd"
          type="password"
          className="border px-3 py-2 rounded w-full"
          placeholder="미입력 시 기존 유지"
        />
        {fieldErrors.userPwd && (
          <p className="text-sm text-red-600">{fieldErrors.userPwd}</p>
        )}
      </div>

      <div>
        <label className="block text-sm font-medium">직업(선택)</label>
        <input
          name="userJob"
          defaultValue={user.userJob ?? ""}
          className="border px-3 py-2 rounded w-full"
        />
        {fieldErrors.userJob && (
          <p className="text-sm text-red-600">{fieldErrors.userJob}</p>
        )}
      </div>

      <div className="flex gap-2">
        <ConfirmSubmit
          message="수정사항을 저장하시겠습니까?"
          className="bg-black text-white px-4 py-2 rounded flex-1 disabled:opacity-40"
        >
          {pending ? "저장중..." : "저장"}
        </ConfirmSubmit>

        <Link
          href="/members"
          className="border px-4 py-2 rounded flex-1 text-center"
        >
          목록
        </Link>
      </div>

      {msg && (
        <p
          className={`text-sm mt-2 ${
            state?.ok ? "text-green-700" : "text-red-700"
          }`}
        >
          {msg}
        </p>
      )}
    </form>
  );
}
 
 
 

 

이 글에서는 Next.js 16(App Router) 환경에서 회원가입/중복확인/목록/수정/삭제까지 구현한 예제를 통해,

  • 프로젝트 구조를 어떻게 잡는지
  • Server Actions로 폼을 처리할 때 흐름이 어떻게 흘러가는지
  • “입력값 유지”, “중복확인 버튼 UX”, “confirm 처리” 같은 실전 원칙을 왜 적용했는지

를 단계별로 정리합니다.

 

1. 기술 스택과 목표

사용 기술

  • Next.js 16 (App Router)
  • Prisma ORM 7
  • SQLite (로컬 파일 DB)
  • Server Actions 기반 폼 처리
  • bcryptjs로 비밀번호 해싱

목표 기능

  • 회원가입
    • userId, userNick은 중복확인 버튼으로만 검사(입력할 때마다 Ajax X)
    • 제출 시(Submit) 모든 유효성 검사 수행
    • 검증 실패 시 입력값 유지, 단 비밀번호/비밀번호 확인은 항상 비움
  • 회원목록 조회
    • “새로고침(다른 PC에서 등록한 회원 반영)”
  • 회원수정
    • 저장 시 “수정하시겠습니까?” confirm
    • 닉네임 변경 시에만 중복확인 강제
  • 회원삭제
    • “삭제하시겠습니까?” confirm

2. 프로젝트 구조 (왜 이렇게 나눴나?)

예제의 핵심 구조는 다음과 같습니다.

src/
  app/
    members/
      page.tsx                // 회원목록 (Server Component)
      actions.ts              // 서버액션모음 (등록/수정/삭제)
      register/
        page.tsx              // 회원가입 페이지
        SignupForm.tsx        // 회원가입 폼 (Client Component)
      [id]/
        edit/
          page.tsx            // 수정 페이지 (Server Component)
          EditForm.tsx        // 수정 폼 (Client Component)
  lib/
    db/
      prisma.ts               // PrismaClient singleton(Prisma7 adapter 포함)
    validators.ts             // 정규식/검증 함수 모음
  components/
    MembersRefresh.tsx        // 목록 새로고침(router.refresh)
    ConfirmSubmit.tsx         // submit confirm 유틸 컴포넌트
    
prisma/
  schema.prisma               // Prisma schema (SQLite용)
  seed.ts                     // 초기 데이터 seed

핵심 원칙

  • DB 접근(Prisma)은 Server에서만
    → Client 컴포넌트에서는 DB를 직접 못 만집니다.
  • 폼 UI는 Client Component, 저장/조회는 Server Action
    → UI/상태는 클라이언트가 담당하고, 보안/검증/DB는 서버가 담당합니다.
  • 검증 로직은 validators.ts로 모아서 재사용
    → 회원가입/수정 등 여러 곳에서 똑같은 규칙을 중복 구현하지 않게 합니다.

3. Prisma 7 + SQLite에서 꼭 알아야 할 포인트

3-1) SQLite는 @db.VarChar(20) 같은 길이 타입을 지원하지 않음

SQLite는 타입 제약이 약해서 Prisma 스키마에서 @db.VarChar() 같은 native type을 쓰면 에러가 납니다.

✅ 해결: 길이 제한은 DB가 아니라 검증 로직에서 처리

3-2) Prisma 7은 PrismaClient 생성 방식이 다름 (adapter 필요)

Prisma 7은 단순히 new PrismaClient()로 동작하지 않고,
SQLite의 경우 @prisma/adapter-better-sqlite3 같은 adapter를 통해 생성합니다.

그래서 src/lib/db/prisma.ts에서 다음처럼 생성합니다.

  • PrismaClient singleton으로 만들어 hot-reload에서 연결 폭주 방지
  • adapter에 DATABASE_URL 전달

이 구성이 “충돌 없이” 가장 안정적입니다.


4. 회원가입 흐름 (Server Actions + UX 중복확인)

회원가입의 흐름은 크게 3단계입니다.

4-1) 입력 단계: userId / userNick은 “중복확인 버튼”으로만 검사

요구사항의 핵심은 “입력할 때마다 Ajax로 중복검사하지 말 것”입니다.

그래서 UI에서:

  • 아이디 입력 → [중복확인] 버튼 클릭 → 서버에서 존재 여부 확인 → 메시지 표시
  • 닉네임 입력 → [중복확인] 버튼 클릭 → 서버에서 존재 여부 확인 → 메시지 표시

이때 중복확인 결과는 hidden input으로 제출합니다.

4-2) 제출 단계: 서버에서 “최종 검증”을 반드시 수행

여기서 중요한 원칙:

클라이언트 검증은 UX용이고, 최종 정답은 서버 검증이다.

왜냐하면 사용자가 개발자 도구로 hidden 값들을 조작할 수 있기 때문입니다.

따라서 서버 액션에서는 다음을 반드시 합니다.

  1. 정규식/길이 등 기본 검증
  2. “중복확인 버튼을 눌렀는지” + “눌렀던 값과 현재 값이 동일한지” 확인
  3. DB에서 최종 중복 체크(레이스 컨디션 방지)
  4. bcrypt로 비밀번호 해시 후 저장

4-3) 검증 실패 시 입력값 유지 (단, 비밀번호는 비움)

초보자들이 가장 많이 겪는 문제가:

  • submit 후 서버 검증 실패하면
  • 폼 전체가 리셋되어 사용자가 다시 입력해야 하는 문제

해결 원칙은 다음입니다.

  • userId/userNick/email/phone/job은 React state로 관리해서 화면에 유지
  • 서버 액션이 실패할 때 **values(비밀번호 제외)**를 state로 반환
  • 클라이언트에서 state.values를 다시 setState
  • 비밀번호/비밀번호 확인은 보안상 유지하지 않고, 실패 시 무조건 비움
    → key를 바꿔 input을 리마운트해서 강제 초기화

이 정책은 실무에서도 많이 씁니다.


5. 회원목록 페이지: 갱신(새로고침) 전략

다른 PC에서 회원을 등록했을 때 목록을 최신으로 유지하려면,
가장 간단한 방법은:

  • 새로고침 버튼
  • 클릭 시 router.refresh()

router.refresh()는 현재 페이지의 서버 컴포넌트를 다시 실행하게 만들어서,
prisma.user.findMany()가 다시 수행되고 목록이 갱신됩니다.

(자동 갱신이 필요하면 interval 폴링도 가능하지만, 초보 단계에서는 수동 갱신이 더 안정적입니다.)


6. 수정 기능: “저장할 때 confirm” + 닉네임 변경 시만 중복확인

수정 기능의 UX 정책은 다음입니다.

  • 수정 페이지 진입은 그냥 들어가도 됨
  • 저장 버튼 클릭 시 confirm
  • 닉네임이 바뀐 경우에만 “닉네임 중복확인” 강제

이렇게 하면 불필요하게 매번 중복확인을 요구하지 않고,
“변경한 경우에만” 요구사항을 만족시킬 수 있습니다.

또한 비밀번호는 수정 폼에서:

  • 입력하지 않으면 기존 유지
  • 입력했을 때만 규칙 검증 + 해시 후 업데이트

이 방식이 가장 자연스럽습니다.


7. 삭제 기능: submit confirm

삭제는 실수 방지를 위해 confirm이 필수입니다.

하지만 Next.js 서버 액션은 <form action={...}> 형태로 submit되기 때문에,
클라이언트에서 submit 직전에 confirm을 걸어야 합니다.

그래서 ConfirmSubmit 같은 작은 Client Component를 만들어 재사용합니다.


8. 이 예제에서 지킨 “실전 원칙” 요약

✅ 1) 검증은 서버가 최종 책임

클라이언트는 사용자 편의(UX)
서버는 보안/무결성

✅ 2) 중복확인은 “버튼 방식” + 서버 최종 검증

요구사항 충족 + 트래픽 절약 + 입력 스트레스 감소

✅ 3) 비밀번호는 유지하지 않는다

검증 실패 시에도 비밀번호 필드는 항상 비움(보안/UX 표준)

✅ 4) PrismaClient는 singleton

Next dev 환경 hot reload에서 연결 폭주 방지

✅ 5) SQLite는 DB 길이 제한을 신뢰하지 않는다

길이/형식 제한은 validators에서 처리


9. 다음 확장 아이디어 (초보 → 실무로 가는 단계)

이 예제는 “회원관리 CRUD”의 골격이므로,
여기서 한 단계씩 확장하면 실무 수준으로 올라갈 수 있습니다.

  • 로그인/세션(쿠키) 추가
  • userEmail/userPhone도 unique 정책 추가(업무 규칙에 따라)
  • 소프트 삭제(삭제 플래그)로 변경
  • pagination / 검색(아이디, 닉네임) 추가
  • optimistic UI(삭제 즉시 제거) + 실패 시 롤백
  • audit log(누가 언제 수정/삭제했는지)
반응형