View on GitHub

FireFours

React Study: 발등에 불떨어진 4학년들의 모임

2020년 1월 19일

1. 배운 내용

18. 리덕스 미들웨어를 통한 비동기 작업 관리

- 미들웨어란?

리덕스 미들웨어는 액션을 디스패치했을 때 리듀서에서 이를 처리하기에 앞서 사전에 지정된 작업들을 실행합니다. 미들웨어는 액션과 리듀서 사이의 중간자라고 볼 수 있습니다.

pic

액션 발생 -> 미들웨어에서 필요한 데이터를 API서버를 통해 가져옴 -> 해당 액션에 따른 리듀서 함수 실행 -> 업데이트된 내용과, 받아온 데이터를 store에 저장 (이 과정을 렌더링시 계속 반복)
const loggerMiddleware = store => next => action => {
    //미들웨어 기본 구조
};
export default loggerMiddleware;
위 코드에서 리덕스 미들웨어의 구조를 볼 수 있습니다. 화살표 함수를 연달아서 사용했는데, 일반 function 키워드로 풀어서 쓴다면 다음과 같은 구조입니다.
const loggerMiddleware = function loggerMiddleware(store) {
    return function(next) {
        return function(action) {
            // 미들웨어 기본 구조
        };
    };
};
미들웨어는 결국 함수를 반환하는 함수를 반환하는 함수입니다. 여기에 있는 함수에서 파라미터로 받아 오는 store는 리덕스 스토어 인스턴스를, action은 디스패치된 액션을 가리킵니다. next 파라미터는 함수 형태이며, store.dispatch와 비슷한 역할을 합니다. 하지만 가장 큰 차이점이 있습니다. next(action)을 호출하면 그다음 처리해야 할 미들웨어에게 액션을 넘겨주고, 만약 그다음 미들웨어가 없다면 리듀서에게 액션을 넘겨준다는 것입니다.

- redux-saga

redux-saga는 redux-thunk 다음으로 많이 사용되는 비동기 작업 관련 미들웨어입니다. 함수 형태의 액션을 디스패치하여 미들웨어에서 해당 함수에 스토어의 dispatch와 getState를 파라미터로 넣어서 사용하는 원리입니다. API 요청도 하고, 다른 액션을 디스패치하거나 현재 상태를 조회하기도 했습니다. 추가적으로 redux-saga는 좀 더 까다로운 상황에서 유용합니다.

19. 코드 스플리팅

리액트 프로젝트를 완성하여 사용자에게 제공할 때는 빌드 작업을 거쳐서 배포해야 합니다. 빌드 작업을 통해 프로젝트에서 사용되는 자바스크립트 파일 안에서 불필요한 주석, 경고 메시지, 공백 등을 제거하여 파일 크기를 최소화하기도 하고, 브라우저에서 JSX 문법이나 다른 최신 자바스크립트 문법이 원활하게 실행되도록 코드의 트랜스파일 작업도 할 수 있습니다. create-react-app의 기본 웹팩 설정에는 SplitChunks라는 기능이 적용되어 node_modules에서 불러온 파일, 일정 크기 이상의 파일, 여러 파일 간에 공유된 파일을 자동을 따로 분리시켜서 캐싱의 효과를 제대로 누릴 수 있게 해 줍니다. 이렇게 파일을 분리하는 작업을 코드 스플리팅이라고 합니다. 그러나 프로젝트에 기본 탑재된 SplitChunks 기능을 통한 코드 스플리팅은 단순히 효율적인 캐싱 효과만 있을 뿐입니다. 만약 애플리케이션의 규모가 커질 경우 당장 필요하지 않은 컴포넌트 정보도 모두 불러오면서 파일 크기가 매우 커집니다. 그러면 로딩이 오래 걸리기 때문에 사용자 경험도 안 좋아지고 트래픽도 많이 나올 것입니다. 이러한 문제점을 해결해 줄 수 있는 방법이 바로 코드 비동기 로딩입니다. 코드 비동기 로딩을 통해 자바스크립트 함수, 객체, 혹은 컴포넌트를 처음에는 불러오지 않고 필요한 시점에 불러와서 사용할 수 있습니다.

