코린이의 개발 일지

React로 Server Side Rendering 구현하기 - 3. Data fetching 본문

웹 (web)/프론트엔드

React로 Server Side Rendering 구현하기 - 3. Data fetching

폴라민 2023. 3. 30. 02:20
반응형

이전 글에서 Server에서 Components 컴파일해서 HTML문서 생성하고

클라이언트에서 응답으로 받은 HTML에 다운 받은 JavaScript 파일을 Hydration해서 이벤트 동작까지 하는 과정을 살펴봤다.

 

 

React로 Server Side Rendering 구현하기 - 1. 웹팩 설정

Next.js 쓰면 되는데, SSR을 직접 구현하는 이유? Next.js를 다뤄 보면서 내가 SSR에 제대로 이해하지 못하고, 구현에 급급하여 사용하고 있다는걸 느꼈다. Server Side Rendering을 조금 더 깊게 이해해보고

polarmin.tistory.com

 

React로 Server Side Rendering 구현하기 - 2. 서버 구축

서버 구축 이제 서버 사이드 렌더링을 처리할 서버를 작성할 차례이다. Express를 설치한다. npm install express 앞서 만들어 뒀던 index.server.tsx 파일 내용을 초기 렌더링을 해서 정적파일을 클라이언트

polarmin.tistory.com

 

 

이번 글에서는 서버에서 api 요청을 통해 fetching한 데이터를 html에 넣어주는 데이터 로딩을 다룰 예정이다.

 

미리 api 요청을 보내서 pre-rendering을 하는 것이 사실상 서버 사이드 렌더링의 핵심이라 볼 수 있다.

데이터 로딩을 해오는 과정이 상당히 까다로워서 이런저런 트러블 슈팅이 많았는데.. 이번 글에서 차근차근 풀어보자.

 

 

목표

현재 요청 첫 응답으로 받는 HTML 문서는 아래와 같은 형태이다.

데이터 없이 정적 컴포넌트만 렌더링된 HTML

우리는 서버에서 미리 data fetch 해와서 pre-rendering을 한 후에, 첫 응답으로 다음과 같이 데이터가 포함된 HTML을 보내줄 것이다.

 

pre-loading data가 필요한 곳

서버에서 pre-loading한 데이터가 필요한 곳은 다음 두가지 이다.

  • 응답으로 받은 데이터를 포함하여 서버에서 렌더링 (HTML 생성)
  • 서버에서 응답으로 받은 데이터 클라이언트에서 재사용 (api 요청 두번 보내는 거 방지)

 

그러니까

1. 서버에서 요청 응답으로 받아온 데이터는 서버에서 렌더링할 때 필요하다.

2. 그리고 이 데이터를 클라이언트에서 재 요청 없이 사용할 수 있어야한다.

 

아래 코드를 보자

function TodoList() {
  const { todos, loading } = // todo 리스트 데이터 fetch 해오는 로직
  return (
    <TodoSection>
      <Header>Todo List</Header>
      <ListBox>
        <TodoInputBox />
        {loading ? (
          <Loading />
        ) : (
          todos.map((item) => <TodoItemBox key={item.id} currentTodo={item} />)
        )}
      </ListBox>
    </TodoSection>
  );
}

api 요청 응답으로 받은 데이터를 HTML에 주입한 상태를 서버에서 렌더링하는게 목표이기 때문에 서버에서 위의 코드를 렌더링 할 때, 요청 응답으로 받은 todos를 가지고 있는 상태여야한다.

 

만약 todos 데이터를 가지고 있지 않은 상태라면 왼쪽 사진처럼 페이지가 첫 응답으로 오게 된다.

오른쪽 사진 처럼 데이터가 채워져 있는 Html을 첫 응답으로 보내줘야 한다.

 

 

그리고 클라이언트에서 렌더링 할때는 api 요청 로직을 호출하면 안되기 때문에, todos 데이터를 이미 가지고 있어야한다.

따라서 서버에서 보내줄 정보는 다음 두개이다. 

1. 데이터까지 포함해서 렌더링한 HTML

2. 응답으로 받은 데이터

 

데이터를 따로 보내줄 수는 없으니 window 전역 객체를 활용했다.

아래처럼 HTML문서를 생성하여 보내줄 때, window전역 객체에 데이터를 등록하는 script 태그를 포함하여 보내면, client에서 window객체에 접근하여 preload 된 데이터를 사용할 수 있을 것이다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no" />
    <meta name="theme-color" content="#000000" />
    <title>React App</title>
  </head>
  <body>
    <div id="root"></div>
    <script>__PRELOADED_STATE__=응답으로 받은 데이터 정보</script>
    <script src="${manifest.files['main.js']}"></script>
  </body>
