| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- Python
- django virtualenv
- 넥스트js
- Kotlin 클래스
- 파이썬 장고
- Kotlin 클래스 속성정의
- Kotlin else if
- 파이썬
- Kotlin 조건문
- git
- python Django
- 자바 기본타입
- 파이썬 클래스
- github
- Python Class
- 파이썬 제어문
- 도전
- 클래스 속성
- Variable declaration
- 희망
- 다중조건문
- 성공
- 파이썬 반복문
- 강제 타입변환
- Kotlin Class
- 장고 가상환경
- activate 오류
- NextJs
- 좋은글
- Kotlin If
- Today
- Total
키모스토리
#37. Next.js 16 + Prisma 7 + SQLite 회원관리 예제 본문
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:
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
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
✅ (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 값들을 조작할 수 있기 때문입니다.
따라서 서버 액션에서는 다음을 반드시 합니다.
- 정규식/길이 등 기본 검증
- “중복확인 버튼을 눌렀는지” + “눌렀던 값과 현재 값이 동일한지” 확인
- DB에서 최종 중복 체크(레이스 컨디션 방지)
- 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(누가 언제 수정/삭제했는지)
'Web Devlopment > NextJs' 카테고리의 다른 글
| #36. form action(Server Actions) vs onSubmit(Client Handler) (0) | 2026.01.01 |
|---|---|
| #35. Next.js 16(App Router) + Prisma v7 + SQLite (0) | 2026.01.01 |
| #34. Data Fetching Pattern (0) | 2025.12.31 |
| #33. Redering section Summary (0) | 2025.12.30 |
| #32. Interleaving Server and Client Components (0) | 2025.12.30 |
