[NextJS] 회원 탈퇴 (feat. prisma)
회원 탈퇴는 탈퇴하려는 유저와 관련된 모든 정보를 삭제하는 것을 의미한다.
단계별로 설명하자면
1. 클라우드에 저장된 이미지 등 삭제
2. DB에 저장된 유저와 관련된 모든 정보 삭제
3. 쿠키, 세션, 로컬스토리지에 저장된 값 삭제
4. 관련된 캐시 초기화
5. 로그인 전 메인 페이지로 이동하기
DB 설정 (prisma)
// schema.prisma
model User {
id Int @id @default(autoincrement()) // 1부터 순차적으로 커진다.
username String @unique
email String? @unique // 전화번호 또는 소셜 로그인으로 로그인할 수 있기에 필수값 x
password String? // 전화번호 또는 소셜 로그인으로 로그인할 수 있기에 필수값 x
phone String? @unique // 이메일 또는 소셜 로그인으로 로그인할 수 있기에 필수값 x
github_id String? @unique // 깃허브로 로그인 할 수도 있기 때문에 필수값 x
avatar String?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
products Product[]
}
model Product {
id Int @id @default(autoincrement()) // 1부터 순차적으로 커진다.
title String
price Float // 소수점까지 표시됌 ex) 31.4
photo String
description String
created_at DateTime @default(now())
updated_at DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) // userId의 경로를 알려주는 줄임
userId Int
}
// onDelete: Cascade | Restrict | SetNull
// Cascade : 부모 노드 삭제하면 현재 노드 삭제
// Restrict : 현재 노드를 삭제하지 않으면 부모 노드는 삭제 불가
// SetNull : 부모 노드가 삭제되면, 현재 노드에 부모를 가리키는 속성 값은 NULL (이 때, Type은 ? (Optional) 해야한다.)
User와 Product를 연결하고, onDelete의 옵션으로 Cascade를 입력한다.
Cascade는 쉽게 말해 User를 삭제하면 유저와 관련된 Product를 전부 삭제시킨다.
ProfilePage
함수 getUser는 세션 정보를 가져오고, 세션에 있는 id값과 일치하는 DB의 유저 정보를 가져온다.
가져온 user.id값을 <UserDeleteBtn> 컴포넌트에 Props로 전달한다.
// app/(tabs)/profile/page.tsx
import db from "@/lib/db";
import getSession from "@/lib/session/getSession";
import { notFound } from "next/navigation";
import UserDeleteBtn from "@/components/buttons/user-delete-btn";
// 유저 정보 가져옴
async function getUser() {
const session = await getSession();
if (session.id) {
const user = await db.user.findUnique({
where: {
id: session.id,
},
});
if (user) {
return user;
}
}
// 세션id가 없는 경우 (= 잘못된 경로로 접속한 경우), 에러 페이지 보여줌
notFound();
}
export default async function Profile() {
const user = await getUser();
const githubId = user.github_id;
return (
<div>
...
<section className="mt-20">
<UserDeleteBtn id={user.id} />
</section>
</div>
);
}
UserDeleteBtn
<UserDeleteBtn>를 만든 이유는 버튼은 유저와 상호작용을 거쳐 동작하기 시작하기 때문에,
("use server"에서 동작하지 않고, "use client"에서 동작하기 때문에)
따로 파일을 만들어 "use client"를 작성해 주었다.
삭제 함수가 실행되면 유저가 실수로 회원 탈퇴 버튼을 눌렸을 수도 있기에, 정말로 회원 탈퇴할 것인지 재차 확인한다.
삭제 함수가 동작하는 경우에 해당 버튼을 누를 수 없도록 disabled를 사용하며,
진행 중인 상태를 보여주기 위해 text와 icon을 추가해 주었다.
회원 탈퇴가 끝난 후 useRouter를 통해 메인 페이지('/')로 이동한다.
use client에서 "next/navigation"의 useRouter가 아닌 다른 useRouter를 사용하면 에러가 발생하니 주의해야 한다.
// components/buttons/user-delete-btn.tsx
"use client";
import { onClickDeleteUser } from "@/app/(tabs)/profile/actions";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { useState } from "react";
interface userDeleteBtnProps {
id: number;
}
// id === user.id
export default function UserDeleteBtn({ id }: userDeleteBtnProps) {
const [isLoading, setLoading] = useState(false);
const router = useRouter();
const onUserDelete = async () => {
const confirm = window.confirm("정말로 회원 탈퇴 하시겠습니까?");
if (!confirm) return;
setLoading(true);
await onClickDeleteUser(id);
setLoading(false);
router.push("/");
};
return (
<section className="flex flex-row justify-center text-sm">
{isLoading ? (
<button
className="text-gray-300 hover:text-white hover:underline underline-offset-4 flex flex-row gap-2 items-center "
disabled
>
<ArrowPathIcon className="size-5 animate-spin" />
진행 중...
</button>
) : (
<button
onClick={onUserDelete}
className=" text-gray-300 hover:text-white hover:underline underline-offset-4 "
>
회원 탈퇴하기
</button>
)}
</section>
);
}
Actions
처음에 Prisma에서 설정한 onDelete 때문에 유저만 삭제하면 해당 유저와 관련된 정보는 DB에서 전부 삭제된다.
단, DB에 저장되어 있지 않은 데이터, 즉, 클라우드에 저장된 이미지는 클라우드마다 삭제 방법이 다를 것이니
각 클라우드의 공식 홈페이지를 참조하여 만들기 바랍니다. (cloudflare를 사용했었었습니다.)
id값으로 유저를 찾고 유저가 가지고 있는 상품의 이미지들을 가져옵니다.
해당 이미지 URL들을 가지고 있는 배열의 각 요소들에서 id값을 추려내고,
cloudflare에게 이미지 삭제 요청을 보냅니다.
DB에 user를 삭제하고,
세션도 삭제하고,
캐시를 최신화시켜 줍니다.
// app/(tabs)/profile/actions.ts
"use server";
import db from "@/lib/db";
import getSession from "@/lib/session/getSession";
import { revalidateTag } from "next/cache";
// id === user.id
export async function onClickDeleteUser(id: number) {
const productPhoto = await db.user.findMany({
where: {
id,
},
select: {
products: {
select: {
photo: true,
},
},
},
});
// flatMap : 평탄화
// map : 요소들 값 변경
const productPhotoIds = productPhoto.flatMap((user) =>
user.products.map((product) => {
const photoUrl = product.photo;
// URL에서 "https://imagedelivery.net/~~~/" 이후의 문자열을 추출하여 id로 사용합니다.
const id = photoUrl.split(
"https://imagedelivery.net/~~~/"
)[1];
return id;
})
);
// productPhotoIds를 순회하면서 순서대로 cloudflare의 이미지를 지운다.
const deletePromises = productPhotoIds.map(async (photoId) => {
await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID}/images/v1/${photoId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${process.env.CLOUDFLARE_API_KEY}`,
"Content-Type": "application/json",
},
}
);
});
// Promise.all를 사용하여 모든 작업이 완료될 때까지 기다리도록 함
await Promise.all(deletePromises);
// 유저 정보 삭제
await db.user.delete({
where: {
id,
},
select: {
id: true,
},
});
// session 삭제
const session = await getSession();
await session.destroy();
// 캐시 최신화
revalidateTag(`home-product-list`);
revalidateTag(`life-post-list`);
// redirect는 user delete btn에 있음
}