📝 DAY 02 - 220519
- redux-saga / Axios (API 연동)
- 회원가입 구현
- 로그인 구현
✅ axios
- 가장 많이 사용중인 HTTP 클라이언트.
- HTTP 요청을 Promise 기반으로 처리.
redux-saga
를 이용.$ yarn add axios redux-saga
axios.create()
- 인스턴스 생성🔻 예시
const instance = axios.create({
baseURL: 'https://some-domain.com/api/', // API 주소
headers: { 'X-Custom-Header': 'foobar' }, // 헤더
timeout: 1000, // 비동기 (setTimeout)
});
아래와 같이 코드를 작성함.
src/lib/api/client.js 생성
import axios from 'axios';
const client = axios.create();
/* 예시
// API 주소 다른곳으로 사용시
client.defaults.baseURL = 'https://external-api-server.com/'
// 헤더 설정
client.defaults.headers.common['Authorization'] = 'Bearer a1b2c3d4';
// 인터셉터 설정
axios.intercepter.response.use({
respoonse => {
return response;
},
error => {
return Promise.reject(error);
}
})
*/
export default client;
현재 백엔드는 localhost:4000 (4000포트)에 열려있고,
프론트엔드는 localhost:3000 (3000포트)에 열려있기 때문에
별도의 설정 없이 API 호출시 오류가 발생함.
✅ CORS(Cross Origin Request) 오류
- 네트워크 요청시 주소가 다른 경우 발생하는 오류.
- 다른 주소에서도 API를 호출할 수 있도록 서버쪽 코드를 수정해야 함.
🔺 프록시(proxy)란?
- 웹팩 개발 서버에서 지원하는 기능.
- 개발 서버로 요청하는 API들을 우리가 프록시로 정해둔 서버로 전달해주고,
그 응답(response)를 웹 어플리케이션에서 사용 가능하게 해줌.
CRA 에서 proxy를 설정해주려면, package.json
을 수정해준다.
{
"name": "blog-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
...
},
// 🔻 추가한 것
"proxy": "http://localhost:4000/"
}
src/lib/api/auth.js 생성
import client from './client';
export const login = ({ username, password }) =>
client.post('/api/auth/login', { username, password });
export const register = ({ username, password }) =>
client.post('/api/auth/register', { username, password });
export const check = () => client.get('/api/auth/check');
modules/loading.js 생성
import { createAction, handleActions } from 'redux-actions';
const START_LOADING = 'loading/START_LOADING';
const FINISH_LOADING = 'loading/FINISH_LOADING';
export const startLoading = createAction(
START_LOADING,
(requestType) => requestType,
);
export const finishLoading = createAction(
FINISH_LOADING,
(requestType) => requestType,
);
const initialState = {};
const loading = handleActions(
{
[START_LOADING]: (state, action) => ({
...state,
[action.payload]: true,
}),
[FINISH_LOADING]: (state, action) => ({
...state,
[action.payload]: false,
}),
},
initialState,
);
export default loading;
modules/index.js 수정
import { combineReducers } from 'redux';
import auth from './auth';
import loading from './loading';
const rootReducer = combineReducers({
auth,
loading,
});
export default rootReducer;
📌 redux-saga는 제네레이터 함수 (
function*()
)를 이용한 리덕스 미들웨어이다.
-> 자세한 내용은 공식 문서와 참조 문서를 참조!
- 참고) 스토어에 미들웨어 적용시 createStore의 두번째 인자로 applyMiddleware를 넣어줌
const store = createStore(modules, applyMiddleware(logger, sagaMiddleware));
src/lib/createRequestSaga.js
import { call, put } from 'redux-saga/effects';
import { startLoading, finishLoading } from '../modules/loading';
// call = 첫인자에 두번째 인자를 전달하여 호출함
// put = 새 액션을 디스패치 -> 액션생성함수(Payload)
export default function createRequestSaga(type, request) {
// type은 요청 성공 or 실패 여부.
// request는 함수형태. (여기에 API 요청 - axios.get이 들어갈듯)
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return function* (action) {
yield put(startLoading(type)); // 로딩 시작
try {
const response = yield call(request, action.payload);
yield put({
type: SUCCESS,
payload: response.data,
});
} catch (e) {
yield put({
type: FAILURE,
payload: e,
error: true,
});
}
yield put(finishLoading(type));
};
}
🔺 yield 뒤에 오는 함수들 - call, put, delay
call
: 첫번째 파라미터로 전달한 함수에 그 뒤에 있는 파라미터들은 전달하여 호출
put
: 새 액션을 dispatch 함.
delay
: ms 단위로 대기
+) 그 외에도 takeEvery, takeLastest 등이 있음
-> 6개를 선언해야 하므로, 같은 작업이 반복됨.
createRequestActionTypes 라는 함수를 선언하여 리팩토링.
createRequestSaga.js 수정
export const createRequestActionTypes = (type) => {
const SUCCESS = `${type}_SUCCESS`;
const FAILURE = `${type}_FAILURE`;
return [type, SUCCESS, FAILURE];
};
...
import { createRequestActionTypes } from '../lib/createRequestSaga';
...
const [REGISETR, REGISETR_SUCCESS, REGISETR_FAILURE] = createRequestActionTypes('auth/REGISTER');
const [LOGIN, LOGIN_SUCCESS, LOGIN_FAILURE] = createRequestActionTypes('auth/LOGIN');
-> saga란, 제너레이터 함수를 의미함.
-> createRequestSaga()
함수가 return function*()
을 하므로, 사가를 생성하는 것임.
modules/auth.js 수정
import { createAction, handleActions } from 'redux-actions';
import produce from 'immer';
import { takeLatest } from 'redux-saga/effects';
import createRequestSaga, {
createRequestActionTypes,
} from '../lib/createRequestSaga';
import * as authAPI from '../lib/api/auth';
const CHANGE_FIELD = 'auth/CHANGE_FIELD';
const INITIALIZE_FORM = 'auth/INITIALIZE_FORM';
const [REGISTER, REGISTER_SUCCESS, REGISTER_FAILURE] =
createRequestActionTypes('auth/REGISTER');
const [LOGIN, LOGIN_SUCCESS, LOGIN_FAILURE] =
createRequestActionTypes('auth/LOGIN');
export const changeField = createAction(
CHANGE_FIELD,
({ form, key, value }) => ({ form, key, value }),
// form: register, login / key: username, password, passwordConfirm
);
export const initializeForm = createAction(INITIALIZE_FORM, (form) => form);
// register, login
export const register = createAction(REGISTER, ({ username, password }) => ({
username,
password,
}));
export const login = createAction(LOGIN, ({ username, password }) => ({
username,
password,
}));
// 🔻 saga 생성
// 첫번째 인자 = type (REGISTER/LOGIN) , 두번째 인자 = request (액션생성함수)
const registerSaga = createRequestSaga(REGISTER, authAPI.register);
const loginSaga = createRequestSaga(LOGIN, authAPI.login);
export function* authSaga() {
yield takeLatest(REGISTER, registerSaga);
yield takeLatest(LOGIN, loginSaga);
}
const initialState = {
register: {
username: '',
password: '',
passwordConfirm: '',
},
login: {
username: '',
password: '',
},
auth: null,
authError: null,
};
const auth = handleActions(
{
[CHANGE_FIELD]: (state, { payload: { form, key, value } }) =>
produce(state, (draft) => {
draft[form][key] = value; // ex> state.register.username
}),
[INITIALIZE_FORM]: (state, { payload: form }) => ({
...state,
[form]: initialState[form],
authError: null,
}),
[REGISTER_SUCCESS]: (state, { payload: auth }) => ({
...state,
authError: null,
auth, // auth: auth
}),
[REGISTER_FAILURE]: (state, { payload: error }) => ({
...state,
authError: error,
}),
[LOGIN_SUCCESS]: (state, { payload: auth }) => ({
...state,
authError: null,
auth,
}),
[LOGIN_FAILURE]: (state, { payload: error }) => ({
...state,
authError: error,
}),
},
initialState,
);
export default auth;
🔺 redux-saga의
takeLatests
함수기존에 진행 중이던 작업이 있다면 취소 처리하고 가장 마지막으로 실행된 작업만 수행.
예>takeLatest(DECREASE_ASYNC, decreaseSaga)
→DECREASE_ASYNC
액션에 대해서 기존에 진행 중이던 작업이 있다면 취소 처리하고
가장 마지막으로 실행된 작업에 대해서만decreaseSaga()
함수를 실행한다.
rootReducer과 마찬가지로 modules/index.js 에 생성.
import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import loading from './loading';
const rootReducer = combineReducers({
auth,
loading,
});
// 🔻 all()함수의 인자 = 함수값 배열을 넣어줌.
export function* rootSaga() {
yield all([authSaga()]);
}
export default rootReducer;
combineReducers()
의 경우에는 리듀서함수(auth, loading) 자체를 담은 객체를 인자로 넣어줬다.
반면에 all()
함수의 인자로는 사가를 호출한 후(=결과값) 배열에 담아 인자로 넣어준다.
src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import './index.css';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import createSagaMiddleware from 'redux-saga';
import rootReducer, { rootSaga } from './modules';
// 🔻 1. sagaMiddleware 생성
const sagaMiddleware = createSagaMiddleware();
// 🔻 2. 두번째 인자에 applyMiddleware(sagaMiddleware) 넣어줌
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(sagaMiddleware)),
);
// 🔻 3. sagaMiddleware.run(rootSaga) 로 실행
sagaMiddleware.run(rootSaga);
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
);
🛠 미들웨어 적용 과정
- const sagaMiddleware = createSagaMiddleware();
- const store = createStore(rootReducer, composeWithDevTools(applyMiddleware(sagaMiddleware))
- sagaMiddleware.run(rootSaga)
이제 리덕스 관련 코드는 모두 끝!
회원가입 기능 구현 + 로그인 기능 구현 필요.
containers/auth/RegisterForm.js 수정
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, initializeForm, register } from '../../modules/auth';
import AuthForm from '../../components/auth/AuthForm';
const RegisterForm = () => {
const dispatch = useDispatch();
const { form, auth, authError } = useSelector(({ auth }) => ({
// auth 모듈의 state를 불러옴
form: auth.register,
auth: auth.auth,
authError: auth.authError,
}));
const onChange = (e) => {
const { value, name } = e.target;
dispatch(
changeField({
form: 'register',
key: name,
value, // value: e.target.value
}),
);
};
// 🔻 폼 제출시 - password와 passwordConfirm이 일치하는지 확인 후 register 액션 디스패치
const onSubmit = (e) => {
e.preventDefault();
const { username, password, passwordConfirm } = form;
if (password !== passwordConfirm) {
// 비밀번호 확인 불일치 - 에러처리
return;
}
// 🔻
dispatch(register({ username, password }));
};
useEffect(() => {
dispatch(initializeForm('register'));
}, [dispatch]);
// 🔻 회원가입 실패 / 성공 처리.
useEffect(() => {
if (authError) {
console.log('오류 발생');
console.log(authError);
return;
}
if (auth) {
console.log('회원가입 성공');
console.log(auth);
}
}, [auth, authError]);
return (
<AuthForm
type="register"
form={form}
onChange={onChange}
onSubmit={onSubmit}
/>
);
};
export default RegisterForm;
yarn start:dev
로 실행시켜주고,yarn start
로 실행시켜줘야 함.회원가입 성공이라고 뜸.
만약 회원가입 버튼을 한번 더 누르면?
-> 이전에 백엔드에서 작성했던 로직에 의해 409(Conflict) 에러가 발생함.
src/api/auth/auth.ctrl.js
// 1. 회원 가입 (등록)
export const register = async (ctx) => {
// request body 검증
const schema = Joi.object().keys({
username: Joi.string().alphanum().min(3).max(20).required(),
password: Joi.string().required(),
});
const result = schema.validate(ctx.request.body);
if (result.error) {
ctx.status = 400;
ctx.body = result.error;
return;
}
const { username, password } = ctx.request.body;
try {
// username 이미 존재하는지 체크
const exists = await User.findByUsername(username);
if (exists) {
ctx.status = 409; // conflict
return;
}
const user = new User({
username,
});
await user.setPassword(password);
await user.save(); // DB에 저장
ctx.body = user.serialize();
const token = user.generateToken();
ctx.cookies.set('access_token', token, {
maxAge: 1000 * 60 * 60 * 24 * 7, // 7d
httpOnly: true,
});
} catch (e) {
ctx.throw(500, e);
}
};
mongoDB Compass에서 blog DB에 users 컬렉션을 살펴보자.
-> 맨 아래에 방금 가입을 누른 유저 정보가 보인다! db에도 잘 등록된 것.
import { createAction, handleActions } from 'redux-actions';
import { takeLatests } from 'redux-saga/effects';
import * as authAPI from '../lib/api/auth';
import createRequestSaga, {
createRequestActionTypes,
} from '../lib/createRequestSaga';
const TEMP_SET_USER = 'user/TEMP_SET_USER';
const [CHECK, CHECK_SUCCESS, CHECK_FAILURE] =
createRequestActionTypes('user/CHECK');
export const tempSetUser = createAction(TEMP_SET_USER, (user) => user);
export const check = createAction(CHECK);
// saga 생성
const checkSaga = createRequestSaga(CHECK, authAPI.check); // client.get('/api/auth/check')
export function* userSaga() {
yield takeLatests(CHECK, checkSaga);
} // userSaga를 rootSaga로 합쳐줌
const initialState = {
user: null,
checkError: null,
};
export default handleActions(
{
[TEMP_SET_USER]: (state, { payload: user }) => ({
...state,
user,
}),
[CHECK_SUCCESS]: (state, { payload: user }) => ({
...state,
user,
checkError: null,
}),
[CHECK_FAILURE]: (state, { payload: error }) => ({
...state,
user: null,
checkError: error,
}),
},
initialState,
);
modules/index.js
import { combineReducers } from 'redux';
import { all } from 'redux-saga/effects';
import auth, { authSaga } from './auth';
import loading from './loading';
import user, { userSaga } from './user';
const rootReducer = combineReducers({
auth,
loading,
user,
});
export function* rootSaga() {
yield all([authSaga(), userSaga()]);
}
export default rootReducer;
containers/auth/RegisterForm.js 수정
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, initializeForm, register } from '../../modules/auth';
import AuthForm from '../../components/auth/AuthForm';
import { check } from '../../modules/user';
const RegisterForm = () => {
const dispatch = useDispatch();
const { form, auth, authError, user } = useSelector(({ auth, user }) => ({
// auth 모듈의 state를 불러옴
form: auth.register,
auth: auth.auth,
authError: auth.authError,
// 🔻 user 모듈의 state를 불러옴 (state.user.user)
user: user.user,
}));
const onChange = (e) => {
const { value, name } = e.target;
dispatch(
changeField({
form: 'register',
key: name,
value, // value: e.target.value
}),
);
};
const onSubmit = (e) => {
e.preventDefault();
const { username, password, passwordConfirm } = form;
if (password !== passwordConfirm) {
// 비밀번호 확인 불일치 - 에러처리
return;
}
dispatch(register({ username, password }));
};
useEffect(() => {
dispatch(initializeForm('register'));
}, [dispatch]);
// 회원가입 실패/성공 처리
useEffect(() => {
if (authError) {
console.log('오류 발생');
console.log(authError);
return;
}
if (auth) {
console.log('회원가입 성공');
console.log(auth);
// 🔻 check (액션 생성함수)를 dispatch (payload는 없음)
dispatch(check());
}
}, [auth, authError, dispatch]);
// 🔻 user 값이 잘 설정되었나 체크
useEffect(() => {
if (user) {
console.log('check API 성공');
console.log(user);
}
}, [user]);
return (
<AuthForm
type="register"
form={form}
onChange={onChange}
onSubmit={onSubmit}
/>
);
};
export default RegisterForm;
redux devTool을 보면 아래와 같이 user 안에 값이 들어가 있다면 성공!
withRouter
로 컴포넌트를 감싸주면 됨.useNavigate()
함수를 이용함.containers/auth/RegisterForm.js 수정
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
useEffect(() => {
if (user) {
// user 값이 있으면? -> 회원가입 성공 -> 홈으로 이동시켜야함
navigate('/'); // 홈으로 이동
}
}, [navigate, user]);
containers/auth/LoginForm.js 수정
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, initializeForm, login } from '../../modules/auth';
import AuthForm from '../../components/auth/AuthForm';
import { check } from '../../modules/user';
import { useNavigate } from 'react-router-dom';
const LoginForm = () => {
const dispatch = useDispatch();
const { form, auth, authError, user } = useSelector(({ auth, user }) => ({
form: auth.login, // { username, password}
auth: auth.auth,
authError: auth.authError,
user: user.user,
}));
const onChange = (e) => {
const { value, name } = e.target;
dispatch(
changeField({
form: 'login',
key: name,
value, // value: e.target.value
}),
);
};
const onSubmit = (e) => {
e.preventDefault();
const { username, password } = form;
// LOGIN 액션 디스패치
dispatch(login({ username, password }));
};
useEffect(() => {
dispatch(initializeForm('login'));
}, [dispatch]);
// 로그인 실패, 성공 여부
useEffect(() => {
if (authError) {
console.log('오류 발생');
console.log(authError);
}
if (auth) {
console.log('로그인 성공');
console.log(auth);
// check
dispatch(check()); // CHECK 액션 디스패치
}
}, [auth, authError, dispatch]);
// 로그인 성공시 -> 홈으로 이동
const navigate = useNavigate();
useEffect(() => {
if (user) {
navigate('/');
}
}, [navigate, user]);
return (
<AuthForm
type="login"
form={form}
onChange={onChange}
onSubmit={onSubmit}
/>
);
};
export default LoginForm;
🔐 1. 로그인시
-> 비밀번호 불일치시 에러
components/auth/AuthForm.js 수정
import styled from 'styled-components';
import palette from '../../lib/styles/palette';
import { Link } from 'react-router-dom';
import Button from '../common/Button';
...
const ErrorMessage = styled.div`
color: #ff0000;
text-align: center;
font-size: 0.85rem;
margin-top: 1rem;
`;
const AuthForm = ({ type, form, onChange, onSubmit, error }) => {
const text = textMap[type];
return (
<AuthFormBlock>
<h3>{text}</h3>
<form onSubmit={onSubmit}>
<StyledInput
autoComplete="username"
name="username"
placeholder="아이디"
onChange={onChange}
value={form.username}
/>
<StyledInput
type="password"
autoComplete="new-password"
name="password"
placeholder="비밀번호"
onChange={onChange}
value={form.password}
/>
{type === 'register' && (
<StyledInput
autoComplete="new-password"
name="passwordConfirm"
placeholder="비밀번호 확인"
type="password"
onChange={onChange}
value={form.passwordConfirm}
/>
)}
// 🔻 error이 true일때만 ErrorMessage를 띄움.
{error && <ErrorMessage>비밀번호가 일치하지 않습니다.</ErrorMessage>}
<ButtonWithMarginTop teal fullWidth>
{text}
</ButtonWithMarginTop>
</form>
<Footer>
{type === 'login' ? (
<Link to="/register">회원가입</Link>
) : (
<Link to="/login">로그인</Link>
)}
</Footer>
</AuthFormBlock>
);
};
export default AuthForm;
containers/auth/LoginForm
-> 컨테이너에서 에러를 처리하는 것이 훨씬 쉬움.
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, initializeForm, login } from '../../modules/auth';
import AuthForm from '../../components/auth/AuthForm';
import { check } from '../../modules/user';
import { useNavigate } from 'react-router-dom';
const LoginForm = () => {
// 🔻 state 추가
const [error, setError] = useState(false);
...
// 로그인 실패, 성공 여부
useEffect(() => {
if (authError) {
console.log('오류 발생');
console.log(authError);
setError(true);
}
if (auth) {
console.log('로그인 성공');
console.log(auth);
// check
dispatch(check()); // CHECK 액션 디스패치
}
}, [auth, authError, dispatch]);
return (
<AuthForm
type="login"
form={form}
onChange={onChange}
onSubmit={onSubmit}
error={error}
/>
);
};
export default LoginForm;
👥 2. 회원가입시
-> 입력창이 비어있을 때 / password와 passwordConfirm이 일치하지 않을 때 / username이 중복될 때 에러
containers/auth/RegisterForm.js 수정
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, initializeForm, register } from '../../modules/auth';
import AuthForm from '../../components/auth/AuthForm';
import { check } from '../../modules/user';
import { useNavigate } from 'react-router-dom';
const RegisterForm = () => {
const [error, setError] = useState(null);
const dispatch = useDispatch();
const { form, auth, authError, user } = useSelector(({ auth, user }) => ({
// auth 모듈의 state를 불러옴
form: auth.register,
auth: auth.auth,
authError: auth.authError,
// user 모듈의 state를 불러옴
user: user.user,
}));
const onChange = (e) => {
const { value, name } = e.target;
dispatch(
changeField({
form: 'register',
key: name,
value, // value: e.target.value
}),
);
};
const onSubmit = (e) => {
e.preventDefault();
const { username, password, passwordConfirm } = form;
// 인풋 하나라도 비어있으면
if ([username, password, passwordConfirm].includes('')) {
setError('빈 칸을 모두 입력하세요.');
return;
}
if (password !== passwordConfirm) {
// 비밀번호 확인 불일치시
setError('비밀번호가 일치하지 않습니다.');
dispatch(changeField({ form: 'register', key: 'password', value: '' })); // password 인풋을 빈칸으로 만듬
dispatch(
changeField({ form: 'register', key: 'passwordConfirm', value: '' }),
);
return;
}
dispatch(register({ username, password })); // REGISTER 액션 디스패치
};
useEffect(() => {
dispatch(initializeForm('register'));
}, [dispatch]);
// 회원가입 실패/성공 처리
useEffect(() => {
if (authError) {
// 계정명이 이미 존재할 때 - 409 Conflict
if (authError.response.status === 409) {
setError('이미 존재하는 계정명입니다.');
return;
}
// 그 외 이유
setError('회원가입 실패');
return;
}
if (auth) {
console.log('회원가입 성공');
console.log(auth);
dispatch(check());
}
}, [auth, authError, dispatch]);
// user 값이 잘 설정되었나 체크
useEffect(() => {
if (user) {
console.log('check API 성공');
console.log(user);
}
}, [user]);
const navigate = useNavigate();
useEffect(() => {
if (user) {
// user 값이 있으면? -> 회원가입 성공 -> 홈으로 이동시켜야함
navigate('/'); // 홈으로 이동
}
}, [navigate, user]);
return (
<AuthForm
type="register"
form={form}
onChange={onChange}
onSubmit={onSubmit}
error={error}
/>
);
};
export default RegisterForm;
components/auth/AuthForm.js
{error && <ErrorMessage>{error}</ErrorMessage>}
error 문구가 달라져야 하므로 위와 같이 변경함.
-> 하나라도 빈칸이 있을 경우
-> password, passwordConfirm 칸이 비워짐
dispatch(changeField({ form: 'register', key: 'password', value: '' })); // password 인풋을 빈칸으로 만듬
dispatch(
changeField({ form: 'register', key: 'passwordConfirm', value: '' }),
);
changeField 액션을 실행시켜 value를 ''로 만들어버렸기 때문.
-> 이미 존재하는 계정명인 경우