관리 메뉴

거니의 velog

(4) 프론트엔드 프로젝트 : 시작 및 회원 인증 구현 4 본문

React_프론트엔드 프로젝트

(4) 프론트엔드 프로젝트 : 시작 및 회원 인증 구현 4

Unlimited00 2024. 2. 22. 18:26

(3) API 연동하기

* 이제 API를 연동해 보자. axios를 사용하여 API를 연동해 보자. 그리고 리덕스에서 비동기 작업을 쉽게 관리하기 위해 redux-saga와 이전에 만들어서 사용했던 createRequestSaga 유틸 함수를 이용해 보겠다.

* 먼저 필요한 라이브러리를 설치해 주자.

$ yarn add axios redux-saga
{
  "name": "blog-frontend",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "axios": "^0.19.0", // axios 추가
    "immer": "^3.1.3",
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "react-redux": "^7.1.0",
    "react-router-dom": "^5.0.1",
    "react-scripts": "3.0.1",
    "redux": "^4.0.1",
    "redux-actions": "^2.6.5",
    "redux-devtools-extension": "^2.13.8",
    "redux-saga": "^1.0.3", // redux-saga 추가
    "styled-components": "^4.3.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
}

* 이번 프로젝트에서 사용되는 API의 수가 적은 편이므로 모든 API를 함수로 만들고 하나의 파일에 넣어서 관리해도 큰 지장은 없다. 하지만 유지 보수성을 높이기 위해 기능별로 파일을 나누어서 작성해 볼 것이다.

* src/lib/api 디렉터리를 만들고 그 안에 기능별로 파일을 따로따로 생성하겠다.

[1] axios 인스턴스 생성

* API 함수를 작성하기 전에 먼저 해야 할 작업이 있다. 바로 axios 인스턴스를 생성하는 것이다.

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(\
    response => {
      // 요청 성공 시 특정 작업 수행
      return response;
    }, 
    error => {
      // 요청 실패 시 특정 작업 수행
      return Promise.reject(error);
    }
  })  
*/

export default client;

* 이렇게 axios 인스턴스를 만들면 나중에 API 클라이언트에 공통된 설정을 쉽게 넣어 줄 수 있다. 사실 인스턴스를 만들지 않아도 이러한 작업을 할 수 있다. 하지만 인스턴스를 만들지 않으면 애플리케이션에서 발생하는 모든 요청에 대해 설정하게 되므로, 또 다른 API 서버를 사용하려 할 때 곤란해 질 수 있다. 따라서 처음 개발할 때부터 이렇게 인스턴스를 만들어서 작업하는 것을 권장한다.

* 추가로 나중에 axios를 사용하지 않는 상황이 왔을 때 쉽게 클라이언트를 교체할 수 있는 것 또한 장점이다.

[2] 프록시 설정

* 현재 백엔드 서버는 4000 포트, 리액트 개발 서버는 3000 포트로 열려 있기 때문에 별도의 설정 없이 API를 호출하려고 하면 오류가 발생한다. 이 오류를 CORS(Cross Origin Request) 오류라고 부르는데, 네트워크 요청을 할 때 주소가 다른 경우에 발생한다. 이 오류를 해결하려면 다른 주소에서도 API를 호출할 수 있도록 서버 쪽 코드를 수정해야 한다. 그런데 최종적으로 프로젝트를 다 완성하고 나면 결국 리액트 앱도 같은 호스트에서 제공할 것이기 때문에 이러한 설정을 하는 것은 불필요하다.

* 그 대신 프록시(proxy)라는 기능을 사용할 것이다. 웹팩 개발 서버에서 지원하는 기능인데, 개발 서버로 요청하는 API들을 우리가 프록시로 설정해 둔 서버로 그대로 전달해 주고 그 응답을 웹 애플리케이션에서 사용할 수 있게 해 준다.

* CRA로 만든 프로젝트에서 프록시를 설정할 때는 package.json 파일을 수정하면 된다. 해당 파일을 열어 다음 내용을 추가하자.

{
  "name": "blog-frontend",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "axios": "^0.19.0",
    "immer": "^3.1.3",
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "react-redux": "^7.1.0",
    "react-router-dom": "^5.0.1",
    "react-scripts": "3.0.1",
    "redux": "^4.0.1",
    "redux-actions": "^2.6.5",
    "redux-devtools-extension": "^2.13.8",
    "redux-saga": "^1.0.3",
    "styled-components": "^4.3.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
  "eslintConfig": {
    "extends": "react-app"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  },
  "proxy": "http://localhost:4000/"
}

* 이제 리액트 애플리케이션에서 client.get('api/posts')를 하면, 웹팩 개발 서버가 프록시 역할을 해서 http://localhost:4000/api/posts에 대신 요청한 뒤 결과물을 응답해 준다.

[3] API 함수 작성

* 프록시 설정이 끝났으면 개발 서버를 껐다가 다시 실행해 주자. 이어서 회원 인증에 필요한 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');

[4] 더 쉬운 API 요청 상태 관리

