개인 프로젝트를 진행하면서 공부한 내용을 포스팅하기에, 어느부분은 정확하지 않을 수 있습니다..
예전에 기본 셋팅을 마무리 하고, Login 페이지를 설계 및 구현하기 위해 고려해야 할 부분들에 대해서 생각해보았다.
- 기본적으로 회원가입을 통해서 가입이 가능해야 한다.
- 로그인이 되었다면, 로그인 페이지로는 이동이 불가능해야하며, 반대로 로그인되지 않았다면 메인페이지로 이동이 되지 말아야 한다.
- 아이디를 분실하였다면 그 아이디를 찾을 수 있어야 한다.
- 패스워드를 분실할 경우, 인증 절차를 통해 새로운 비밀번호 생성이 가능하여야 한다.
- 우리가 주로 사용하는 구글이나 카카오 중 한 군데를 선택하여 로그인을 구현하여야 한다.
고려할 부분에 대해 한번에 구현하지는 못하겠지만, 우선적으로 기본적인 회원가입과 로그인, 그리고 로그인 여부에 따른 페이지 인가를 설정해주려고 하였다.
나머지 구현사항에 대해서는 추후 진행하기로 하고, 일단 로그인과 회원가입의 레이아웃을 설정하였다.
간략한 Login 구현 과정
로그인과 회원가입의 레이아웃은 거진 동일하면서, 2개의 페이지를 만들 이유를 느끼지 못해 2개의 컴포넌트로 나누어서 특정 상태값의 불리언 값에 따라 컴포넌트 렌더링이 선택되도록 설계를 하였다.
const Auth = () => {
const [gotoAccount, setGotoAccount] = useState<boolean>(false);
const toggleGotoAccount = () => {
setGotoAccount(prev => !prev);
};
return (
<Container>
<Section>
{gotoAccount ? <Signup toggleGotoAccount={toggleGotoAccount} /> : <Login toggleGotoAccount={toggleGotoAccount} />}
<ImageBox>
<Image alt='todo' src={todoImage} width={500} height={500} placeholder='blur' />
</ImageBox>
</Section>
</Container>
);
};
gotoAccount 라는 상태값이 true 라면 Signup 컴포넌트를 렌더링하며, 아니라면 Login 컴포넌트를 렌더링 한다. 추후에 이렇게 작업하였던 것이 옳았는지 아니면 다시 수정이 필요한지까지는 아직 모르겠지만 현재까지는 의도한 대로 작동되어서 그대로 이어나가고 있다.
로그인 컴포넌트와 회원가입 컴포넌트를 간단하게 살펴보면 다음과 같다.
const Login = (props: SIprops) => {
const dispatch = useDispatch();
const { toggleGotoAccount } = props;
const [email, setEmail, onChangeEmail] = useInput('');
const [password, setPassword, onChangePassword] = useInput('');
const emailRegExp = /^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]{2,3}$/;
const passwordRegExp = /^.{8,}$/;
// 이메일과 패스워드를 검증하게 된다. 위의 정규화 조건에 따라
const isEmailValid = emailRegExp.test(email);
const isPasswordValid = passwordRegExp.test(password);
// 로그인 시도 (dispatch)
const onSubmit = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
dispatch(loginRequestAction({ email: email, password: password }));
};
return (
<>
<LoginBox>
<LeftTopBrand>
<span>@Yelihi</span>
</LeftTopBrand>
<LoginSection>
<LoginForm>
<h1>Welcome!</h1>
<span>하루의 스케줄을 관리해 보세요.</span>
<input type='email' value={email} onChange={onChangeEmail} placeholder='Email' />
<div>{email && !isEmailValid && `이메일이 올바르지 않습니다`}</div>
<input type='password' value={password} onChange={onChangePassword} placeholder='Password' />
<div>{password && !isPasswordValid && `비밀번호가 올바르지 않습니다`}</div>
<Button color='black' onClick={onSubmit} disabled={!(isEmailValid && isPasswordValid)}>
Sign in
</Button>
<Button color='' onClick={toggleGotoAccount} disabled={false}>
Create account
</Button>
</LoginForm>
</LoginSection>
</LoginBox>
</>
);
};
export default Login;
export interface SIprops {
toggleGotoAccount: () => void;
}
const Signup = (props: SIprops) => {
const dispatch = useDispatch();
const { signInDone } = useSelector((state: RootState) => state.user);
const { toggleGotoAccount } = props;
const [isCollect, setIsCollect] = useState<boolean>(false);
const [name, setName, onChangeName] = useInput('');
const [email, setEmail, onChangeEmail] = useInput('');
const [password, setPassword] = useState<string>('');
const [passwordCheck, setPasswordCheck] = useState<string>('');
// 비밀번호를 제대로 기입했는지 확인하기
const onChangePassword = (e: React.ChangeEvent<HTMLInputElement>) => {
setPassword(e.target.value);
};
// 비밀번호 확인 절차
const onChangePasswordCheck = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setPasswordCheck(e.target.value);
setIsCollect(e.target.value === password);
},
[password]
);
// 최종적으로 서버에 dispatch 하기
const onSubmit = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (password !== passwordCheck) {
setIsCollect(false);
}
dispatch(signinRequestAction({ email: email, nickname: name, password: password, src: '' }));
},
[email, password, passwordCheck, name]
);
// 만약 입력값이 제대로 서버에 등록이 되었다면, 다시 로그인 컴포넌트로 이동한다.
// 컴포넌트 업데이트
useEffect(() => {
if (signInDone) {
toggleGotoAccount();
}
}, [signInDone]);
const emailRegExp = /^[a-zA-Z0-9+-_.]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]{2,3}$/;
const passwordRegExp = /^.{8,}$/;
const isEmailValid = emailRegExp.test(email);
const isPasswordValid = passwordRegExp.test(password);
return () // 생략
}
요약해보자면 이메일의 양식을 따르면서 8자리 이상의 비밀번호가 회원가입의 조건이자 로그인창의 입력값의 조건이다. 인풋창의 입력값들은 모두 dispatch 를 통해 action 으로 전달되며, reducer 에서 처리한다.
reducer에서 처리되기 전 미들웨어인 saga를 통해 서버로의 요청에 대한 성공 및 실패 응답을 분기화하여 reducer에 action을 보내준다.
// 대표적으로 로그인 부분을 가져왔다.
function logInAPI(data: UserInfo) {
return axios.post('/user/login', data);
}
function* logIn(action: AnyAction) {
try {
console.log('saga logIn');
const result: AxiosResponse<LoginSuccess> = yield call(logInAPI, action.data);
yield put({
type: t.LOGIN_SUCCESE,
data: result.data,
});
} catch (err: any) {
console.error(err);
yield put({
type: t.LOGIN_FAILURE,
error: axios.isAxiosError(err) ? err.response?.data : err.response.data,
});
}
}
데이터에는 입력한 이메일과 패스워드가 들어있으며, 이를 기반으로 서버에서 가입이 되어있는 유저인지 확인을 한 뒤, 이에대한 응답을 보내주게 된다.
로그인이 성공이라면 이에 대해 쿠키에 세션 ID 를 담아서 보내주게 되는데, 이 ID 를 기반으로 추후에 인가가 필요한 페이지에 접속할 수 있게된다. 즉, 로그인이 되어야지만 이용할 수 있는 서비스를 이용하게 하며 만일 로그인이 되어있지 않다면 로그인 창으로 전환시키려고 할 때 사용이 될 것이다.
세션 ID 를 express 에서 다루기 위해서 express-session 과 쿠키 전달을 위해 cookie-parser 라이브러리를 활용하였다. 좀 더 편리하게 작업을 할 수 있도록 도와주는 라이브러리이며, 사용하기 위해선 약간의 설정이 필요하다. (CORS, Credential)
// app.js
const cors = require("cors");
const session = require("express-session");
const cookieParser = require("cookie-parser");
// cors 설정과 쿠키 설정
app.use(
cors({
origin: true,
credentials: true,
})
);
// session 설정
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(
session({
saveUninitialized: false,
resave: false,
secret: process.env.COOKIE_SECRET,
})
);
동시에 프론트엔드 서버에서도 쿠키 설정을 해주자.
//saga/index.tsx
axios.defaults.withCredentials = true;
쿠키 설정은 벡엔드 서버와 프론트엔드 서버 두 군데서 설정을 해주어야 요청과 응답시 자동으로 쿠키가 전송이 되며, 브라우저에 쿠키가 저장이 된다. 사실 브라우저이기에 쿠키를 활용하는 것이고 안드로이드나 ios 의 경우 token 을 이용하여 인증 절차를 거치게 된다. 이 토큰에 전자서명과 유저정보를 담은 JWT 역시 어플리케이션과 브라우저 가리지않고 사용되어지고 있다.
제대로 가입한 이메일과 비밀번호가 서버에 전송이 되었다면, 서버 역시 상태 코드 200 과 함께 미리 설정해둔 성공 응답메세지를 전송할 것이다. 브라우저 내 네트워크 탭에서 응답 상태를 확인하고, 쿠키가 제대로 브라우저에 저장되었는지 확인이 되면 성공적으로 로그인이 이루어진 것이다. 이제 서버에서는 로그인 된 유저의 세션 id 를 세션 ID DB 에 저장해놓고 있을 것이다.
인증된 유저만 메인 페이지에 접근 할 수 있도록 하자.
Next.js 에서는 따로 Routing 설정을 하지 않아도, page 폴더 내에서 생성시 자동으로 path 가 설정이 된다. index.tsx 의 경우 '/' 로 자동 설정이 되어있으며, 나머지 page 의 경우 '하위폴더/컴포넌트/...' 식으로 자동 설정된다.
어떠한 메뉴나 글자를 클릭하여 이동시킬 경우 주로 <Link /> 태그를 사용하여 간단하게 이동시킬 수 있으며, 그 외 함수내에서 페이지를 이동시킬 때는 Router.push('path주소') 를 이용하면 된다. 기존 리엑트만을 활용할 때보다 페이지 이동방식이 좀 더 간편화되었다. (그렇다고 큰 차이가 나는것 같지는 않다...)
redux 를 통해서 상태값을 변화시키고 있기에, 나는 페이지 이동 로직을 reducer 에서 구현하였다.
// 생략
case t.LOGIN_SUCCESE: {
draft.logInLoading = false;
draft.logInDone = true;
draft.logInError = null;
draft.me = action.data;
alert(`반갑습니다! ${action.data.nickname}님!`);
Router.push('/closet');
break;
}
// 생략
로그인이 성공하게 되면, 데이터와 함께 알림창을 띄우면서 페이지가 이동이 된다.
로직상 로그인이 되면 페이지가 이동이 되니, 적합하다 생각이 들 수 있지만, 로그인이 되지 않은 상태에서도 주소창에 '/closet' 을 입력하게 되면 페이지 이동이 된다는 점에서 문제점이 있었다. 프로젝트를 기획할 때 /closet 페이지는 마치 개인 대시보드 처럼 활용될 페이지이기 때문이다. 앞으로 로그인이 인증된 사용자에게 서버는 유저가 게시한 각종 데이터들을 전달하게 될 것이기에, 로그인 되지 않는 사람은 이 페이지에 접근이 되어서는 안된다.
우선적으로 로그인여부를 확인하는 방법으로 logInDone 상태값을 활용해보자.
로그인이 성공될경우 logInDone 상태값이 true 로 변경이 된다. 이 조건을 활용해서 컴포넌트를 업데이트 시키면 손쉽게 re-direct 를 시킬 수 있다고 생각했다.
const closet = () => {
const { logOutDone } = useSelector((state: RootState) => state.user);
// useEffect 를 통해서 상태값 logInDone 의 상황에 따라 페이지 전환을 시도한다.
useEffect(() => {
if (!logOutDone) {
Router.push('/auth');
}
}, [logOutDone]);
return (
<AppLayout>
<div>실험중</div>
</AppLayout>
);
};
실제로 로그인 되지 않을 경우 다시 로그인 페이지로 전환이 되었기에 의도적으로 작동은 한것처럼 보인다.
하지만 useEffect의 발동은 우선 closet 이라는 컴포넌트가 랜더링이 된 이후(페인팅까지) 작동을 하게 된다. 그러니깐 로그인이 되지 않은 사람도 잠깐은 closet 페이지가 브라우저에 띄어지고, 그 다음 로그인 페이지로 전환이 된다.
우선 보기에도 안좋다. 아에 못들어갈꺼면 처음부터 막혀야 되는데 이런 부자연스러운 이동은 의도한 페이지 전환이 아니었다. 거기에 더해 지금은 그저 실험중이라는 문구 하나가 렌더링 될 뿐이지만, 만일 렌더링 과정에서 로그인된 유저 데이터가 사용된다면(실제로 그럴것이고..) useEffect 가 발동되기 전에 렌더링 과정에서 문제가 발생하여 오류 페이지가 뜨게 될 것이다.
일단 이 방식은 아닌것 같고, 예전에 리엑트로 작업했을 때는 라우터에서 페이지전환을 작업했던 것으로 기억이.. 나지만 지금은 Next.js 를 활용하고 있으니, 페이지전환에 대해서 방법을 찾아보았다.
Re-Directs
Next.js 에서는 re-direct 기능을 페이지 렌더링 전 단계에서 부터 설정할 수 있다.
https://nextjs.org/docs/api-reference/next.config.js/redirects
next.config.js: Redirects | Next.js
Add redirects to your Next.js app.
nextjs.org
어떠한 페이지의 path 로 접근하면 지정한 다른 path 로 이동이 가능하다. 기본적으로 next.config.js 에서 설정을 할 수 있으며, 각 페이지 마다 getServerSideProps, getStaticProps 내부에 설정이 가능하다.
첫 페이지를 index.tsx 에 작업하질 않았기에(아직은 index.tsx 를 사용하고 있지 않다), 첫 페이지 렌더링이 로그인 페이지였으면 좋겠다고 판단을 하였다. 그래서 직접 경로를 수정하였다.
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
compress: true,
async redirects() {
return [
{
source: '/',
destination: '/auth',
permanent: false,
},
];
},
};
module.exports = nextConfig;
redirect 함수에 return 값으로 설정이 가능하며, return 이 배열이기에 여러 페이지에 대해 설정이 가능하다. 전역적으로 설정이기에 source 가 필요하고 출발점이다. 전환될 페이지는 destination 에 작성해주면 된다. 마지막 permanent 의 경우 boolean 값으로 설정이 되는데, true 인 경우는 한마디로 이 설정을 캐시화 시켜 영원히 적용하겠다는 설정이다. 보통은 false 로 하지만 지금 내 상황같은 경우 true 로 해도 문제될건 없을 것 같다.
이렇게 Next.js 에서 제공하는 redirect 기능을 활용하면 각 페이지에 대해서 인증 전환을 할 수 있도록 설정할 수 있겠다고 생각했다. 다시 정리해보면 구현목표는 다음과 같다.
- 로그인이 되지 않았다면 /closet 으로 접근해도 /auth 로 redirect 한다.
- 로그인이 되었따면 /auth 로 접근해도 /closet 으로 redirect 한다.
- 로그아웃을 한다면 /closet 에서 /auth 로 이동하고 다시 /closet 으로 가진 못한다.
이제 하나하나 구현을 해보자
이 모든과정에 필요한것은 프론트 서버가 브라우저 랜더링 전 로그인 사실 여부를 판별할 수 있어야 하는것이었다.
방법을 고민하다가 그냥 심플하게 서버에 이 유저가 로그인 되어있는지를 서버에 요청하는것으로 방향을 잡았다. 즉 내가 로그인이 되었다면 쿠키에 세션 id 가 들어있을 것이고, 서버에도 데이터베이스에 세션 id 가 들어와있을 것이다.
getServerSideProps 에서 (즉, 페이지가 렌더링 되기 전 미리 서버에서 필요 데이터를 가져오는 단계) 서버에 로그인 여부를 요청한 다음 그 결과값 여부에 따라서 페이리 redirect 를 결정하면 된다고 판단했다.
우선 서버에 API 를 하나 생성하고 (로그인이 아니라면 null 이라도 보내게 하자)
router.get("/", async (req, res, next) => {
// GET /user
try {
if (req.user) {
const fullUserWithoutPassword = await User.findOne({
where: { id: req.user.id },
attributes: {
exclude: ["password"],
},
});
res.status(200).json(fullUserWithoutPassword);
} else {
res.status(200).json(null);
}
} catch (err) {
console.error(err);
next(err);
}
});
이후 각 페이지에 redirect 를 설정해주자.
처음에는 그냥 바로 axios.get('/user').then(...) 으로 요청을 하고 그에대한 응답 데이터 여부에 따라 redirects 를 진행하였었다. 사실 이렇게 해도 문제는 없지 않을까 싶었지만, reducer 와 saga 를 통해서 서버와 응답을 이어가고 있으니, 통일성을 지키자 생각하여서 dispatch 를 통해서 서버에 요청을 하려고 작성하였다.
export const getServerSideProps = wrapper.getServerSideProps(store => async (context: GetServerSidePropsContext) => {
console.log(context);
const cookie = context.req ? context.req.headers.cookie : '';
axios.defaults.headers.Cookie = '';
if (context.req && cookie) {
axios.defaults.headers.Cookie = cookie;
}
store.dispatch({
// store에서 dispatch 하는 api
type: t.LOAD_TO_MY_INFO_REQUEST,
});
store.dispatch(END);
await (store as SagaStore).sagaTask?.toPromise();
if (!store.getState().user.me) {
// getState() 는 store의 트리를 가져와준다.
return {
redirect: {
destination: '/auth',
permanent: false,
},
};
}
return {
props: {},
};
});
위 페이지는 closet 페이지의 getServerSideProps 부분이다. 가장 먼저 작성해줘야 할 부분은 바로 쿠키부분이다.
브라우저에 쿠키가 있기 때문에 문제 없는거 아니냐 할 수 있겠지만, 이 getServerSideProps 의 작동은 브라우저가 아닌 프론트엔드 서버에서 이루어진다. 프론트엔드 서버는 쿠키를 가지고 있지 않다. 그래서 아래처럼 설정을 해주어야 한다.
const cookie = context.req ? context.req.headers.cookie : '';
// default 값은 매번 빈 배열로 우선 설정을 해야한다.
// 이 설정이 제외되면 여러 사람이 한 유저의 정보가 담긴 페이지에 접근이 될 수 있기 때문이다.
axios.defaults.headers.Cookie = '';
// 쿠키가 있다면 기본 헤더의 쿠키를 변경해주자
if (context.req && cookie) {
axios.defaults.headers.Cookie = cookie;
}
쿠키가 담겼으니 이제 서버에 로그인 여부를 검증하기위해 요청을 하자.
리덕스의 API 인 dispatch를 사용하기 위해선(dispatch 로 인한 action 만이 store 의 state 를 변경시킬 수 있다.) 리덕스의 store 에 접근이 가능하여야 한다. 즉, store 로 감싸주어야 한다.
사실 이 과정에서 typescript 와 같이 사용하기에 많이 햇갈렸다. 분명 문법상으로 맞지만, context 의 타입을 정하는데 있어서 많이 해매었는데, 즉 리덕스의 store 에는 getServerSideProps 의 context 에 맞는 타입을 제공하지 못하는 것이었다. 애초에 store에는 context의 인자를 제공하지 않는것으로 알고 있었고, store 와 context 를 다 사용해야 하는 시점에서, 어떻게 해야할까 고민중에 예전에 로그인 과정에서 passport 를 도입했을 때, 고차 함수를 활용했던 사례가 떠올랐다.
// user.js
// 로그인 과정의 api
// 고차함수를 활용하여 원래 필요한 req, res, next 와 passport 미들웨어에서 사용될 err,user,info
// 이 모두를 사용가능하게 한다.
router.post("/login", isNotLoggedIn, (req, res, next) => {
// "Post /user/login"
passport.authenticate("local", (err, user, info) => {
if (err) {
console.log(err);
return next(err);
}
아마 비슷한 원리로 적용하면 되지 않을까 해서 이러한 사례를 검색해본 결과 store 로 감싸주는 방법에 대해 알 수 있게 되었다.
xport const getServerSideProps =
wrapper.getServerSideProps(store => async (context: GetServerSidePropsContext) => {
store 로 감싸주는 고차함수로 작성하니 둘다 활용할 수 있게 되었다. GetServerSidePropsContext 는 context 의 타입이다.
이제 dispatch 를 통해서 서버에게 요청을 한 뒤, 그 결과값을 통해 redirect 를 진행하면 된다.
사용법은 next.js 에서 제공하는 redux 사용방법을 그대로 참고해서 작업하였다.
참고로 아래 코드에서 확인하겠지만 sagaTask 를 사용하기 위해서는 타입을 오버로딩 해서 따로 지정을 해주어야 한다. 왜냐하면 saga 는 자체 타입을 제공하지 않기 때문이다. (이러한 이유로 다음부터는 그냥 toolkit 과 react-query, aws 등을 활용하지 않을까 싶다..)
store.dispatch({
// store에서 dispatch 하는 api
type: t.LOAD_TO_MY_INFO_REQUEST,
});
// 결과가 올 때까지 대기
store.dispatch(END);
// 여기서 SagaStore 타입은 직접 지정해준 타입이다. 이 타입은 이전 초기셋팅때 configureStore.tsx
// 에서 정의를 한적 있는 타입이니 그대로 가져오자.
await (store as SagaStore).sagaTask?.toPromise();
if (!store.getState().user.me) {
// getState() 는 store의 트리를 가져와준다. 즉 reducer 의 구조!
return {
redirect: {
destination: '/auth', // redirect 시킨다.
permanent: false,
},
};
}
return {
props: {},
};
이제 실제로 아까 구현하고자 했던 과정을 다 실행해보면, 정상적으로 redirect 됨을 확인할 수 있었다.
나머지 auth 페이지도 동일하게 작업을 해주면 된다.
프로젝트를 더 진행하다보면 Next.js 의 다른 기능도 사용할 수 있을 듯 싶다.
프레임워크여서 그런지 편리한 기능들을 다수 제공해주고 있다. 포스팅엔 다루지 않았지만 Image 역시 유용하게 쓰일 듯 하고, 이후에도 다양한 기능들을 활용할 듯 싶다.
빌드까지 가게 되면 설정하게 될 compress 같은 부분도 next.js 에서는 이미 제공을 하고 있고, 그 외 typescript 무시방법, 커스텀 webpack 설정 등등 여러가지가 있다. 계속해서 공부를 진행하면서 하나하나 특성을 익혀나가야 겠다.
이제 인증에 따른 페이지 전환까지 구현을 완료하였고, 남은 구현은 소셜 로그인과 아이디 페스워드 찾기 부분이 있을 듯 싶다. 쇼셜 로그인까지는 아마 첫 배포전에 진행이 될 거 같고, 나머지도 최대한 빨리 방법을 구해서 진행해보자.
'Practice' 카테고리의 다른 글
Next.js Middleware (1) | 2023.04.24 |
---|---|
Polymorphic Component 는 어떻게 구현될까 (0) | 2023.03.02 |
반응형 페이지로 전체 레이아웃 설정하기 (styled-component) (0) | 2023.02.24 |
Passport 를 활용하여 소셜 로그인 연동하기 (0) | 2023.01.26 |
[Closet] Next.js + Typescript 로 초기 셋팅을 해보자 (0) | 2022.11.22 |