</html>

 

 

api 요청 응답으로 받은 데이터를 TodoList 컴포넌트 내에서도 사용해야하고, HTML을 생성하는 index.server.tsx에서도 사용해야하니, 전역 상태로 저장하는 방식을 택했다.

 

  • Redux toolkit을 사용할 것이다.
  • 원래는 plain redux를 사용하려고 했으나 아래의 redux 공식문서 내용을 보고 생각을 바꿨다.

We want all Redux users to write their Redux code with Redux Toolkit, because it simplifies your code and eliminates many common Redux mistakes and bugs!

 

디버깅 시 이점과 코드의 간결함 때문에 Redux toolkit을 사용하는 걸 권장한다고 한다.

https://redux.js.org/introduction/why-rtk-is-redux-today

 

Why Redux Toolkit is How To Use Redux Today | Redux

Introduction > Why RTK is Redux Today: details on how RTK replaces the Redux core

redux.js.org

https://stackoverflow.com/questions/71944111/redux-createstore-is-deprecated-cannot-get-state-from-getstate-in-redux-ac

 

Redux createStore() is deprecated - Cannot get state from getState() in Redux action

So, the createStore() Redux is now deprecated and configureStore() is recommended from @reduxjs/toolkit. I'm pretty sure it's related to not being able to get userInfo state using getState() in my

stackoverflow.com

 

 

Redux 코드

plain redux를 쓰려면 api 요청 로직을 구현할 때, redux thunk나 redux saga와 같은 미들웨어를 사용해야하나, redux toolkit은 redux thunk가 기본적으로 내장되어 있어서 따로 설치하지 않아도 된다.

 

밑에 코드는 서버 사이드 렌더링과 관계없이 그냥 redux 사용하는 코드이다. 

비동기 처리에 필요한 로직만 가져왔다. 

// ./module/todos.ts

import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';

// api 요청
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
  const data = await httpGet(getApiUrl('/todos'));
  return data;
});

export const initialState: TodoSliceState = { loading: false, error: null, todos: [] };

export const todoList = createSlice({
  name: 'todoList',
  initialState,
  reducers: {
    ...
  },
  extraReducers: (builder) => {
    builder
      // 통신 중
      .addCase(fetchTodos.pending, (state) => ({ ...state, loading: true, error: null }))
      // 통신 성공
      .addCase(fetchTodos.fulfilled, (state, { payload }) => ({
        ...state,
        error: null,
        loading: false,
        todos: payload,
      }))
      // 통신 에러
      .addCase(fetchTodos.rejected, (state, { payload }) => ({
        ...state,
        error: payload as FetchError,
        loading: false,
      }));
  },
});

export const { addTodo, removeTodo, updateTodo } = todoList.actions;
export default todoList.reducer;

 

여기서는 redux toolkit의 사용방식에 대해서 자세히 설명하지는 않겠다. 아래 링크를 참고해주길 바란다.

https://blog.hwahae.co.kr/all/tech/6946

 

Redux Toolkit (리덕스 툴킷)은 정말 천덕꾸러기일까?

Redux Toolkit 최근 훅 기반의 API 지원이 가속화되고 React Query, SWR 등 강력한 데이터 패칭과 캐싱 라이브러리를 사용하면서 리덕스 사용이 줄어드는 방향으로 프론트엔드 기술 트렌드가 변화하고 있

blog-wp.hwahae.co.kr

 

https://velog.io/@yeogenius/Redux-Toolkit-%EC%97%90%EC%84%9C-createAsyncThunk-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0

 

Redux Toolkit 에서 createAsyncThunk 사용하여 비동기 처리하기

thunk 란? > Thunk 란? 특정 작업을 나중에 할 수 있도록 미루기 위해 함수 형태로 감싼 것 예를 들어 주어진 파라미터에 1을 더하는 작업이 있다면 addOne() 이라는 작업을 1초 뒤에 실행할 수 있도록 ad

velog.io

 

각각 만든 Reducer를 아래 rootReducer에서 합쳐서 하나의 reducer로 export한다.

// ./module/rootReducer.ts

import { combineReducers } from '@reduxjs/toolkit';
import todos from './todos';
import modalInfo from './modalInfo';

// 만들어 놓은 리듀서들을 합친다.
const reducer = combineReducers({
  todos,
  modalInfo,
});

