개발일기장/Next.js로 리팩토링하기
[D'art] 로그인 / 회원가입 기능 구현하기
고래강이
2025. 2. 21. 08:45
정보처리기사 실기를 준비하느라 프로젝트에 대해서 좀 소홀했던 것 같다. 다시 시작한다는 마음으로 오늘은 꼭 회원가입 / 로그인을 구현하고 말겠어...
📋 목표
- 로그인 UI 완성하기.
- 로그인 기능 구현하기.
- 회원가입 UI 완성하기.
- 회원가입 기능 구현하기.
- Supabase와 연동하기.
- 코드 리뷰 및 개선 점 찾기.
🖥️ 진행 사항
1️⃣ 회원가입 UI 완성하기
1-1. 약관동의 상태관리 어떻게 할까?
일단 상위 노드에서 하위 노드의 값을 조절하는 방식으로 하는 것이 좋다고 생각이 들었으며 check 여부를 최대한 간단히 나타내고자 하였다.
"use client";
...
const SignupAgreePage = () => {
const [agreeState, setAgreeState] = useState<boolean[]>([
false,
false,
false,
]);
return (
<section className="h-fit w-full">
<p className="text-4xl">약관동의</p>
<Line />
<div className="flex flex-col gap-6 px-4">
{agreeInfo.map(({ desc, title }) => (
<SignupCheckBox key={title} title={title} desc={desc} />
))}
</div>
...
</section>
);
};
export default SignupAgreePage;
- 일단 Page 컴포넌트에서 client component로 구성되어있는 부분을 수정할 수 있지 않을까? 생각이 들었다. 근데 이렇게 되면 boolean값의 여부에 따라서 button을 활성화할지말지에 대해서 구성하기가 귀찮아지는 부분이 있다. 재활용성도 떨어지게 될 거 같은 부분이 있어서 일단은 그대로 가자.
1-2. "use client"를 비활성화시키려는 노력은 도움이 될까?
next.js를 사용하는 이유 중 하나가 server component의 활용에 있다고 생각한다. 그렇기에 일반적으로 client component의 수를 줄이는 것이 도움이 될 것이라고 생각했는데 성능으로 내가 고치려고 맘 먹은 부분을 분석해보았다.
해당 페이지의 정보를 보면 서버와의 송수신이 없는 단순히 hook과 state를 활용한 페이지이기에 'use client'를 자연스럽게 붙일 수 밖에 없던 페이지인데 성능면으로는 전혀 문제가 없음을 보인다. 이에 따라 송•수신까지 하는 정도 아니라면 굳이 server component에 집착할 필요 없을 것 같다. 접근성 깎아먹은 5점이나 올리는 노려을 담에 해보자(대충 봤는데 url이나 기타 등등의 요소가 들어가는 것 같더라)
1-3. middleware를 사용해서 2 step의 signup단계에서 이전 단계를 필수로 거치게 하는 방법은?
처음에는 localstorage에 값을 저장하고 middleware에서 이를 불러오면되겠다고 생각을했지만 실제로는 middleware는 서버에서 실행되기에 localstorage를 불러올 수가 없는 상황이다.
- 해당 방법을 cookie를 통해서 해결을 했다. localstorage는 클라이언트에서 작동하는 반면에 cookie를 통해서 http 요청에 내가 원하는 값을 middleware에서 참조해서 사용할 수 있었다.
// middleware.ts
import { NextResponse } from "next/server";
import { NextRequest } from "next/server";
const allowedPaths = [
...
];
export function middleware(request: NextRequest) {
const url = request.nextUrl.pathname;
...
// 회원가입 시 step1을 거치지 않았다면 접근할 수 없음
const agreement = request.cookies.get("agreement")?.value || false;
if (url === "/signup/info" && !agreement) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
...
// set cookies handler
const onClickNextButton = () => {
if (!disabled) {
router.push("/signup/info");
document.cookie = "agreement=true; path=/; max-age=2592000"; // 30일 유지
}
};
1-4. email과 nickname에서는 모달을 띄울건데 모달은 어디에 위치하고 어떻게 구현하는 게 좋을까?
일단 portal을 사용해서 모달을 구현해놓은 기존의 상태인데 뭔가 파일구조가 복잡하고 귀찮아보이는 느낌이 있다. 그래서 몇가지 부분을 수정하고자 한다.
- modal Portal을 전역변수로 사용해서 button에 handler와 띄우고 싶은 modal의 정보만 전달하는 방법은 어떨까?
- 이 때 provider에 전역적으로 modal을 설정해 놓는다면 어디서든 사용이 가능하지 않을까?
- zustand를 활용해서 좀 더 쉽고 간단하게 구현해보자!!
기존 코드
더보기
// usage
"use client";
...
const InputWithModal = (...) => {
const { isExpand, onToggle } = useOutsideClick();
return (
<div>
<div className="flex flex-row items-end gap-10">
<InputField {...props} disabled />
<button
className="h-10 w-20 min-w-24 border text-xs"
onClick={onToggle}
>
{buttonLabel}
</button>
</div>
{isExpand && (
<ModalPortal>
...
</ModalPortal>
)}
</div>
);
};
...
export default InputWithModal;
/**
기존에 사용하던 코드에서는 portal이 해당 컴포넌트에 의존하게 된다는 단점이 생기는 것 같다.
*/
// Modal Portal
"use client";
import { PropsWithChildren, useEffect, useState } from "react";
import { createPortal } from "react-dom";
const ModalPortal = ({ children }: PropsWithChildren) => {
const [modalRoot, setModalRoot] = useState<HTMLElement | null>(null);
useEffect(() => {
setModalRoot(document.getElementById("modal-portal"));
}, []);
return (
<>
{modalRoot
? createPortal(
<div className="modal-positon bg-black bg-opacity-20 backdrop-blur-md">
{children}
</div>,
modalRoot,
)
: null}
</>
);
};
export default ModalPortal;
/**
나중에 알림을 띄울 alert창의 경우에는 모달이 띄어진 상태에서도 사용될 수 있으니깐 분리해서
사용하자
*/
개선된 코드
더보기
// modalPortal.tsx
"use client";
import { useModalStore } from "@/store/modal";
import SignupEmailCheckBox from "@/ui/signup/signupEmailCheckBox";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
const ModalPortal = () => {
const [modalRoot, setModalRoot] = useState<HTMLElement | null>(null);
const type = useModalStore((state) => state.type);
useEffect(() => {
setModalRoot(document.getElementById("modal-portal"));
}, []);
return (
<>
{modalRoot
? createPortal(
<div className="bg-black bg-opacity-20 backdrop-blur-md modal-positon">
{render(type)}
</div>,
modalRoot,
)
: null}
</>
);
};
const render = (type: string) => {
switch (type) {
case "email":
return <SignupEmailCheckBox />;
}
};
export default ModalPortal;
// store with zustand
import { create } from "zustand";
interface ModalState {
isExpended: boolean;
type: string;
expendModal: (type: string) => void;
closeModal: () => void;
}
export const useModalStore = create<ModalState>((set) => ({
isExpended: false,
type: "email",
expendModal: (type) =>
set((state) => ({ ...state, isExpended: true, type: type })),
closeModal: () => set((state) => ({ ...state, isExpended: false })),
}));
// provider
"use client";
import ModalPortal from "@/context/modalPortal";
import { useModalStore } from "@/store/modal";
import { PropsWithChildren } from "react";
const ModalProvider = ({ children }: PropsWithChildren) => {
const isExpended = useModalStore((state) => state.isExpended);
return (
<>
{isExpended && <ModalPortal />}
{children}
</>
);
};
export default ModalProvider;
Root Layout에 provider를 위치시키고 usage에서는 zustand의 expendModal을 button의 onClick handler로 사용하는 구조