NextJS를 이용하여 커뮤니티를 만드는 토이프로젝트를 진행하던중 Auth 개발을 한 과정을 포스팅하려고 한다.
JWT_SECRET=my_private_secret
NEXT_PUBLIC_API_URL = http://localhost:3000
// lib/api/index.ts
import Axios from 'axios';
const axios = Axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});
export default axios;
// lib/api/auth.ts
import { UserType } from '../type';
import axios from '.';
interface SignUpAPIBody {
email: string;
name: string;
password: string;
birthday: string;
}
// UserType: 유저에게 정보 전달하는 타입, 패스워드를 뺌
export const signupAPI = (body: SignUpAPIBody) =>
axios.post<UserType>('/api/auth/signup', body);
API를 만드는 과정은 다음과 같다.
// lib/data/user.ts
import { readFileSync, writeFileSync } from 'fs';
import { StoredUserType } from '../type';
// user.json 불러오기
const getList = () => {
const usersBuffer = readFileSync('data/user.json');
const usersString = usersBuffer.toString();
if (!usersString) {
return [];
}
const users: StoredUserType[] = JSON.parse(usersString);
return users;
};
// 이메일 중복 확인
const exist = ({ email }: { email: string }) => {
const users = getList();
return users.some((user) => user.email === email);
};
// 유저 리스트 저장
const write = async (users: StoredUserType[]) => {
writeFileSync('data/user.json', JSON.stringify(users));
};
export default { getList, exist, write };
// POST: /api/auth/signup
export default async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') { // (1)
const { email, name, password, birthday } = req.body;
if (!email || !name || !password || !birthday) {
res.statusCode = 400;
return res.send('필요한 데이터가 없습니다.');
} // (2)
const userExist = Data.user.exist({ email });
if (userExist) {
res.statusCode = 405;
return res.send('이미 가입된 이메일입니다.');
} // (3)
// (4), password를 hash password로 만듦
const hashedPassword = bcrypt.hashSync(password, 8);
// (5)
const users = Data.user.getList();
let userId;
if (users.length === 0) {
userId = 1;
} else {
userId = users[users.length - 1].id + 1;
}
const newUser: StoredUserType = {
id: userId,
email,
name,
password: hashedPassword,
birthday,
userImage: '/default_user.png',
};
Data.user.write([...users, newUser]);
// (6-1)사용자 인증 토큰 만들기
const token = jwt.sign(String(newUser.id), process.env.JWT_SECRET!);
// (6-2) 토큰을 쿠키에 저장하기
res.setHeader(
'Set-Cookie',
`access_token=${token}; Path=/; Expires=${new Date(
Date.now() + 60 * 60 * 24 * 1000 * 3,
).toUTCString()}; HttpOnly`,
);
// TS 유틸리티 모듈
const newUserWithoutPassword: Partial<Pick<StoredUserType, 'password'>> =
newUser;
delete newUserWithoutPassword.password;
res.statusCode = 200;
// (6-3) User에게 반환할 때 패스워드는 빼고 반환함
return res.send(newUser);
}
};
form 작성을 위해 react-hook-form을 이용하였다.
또한 회원가입을 하면 유저 정보를 recoil을 사용하여 전역에 저장하도록 하였다.
// components/signup.tsx
/* .. */
const onSubmit = async (data: IForm) => {
if (data.password !== data.checkPassword) {
return setError(
'checkPassword',
{ message: '비밀번호가 일치하지 않습니다.' },
{
shouldFocus: true,
},
);
}
if (
data.password.includes(data.name) ||
data.password.includes(data.email.split('@')[0])
) {
return setError(
'password',
{ message: '비밀번호에 이름 또는 이메일이 포함되어있습니다.' },
{
shouldFocus: true,
},
);
}
try {
const signUpBody = {
email: data.email,
name: data.name,
password: data.password,
birthday: new Date(
`${data.yar}-${data.month.replace('월', '')}-${data.day}`,
).toISOString(),
};
const res = await signupAPI(signUpBody);
// Recoil store에 회원가입한 유저 정보 저장
setLogged({ ...res.data, isLogged: true });
closeModal();
window.location.reload();
} catch (error) {
console.log(error);
}
};
/* .. */
커스텀 Hook인 useModal을 Signup.tsx의 부모 컴포넌트에서 정의하여 Modal을 구현하였다. 그리고 Modal을 닫기 위해 Signup.tsx 내에서 closeModal을 불러와 사용하였는데 되지 않았다. 아마 Portal을 최초로 사용한 곳에서 closeModal을 props로 전달하여 사용해야 하는 것 같다.
회원가입을 하면 새로고침을 하도록 하였는데, recoil을 이용하여 전역에만 저장하면 새로고침 후 그 상태가 초기화된다. 그래서 SessionStorage를 저장하고, 회원가입이 끝나면 새로고침을 통해 로그인된 상태로 이루어지게 하였다.
SessionStorage는 탭을 닫으면 데이터가 삭제된다. localStroage는 탭을 닫아도 여전히 유지된다.
먼저 라이브러리를 설치해야한다.
$ npm i recoil-persist
// atom.ts
const sessionStorage =
typeof window !== 'undefined' ? window.sessionStorage : undefined;
// 따로 설정을 안하면 default 값인 localStroage에 저장이된다.
const { persistAtom } = recoilPersist({
key: 'userSession',
storage: sessionStorage,
});
// user recoil state
export const initialState = atom<UserState>({
key: 'user',
default: {
id: 0,
email: '',
name: '',
birthday: '',
isLogged: false,
userImage: '',
},
effects_UNSTABLE: [persistAtom],
});
가장 애를 먹은 부분이다. 단순히 저장한 후에 useRecoilValue로 사용하면 될 줄 알았는데, hydration fail이 발생했다.
SSR(server-side rendering)방식으로 서버에서 렌더링 시키고 브라우저단 CSR(client-side rendering)에서 렌더링된 것 과 일치하지 않아서 발생하는 에러
sessionStorage는 브라우저(클라이언트)에 존재하는 저장소인데, SSR 방식으로 렌더링을 하는 pre-rendering 과정에서 sessionStorage가 존재하지 않으므로 에러가 발생한다.
Stroage가 pre-rendering 과정에서 실행되지 않도록 하면 된다.
클라이언트의 useEffect은 SSR 끝난 상태에서 실행되므로, useEffect에서 sessionStorage의 값을 가져오도록 하자.
const [userState, setUserState] = useState<any>({});
useEffect(() => {
const sessionState = JSON.parse(
sessionStorage.getItem('userSession') as string,
);
setUserState(sessionState || {});
}, []);
const { isLogged } = userState.user || false;
const { userImage } = userState.user || '';
recoil로 값을 저장했는데 다시 꺼내와 useState에 값을 저장하고 있어서 메모리 낭비가 있을 것 같다. 해결하는 방법은 나중에 또 공부해야겠다.
또한 아직 TS type을 지정하는 실력이 미숙한 것 같다. 노력해야겠다.