* 다음으로 redux-saga를 통해 더 쉽게 API를 요청할 수 있도록 loading 리덕스 모듈과 createRequestSaga 유틸 함수를 설정하겠다.

* 먼저 loading 리덕스 모듈을 작성하자.

[modules/loading.js]

import { createAction, handleActions } from 'redux-actions';

const START_LOADING = 'loading/START_LOADING';
const FINISH_LOADING = 'loading/FINISH_LOADING';

/*
    요청을 위한 액션 타입을 payload로 설정합니다 (예: "sample/GET_POST")
*/

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;

* 이어서 lib 디렉터리에 새 파일을 만들고 createRequestSaga 함수를 작성하자.

import { call, put } from 'redux-saga/effects';
import { startLoading, finishLoading } from '../modules/loading';

export default function createRequestSaga(type, request) {
  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)); // 로딩 끝
  };
}

[5] auth 리덕스 모듈에서 API 사용하기

* 그럼 방금 만든 유틸 함수를 사용하여 auth 리덕스 모듈에서 API를 사용할 수 있도록 구현하겠다.

* 우선 다음과 같이 여섯 가지 액션 타입을 추가로 더 선언해야 한다.

[modules/auth.js]

import { createAction, handleActions } from 'redux-actions';
import produce from 'immer';

const CHANGE_FIELD = 'auth/CHANGE_FIELD';
const INITIALIZE_FORM = 'auth/INITIALIZE_FORM';

const REGISTER = 'auth/REGISTER';
const REGISTER_SUCCESS = 'auth/REGISTER_SUCCESS';
const REGISTER_FAILURE = 'auth/REGISTER_FAILURE';

const LOGIN = 'auth/LOGIN';
const LOGIN_SUCCESS = 'auth/LOGIN_SUCCESS';
const LOGIN_FAILURE = 'auth/LOGIN_FAILURE';

(...)

* 각 요청마다 액션 타입을 세 개 선언해야 하는데, 같은 작업이 조금 반복된다. 코드를 반복해서 작성하는 것이 사람에 따라 조금 귀찮을 수 있다. 이와 같은 경우 액션 타입을 한꺼번에 만드는 함수를 선언하는 방법도 있다. 위 코드를 한번 리팩토링해 보자.

* 우선 createRequestSaga.js 파일을 열고 그 내부에 createRequestActionTypes 라는 함수를 선언하여 내보내자.

[lib/createRequestSaga.js]

import { call, put } from 'redux-saga/effects';
import { startLoading, finishLoading } from '../modules/loading';

export const createRequestActionTypes = (type) => {
  const SUCCESS = `${type}_SUCCESS`;
  const FAILURE = `${type}_FAILURE`;
  return [type, SUCCESS, FAILURE];
};

export default function createRequestSaga(type, request) {
	(...)
}

* 이 함수를 사용하면 요청에 관련된 액션 타입들을 선언할 때 다음과 같이 작성할 수 있다.

[modules/auth.js]

import { createAction, handleActions } from 'redux-actions';
import produce from 'immer';
import { createRequestActionTypes } from '../lib/createRequestSaga';

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');

* 어떤가? 중복되는 코드들이 많이 사라졌다. 취향에 따라 이렇게 리팩토링하여 사용해도 되고, const를 세 번 사용하여 선언해도 상관없다.

* 이제 createRequestSata를 통해 각 API를 위한 사가를 생성하고, 액션 생성 함수와 리듀서도 구현해 보겠다.

[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, // register , login
    key, // username, password, passwordConfirm
    value, // 실제 바꾸려는 값
  }),
);
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 생성
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; // 예: state.register.username을 바꾼다
      }),
    [INITIALIZE_FORM]: (state, { payload: form }) => ({
      ...state,
      [form]: initialState[form],
      authError: null, // 폼 전환 시 회원 인증 에러 초기화
    }),
    // 회원가입 성공
    [REGISTER_SUCCESS]: (state, { payload: auth }) => ({
      ...state,
      authError: null,
      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;

* 구현할 때 로딩에 관련된 상태는 이미 loading 리덕스 모듈에서 관리하므로, 성공했을 때와 실패했을 때의 상태에 대해서만 신경 쓰면 된다.

* 리덕스 모듈을 저장했으면 프로젝트의 rootSaga를 만들어 주자.

[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,
});

export function* rootSaga() {
  yield all([authSaga()]);
}

export default rootReducer;

* 다음으로 스토어에 redux-saga 미들웨어를 적용하자.

[src/index.js]

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
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';

const sagaMiddleware = createSagaMiddleware();
const store = createStore(
  rootReducer,
  composeWithDevTools(applyMiddleware(sagaMiddleware)),
);

sagaMiddleware.run(rootSaga);

ReactDOM.render(
  <Provider store={store}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </Provider>,
  document.getElementById('root'),
);

* 이제 회원가입 및 로그인 기능을 구현하는 데 필요한 리덕스 관련 코드의 준비를 모두 마쳤다.