https://velog.io/@hi-rachel/토큰세션쿠키의-차이-JWT-토큰-Oauth-보안-고려XSS-CSRF
지난 글에서는 토큰, 세션, 쿠키, JWT 토큰, Oauth, 다양한 보안 관련 사항에 대해 알아봤다.
토큰 저장 위치는 보안을 위해 accessToken은 메모리에, refreshToken은 HTTP Only Cookie(secure)에 저장하기로 했다.
accessToken은 1시간, refreshToken은 7일의 유효 시간으로 정했다.
이번 슬램톡 프로젝트를 하면서 담당 역할을 정할 때 로그인, 회원가입 역할을 맡은 적이 없어서 하고 싶다고 어필해 담당하게 되었다. 서비스 대부분에서 필요하고 중요한 기능이라 하면서 배우고 싶었다.
Github | Slam Talk Site
회원가입 로직 전체는 다음과 같다.
이메일 인증 완료 -> 닉네임 및 비밀번호 입력 -> 회원가입 완료 -> 유저 정보 수집(선택)
리팩토링시 비밀번호 검증이 하나 더 있으면 좋겠다는 의견이 있어 추가했고, Input이 너무 많아질수록 입력하기 싫어지는 화면이 되어서 아래처럼 단계별로 화면을 분리했다.
이렇게 화면을 분리하면 사용자가 각 단계를 한 눈에 들어오게 보고 현재 보이는 화면에 집중할 수 있다.
리팩토링하며 Input이 검증되지 않으면 버튼이 disable 되게 바꿨다. 입력 정보를 확인해달라는 알림을 원래 넣어놓긴 했지만 원래 제출되서는 안되는 버튼을 사전에 미리 막아놓는 방법도 좋다고 생각한다.
처음에는 회원가입 후 다시 로그인을 해야 하는 로직이었는데 백엔드분에게 회원가입 후 바로 로그인이 되는게 훨씬 편하겠다고 의견을 제시해 회원가입 후에도 토큰을 넘겨줘 바로 로그인이 가능해졌다.
단계별로 화면을 분리하니 이미 이메일 인증 화면을 넘어갔는데 2번째 화면에서 가입 완료를 눌렀을 때 중복 이메일 에러 메시지가 나와서 첫 화면에서 이메일 인증시 중복 유저 검증을 같이 하는 쪽으로 개선하면 좋겠다.
리팩토링하면서 isinValid 변수명이 잘 안읽혀서 isValid로 변수명을 바꾸고 로직을 변수명에 맞춰 다 반대로 바꾸었다.
나중에 원티드 프론트엔드 챌린지 특강 코드 컨벤션 내용 중 긍정이 더 잘읽혀서 if, else에서도 앞에 둔다는 것을 보고 리팩토링 잘했구나 느꼈다.
현재 Next.js 14 app router를 사용하고 있다. 회원가입, 로그인 관련 폴더가 많이 생겨 (auth) 폴더로 묶어 관리해주고 있다.
"use client";
import React, { useState } from "react";
import EmailValidation from "./components/EmailValidation";
import NicknamePassword from "./components/NicknamePassword";
const SignUp = () => {
const [emailValidate, setEmailValidate] = useState(false);
const [validEmail, setValidEmail] = useState("");
const handleEmailValidate = (email: string) => {
setEmailValidate(true);
setValidEmail(email);
};
return (
<>
<title>슬램톡 | 회원가입</title>
<div className="flex h-full w-full flex-col flex-wrap gap-2 p-4 md:flex-nowrap">
{!emailValidate ? (
<EmailValidation onEmailValidate={handleEmailValidate} />
) : (
<NicknamePassword validEmail={validEmail} />
)}
</div>
</>
);
};
export default SignUp;
import React, { useMemo, useState } from "react";
import {
Button,
Input,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from "@nextui-org/react";
import axiosInstance from "@/app/api/axiosInstance";
import { validateEmail } from "@/utils/validations";
import { IoChevronBackSharp } from "react-icons/io5";
import { useRouter } from "next/navigation";
interface EmailValidateProps {
onEmailValidate: (email: string) => void;
}
const EmailValidation: React.FC<EmailValidateProps> = ({ onEmailValidate }) => {
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [sendCode, setSendCode] = useState(false);
const [msg, setMsg] = useState("");
const { isOpen, onOpen, onClose } = useDisclosure();
const isEmailValid = useMemo(() => {
if (!email) return true;
return validateEmail(email);
}, [email]);
const handleValidateEmailCode = async () => {
if (!email) {
setMsg("이메일을 입력해 주세요.");
onOpen();
return;
}
if (!code) {
setMsg("인증 코드를 입력해 주세요.");
onOpen();
return;
}
try {
const response = await axiosInstance.get(
`/api/mail-check?email=${email}&code=${code}`
);
if (response.status === 200) {
setMsg("이메일 인증에 성공했습니다!");
onOpen();
onEmailValidate(email);
}
} catch (error) {
console.log("이메일 인증 실패:", error);
setMsg("이메일 인증에 실패했습니다.");
onOpen();
}
};
const handleSendEmailCode = async () => {
if (!email) {
setMsg("이메일을 입력해 주세요.");
onOpen();
return;
}
try {
const response = await axiosInstance.post("/api/send-mail", {
email,
});
if (response.status === 200) {
setSendCode(true);
setMsg(
"이메일 인증 요청을 보냈습니다. 24시간 안에 인증코드를 입력해 주세요."
);
onOpen();
}
} catch (error) {
setMsg("이메일 인증 요청에 실패했습니다.");
onOpen();
}
};
const router = useRouter();
const handleGoBack = () => {
router.back();
};
return (
<div className="relative h-full w-full">
<div
aria-label="뒤로가기"
role="link"
tabIndex={0}
className="absolute left-0 top-0"
onClick={handleGoBack}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleGoBack();
}
}}
>
<IoChevronBackSharp size={24} />
</div>
<h2 className="text-center text-lg font-semibold">
회원가입 - 이메일 인증
</h2>
<h1 className="mb-8 mt-20 text-2xl font-bold sm:text-xl">
이메일을 인증해주세요.
</h1>
<div className="flex items-center gap-3">
<Input
radius="sm"
isClearable
isRequired
type="email"
labelPlacement="outside"
label="이메일"
value={email}
onValueChange={setEmail}
placeholder="로그인시 필요"
isInvalid={!isEmailValid}
/>
<Button
color="primary"
type="submit"
radius="sm"
className="top-3 w-1/4"
onClick={handleSendEmailCode}
>
인증 요청
</Button>
</div>
<div className="mb-6 mt-3 h-3 text-sm text-danger">
{!isEmailValid && "올바른 이메일을 입력해 주세요."}
</div>
<div className="flex items-center justify-end gap-3">
<Input
radius="sm"
isRequired
isClearable
labelPlacement="outside"
label="인증코드"
placeholder="인증코드 입력"
value={code}
onValueChange={setCode}
/>
{sendCode ? (
<Button
color="primary"
type="submit"
radius="sm"
className="top-3 w-1/4"
onClick={handleValidateEmailCode}
>
확인
</Button>
) : (
<Button
color="primary"
isDisabled
type="submit"
radius="sm"
className="top-3 w-1/4"
onClick={handleValidateEmailCode}
>
확인
</Button>
)}
</div>
<Modal size="sm" isOpen={isOpen} onClose={onClose} placement="center">
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">
이메일 인증
</ModalHeader>
<ModalBody>
<p>{msg}</p>
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={onClose}>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
);
};
export default EmailValidation;
if (!email) return true;
이렇게 아무것도 입력하지 않았을 때 isValid true를 반환해주는 이유는 화면에 처음 들어와서 아직 아무것도 입력하지 않았는데 빨간 에러부터 뜨는걸 방지하기 위해서다. 그래서 일단 input 검증은 통과시켜주고 버튼을 눌렀을 때 입력을 해달라고 알림을 주고 제출이 되지 않게 구현했다.
로그인, 회원가입 화면에서는 무엇보다도 정확한 값이 들어가야 해 검증에 신경썼다.
각 상황에 대한 알림과 에러 메시지를 표시했다.
에러 메세지는 항상 고정된 div 높이 안에 표시해 에러 메세지 존재 여부에 따라 UI가 변동되지 않도록 고정했다.
"use client";
import React, { useMemo, useState } from "react";
import {
Button,
Input,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from "@nextui-org/react";
import { useRouter } from "next/navigation";
import { validateNickname, validatePassword } from "@/utils/validations";
import axiosInstance from "@/app/api/axiosInstance";
import confetti from "canvas-confetti";
import { AxiosError } from "axios";
import { IoChevronBackSharp } from "react-icons/io5";
import { EyeSlashFilledIcon } from "@/app/components/input/EyeSlashFilledIcon";
import { EyeFilledIcon } from "@/app/components/input/EyeFilledIcon";
interface EmailProps {
validEmail: string;
}
const NicknamePassword: React.FC<EmailProps> = ({ validEmail }) => {
const router = useRouter();
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] =
useState(false);
const [nickname, setNickname] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [successSignUp, setSuccessSignUp] = useState(false);
const [msg, setMsg] = useState("");
const { isOpen, onOpen, onClose } = useDisclosure();
const [alert, setAlert] = useState(false);
const isNicknameValid = useMemo(() => {
if (!nickname) return true;
return validateNickname(nickname);
}, [nickname]);
const isPasswordValid = useMemo(() => {
if (!password) return true;
return validatePassword(password);
}, [password]);
const isPasswordSame = useMemo(() => {
if (!confirmPassword) return true;
if (password === confirmPassword) return true;
return false;
}, [password, confirmPassword]);
const canSignUp = useMemo(() => {
if (!nickname || !password || !confirmPassword) return false;
if (isNicknameValid && isPasswordValid && isPasswordSame) return true;
return false;
}, [
nickname,
password,
confirmPassword,
isNicknameValid,
isPasswordValid,
isPasswordSame,
]);
const handleConfetti = () => {
confetti({
particleCount: 100,
spread: 160,
});
};
const handleSignup = async () => {
if (!isPasswordValid || !isNicknameValid) {
setMsg("입력 정보를 확인해주세요.");
onOpen();
return;
}
try {
const response = await axiosInstance.post("/api/sign-up", {
email: validEmail,
password,
nickname,
});
if (response.status === 200) {
localStorage.setItem("isLoggedIn", "true");
setSuccessSignUp(true);
setMsg("감사합니다. 회원가입에 성공했습니다!");
onOpen();
handleConfetti();
}
} catch (error) {
if (error instanceof AxiosError) {
const message =
error.response?.data?.message ||
"죄송합니다. 회원가입에 실패했습니다. 서버 오류 발생.";
setMsg(message);
onOpen();
} else {
console.log(error);
setMsg("죄송합니다. 알 수 없는 오류가 발생했습니다.");
}
}
};
const handleCloseModal = () => {
if (successSignUp) {
const currentUrl = window.location.href;
const domain = new URL(currentUrl).origin;
if (domain === "http://localhost:3000") {
window.location.href = "http://localhost:3000/user-info";
} else {
window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}/user-info`;
}
} else {
onClose();
}
};
const handleGoBack = () => {
router.back();
};
const handleShowAlert = () => {
setAlert(true);
onOpen();
};
const handleCloseAlert = () => {
setAlert(false);
onClose();
};
return (
<>
<div className="relative h-full w-full">
<div
aria-label="뒤로가기"
role="link"
tabIndex={0}
className="absolute left-0 top-0"
onClick={handleShowAlert}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleShowAlert();
}
}}
>
<IoChevronBackSharp size={24} />
</div>
<h2 className="text-center text-lg font-semibold">
회원가입 - 닉네임, 비밀번호
</h2>
<h1 className="mb-8 mt-14 text-2xl font-bold sm:text-xl">
닉네임, 비밀번호를 입력해주세요.
</h1>
<div className="flex">
<Input
radius="sm"
isRequired
label="닉네임"
maxLength={13}
minLength={2}
labelPlacement="outside"
value={nickname}
onValueChange={setNickname}
isClearable
placeholder="특수 문자 제외 2자 이상 13자 이하"
isInvalid={!isNicknameValid}
/>
</div>
<div className="my-3 h-3 text-sm text-danger">
{!isNicknameValid &&
"닉네임은 특수 문자 제외 2자 이상 13자 이하이어야 합니다."}
</div>
<div className="mt-6 flex">
<Input
radius="sm"
isRequired
type={isPasswordVisible ? "text" : "password"}
labelPlacement="outside"
label="비밀번호"
maxLength={16}
placeholder="영문, 숫자, 특수문자 포함 8자 이상 16자 이하"
endContent={
<button
className="focus:outline-none"
type="button"
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
>
{isPasswordVisible ? (
<EyeSlashFilledIcon className="pointer-events-none text-2xl text-default-400" />
) : (
<EyeFilledIcon className="pointer-events-none text-2xl text-default-400" />
)}
</button>
}
value={password}
onValueChange={setPassword}
isInvalid={!isPasswordValid}
/>
</div>
<div className="my-3 h-3 text-sm text-danger">
{!isPasswordValid &&
"비밀번호는 영문, 숫자, 특수문자를 포함한 8자 이상 16자 이하"}
</div>
<div className="my-6 flex">
<Input
radius="sm"
isRequired
type={isConfirmPasswordVisible ? "text" : "password"}
labelPlacement="outside"
label="비밀번호 확인"
maxLength={16}
placeholder="영문, 숫자, 특수문자 포함 8자 이상 16자 이하"
endContent={
<button
className="focus:outline-none"
type="button"
onClick={() =>
setIsConfirmPasswordVisible(!isConfirmPasswordVisible)
}
>
{isConfirmPasswordVisible ? (
<EyeSlashFilledIcon className="pointer-events-none text-2xl text-default-400" />
) : (
<EyeFilledIcon className="pointer-events-none text-2xl text-default-400" />
)}
</button>
}
value={confirmPassword}
onValueChange={setConfirmPassword}
isInvalid={!isPasswordSame}
/>
</div>
{canSignUp ? (
<Button
size="lg"
radius="sm"
color="primary"
onClick={handleSignup}
className="mt-6 w-full"
>
가입 완료
</Button>
) : (
<Button
isDisabled
size="lg"
radius="sm"
color="primary"
onClick={handleSignup}
className="mt-6 w-full"
>
가입 완료
</Button>
)}
</div>
{alert ? (
<Modal
size="sm"
isOpen={isOpen}
onClose={handleCloseAlert}
placement="center"
>
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">알림</ModalHeader>
<ModalBody>
<p>
지금 뒤로가기 하면 처음부터 다시 입력해야 합니다. 정말 뒤로
가시겠습니까?
</p>
</ModalBody>
<ModalFooter>
<Button
color="danger"
variant="light"
onPress={handleCloseAlert}
>
취소
</Button>
<Button color="primary" onPress={handleGoBack}>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
) : (
<Modal
size="sm"
isOpen={isOpen}
onClose={handleCloseModal}
placement="center"
>
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">
회원가입
</ModalHeader>
<ModalBody>
<p>{msg}</p>
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={handleCloseModal}>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)}
</>
);
};
export default NicknamePassword;
2단계에서 뒤로가기 하면 변경사항이 지워짐에 대한 알림을 띄운다.
회원가입이 성공하면 canvas-confetti을 이용해 축하 폭죽을 터트린다.
export const validateNickname = (nickname: string) => {
const regex = /^[A-Za-z0-9가-힣]+$/;
return regex.test(nickname);
};
export const validateEmail = (email: string) =>
/^[A-Z0-9._%+-]+@[A-Z0-9.-]+.[A-Z]{2,4}$/i.test(email);
export const validatePassword = (password: string) =>
/^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^&*])[A-Za-z0-9!@#$%^&*]{8,16}$/.test(
password
);
닉네임, 비밀번호는 정규 표현식을 이용해 검증한다.
검증 코드는 재사용성을 위해 utils 폴더에 validate.ts 파일로 따로 분리했고 useMemo를 이용해 각 Input에 해당하는 value 값이 변할 때만 재검사가 이뤄지도록 최적화했다.
프로젝트 기획, 설계시 다른 프론트 팀원이 Funnel 패턴이라는게 있다고 알려주셨고 영상을 보고 나도 이번 프로젝트에 꼭 써보고 싶었다.
원래 로그인하면 간단 설문조사 후 추천 서비스로 라우팅 시켜주는 페이지도 만들까 고려해봤지만 메인과 푸터로 바로 다 노출되는 서비스라 굳이 안내를 넣어줄 필요가 없었다.
적용해볼 수 있는 곳을 고민했고 각 2단계밖에 되지 않지만 회원가입과 유저 정보 수집에 적용했다.
'use client';
import React, { useState } from 'react';
import axiosInstance from '@/app/api/axiosInstance';
import { useRouter } from 'next/navigation';
import { Progress } from '@nextui-org/react';
import LocalStorage from '@/utils/localstorage';
import UserSkill from './components/UserSkill';
import UserPosition from './components/UserPosition';
const SignUpProcess = () => {
const router = useRouter();
const isLoggedIn = LocalStorage.getItem('isLoggedIn');
const [currentStep, setCurrentStep] = useState<'skill' | 'position'>('skill');
const [selectedSkill, setSelectedSkill] = useState<string | null>(null);
const [selectedPosition, setSelectedPosition] = useState<string | null>(null);
if (isLoggedIn === 'false') {
router.push('/login');
}
const sendUserInfo = async () => {
try {
if (selectedSkill !== null && selectedPosition !== null) {
await axiosInstance.patch('/api/user/update/info', {
basketballSkillLevel: selectedSkill,
basketballPosition: selectedPosition,
});
}
} catch (error) {
console.error('정보 전송 실패:', error);
}
router.push('/');
};
const goToNextStep = async () => {
if (currentStep === 'skill') {
setCurrentStep('position');
} else {
sendUserInfo();
}
};
const handleSkillSelect = (skill: string) => {
const skillMapping: Record<string, string | null> = {
고수: 'HIGH',
중수: 'LOW',
하수: 'MIDDLE',
입문: 'BEGINNER',
};
const updatedSkill = skillMapping[skill];
setSelectedSkill(updatedSkill);
if (selectedSkill !== null) {
goToNextStep();
}
};
const handlePositionSelect = async (position: string) => {
const positionMapping: Record<string, string | null> = {
가드: 'GUARD',
포워드: 'FORWARD',
센터: 'CENTER',
몰라요: 'UNDEFINED',
'이것저것 해요': 'UNSPECIFIED',
};
const updatedPosition = positionMapping[position];
setSelectedPosition(updatedPosition);
if (selectedPosition !== null) {
goToNextStep();
}
};
return (
<div>
<Progress
isStriped
color="primary"
aria-label="Loading..."
value={currentStep === 'skill' ? 0 : 50}
/>
{currentStep === 'skill' && (
<UserSkill onSkillSelect={handleSkillSelect} />
)}
{currentStep === 'position' && (
<UserPosition onPositionSelect={handlePositionSelect} />
)}
</div>
);
};
export default SignUpProcess;
'use client';
import React from 'react';
import { Button } from '@nextui-org/react';
interface UserSkillProps {
onSkillSelect: (skill: string) => void;
}
const skillLevels = ['고수', '중수', '하수', '입문'];
const UserSkill: React.FC<UserSkillProps> = ({ onSkillSelect }) => {
const handleSkillSelect = (selectedSkill: string) => {
onSkillSelect(selectedSkill);
};
return (
<div className="sm:mt-18 mt-24 flex h-full w-full flex-col align-middle">
<h1 className="text-center text-4xl font-bold sm:mb-4 sm:text-3xl">
농구 실력을 알려주세요
</h1>
<div className="flex flex-col gap-7 p-20 sm:p-10">
{skillLevels.map((level) => (
<Button
key={level}
size="lg"
radius="full"
className="bg-gradient-to-tr from-primary to-secondary text-xl font-semibold text-white shadow-lg dark:shadow-gray-600"
onClick={() => handleSkillSelect(level)}
>
{level}
</Button>
))}
</div>
</div>
);
};
export default UserSkill;
'use client';
import React from 'react';
import { Button } from '@nextui-org/react';
interface UserPositionProps {
onPositionSelect: (position: string) => void;
}
const positions = ['가드', '포워드', '센터', '몰라요', '이것저것 해요'];
const UserPosition: React.FC<UserPositionProps> = ({ onPositionSelect }) => {
const handlePositionSelect = (selectedPosition: string) => {
onPositionSelect(selectedPosition);
};
return (
<div className="mt-24 flex h-full w-full flex-col align-middle sm:mt-16">
<h1 className="text-center text-4xl font-bold sm:mb-4 sm:text-3xl">
포지션을 알려주세요
</h1>
<div className="flex flex-col gap-6 p-20 sm:p-10">
{positions.map((pos) => (
<Button
key={pos}
size="lg"
radius="full"
className="bg-gradient-to-tr from-primary to-secondary text-xl font-semibold text-white shadow-lg dark:shadow-gray-600"
onClick={() => handlePositionSelect(pos)}
>
{pos}
</Button>
))}
</div>
</div>
);
};
export default UserPosition;
유저의 편의성을 위해 다양한 로그인 방법을 도입했다.
const handleKakaoLogin = () => {
const kakaoLoginURL = `${process.env.NEXT_PUBLIC_BACKEND_URL}/oauth2/authorization/kakao`;
router.push(kakaoLoginURL);
};
const handleNaverLogin = () => {
const naverLoginURL = `${process.env.NEXT_PUBLIC_BACKEND_URL}/oauth2/authorization/naver`;
router.push(naverLoginURL);
};
const handleGoogleLogin = () => {
const googleLoginURL = `${process.env.NEXT_PUBLIC_BACKEND_URL}/oauth2/authorization/google`;
router.push(googleLoginURL);
};
소셜 로그인은 백엔드에서 Oauth와 스프링 시큐리티를 활용해 구현해 프론트에서는 각 버튼을 누를 때 백엔드 서버에 이 요청을 보내주기만 하면 됐다.
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import LocalStorage from "@/utils/localstorage";
import axiosInstance from "@/app/api/axiosInstance";
const refreshToken = async () => {
try {
const result = await axiosInstance.post("/api/tokens/refresh");
const accessToken = result.headers.authorization;
axiosInstance.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
return accessToken;
} catch (error) {
console.log("Token refresh failed:", error);
throw error;
}
};
const SocialLogin = () => {
const router = useRouter();
const searchParams = useSearchParams();
const login = searchParams.get("loginSuccess");
const firstLoginCheck = searchParams.get("firstLoginCheck");
// https://www.slam-talk.site/social-login?loginSuccess=true&firstLoginCheck=false
if (login === "true") {
refreshToken().then((token) => {
if (token !== null) {
LocalStorage.setItem("isLoggedIn", "true");
if (firstLoginCheck === "true") {
router.push("/user-info");
} else {
const currentUrl = window.location.href;
const domain = new URL(currentUrl).origin;
if (domain === "http://localhost:3000") {
window.location.href = "http://localhost:3000";
} else {
window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}`;
}
}
}
});
} else if (login === "false") {
alert(
"탈퇴한 유저입니다. 같은 계정으로 로그인을 원하시면 탈퇴 7일 이후에 재가입 해주세요."
);
router.push("/login");
}
return null;
};
export default SocialLogin;
'/social-login' 페이지는 소셜 로그인 후 자체 토큰을 발급해줄 장소가 필요해 만든 페이지이다.
소셜 로그인 성공 후 이 페이지로 리다이렉트되면 여기서 토큰 발급 요청을 보내서 자체 토큰을 발급한다.
"use client";
import React, { useMemo, useState } from "react";
import {
Button,
Input,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from "@nextui-org/react";
import { validateEmail, validatePassword } from "@/utils/validations";
import Link from "next/link";
import axiosInstance from "@/app/api/axiosInstance";
import { AxiosError } from "axios";
import { EyeSlashFilledIcon } from "@/app/components/input/EyeSlashFilledIcon";
import { EyeFilledIcon } from "@/app/components/input/EyeFilledIcon";
const EmailLogin = () => {
const [isVisible, setIsVisible] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { isOpen, onOpen, onClose } = useDisclosure();
const [msg, setMsg] = useState("");
const isEmailValid = useMemo(() => {
if (!email) return true;
return validateEmail(email);
}, [email]);
const isPasswordValid = useMemo(() => {
if (!password) return true;
return validatePassword(password);
}, [password]);
const handleLogin = async () => {
if (!email) {
setMsg("이메일을 입력해주세요.");
onOpen();
return;
}
if (!password) {
setMsg("비밀번호를 입력해주세요.");
onOpen();
return;
}
if (!isEmailValid || !isPasswordValid) {
setMsg("입력 정보를 확인해주세요.");
onOpen();
return;
}
try {
const response = await axiosInstance.post("/api/login", {
email,
password,
});
if (response.status === 200) {
const accessToken = response.headers.authorization;
axiosInstance.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
localStorage.setItem("isLoggedIn", "true");
const currentUrl = window.location.href;
const domain = new URL(currentUrl).origin;
if (domain === "http://localhost:3000") {
window.location.href = "http://localhost:3000";
} else {
window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}`;
}
}
} catch (error) {
console.log("로그인 실패:", error);
if (error instanceof AxiosError && error.response?.data.message) {
setMsg(error.response.data.message);
onOpen();
}
}
};
const handleToggleVisibility = () => setIsVisible(!isVisible);
return (
<>
<title>슬램톡 | 로그인</title>
<div className="mt-14 flex h-full w-full flex-col flex-wrap justify-center gap-3 p-4 align-middle md:flex-nowrap">
<h1 className="mb-4 text-2xl font-bold sm:text-xl">
이메일과 비밀번호를 입력해 주세요.
</h1>
<Input
radius="sm"
isClearable
isRequired
type="email"
labelPlacement="outside"
label="이메일"
value={email}
onValueChange={setEmail}
placeholder="이메일"
isInvalid={!isEmailValid}
/>
<div className="mb-3 h-3 text-sm text-danger">
{!isEmailValid && "올바른 이메일을 입력해 주세요."}
</div>
<Input
radius="sm"
isRequired
type={isVisible ? "text" : "password"}
labelPlacement="outside"
label="비밀번호"
placeholder="비밀번호"
endContent={
<button
className="focus:outline-none"
type="button"
onClick={handleToggleVisibility}
>
{isVisible ? (
<EyeSlashFilledIcon className="pointer-events-none text-2xl text-default-400" />
) : (
<EyeFilledIcon className="pointer-events-none text-2xl text-default-400" />
)}
</button>
}
value={password}
onValueChange={setPassword}
/>
<div className="mb-3 h-3 text-sm text-danger" />
{validateEmail(email) && validatePassword(password) ? (
<Button size="lg" radius="sm" color="primary" onClick={handleLogin}>
로그인
</Button>
) : (
<Button size="lg" radius="sm" color="primary" isDisabled>
로그인
</Button>
)}
<div className="mt-4 flex justify-center gap-3 align-middle text-sm text-gray-400">
<Link href="/find-password">
<p>비밀번호 찾기</p>
</Link>
</div>
</div>
<Modal size="sm" isOpen={isOpen} onClose={onClose} placement="center">
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">로그인</ModalHeader>
<ModalBody>
<p>{msg}</p>
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={onClose}>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
};
export default EmailLogin;
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { IoChevronBackSharp } from "react-icons/io5";
const FindPasswordlayout = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const handleGoBack = () => {
router.back();
};
return (
<div className="md:h-100 sm:h-100 relative h-full w-full">
<div
aria-label="뒤로가기"
role="link"
tabIndex={0}
className="absolute left-4 top-0"
onClick={handleGoBack}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleGoBack();
}
}}
>
<IoChevronBackSharp size={24} />
</div>
<h2 className="mt-4 text-center text-lg font-semibold">비밀번호 찾기</h2>
<main>{children}</main>
</div>
);
};
export default FindPasswordlayout;
"use client";
import { validateEmail } from "@/utils/validations";
import { Button, Input } from "@nextui-org/react";
import React, { useMemo, useState } from "react";
const FindPassword = () => {
const [email, setEmail] = useState("");
const isEmailInvalid = useMemo(
() => !validateEmail(email) && email !== "",
[email]
);
const handleSendEmail = () => {
alert(`비밀번호 찾기 구현 예정. 문의해주세요.`);
};
return (
<>
<title>슬램톡 | 비밀번호 찾기</title>
<div className="mt-14 flex h-full w-full flex-col flex-wrap justify-center gap-3 p-5 align-middle md:flex-nowrap">
<h1 className="mb-4 text-2xl font-bold sm:text-xl">비밀번호 재설정</h1>
<Input
radius="sm"
isClearable
isRequired
type="email"
labelPlacement="outside"
label="이메일"
value={email}
onValueChange={setEmail}
placeholder="이메일"
isInvalid={isEmailInvalid}
/>
<div
className={`mb-3 h-3 text-sm text-danger ${
isEmailInvalid ? "visible" : "invisible"
}`}
>
{isEmailInvalid && "올바른 이메일을 입력해 주세요."}
</div>
<Button size="lg" radius="sm" color="primary" onClick={handleSendEmail}>
메일 발송
</Button>
</div>
</>
);
};
export default FindPassword;
"use client";
import { validatePassword } from "@/utils/validations";
import {
Button,
Input,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from "@nextui-org/react";
import React, { useMemo, useState } from "react";
import { getUserData } from "@/services/user/getUserData";
import { useQuery } from "@tanstack/react-query";
import LocalStorage from "@/utils/localstorage";
import { useRouter } from "next/navigation";
import axiosInstance from "@/app/api/axiosInstance";
import { IoChevronBackSharp } from "react-icons/io5";
import { EyeSlashFilledIcon } from "@/app/components/input/EyeSlashFilledIcon";
import { EyeFilledIcon } from "@/app/components/input/EyeFilledIcon";
const ChangePassword = () => {
const router = useRouter();
const isLoggedIn = LocalStorage.getItem("isLoggedIn");
const [isVisible, setIsVisible] = useState(false);
const [password, setPassword] = useState("");
const { isOpen, onOpen, onOpenChange } = useDisclosure();
const [msg, setMsg] = useState("");
if (isLoggedIn === "false") {
router.push("/login");
}
const handleGoBack = () => {
router.back();
};
const { error, data: user } = useQuery({
queryKey: ["loginData"],
queryFn: getUserData,
});
if (error) {
console.log(error);
}
const isPasswordValid = useMemo(() => {
if (!password) return true;
return validatePassword(password);
}, [password]);
const handletoggleVisibility = () => setIsVisible(!isVisible);
if (user) {
const handleChangePassword = async () => {
if (!password) {
setMsg("비밀번호를 입력해주세요.");
onOpen();
return;
}
try {
const response = await axiosInstance.patch(
"/api/user/change-password",
{
email: user.email,
password,
}
);
if (response.status === 200) {
console.log(response);
setMsg("비밀번호 변경에 성공했습니다.");
onOpen();
}
} catch (ChangeError) {
console.log(ChangeError);
setMsg("비밀번호 변경에 실패했습니다. 다시 시도해주세요.");
onOpen();
}
};
return (
<>
<title>슬램톡 | 비밀번호 변경</title>
<div className="relative">
<div
aria-label="뒤로가기"
role="link"
tabIndex={0}
className="absolute left-4 top-4"
onClick={handleGoBack}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleGoBack();
}
}}
>
<IoChevronBackSharp size={24} />
</div>
<h2 className="pt-4 text-center text-lg font-semibold">설정</h2>
<hr className="w-90 my-4 h-px bg-gray-300" />
<div className="flex h-full w-full flex-col flex-wrap justify-center gap-3 p-5 align-middle md:flex-nowrap">
<h1 className="mb-4 text-2xl font-bold sm:text-xl">
비밀번호 변경
</h1>
<Input
radius="sm"
isDisabled
isRequired
type="email"
labelPlacement="outside"
label="이메일"
value={user.email}
placeholder="이메일"
className="mb-3"
/>
<Input
radius="sm"
isRequired
type={isVisible ? "text" : "password"}
labelPlacement="outside"
label="비밀번호"
maxLength={16}
placeholder="영문, 숫자, 특수문자 포함 8자 이상 16자 이하"
endContent={
<button
className="focus:outline-none"
type="button"
onClick={handletoggleVisibility}
>
{isVisible ? (
<EyeSlashFilledIcon className="pointer-events-none text-2xl text-default-400" />
) : (
<EyeFilledIcon className="pointer-events-none text-2xl text-default-400" />
)}
</button>
}
value={password}
onValueChange={setPassword}
isInvalid={!isPasswordValid}
/>
<div className="mb-7 h-3 text-sm text-danger">
{!isPasswordValid &&
"비밀번호는 영문, 숫자, 특수문자를 포함한 8자 이상 16자 이하이어야 합니다."}
</div>
{validatePassword(password) ? (
<Button
size="lg"
radius="sm"
color="primary"
onClick={handleChangePassword}
>
비밀번호 변경
</Button>
) : (
<Button size="lg" radius="sm" color="primary" isDisabled>
비밀번호 변경
</Button>
)}
</div>
</div>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} placement="center">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
비밀번호 변경
</ModalHeader>
<ModalBody>{msg}</ModalBody>
<ModalFooter>
<Button color="primary" onPress={onClose}>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
}
return null;
};
export default ChangePassword;
거의 모든 페이지에서 쓰이는 Header 컴포넌트에 토큰 요청 useQuery 함수를 사용해 메모리에 accessToken이 유지되게 했다.
"use client";
import React from "react";
import Link from "next/link";
import { Anton } from "next/font/google";
import { LuLogIn } from "react-icons/lu";
import { useQuery } from "@tanstack/react-query";
import { postTokenRefresh } from "@/services/token/postTokenRefresh";
import { Avatar, Button, Tooltip } from "@nextui-org/react";
import { getUserData } from "@/services/user/getUserData";
// Anton 폰트 설정
const anton = Anton({ weight: "400", subsets: ["latin"] });
const Header = () => {
const { data: token } = useQuery({
queryKey: ["tokenData"],
queryFn: postTokenRefresh,
});
const { data: user } = useQuery({
queryKey: ["loginData"],
queryFn: getUserData,
});
return (
<div className="sticky top-0 z-30 flex h-[61px] w-full max-w-[600px] items-center justify-between border-b-1 bg-background pl-4">
<div className={`${anton.className} text-2xl`}>
<Link href="/">
<div>SLAM TALK</div>
</Link>
</div>
<div className="flex items-center gap-2 pr-4">
<div>
{token && user ? (
<Link href="/my-page">
<div className="flex items-center gap-2 font-medium">
{user.nickname} (Lv.{user.level})
<Tooltip showArrow content="내 정보 바로가기">
<Avatar
showFallback
name={user.nickname}
size="sm"
alt="마이 페이지"
src={user.imageUrl}
/>
</Tooltip>
</div>
</Link>
) : (
<Link href="/login">
<Tooltip showArrow content="로그인하러 가기" placement="left-end">
<Button
radius="full"
isIconOnly
aria-label="로그인"
variant="light"
>
<LuLogIn aria-label="로그인" size={24} />
</Button>
</Tooltip>
</Link>
)}
</div>
</div>
</div>
);
};
export default Header;
import axiosInstance from "@/app/api/axiosInstance";
import LocalStorage from "../../utils/localstorage";
export const postTokenRefresh = async () => {
const isLoggedIn = LocalStorage.getItem("isLoggedIn");
if (isLoggedIn === "true") {
const result = await axiosInstance.post("/api/tokens/refresh");
const accessToken = result.headers.authorization;
axiosInstance.defaults.headers.common.Authorization = `Bearer ${accessToken}`;
return accessToken;
}
return null;
};
토큰을 받으면 axiosInstance 헤더에도 넣어준다.
import axios, { AxiosError } from "axios";
const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_BACKEND_URL,
headers: { "Content-Type": "application/json" },
withCredentials: true,
timeout: 10000,
});
let isRefreshing = false;
let failedRequests: (() => void)[] = [];
const processFailedRequests = (token?: string) => {
failedRequests.forEach((callback) => {
if (token) {
callback();
}
});
failedRequests = [];
};
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest.retryFlag) {
originalRequest.retryFlag = true;
if (isRefreshing) {
return new Promise((resolve) => {
failedRequests.push(() => {
originalRequest.retryFlag = true;
resolve(axiosInstance(originalRequest));
});
});
}
isRefreshing = true;
originalRequest.retryFlag = true;
try {
const refreshResponse = await axiosInstance.post(
"/api/tokens/refresh",
{}
);
const newAccessToken = refreshResponse.headers.authorization;
axiosInstance.defaults.headers.common.Authorization = `Bearer ${newAccessToken}`;
processFailedRequests(newAccessToken);
return await axiosInstance(originalRequest);
} catch (refreshError) {
if (
refreshError instanceof AxiosError &&
refreshError.response?.status === 401
) {
console.log("토큰 재발급 실패");
try {
const response = await axiosInstance.post("/api/logout");
if (response.status === 200) {
alert("로그아웃되었습니다!");
if (typeof window !== undefined) {
localStorage.setItem("isLoggedIn", "false");
window.location.href = "/login";
}
}
} catch (logoutError) {
console.log("로그아웃 실패: ", logoutError);
alert(
"죄송합니다. 로그아웃에 실패했습니다. 잠시 후 다시 시도해 주세요."
);
}
}
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default axiosInstance;
백엔드에서 401 에러를 받으면 토큰 재발급 요청을 보내고 한 번 더 401을 받으면 로그아웃 해달라고 해서 axiosInstance.interceptors.response로 전체 응답에 해당 설정을 해놨다. 401을 받으면 refreshToken이 만료되지 않는 한 재발급이 돼서 로그인 유지가 가능하다.
로그인 관련해서는 잘 모르는 상태였는데 프로젝트 하면서 정말 많이 배웠다.
중간에 메모리에 액세스 토큰을 저장 해야 하는데 잘 안되서 이 부분이 제일 어려웠다.. 🤯
위에는 최종 성공한 로그인 코드이고 중간에 여러 문제를 겪으며 해결한 과정을 설명하려고 한다.
문제1
accessToken을 메모리에 저장하고자 했지만 계속 로그인이 풀렸다. 이때 처음 API를 연결해 보면서 로그인이 풀리는 이유가 토큰 재발급 API에 있는지, refreshToken인지 accessToken인지 zustand 코드를 잘못 짠 건지 등 많은 고려요소 중에서 어떤 문제인지 정확히 파악하는데까지 시간이 걸렸고 리액트는 새로고침시 원래 모든 상태값이 날아간다는 사실을 뒤늦게 알아 이 원인을 알아내는데도 시간이 걸렸다. 이때 관련 개념들을 다시 공부했다.
문제1 해결 방법, 개선 피드백
'새로고침시 원래 모든 상태값이 날아가면 토큰값을 어떻게 유지하지?' 이 문제를 해결하지 못해 로컬 스토리지로 accessToken 저장 위치를 변경했는데 이후 멘토링에서 프로젝트 멘토님이 보안상 로컬 스토리지에 저장하는 건 좋지 못하고 zustand나 react-query를 활용하는 것을 추천해 주셨다.
로컬 스토리지에 저장해 이미 로그인에 성공했지만 보안적으로 좀 더 개선시키기 위해 accessToken을 원래대로 메모리에 저장하도록 시도했고 처음에는 원래 쓰려고 했던 상태관리툴 zustand를 사용했다. 그리고 새로고침마다 날아가는 상태값에 대한 해결방법으로 최상단, 전역에서 적용되는 layout 파일에 useEffect를 사용해 새로고침마다 토큰 재발급 요청을 보냈다.
https://github.com/SlamTalk/slam-talk-frontend/pull/64/files
const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
setAccessToken: (token) => set({ accessToken: token }),
}));
import axiosInstance from "./axiosInstance";
export const fetchAccessToken = async (
setAccessToken: (accessToken: string | null) => void
) => {
try {
const response = await axiosInstance.patch("/api/tokens/refresh");
if (response.status === 200) {
const newAccessToken = response.headers.authorization;
setAccessToken(newAccessToken);
axiosInstance.defaults.headers.common.Authorization = `Bearer ${newAccessToken}`;
}
} catch (error) {
console.log("Failed to fetch access token:", error);
}
};
const { accessToken, setAccessToken } = useAuthStore();
useEffect(() => {
fetchAccessToken(setAccessToken);
}, []);
문제2
useEffect를 쓰니 또 다른 문제가 발생했는데 strict 환경에서 useEffect 안에 쓴 토큰 요청이 2번씩 가는 문제였다.
axiosInstance 설정에서 401을 받으면 토큰 재발급을 요청하기 때문에 로그인 유지는 되었지만 useEffect로 재발급 요청이 2번씩 짧은 간격으로 보내지면 RTR 백엔드 로직에서 간헐적으로 refreshToken이 제대로 업데이트가 안되서 401 에러를 보냈다. 오류를 받으면 다시 토큰 재발급 요청이 가며 총 API 요청이 너무 많이 일어났다.
문제2 해결 방법
이후 react-query를 프로젝트에 도입하며 react-query를 활용해 요청하는 코드로 수정했다. 지금은 매 새로고침마다 1번씩만 요청이 간다.
위에 토큰 관리에서 최종 코드를 확인할 수 있다.
zustand 사용해 로컬 변수에 저장(실패) -> accessToken 로컬 스토리지 저장(해결) -> zustand 사용해 로컬 변수에 저장(보안 이슈로 다시 시도, 성공했지만 요청이 너무 많이 가는 문제) -> react-query로 변경(해결)
정리하면 위와 같이 정말 여러번 로직을 변경했는데 총 3가지 방법으로 로그인을 구현해볼 수 있었다..
처음 구현하는 로그인, 회원가입이라 어떻게 해야할지 몰라 여러 번 로직을 바꾸게 되었는데 한 번 해봤으니 이제 각각의 장단점을 알게 됐고 다음부터 좀 더 빠르고 올바르게 구현 할 수 있게 되었다.
멘토님도 로컬 스토리지로 저장하는게 보안 이슈가 있지만 아예 틀린 방식은 아니라고 하셨고 로그인 구현하는데 워낙 다양한 방법이 존재한다는 걸 배웠다. (url params에 암호화해서 토큰을 주고 받는 방법도 봤다.)
그래도 처음에 좀 더 자료 조사를 많이 하고 개발에 들어갔으면 좋았을 것 같다. 시간이 촉박한 프로젝트가 아니였다면 백엔드와 협업해야 하는 부분도 정말 많고 중요한 부분이니 제대로 설계를 하고 들어가는게 제일 베스트라고 생각한다.
다행인건 로직을 변경하는 과정을 2주 안에 처리했고 프로젝트 자체가 로그인 안하고 조회가 가능하기 때문에 다른 팀원들이 로그인 때문에 크게 작업을 못하는 상황이 발생하진 않았다.
백엔드분과 협업하는 부분도 많았는데 문제가 발생하면 원인 파악이 어려워 같이 찾아보는 과정이 필요하고, 하나를 정하더라도 서로 의견을 맞춰야 했다. 물론 다른 부분도 항상 백엔드분과 타입부터 맞춰가며 협업해야 할 부분들이 많지만 로그인은 더 많다고 느낀다.
예를 들어서 accessToken을 응답 헤더로 받을지, 바디로 받을지 결정해야 한다. 이 부분도 백엔드분은 카카오 문서 예시를 보고 바디로 주고자 하셨는데, 나는 찾아보니 관행적으로 토큰은 헤더를 통해 가져오고 header Authorization에 담으면 인증 메타 데이터 의미도 있다는 점을 말씀 드렸다. 이후 백엔드분이 HTTP 문서를 읽어보시고 헤더로 주시겠다고 해 의견을 맞췄다.
의견이 다를 때는 감정을 빼고 최대한 논리적인 근거를 가져와서 커뮤니케이션 해야 한다는 점을 배웠다.
같이 정말 고생 많이 했는데 서로 성장했다고 생각한다. 🤝
벨로그 글을 쓰던 중 날라간 경험을 하고 계속 백업 문서도 만들면서 작성해주고 있는데 또다른 문제를 발견했다..🥲
img 태그의 width height이 미리보기에는 잘 반영되어 보이는데 실제 글에는 적용이 안된다.
-> 이 문제로 글 작성시 최대한 사진 2개로 맞춰서 올렸다.
이 문법은 잘 적용된다.