20. 서버 사이드 렌더링

서버 사이드 렌더링은 UI를 서버에서 렌더링하는 것을 의미합니다. 앞에서 만든 프로젝트는 기본적으로 클라이언트 사이드 렌더링을 하고 있습니다. 클라이언트 사이드 렌더링은 UI 렌더링을 브라우저에서 모두 처리하는 것입니다. 즉, 자바스크립트를 실행해야 우리가 만든 화면이 사용자에게 보입니다.
서버 사이드 렌더링 장점
참조 https://medium.com/humanscape-tech/redux%EC%99%80-%EB%AF%B8%EB%93%A4%EC%9B%A8%EC%96%B4-thunk-saga-43bb012503e4 (사진) / 리액트를 다루는 기술

2. Q&A

질문: loading의 상태를 관리할 때 loading.GET_POST가 아니라 loading['sample/GET_POST']의 형태로 써야하는 특별한 이유가 있는지?

export default connect(
  ({ sample, loading }) => ({
    post: sample.post,
    users: sample.users,
    loadingPost: loading["sample/GET_POST"],
    loadingUsers: loading["sample/GET_USERS"]
  }),
 (...)

해결:

sample/GET_POST에 /가 있기 때문에 loading과 .으로 연결할 수 없다. 그래서 []를 쓴 것. 그리고 loading.GET_POST을 써도 똑같이 잘 작동한다. 특별한 이유로 위와 같이 쓴 것은 아닌 것으로 함께 결론지었다.

질문: ‘redux-saga를 사용한 비동기 카운터’의 작동 흐름 정리

// modules/counter.js

import { createAction, handleActions } from "redux-actions";
import { delay, put, takeLatest, takeEvery } from "redux-saga/effects";

const INCREASE = "counter/INCREASE";
const DECREASE = "counter/DECREASE";

const INCREASE_ASYNC = "counter/INCREASE_ASYNC";
const DECREASE_ASYNC = "counter/DECREASE_ASYNC";

// 4) increase, decrease가 실행되어 INCREASE, DECREASE 액션이 생성된다.
export const increase = createAction(INCREASE);
export const decrease = createAction(DECREASE);

// 1) Counter컴포넌트의 +1버튼, -1버튼을 클릭하면 increaseAsync, decreaseAsync가 실행되면서 INCREASE_ASYNC, DECREASE_ASYNC 액션을 생성한다.
export const increaseAsync = createAction(INCREASE_ASYNC, () => undefined);
export const decreaseAsync = createAction(DECREASE_ASYNC, () => undefined);

// 3) increaseSaga, decreaseSaga가 실행되면서 각각 increase, decrease 액션을 디스패치한다.
function* increaseSaga() {
  yield delay(1000);
  yield put(increase());
}

function* decreaseSaga() {
  yield delay(1000);
  yield put(decrease());
}

// 2) INCREASE_ASYNC, DECREASE_ASYNC 액션이 들어올 때 각각 increaseSaga, decreaseSaga(제너레이터함수=사가)를 실행해준다.
export function* counterSaga() {
  yield takeEvery(INCREASE_ASYNC, increaseSaga);
  yield takeLatest(DECREASE_ASYNC, decreaseSaga);
}

const initialState = 0;

const counter = handleActions(
  {
    // 5) INCREASE, DECREASE액션이 실행되며 state에 +1 혹은 -1이 적용되어 state의 값이 변한다!
    [INCREASE]: state => state + 1,
    [DECREASE]: state => state - 1
  },
  initialState
);

export default counter;

해결:

작동하는 흐름을 순서대로(step1 ~ step5) 정리하여, 위 코드에 주석으로 달아놓았다.

3. 심화 이해

(P570) SSR에서 PreloadContext 만들기 이해하기

서버 사이드 렌더링에선 useEffect나 componentDidMount에서 설정한 작업이 호출되지 않기 때문에,
이 안에서 Data를 Fetching 하는 작업이 있다면 이를 꼭 미리 요청하여 스토어에 담아두어야 렌더링할 때 필요한 데이터를 보여줄 수 있습니다.

이를 위한 작업을 PreloadContext란 Context API를 만들어 미리 처리해야할 요청들을 담고
Preloader/usePreloader라는 요청을 담을 수 있는 함수/Hook을 만든다.

//src/lib/PreloaderContext.js

import { createContext, useContext } from 'react';

// 클라이언트 환경: null
// 서버 환경:{ done: false, promises: [] }
const PreloadContext = createContext(null);
export default PreloadContext;

// resolve는 함수 타입입니다.
export const Preloader = ({ resolve }) => {
  const preloadContext = useContext(PreloadContext);
  if (!preloadContext) return null; // context 값이 유효하지 않다면 아무것도 하지 않음
  if (preloadContext.done) return null; // 이미 작업이 끝났다면 아무것도 하지 않음

  // promises 배열에 프로미스 등록
  // 설령 resolve 함수가 프로미스를 반환하지 않더라도, 프로미스 취급을 하기 위하여
  // Promise.resolve 함수 사용
  preloadContext.promises.push(Promise.resolve(resolve()));
  return null;
};

위 코드에서 핵심이 되는 코드 preloadContext.promises.push(Promise.resolve(resolve())); 는 Promise.resolve로 감싼 resolve()를 Context에 추가하는 작업을 진행한다.

여기서 왜 Promise.resolve로 감싸는지가 잘 이해가 가지 않았다. 위 설명은 설령 resolve 함수가 프로미스를 반환하지 않더라도, 프로미스 취급을 하기 위한다고 하나
(1) 그럼 resolve가 이미 프로미스인 경우 또 프로미스로 감쌌을 때 문제가 생기지 않을까?
(2) 그리고 그냥 일반적인 값을 감쌌을 때 어떻게 결과가 나올까?

직접 위의 경우를 대입해보니 이해가 금방 갔다.

(1) 아래와 같이 3미만을 입력했을 때 reject가 되는 Promise를 만들고 이를 그냥 실행했을 때와 Promise.resolve()로 감쌌을 때를 비교하였다. 결과는 둘의 차이가 없었다.

const bigger_than_3 = (i) => {
    return new Promise((resolve, reject) => 
      { if (i>3) { resolve(true) } 
        else { reject(false)} }
)}

bigger_than_3(4)
//>> Promise {<resolved>: true}

Promise.resolve(bigger_than_3(4))
//>> Promise {<resolved>: true}

bigger_than_3(2)
//>> Promise {<rejected>: false}

Promise.resolve(bigger_than_3(2))
//>> Promise {<rejected>: false}

(2) 그렇다면 일반적인 값의 경우엔 어떨까? 아래의 경우를 살펴보면 어떤 값이 더라도
resolved를 반환한다.

Promise.resolve(10)
//>> Promise {<resolved>: 10}
Promise.resolve(false)
//>> Promise {<resolved>: false}
Promise.resolve(true)
//>> Promise {<resolved>: true}
Promise.resolve(undefined)
//>> Promise {<resolved>: undefined}

이러한 Preloader를 컴포넌트화 하여 아래와 같이 resolve해야할 Promise를 넣어준다. 이렇게 되면 Context에 getUsers가 담기게된다.

return (
  <>
    <Users users={users} />
    <Preloader resolve={getUsers} />
  </>
);

마지막 index.server.js에서 렌더링 전에 Context 속 모든 Promise를 기다리도록 합니다.

//index.server.js
  try {
    await sagaPromise; // 기존에 진행중이던 saga 들이 모두 끝날때까지 기다립니다.
    await Promise.all(preloadContext.promises); // 모든 프로미스를 기다립니다.
  } catch (e) {
    return res.status(500);
  }
  preloadContext.done = true;
  const root = ReactDOMServer.renderToString(jsx); // 렌더링을 합니다.