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/loginhttp://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));

이제 일부러 잘못된 계정으로 로그인 시도를 해보세요.

에러 메시지가 잘 뜨고있나요?

이제 로그인 / 회원가입은 모두 끝났습니다!

results matching ""

    No results matching ""