// React에서 사용할 수 있도록 타입을 만들어 export 해준다.
export type ReducerType = ReturnType<typeof reducer>;
export default reducer;

 

여기 부분이 좀 중요하다.

store를 만드는 로직인데, preloadedState를 반드시 설정해주어야 한다. preloadedState는 클라이언트에서 window 객체에 등록해 놓은 데이터를 initState로 store에 저장할 때 필요한 부분이다.

preloadedState를 지정해주지 않으면, 클라이언트에서 api요청을 다시 보내게 된다.

// ./module/store.ts

import { configureStore, StateFromReducersMapObject } from '@reduxjs/toolkit';
import reducer from './rootReducer';
import { TodoSliceState } from './todos';

export type RootState = StateFromReducersMapObject<typeof reducer>;

export function initStore(preloadedState?: TodoSliceState) {
  return configureStore({
    reducer,
    preloadedState: {
      todos: preloadedState,
    },
  });
}

type Store = ReturnType<typeof initStore>;

export type AppDispatch = Store['dispatch'];

 

이제 redux 로직은 다 구현했으니 데이터를 preloading해보자

 

서버에서 데이터 preloading

// ./index.server.tsx

import { Provider } from 'react-redux';
import { initStore } from './module/store';
import { TodoSliceState } from './module/todos';


const serverRender = async (req: Request, res: Response) => {
  const store = initStore();
  const jsx = (
      <Provider store={store}>
        <StaticRouter location={req.url}>
          <App />
        </StaticRouter>
      </Provider>
  );
  const root = ReactDOMServer.renderToString(jsx); // 렌더링
  const stateString = JSON.stringify(store.getState().todos).replace(/</g, '\\u003c');
  const stateScript = `<script>__PRELOADED_STATE__=${stateString}</script>`; // 리덕스 초기 상태를 스크립트로 주입
  return res.send(createPage(root, stateScript)); // 클라이언트에 결과물 응답
};

...

 

저번 로직에서 바뀐 부분 위주로 코드를 들고 왔다.

serverRender를 보면 우선 initStore()를 통해 store를 가져온다. 서버에서는 preloading된 데이터가 아직 없으니 초기값이 세팅된 store이다.

따라서 __PRELOADED_STATE__ 전역 객체에 저장되는 데이터는 preloading 된 데이터가 아닌 그냥 초기값이 세팅되게 된다.

 

여기에 preloading 해오는 로직을 끼워 넣어주어야 한다. 아래와 같은 Context를 활용해보자.

const preloadContext = {
    done: false,
    promises: [],
  };

여기서 done은 preloading이 끝났는지 확인하는 프로퍼티이고, promises는 Promise객체들이 들어갈 배열이다.

여기서 Promise 객체는 비동기 로직 실행 후에 반환된 Promise이다.

 

아래의 코드를 보자. preloadContext를 초기화 해주고, Context Provider로 context를 내려준다.

ReactDOMServer.renderToStaticMarkup(jsx) 는 preloader 함수를 실행하여 promises 배열에 반환값을 채우기 위해 넣은 로직이다. 

promises 배열에 Promise객체들이 채워지면, 모든 promise를 기다리고, done = true로 바꿔준다.

이러면 preloading이 끝난 것이다. store에서 todos 상태를 가지고 오면, preloading된 데이터가 출력된다.

 

// ./index.server.tsx

const PreloadContext = createContext<null | ServerContext>(null);

// 서버 사이드 렌더링을 처리할 핸들러 함수
const serverRender = async (req: Request, res: Response) => {
  const preloadContext: ServerContext = {
    done: false,
    promises: [],
  };
  const store = initStore();
  const jsx = (
    <PreloadContext.Provider value={preloadContext}>
      <Provider store={store}>
        <StaticRouter location={req.url}>
          <App />
        </StaticRouter>
      </Provider>
    </PreloadContext.Provider>
  );
  ReactDOMServer.renderToStaticMarkup(jsx); // preloader로 넣어준 함수를 호출하기 위해 넣은 로직
  try {
    await Promise.all(preloadContext.promises);
  } catch (e) {
    return res.status(500);
  }
  preloadContext.done = true;
  const root = ReactDOMServer.renderToString(jsx); // 렌더링
  const stateString = JSON.stringify(store.getState().todos).replace(/</g, '\\u003c');
  const stateScript = `<script>__PRELOADED_STATE__=${stateString}</script>`; // 리덕스 초기 상태를 스크립트로 주입
  return res.send(createPage(root, stateScript)); // 클라이언트에 결과물 응답
};

 

 

