2. 회원가입과 로그인 구현
이제 서비스에 회원가입하는 기능과 로그인을 하는 기능을 구현해보겠습니다. 먼저 UI 개발부터 하고 , 리덕스를 통한 상태 관리 및 API 요청을 구현하겠습니다.
UI 준비하기
우리가 나중에 리덕스를 사용하여 컨테이너 컴포넌트를 만들 것이고, 상태 관리도 할 것이지만, 지금 당장은 다른건 신경쓰지 않고 오직 UI 개발만 진행하겠습니다.
컴포넌트는 src 디렉터리에 components 라는 디렉터리를 만들고, 그 안에 기능별로 디렉터리를 새로 만들어서 컴포넌트를 분류 하세요. 예를 들자면 회원가입이나 로그인에 관련된 컴포넌트들은 auth 라는 이름으로 분류하여 components/auth/ 디렉터리에 생성합니다.
우리가 회원가입과 로그인 기능을 구현하기 위하여 만들 컴포넌트는 두개입니다.
먼저 다음 파일들을 생성해주세요. 각 컴포넌트가 어떤 역할인지는 주석에 간략하게 적혀있으니 참고하세요.
components/auth/AuthForm.js
import React from 'react';
import './AuthForm.scss';
/**
* 회원가입 혹은 로그인 폼을 보여줍니다.
*/
const AuthForm = () => {
return <div className="AuthForm">AuthForm</div>;
};
export default AuthForm;
components/auth/AuthForm.scss
.AuthForm {
}
components/auth/AuthTemplate.js
import React from 'react';
import './AuthTemplate.scss';
/**
* 회원가입/로그인 페이지의 레이아웃을 담당하는 컴포넌트입니다.
*/
const AuthTemplate = ({ children }) => {
return <div className="AuthTemplate">{children}</div>;
};
export default AuthTemplate;
components/AuthTemplate.scss
.AuthTemplate {
}
그리고 나서, LoginPage 에서 방금 우리가 만든 컴포넌트를 사용해주겠습니다.
pages/LoginPage.js
import React from 'react';
import AuthTemplate from '../components/auth/AuthTemplate';
import AuthForm from '../components/auth/AuthForm';
/**
* 로그인 할 때 사용되는 페이지
*/
const LoginPage = () => {
return (
<AuthTemplate>
<AuthForm />
</AuthTemplate>
);
};
export default LoginPage;
RegisterPage 도 마찬가지로 수정해주세요.
pages/RegisterPage.js
import React from 'react';
import AuthTemplate from '../components/auth/AuthTemplate';
import AuthForm from '../components/auth/AuthForm';
/**
* 회원가입 할 때 사용되는 페이지
*/
const RegisterPage = () => {
return (
<AuthTemplate>
<AuthForm />
</AuthTemplate>
);
};
export default RegisterPage;
지금은 LoginPage 와 RegisterPage 의 결과물이 같습니다. 우선 페이지가 제대로 나타나는지 http://localhost:3000/login 과 http://localhost:3000/register 를 열어서 확인해보세요.
AuthForm 이 잘 보여지고 있나요?
AuthTemplate 완성하기
AuthTemplate 은 그냥 children 으로 받아온 내용을 보여주기만 하는 역할만 하기 때문에 정말 간단합니다. 이 컴포넌트의 배경은 회색이고, 중앙에 흰색 박스를 띄워주며, 홈으로 돌아가는 가는 링크도 보여줍니다.
components/auth/AuthTemplate.js
import React from 'react';
import { Link } from 'react-router-dom';
import './AuthTemplate.scss';
/**
* 회원가입/로그인 페이지의 레이아웃을 담당하는 컴포넌트입니다.
*/
const AuthTemplate = ({ children }) => {
return (
<div className="AuthTemplate">
<div className="whitebox">
<div className="logo-area">
<Link to="/" className="logo">
REACTERS
</Link>
</div>
{children}
</div>
</div>
);
};
export default AuthTemplate;
components/auth/AuthTemplate.scss
.AuthTemplate {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
background: $oc-gray-2;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.whitebox {
.logo-area {
display: block;
padding-bottom: 2rem;
text-align: center;
font-weight: 800;
letter-spacing: 2px;
}
box-shadow: 0 0 8px rgba(0, 0, 0, 0.025);
padding: 2rem;
width: 360px;
background: white;
border-radius: 2px;
}
}
다음과 같이 회색 배경과 흰색 박스가 나타났나요?
AuthForm UI 구성하기
이번에는 AuthForm 내부에서, 로그인을 위한 폼 UI 를 만들어주겠습니다.
components/auth/AuthForm.js
import React from 'react';
import './AuthForm.scss';
import { Link } from 'react-router-dom';
/**
* 회원가입 혹은 로그인 폼을 보여줍니다.
*/
const AuthForm = () => {
return (
<div className="AuthForm">
<h3>로그인</h3>
<form>
<input name="username" placeholder="아이디" />
<input name="password" placeholder="비밀번호" type="password" />
<button type="submit">로그인</button>
</form>
<div className="footer">
<Link to="/register">회원가입</Link>
</div>
</div>
);
};
export default AuthForm;
components/auth/AuthForm.scss
.AuthForm {
h3 {
margin: 0;
display: inline-flex;
color: $oc-gray-8;
margin-bottom: 1rem;
}
form {
display: flex;
flex-direction: column;
input {
font-size: 1rem;
border: none;
border-bottom: 1px solid $oc-gray-5;
padding-bottom: 0.5rem;
outline: none;
&:focus {
color: $oc-teal-7;
border-bottom: 1px solid $oc-teal-7;
}
}
input + input {
margin-top: 1rem;
}
button {
border-radius: 1.25rem;
background: $oc-gray-9;
height: 2.5rem;
font-size: 1rem;
color: white;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 2rem;
cursor: pointer;
&:hover {
background: $oc-gray-7;
}
}
}
.footer {
text-align: right;
a {
font-weight: 600;
text-decoration: underline;
color: $oc-gray-6;
&:hover {
color: $oc-gray-9;
}
}
}
}
로그인 폼 디자인이 끝났습니다!
AuthForm 에 type 설정하기
AuthForm 에 type props 를 전달하여 "register" 인 경우엔 회원가입을, "login" 인 경우엔 로그인을 보여주도록 하겠습니다.
회원가입을 할 때에는 비밀번호 확인을 위한 인풋을 하나 더 보여주어야 합니다.
components/auth/AuthForm.js
import React from 'react';
import './AuthForm.scss';
import { Link } from 'react-router-dom';
const textMap = {
login: '로그인',
register: '회원가입'
};
/**
* 회원가입 혹은 로그인 폼을 보여줍니다.
*/
const AuthForm = ({ type }) => {
return (
<div className="AuthForm">
<h3>{textMap[type]}</h3>
<form>
<input name="username" placeholder="아이디" />
<input name="password" placeholder="비밀번호" type="password" />
{type === 'register' && (
<input
name="passwordConfirm"
placeholder="비밀번호 확인"
type="password"
/>
)}
<button type="submit">{textMap[type]}</button>
</form>
<div className="footer">
{type === 'login' ? (
<Link to="/register">회원가입</Link>
) : (
<Link to="/login">로그인</Link>
)}
</div>
</div>
);
};
export default AuthForm;
컴포넌트를 이렇게 수정해주고 나서, 이 컴포넌트를 사용하고 있는 LoginPage 와 RegiserPage 에서 type props 를 명시해주세요.
pages/LoginPage.js
import React from 'react';
import AuthTemplate from '../components/auth/AuthTemplate';
import AuthForm from '../components/auth/AuthForm';
/**
* 로그인 할 때 사용되는 페이지
*/
const LoginPage = () => {
return (
<AuthTemplate>
<AuthForm type="login" />
</AuthTemplate>
);
};
export default LoginPage;
pages/RegisterPage.js
import React from 'react';
import AuthTemplate from '../components/auth/AuthTemplate';
import AuthForm from '../components/auth/AuthForm';
/**
* 회원가입 할 때 사용되는 페이지
*/
const RegisterPage = () => {
return (
<AuthTemplate>
<AuthForm type="register" />
</AuthTemplate>
);
};
export default RegisterPage;
이제 회원가입 페이지도 잘 나타나는지 확인해보세요.
리덕스로 폼 상태 관리하기
이번에는 폼 상태를 리덕스를 사용하여 관리해주겠습니다. 일단, 폼 상태 관리를 할 때에는 무조건 리덕스를 사용 할 필요는 없습니다. 리덕스 스토어에 지니고 있는 상태가 업데이트 됨에 따라 반영이 되는 컴포넌트 수가 많은 경우에는 어쩌면 컴포넌트 로컬 상태 (setState 를 사용하거나 useState, useReducer 를 사용하는 것) 이 적합한 상황일 때도 있습니다.
우선 redux 와 redux-actions 를 설치해주세요.
$ yarn add redux redux-actions react-redux
그리고 src 디렉터리에 modules 디렉터리를 만들고, 그 안에 Ducks 패턴을 따르는 리덕스 모듈들을 만들어주겠습니다.
이번에 만들 모듈 이름은 auth 입니다. 이 모듈에서는 우선 폼 상태를 관리하게 되고, 나중에 로그인 / 회원가입 시도 후 결과를 상태에 담아줍니다.
modules/auth.js
import { createAction, handleActions } from 'redux-actions';
const CHANGE_FIELD = 'auth/CHANGE_FIELD'; // 특정 값을 수정함
const INITIALIZE_FORM = 'auth/INITIALIZE_FORM'; // 폼에 있는 모든 내용을 공백으로 처리
export const changeField = createAction(
CHANGE_FIELD,
({ type, key, value }) => ({
type, // register, login
key, // username, password, passwordConfirm
value // 실제 바꾸려는 값
})
);
export const initializeForm = createAction(INITIALIZE_FORM, type => type); // register / login
const initialState = {
register: {
username: '',
password: '',
passwordConfirm: ''
},
login: {
username: '',
password: ''
}
};
const auth = handleActions(
{
[CHANGE_FIELD]: (state, { payload: { type, key, value } }) => ({
...state,
[type]: {
...state[type],
[key]: value
}
}),
[INITIALIZE_FORM]: (state, { payload: type }) => ({
...state,
[type]: initialState[type]
})
},
initialState
);
export default auth;
그리고 나서, 루트 리듀서를 만들어주세요.
modules/index.js
import { combineReducers } from 'redux';
import auth from './auth';
const rootReducer = combineReducers({
auth
});
export default rootReducer;
이제는 src 디렉터리의 index.js 에서 Provider 를 통하여 프로젝트에 리덕스를 적용해줄 차례입니다.
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './styles/main.scss';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { BrowserRouter } from 'react-router-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import rootReducer from './modules';
const store = createStore(rootReducer);
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById('root')
);
serviceWorker.unregister();
만약 리덕스 개발자 도구를 사용하고 싶다면 redux-devtools-extension 을 설치하셔서 다음과 같이 적용하세요.
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './styles/main.scss';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { BrowserRouter } from 'react-router-dom';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import rootReducer from './modules';
const store = createStore(rootReducer, composeWithDevTools());
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById('root')
);
serviceWorker.unregister();
컨테이너 컴포넌트 만들기
이번에는 로그인과 회원가입을 위한 컨테이너 컴포넌트들을 만들어줄 차례입니다. AuthForm 컴포넌트에 리덕스를 연동해서 LoginForm 과 RegisterForm 이라는 컨테이너 컴포넌트들을 만들겠습니다.
컨테이너 컴포넌트들은 src 디렉터리에 containers 디렉터리를 생성해서 기존에 components 에서 컴포넌트들을 만들었던 것 처럼 기능별로 분류해서 저장하세요.
containers/auth/LoginForm.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import AuthForm from '../../components/auth/AuthForm';
import { changeField, initializeForm } from '../../modules/auth';
class LoginForm extends Component {
handleChange = e => {
const { value, name } = e.target;
this.props.changeField({
type: 'login',
key: name,
value
});
};
handleSubmit = e => {
e.preventDefault();
console.log(this.props.form);
};
componentDidMount() {
this.props.initializeForm();
}
render() {
const { form } = this.props;
return (
<AuthForm
type="login"
form={form}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
/>
);
}
}
export default connect(
({ auth }) => ({ form: auth.login }),
{
changeField,
initializeForm
}
)(LoginForm);
컨테이너를 다 만드셨으면 LoginPage 에서 AuthForm 을 LoginForm 으로 대체시키세요.
pages/LoginPage.js
import React from 'react';
import AuthTemplate from '../components/auth/AuthTemplate';
import LoginForm from '../containers/auth/LoginForm';
/**
* 로그인 할 때 사용되는 페이지
*/
const LoginPage = () => {
return (
<AuthTemplate>
<LoginForm />
</AuthTemplate>
);
};
export default LoginPage;
그 다음에는 AuthForm 에게 props 로 넣어줬던 form 과 onChange, onSubmit 이벤트 핸들러 함수들을 연동해주세요.
components/auth/AuthForm.js
import React from 'react';
import './AuthForm.scss';
import { Link } from 'react-router-dom';
const textMap = {
login: '로그인',
register: '회원가입'
};
/**
* 회원가입 혹은 로그인 폼을 보여줍니다.
*/
const AuthForm = ({ type, form, onChange, onSubmit }) => {
return (
<div className="AuthForm">
<h3>{textMap[type]}</h3>
<form onSubmit={onSubmit}>
<input
name="username"
placeholder="아이디"
value={form.username}
onChange={onChange}
/>
<input
name="password"
placeholder="비밀번호"
type="password"
value={form.password}
onChange={onChange}
/>
{type === 'register' && (
<input
name="passwordConfirm"
placeholder="비밀번호 확인"
type="password"
value={form.passwordConfirm}
onChange={onChange}
/>
)}
<button type="submit">{textMap[type]}</button>
</form>
<div className="footer">
{type === 'login' ? (
<Link to="/register">회원가입</Link>
) : (
<Link to="/login">로그인</Link>
)}
</div>
</div>
);
};
export default AuthForm;
이제 폼에 내용을 입력하고 로그인 버튼을 눌러보세요. 콘솔에 폼 내용이 찍히나요?
마찬가지로, 회원가입을 위하여 RegisterForm 컨테이너 컴포넌트를 만들어봅시다. 방금 만들었던 것과 매우 비슷합니다. 기존 LoginForm 을 복사 후 Login -> Register, login -> register 로 키워드만 교체해주면 됩니다.
containers/auth/RegisterForm.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import AuthForm from '../../components/auth/AuthForm';
import { changeField, initializeForm } from '../../modules/auth';
class RegisterForm extends Component {
handleChange = e => {
const { value, name } = e.target;
this.props.changeField({
type: 'register',
key: name,
value
});
};
handleSubmit = e => {
e.preventDefault();
console.log(this.props.form);
};
componentDidMount() {
this.props.initializeForm();
}
render() {
const { form } = this.props;
return (
<AuthForm
type="register"
form={form}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
/>
);
}
}
export default connect(
({ auth }) => ({ form: auth.register }),
{
changeField,
initializeForm
}
)(RegisterForm);
이제 RegisterPage 에서 AuthForm 을 RegisterForm 으로 대체시키세요.
pages/RegisterPage.js
import React from 'react';
import AuthTemplate from '../components/auth/AuthTemplate';
import RegisterForm from '../containers/auth/RegisterForm';
/**
* 회원가입 할 때 사용되는 페이지
*/
const RegisterPage = () => {
return (
<AuthTemplate>
<RegisterForm />
</AuthTemplate>
);
};
export default RegisterPage;
회원가입 페이지에서도 마찬가지로 폼 내용을 입력 후 버튼을 눌렀을 때 콘솔에 잘 찍히는지 확인해보세요.
API 연동하기
이제 API 를 연동해봅시다. API 를 호출하기 위하여 우리는 axios 를 사용 할 것이고, 리덕스에서 비동기작업을 관리하기 위하여 redux-thunk 를 사용하겠습니다.
필요한 라이브러리를 설치해주세요.
yarn add axios redux-thunk
이번 프로젝트에서 사용되는 API 는 그렇게 많지는 않기 때문에 모든 API 들을 사용하기 쉽게 함수로 만들어서 하나의 파일에 넣어도 큰 상관은 없지만, 더 높은 유지보수성을 위하여 기능별로 분리해서 코드를 작성하겠습니다.
src/lib/api 디렉터리를 만들고 그 안에 기능별로 파일을 따로따로 생성하겠습니다. 그런데, 함수들을 만들기전에 먼저 해줘야 할 작업이 있는데요, 바로 axios 인스턴스를 생성하는 것 입니다.
src/lib/api/client.js
import axios from 'axios';
const client = axios.create();
// 프로덕션 환경에서는 현재 도메인이 아닌 외부 도메인에 요청하도록 합니다.
if (process.env.NODE_ENV === 'production') {
client.defaults.baseURL = 'https://reacters.vlpt.us';
}
export default client;
인스턴스를 따로 만들었을 때의 장점은 나중에 인스턴스의 설정을 수정하여 공통 헤더 혹은 공통 호스트를 설정 해줄 수 있다는 점 입니다. 물론, axios 를 직접 사용해도 axios.defaults.baseURL 혹은 axios.defaults.headers 를 수정하여 설정을 변경해줄 수 있지만, 그렇게 작업을 했을 때 추후 서비스에서 사용하게 되는 API 주소가 두개 이상이 되었을 때 충돌일 일어나게 되므로 미리 이렇게 인스턴스를 만들어서 처리하는 것이 좋습니다.
그리고, 이번 프로젝트에서는 외부 API 를 사용하는 것 이기 때문에 프로덕션에서 baseURL 값을 따로 지정해줬습니다. 이렇게 해주면, 실제 웹서비스가 제공되는 도메인이 달라도, API 요청은 위 주소로 하게 됩니다.
개발 환경에서는 package.json 에서 따로 proxy 값을 설정해주세요.
package.json
(...)
"babel": {
"presets": [
"react-app"
]
},
"proxy": "https://reacters.vlpt.us"
}
지금 당장은 개발 모드에서나 프로덕션 에서다 둘다 똑같은 호스트로 요청을 하고 있기 때문에 방금 해준 작업이 큰 의미는 없지만, 예를 들어서 프록시로 설정하게 되는 주소를 localhost 로 사용한다거나 다른 별도의 테스트서버를 사용해야 하는 경우에 이렇게 설정을 하면 유용합니다.
이 값을 설정하고 난 다음에는 개발 서버를 껐다 다시 켜주세요.
이제 회원인증을 위한 API 를 지니고 있는 함수들을 auth.js 파일을 api 디렉터리에 생성하여 에 작성해봅시다.
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');
redux-thunk 적용하기
리덕스에서 비동기 작업을 관리하기 위하여 우리는 redux-thunk 를 사용하겠습니다. index.js 에서 스토어를 생성하는 코드를 다음과 같이 수정하세요.
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './styles/main.scss';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { BrowserRouter } from 'react-router-dom';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import thunk from 'redux-thunk';
import rootReducer from './modules';
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(thunk))
);
ReactDOM.render(
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>,
document.getElementById('root')
);
serviceWorker.unregister();
이제 회원가입과 로그인을 위한 thunk 함수를 만들어줄 차례인데요, 이 작업을 조금 더 쉽게 하기 위하여 createPromiseThunk 라는 유틸함수를 만들어서 사용해보겠습니다.
lib/api/createPromiseThunk.js
/**
* creates thunk from promiseCreator
* @param {string} actionType
* @param {() => Promise<*>} promiseCreator
*/
export default function createPromiseThunk(actionType, promiseCreator) {
return (...params) => {
return async dispatch => {
// promise begins
dispatch({ type: `${actionType}_PENDING` });
try {
const response = await promiseCreator(...params);
dispatch({
type: `${actionType}_SUCCESS`,
payload: response
});
return response;
} catch (e) {
dispatch({
type: `${actionType}_ERROR`,
payload: e
});
throw e;
}
};
};
}
이 함수의 역할은 첫번째 파라미터에는 액션 타입, 그리고 두번째 파라미터에는 프로미스를 생성하는 함수를 받아와서 프로미스가 시작했을때, 끝났거나 오류났을 때 액션을 자동으로 디스패치해줍니다.
만약에 redux-thunk 를 나중에도 여러분 프로젝트에 적용해서 사용하게 된다면 이러한 유틸함수를 사용하면 그때 그때 thunk 함수를 처음부터 직접 만들어서 사용하는 것 보다 훨씬 유용 할 것입니다.
이제 이 함수를 사용해서 auth 리덕스 모듈에 우리가 만들었던 API 들을 호출하는 thunk 함수들을 생성하고, 이에 따른 상태관리에 필요한 작업들을 해주겠습니다.
modules/auth.js
import { createAction, handleActions } from 'redux-actions';
import createPromiseThunk from '../lib/createPromiseThunk';
import * as authAPI from '../lib/api/auth';
const CHANGE_FIELD = 'auth/CHANGE_FIELD'; // 특정 값을 수정함
const INITIALIZE_FORM = 'auth/INITIALIZE_FORM'; // 폼에 있는 모든 내용을 공백으로 처리
const REGISTER = 'auth/REGISTER'; // 회원가입
const REGISTER_SUCCESS = 'auth/REGISTER_SUCCESS'; // 회원가입 성공
const LOGIN = 'auth/LOGIN'; // 로그인
const LOGIN_SUCCESS = 'auth/LOGIN_SUCCESS'; //로그인 실패
export const changeField = createAction(
CHANGE_FIELD,
({ type, key, value }) => ({
type, // register, login
key, // username, password, passwordConfirm
value // 실제 바꾸려는 값
})
);
export const initializeForm = createAction(INITIALIZE_FORM, type => type); // register / login
export const register = createPromiseThunk(REGISTER, authAPI.register);
export const login = createPromiseThunk(LOGIN, authAPI.login);
const initialState = {
register: {
username: '',
password: '',
passwordConfirm: ''
},
login: {
username: '',
password: ''
},
authorization: null
};
const auth = handleActions(
{
[CHANGE_FIELD]: (state, { payload: { type, key, value } }) => ({
...state,
[type]: {
...state[type],
[key]: value
}
}),
[INITIALIZE_FORM]: (state, { payload: type }) => ({
...state,
[type]: initialState[type]
}),
[REGISTER_SUCCESS]: (state, { payload }) => ({
...state,
authorization: payload.data
}),
[LOGIN_SUCCESS]: (state, { payload }) => ({
...state,
authorization: payload.data
})
},
initialState
);
export default auth;
회원가입부터 구현을 해봅시다. 에러 처리는 나중에 해줄 것이니 지금은 신경쓰지 마세요.
containers/auth/RegisterForm.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import AuthForm from '../../components/auth/AuthForm';
import { changeField, initializeForm, register } from '../../modules/auth';
class RegisterForm extends Component {
handleChange = e => {
const { value, name } = e.target;
this.props.changeField({
type: 'register',
key: name,
value
});
};
handleSubmit = async e => {
e.preventDefault();
const { register, form } = this.props;
if (form.password !== form.passwordConfirm) {
// TODO: 에러 처리
return;
}
try {
await register({
username: form.username,
password: form.password
});
} catch (e) {
// TODO: 에러처리
}
};
componentDidMount() {
this.props.initializeForm();
}
render() {
const { form } = this.props;
return (
<AuthForm
type="register"
form={form}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
/>
);
}
}
export default connect(
({ auth }) => ({ form: auth.register }),
{
changeField,
initializeForm,
register
}
)(RegisterForm);
이렇게 수정을 해주고 나서 한번 회원가입을 시도해보세요. 리덕스 개발자 도구를 열어서 현재 auth.authorization 에 무엇이 있는지 확인해보세요. 아이디가 중복이 되면 오류가 날 것이기 때문에 고유한 아이디로 등록시도해주세요.
회원가입을 성공하면 access_token 과 refresh_token 을 전달받게 되는데, 여기서 전달받은 access_token 을 Authorization 헤더안에 넣으면 로그인을 한 것으로 간주하고 check API 를 사용하여 토큰을 검증합니다. 만약 서버측에서 코드 검증이 제대로 이뤄지면 유저 정보를 응답하게 됩니다.
이제 유저 상태를 담게 될 user 라는 리덕스 모듈을 만들어봅시다.
modules/user.js
import { createAction, handleActions } from 'redux-actions';
import * as authAPI from '../lib/api/auth';
import createPromiseThunk from '../lib/createPromiseThunk';
const CHECK = 'user/CHECK'; // 토큰을 사용하여 회원 정보 확인
const CHECK_SUCCESS = 'user/CHECK_SUCCESS'; // 회원 정보 확인 성공
const CHECK_ERROR = 'user/CHECK_ERROR'; // 회원 정보 확인 실패
const TEMP_SET_USER = 'user/TEMP_SET_USER'; // 새로고침 하고 CHECK 이 성공 할 때 까지 임시로 로그인중임을 보여줘야 할 때 사용
export const check = createPromiseThunk(CHECK, authAPI.check);
export const tempSetUser = createAction(TEMP_SET_USER, user => user);
const initialState = {
user: null
};
export default handleActions(
{
[CHECK_SUCCESS]: (state, { payload }) => ({
...state,
user: payload.data
}),
[CHECK_ERROR]: state => ({
...state,
user: null
}),
[TEMP_SET_USER]: (state, { payload }) => ({
...state,
user: payload
})
},
initialState
);
새 모듈을 만들어줬으니, 루트 리듀서에서도 포함을 시켜주어야겠죠?
modules/index.js
import { combineReducers } from 'redux';
import auth from './auth';
import user from './user';
const rootReducer = combineReducers({
auth,
user
});
export default rootReducer;
그리고, 우리가 이전에 만들었던 axios 인스턴스에 토큰을 적용하는 함수도 client.js 에 작성해주겠습니다.
lib/api/client.js
import axios from 'axios';
const client = axios.create();
export const setToken = token => {
client.defaults.headers.common['Authorization'] = token;
};
// 프로덕션 환경에서는 현재 도메인이 아닌 외부 도메인에 요청하도록 합니다.
if (process.env.NODE_ENV === 'production') {
client.defaults.baseURL = 'https://reacters.vlpt.us';
}
export default client;
이제 회원가입 성공을 하면, 토큰 설정 후 check API 를 호출해보겠습니다.
containers/auth/RegisterForm.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import AuthForm from '../../components/auth/AuthForm';
import { changeField, initializeForm, register } from '../../modules/auth';
import { setToken } from '../../lib/api/client';
import { check } from '../../modules/user';
class RegisterForm extends Component {
handleChange = e => {
const { value, name } = e.target;
this.props.changeField({
type: 'register',
key: name,
value
});
};
handleSubmit = async e => {
e.preventDefault();
const { register, form, check } = this.props;
if (form.password !== form.passwordConfirm) {
// TODO: 에러 처리
return;
}
try {
await register({
username: form.username,
password: form.password
});
const { authorization } = this.props;
localStorage.setItem('authorization', JSON.stringify(authorization)); // 로컬스토리지에 저장
setToken(`Bearer ${authorization.access_token}`);
await check();
} catch (e) {
// TODO: 에러처리
}
};
componentDidMount() {
this.props.initializeForm();
}
render() {
const { form } = this.props;
return (
<AuthForm
type="register"
form={form}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
/>
);
}
}
export default connect(
({ auth }) => ({
form: auth.register,
authorization: auth.authorization
}),
{
changeField,
initializeForm,
register,
check
}
)(RegisterForm);
다시 회원가입을 시도해보세요. 리독서 스토어의 user 값이 제대로 보여지고 있나요?
회원가입이 모두 성공했다면 홈 화면으로 이동시켜줘봅시다. history 객체를 사용하기 위하여 우리는 withRouter 를 사용하여 컴포넌트를 감싸주겠습니다.
containers/auth/RegisterForm.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import AuthForm from '../../components/auth/AuthForm';
import { changeField, initializeForm, register } from '../../modules/auth';
import { setToken } from '../../lib/api/client';
import { check } from '../../modules/user';
import { withRouter } from 'react-router-dom';
class RegisterForm extends Component {
handleChange = e => {
const { value, name } = e.target;
this.props.changeField({
type: 'register',
key: name,
value
});
};
handleSubmit = async e => {
e.preventDefault();
const { register, form, check, history } = this.props;
if (form.password !== form.passwordConfirm) {
// TODO: 에러 처리
return;
}
try {
await register({
username: form.username,
password: form.password
});
const { authorization } = this.props;
localStorage.setItem('authorization', JSON.stringify(authorization)); // 로컬스토리지에 저장
setToken(`Bearer ${authorization.access_token}`);
await check();
if (!this.props.user) return; // TODO: 에러처리
history.push('/');
} catch (e) {
// TODO: 에러처리
}
};
componentDidMount() {
this.props.initializeForm();
}
render() {
const { form } = this.props;
return (
<AuthForm
type="register"
form={form}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
/>
);
}
}
export default connect(
({ auth, user }) => ({
form: auth.register,
authorization: auth.authorization,
user: user.user
}),
{
changeField,
initializeForm,
register,
check
}
)(withRouter(RegisterForm));
여기까지 구현을 하고 나면 회원가입 성공 시 http://localhost:3000/ 페이지로 이동 될 것 입니다.
컴포넌트에서 API 연동을 하게 될 때 꼭 주의 할 점은, API 를 통하여 받아온 데이터를 조회 할 때는 사전에 비구조화 할당하면 안된다는 것 입니다.
// BAD
doSomething = async () => {
const { result, request } = this.props;
await request();
console.log(result); // undefined
}
// GOOD
doSomething = async () => {
const { result, request } = this.props;
await request();
console.log(this.props.result); // 제대로된 데이터
}
이제, 방금 했던 것과 똑같이 LoginForm 도 구현해보겠습니다.
containers/auth/LoginForm.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import AuthForm from '../../components/auth/AuthForm';
import { changeField, initializeForm, login } from '../../modules/auth';
import { check } from '../../modules/user';
import { withRouter } from 'react-router-dom';
import { setToken } from '../../lib/api/client';
class LoginForm extends Component {
handleChange = e => {
const { value, name } = e.target;
this.props.changeField({
type: 'login',
key: name,
value
});
};
handleSubmit = async e => {
e.preventDefault();
const { login, form, check, history } = this.props;
try {
await login({
username: form.username,
password: form.password
});
const { authorization } = this.props;
localStorage.setItem('authorization', JSON.stringify(authorization)); // 로컬스토리지에 저장
setToken(`Bearer ${authorization.access_token}`);
await check();
if (!this.props.user) return; // TODO: 에러처리
history.push('/');
} catch (e) {
// TODO: 에러처리
}
};
componentDidMount() {
this.props.initializeForm();
}
render() {
const { form } = this.props;
return (
<AuthForm
type="login"
form={form}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
/>
);
}
}
export default connect(
({ auth, user }) => ({
form: auth.login,
authorization: auth.authorization,
user: user.user
}),
{
changeField,
initializeForm,
login,
check
}
)(withRouter(LoginForm));
다 작성하셨으면 http://localhost:3000/login 페이지에서 ID:tester, PW:123123 으로 로그인을 시도해보세요. / 경로로 잘 이동 됐나요?
에러 처리하기
회원가입 / 로그인에 있어서 중요한 기능은 거의 구현해주었습니다. 이제 만약 요청이 실패했을 때 보여줄 에러 메시지를 보여주는 UI 를 만들겠습니다.
이런 식으로, 우리는 폼안에 빨간색 텍스트를 띄워주겠습니다.
먼저 AuthForm 컴포넌트를 수정하시구요,
components/auth/AuthForm.js
import React from 'react';
import './AuthForm.scss';
import { Link } from 'react-router-dom';
const textMap = {
login: '로그인',
register: '회원가입'
};
/**
* 회원가입 혹은 로그인 폼을 보여줍니다.
*/
const AuthForm = ({ type, form, onChange, onSubmit }) => {
return (
<div className="AuthForm">
<h3>{textMap[type]}</h3>
<form onSubmit={onSubmit}>
<input
name="username"
placeholder="아이디"
value={form.username}
onChange={onChange}
/>
<input
name="password"
placeholder="비밀번호"
type="password"
value={form.password}
onChange={onChange}
/>
{type === 'register' && (
<input
name="passwordConfirm"
placeholder="비밀번호 확인"
type="password"
value={form.passwordConfirm}
onChange={onChange}
/>
)}
<div className="error">에러입니다.</div>
<button type="submit">{textMap[type]}</button>
</form>
<div className="footer">
{type === 'login' ? (
<Link to="/register">회원가입</Link>
) : (
<Link to="/login">로그인</Link>
)}
</div>
</div>
);
};
export default AuthForm;
.error CSS 클래스를 위한 스타일도 설정한다음에 http://localhost:/3000 에서 로그인 창을 확인해보세요.
components/auth/AuthForm.scss
.AuthForm {
h3 {
margin: 0;
display: inline-flex;
color: $oc-gray-8;
margin-bottom: 1rem;
}
form {
display: flex;
flex-direction: column;
input {
font-size: 1rem;
border: none;
border-bottom: 1px solid $oc-gray-5;
padding-bottom: 0.5rem;
outline: none;
&:focus {
color: $oc-teal-7;
border-bottom: 1px solid $oc-teal-7;
}
}
input + input {
margin-top: 1rem;
}
button {
border-radius: 1.25rem;
background: $oc-gray-9;
height: 2.5rem;
font-size: 1rem;
color: white;
font-weight: 600;
margin-top: 1rem;
margin-bottom: 2rem;
cursor: pointer;
&:hover {
background: $oc-gray-7;
}
}
.error {
font-size: 0.875rem;
color: $oc-red-5;
text-align: center;
margin-top: 1rem;
}
}
.footer {
text-align: right;
a {
font-weight: 600;
text-decoration: underline;
color: $oc-gray-6;
&:hover {
color: $oc-gray-9;
}
}
}
}
빨간색 텍스트가 잘 보였나요? 그렇다면 이 문구는 props 로 error 를 받아왔을 때만 보여주도록 설정하겠습니다.
components/auth/AuthForm.js
import React from 'react';
import './AuthForm.scss';
import { Link } from 'react-router-dom';
const textMap = {
login: '로그인',
register: '회원가입'
};
/**
* 회원가입 혹은 로그인 폼을 보여줍니다.
*/
const AuthForm = ({ type, form, error, onChange, onSubmit }) => {
return (
<div className="AuthForm">
<h3>{textMap[type]}</h3>
<form onSubmit={onSubmit}>
<input
name="username"
placeholder="아이디"
value={form.username}
onChange={onChange}
/>
<input
name="password"
placeholder="비밀번호"
type="password"
value={form.password}
onChange={onChange}
/>
{type === 'register' && (
<input
name="passwordConfirm"
placeholder="비밀번호 확인"
type="password"
value={form.passwordConfirm}
onChange={onChange}
/>
)}
{error && <div className="error">{error}
<button type="submit">{textMap[type]}</button>
</form>
<div className="footer">
{type === 'login' ? (
<Link to="/register">회원가입</Link>
) : (
<Link to="/login">로그인</Link>
)}
</div>
</div>
);
};
export default AuthForm;
이제, auth 리덕스 모듈에서 에러메시지를 설정하는 액션을 추가하세요.
modules/auth.js
import { createAction, handleActions } from 'redux-actions';
import createPromiseThunk from '../lib/createPromiseThunk';
import * as authAPI from '../lib/api/auth';
const CHANGE_FIELD = 'auth/CHANGE_FIELD'; // 특정 값을 수정함
const INITIALIZE_FORM = 'auth/INITIALIZE_FORM'; // 폼에 있는 모든 내용을 공백으로 처리
const REGISTER = 'auth/REGISTER'; // 회원가입
const REGISTER_SUCCESS = 'auth/REGISTER_SUCCESS'; // 회원가입 성공
const LOGIN = 'auth/LOGIN'; // 로그인
const LOGIN_SUCCESS = 'auth/LOGIN_SUCCESS'; //로그인 실패
const SET_ERROR = 'auth/SET_ERROR'; // 에러 메시지 설정
export const changeField = createAction(
CHANGE_FIELD,
({ type, key, value }) => ({
type, // register, login
key, // username, password, passwordConfirm
value // 실제 바꾸려는 값
})
);
export const initializeForm = createAction(INITIALIZE_FORM, type => type); // register / login
export const register = createPromiseThunk(REGISTER, authAPI.register);
export const login = createPromiseThunk(LOGIN, authAPI.login);
export const setError = createAction(SET_ERROR, msg => msg);
const initialState = {
register: {
username: '',
password: '',
passwordConfirm: ''
},
login: {
username: '',
password: ''
},
authorization: null,
error: null
};
const auth = handleActions(
{
[CHANGE_FIELD]: (state, { payload: { type, key, value } }) => ({
...state,
[type]: {
...state[type],
[key]: value
}
}),
[INITIALIZE_FORM]: (state, { payload: type }) => ({
...state,
[type]: initialState[type],
error: null
}),
[REGISTER_SUCCESS]: (state, { payload }) => ({
...state,
authorization: payload.data
}),
[LOGIN_SUCCESS]: (state, { payload }) => ({
...state,
authorization: payload.data
}),
[SET_ERROR]: (state, { payload }) => ({
...state,
error: payload
})
},
initialState
);
export default auth;
이제 회원가입 할 때 에러 처리를 해줍시다.
containers/auth/RegisterForm.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import AuthForm from '../../components/auth/AuthForm';
import {
changeField,
initializeForm,
register,
setError
} from '../../modules/auth';
import { setToken } from '../../lib/api/client';
import { check } from '../../modules/user';
import { withRouter } from 'react-router-dom';
class RegisterForm extends Component {
handleChange = e => {
const { value, name } = e.target;
this.props.changeField({
type: 'register',
key: name,
value
});
};
handleSubmit = async e => {
e.preventDefault();
const { register, form, check, history, setError } = this.props;
setError(null);
if (form.password !== form.passwordConfirm) {
setError('비밀번호가 일치하지 않습니다.');
return;
}
try {
await register({
username: form.username,
password: form.password
});
const { authorization } = this.props;
localStorage.setItem('authorization', JSON.stringify(authorization)); // 로컬스토리지에 저장
setToken(`Bearer ${authorization.access_token}`);
await check();
if (!this.props.user) return setError('오류 발생!'); // TODO: 에러처리
history.push('/');
} catch (e) {
if (!e.repseonse) {
setError('오류 발생!');
return;
}
if (e.response.status === 409) {
setError('이미 존재하는 아이디입니다.');
}
}
};
componentDidMount() {
this.props.initializeForm();
}
render() {
const { form, error } = this.props;
return (
<AuthForm
type="register"
form={form}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
error={error}
/>
);
}
}
export default connect(
({ auth, user }) => ({
form: auth.register,
authorization: auth.authorization,
error: auth.error,
user: user.user
}),
{
changeField,
initializeForm,
register,
check,
setError
}
)(withRouter(RegisterForm));
회원가입 할 때 비밀번호 확인 쪽에 일부러 일치하지 않는 값을 넣어보세요. 에러 메시지가 잘 나타나고 있나요?
회원가입쪽은 이제 끝났습니다. 로그인쪽도 에러처리를 비슷하게 구현시켜줍시다.
containers/auth/LoginForm.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import AuthForm from '../../components/auth/AuthForm';
import {
changeField,
initializeForm,
login,
setError
} from '../../modules/auth';
import { check } from '../../modules/user';
import { withRouter } from 'react-router-dom';
import { setToken } from '../../lib/api/client';
class LoginForm extends Component {
handleChange = e => {
const { value, name } = e.target;
this.props.changeField({
type: 'login',
key: name,
value
});
};
handleSubmit = async e => {
e.preventDefault();
const { login, form, check, history, setError } = this.props;
try {
await login({
username: form.username,
password: form.password
});
const { authorization } = this.props;
localStorage.setItem('authorization', JSON.stringify(authorization)); // 로컬스토리지에 저장
setToken(`Bearer ${authorization.access_token}`);
await check();
if (!this.props.user) return setError('오류 발생!');
history.push('/');
} catch (e) {
if (!e.response) {
return setError('오류 발생!');
}
setError('로그인 실패');
}
};
componentDidMount() {
this.props.initializeForm();
}
render() {
const { form, error } = this.props;
return (
<AuthForm
type="login"
form={form}
error={error}
onChange={this.handleChange}
onSubmit={this.handleSubmit}
/>
);
}
}
export default connect(
({ auth, user }) => ({
form: auth.login,
error: auth.error,
authorization: auth.authorization,
user: user.user
}),
{
changeField,
initializeForm,
login,
check,
setError
}
)(withRouter(LoginForm));
이제 일부러 잘못된 계정으로 로그인 시도를 해보세요.
에러 메시지가 잘 뜨고있나요?
이제 로그인 / 회원가입은 모두 끝났습니다!