위의 설명에서 빠진 부분은 preloader 함수를 실행 시켜서 promises 배열에 api응답으로 온 Promise 객체들을 채우는 작업이다.

(ReactDOMServer.renderToStaticMarkup(jsx) 이부분으로 해결한 부분)

 

renderToStaticMarkup 라는 메소드는 주로 리액트를 사용하여 정적인 페이지를 만들 때 사용하는 메소드이다. 이 함수로 만든 리액트 렌더링 결과물은 클라이언트에서 HTML DOM 인터랙션을 지원하기 힘든 대신 renderToString보자 속도가 빠르다.

 

renderToStaticMarkup 메소드를 실행하여, 컴포넌트들을 쭉 컴파일하고, 그 과정에서 안에 있던 preloading 함수들을 실행 시켜 그 반환값들을 promises객체에 넣었다.

 

아래 코드를 보자

아래에 preloading 해주는 커스텀 훅을 따로 정의해두고 이걸 사용할 것이다. 

// ./lib/PreloaderContext.ts

import { createContext, useContext, Context } from 'react';

// 클라이언트 환경: null
// 서버 환경:{ done: false, promises: [] }

const PreloadContext: PreloadContextInterface = createContext<null | ServerContext>(null);
export default PreloadContext;

// resolve는 함수 타입.
export const usePreloader = (resolve: () => PreloadData) => {
  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;
};

 

TodoList 컴포넌트에서 usePreloader를 호출해서 fetchTodos() 응답으로 받은 data를 리덕스 store에 dispatch한다.

usePreloader는 dispatch하고 응답으로 받은 Promise를 preloadContext에 저장한다.

 

실행을 시켜보면, 아래 console.log로 찍히는 todos는 이미 preloading된 데이터이기 때문에, if 조건문에 걸려서 재요청을 보내지 않고 리턴된다.

// ./component/TodoList/index.tsx

function TodoList() {
  const { setContent, closeModal } = useModal();
  const dispatch = useDispatch<AppDispatch>();
  usePreloader(() => dispatch(fetchTodos()));
  const { todos, loading } = useSelector<ReducerType, TodoSliceState>((state) => state.todos);
  useEffect(() => {
    if (todos && todos.length !== 0) return;
    try {
      dispatch(fetchTodos());
    } catch (e) {
      setContent(`${e}`, [{ name: 'Confirm', handler: closeModal }]);
    }
  }, [dispatch, todos]);
  return (
    <TodoSection>
      <Header>Todo List</Header>
      <ListBox>
        <TodoInputBox />
        {loading ? (
          <Loading />
        ) : (
          todos && todos.map((item: TodoResponseInterface) => <TodoItemBox key={item.id} currentTodo={item} />)
        )}
      </ListBox>
    </TodoSection>
  );
}

 

 

Client에서 preloading 데이터 가져오기 & hydration

import React from 'react';
import { hydrateRoot, createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { initStore } from './module/store';
import App from './App';

const store = initStore(window.__PRELOADED_STATE__);
function Root() {
  delete window.__PRELOADED_STATE__;
  return (
    <Provider store={store}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </Provider>
  );
}

const root = document.getElementById('root') as HTMLElement;
if (process.env.NODE_ENV === 'production') {
  hydrateRoot(root, <Root />);
} else {
  createRoot(root).render(<Root />);
}

window 전역 객체에 저장해둔 데이터를 가져와서 redux store에 preloadState로 넘겨준다.

그리고 응답으로 받은 HTML 문서에 hydration해주면 된다.

 

 

결과

  • 첫 응답으로 server side rendering된 HTML이 잘 온 것을 확인 할 수 있다.

 

기존에 만들어 뒀던 CSR 애플리케이션과 비교해보았다.

CSR은 네트워크가 느린 상황에서 자바스크립트가 로딩되고 실행될 때까지 비어있는 페이지가 나타난다.

SSR은 첫 응답으로 렌더링된 HTML을 받기 때문에 자바스크립트가 로딩되기 전에도 콘텐츠가 채워져있는 Html이 나타난다. 

왼쪽이 CSR, 오른쪽이 SSR

 

 

 

 

Repository

  • 소스코드는 아래 레포지토리에서 확인하실 수 있습니다.

https://github.com/leesunmin1231/React_SSR

 

GitHub - leesunmin1231/React_SSR: React로 server side rendering 구현하기

React로 server side rendering 구현하기. Contribute to leesunmin1231/React_SSR development by creating an account on GitHub.

github.com

 

반응형
Comments