[TIL] Redux 미들웨어 + 코드 스플리팅

지현·2026년 6월 23일
post-thumbnail

🔍 Redux 미들웨어

1. Redux가 필요한 이유

React에서 state는 컴포넌트 안에 있다. 근데 여러 컴포넌트가 같은 데이터를 써야 하면?

App
├── Header      ← 여기서도 로그인 정보 필요
├── Sidebar     ← 여기서도 로그인 정보 필요
└── Main
    └── Profile ← 여기서도 로그인 정보 필요

Redux는 state를 컴포넌트 밖, Store라는 창고에 모아둔다. 모든 컴포넌트가 여기서 꺼내 쓴다.

Store의 state는 직접 바꾸면 안 된다. 반드시 dispatch → Reducer를 통해서만 바꿔야 한다.

버튼 클릭 → dispatch(액션) → Reducer → 새 state → 화면 업데이트
  • Store — state 보관 창고. 앱에 딱 하나.
  • Reducer — "이 액션이 오면 state를 이렇게 바꿔라" 규칙서. 역할별로 여러 개 만들고 combineReducers로 합친다.
  • dispatch — "야 Reducer야, 이 액션 처리해줘!" 하고 전달하는 함수.

2. Store는 어디에 있나?

앱 최상단에서 딱 한 번 만들어진다.

// index.js
const store = createStore(rootReducer, applyMiddleware(logger, thunk));

ReactDOM.render(
  <Provider store={store}> // 전체 앱을 감싸서 Store를 공급
    <App />
  </Provider>,
  document.getElementById('root')
);

combineReducers로 역할별 Reducer를 하나로 합친다:

// modules/index.js
const rootReducer = combineReducers({
  counter,  // 카운터 담당
  sample,   // post, users 담당
  loading,  // 로딩 상태 담당
});

3. 미들웨어가 필요한 이유

dispatch는 기본적으로 객체(액션) 만 받는다. 즉시 Reducer로 보내버린다.
근데 "1초 뒤에 실행", "API 응답 오면 실행" 같은 걸 하려면 끼워넣을 공간이 없다.
미들웨어는 dispatchReducer 사이에 자리를 만들어준다.

dispatch(액션) → logger → thunk → Reducer → 새 state

미들웨어는 Store를 만들 때 등록한다:

const store = createStore(
  rootReducer,
  applyMiddleware(logger, thunk) // 순서대로 통과
);

4. logger 미들웨어 - 직접 만들어보기(개념 이해용)

실무에서는 redux-logger 라이브러리를 쓰면 된다.
여기서는 미들웨어가 내부적으로 어떻게 동작하는지 이해하기 위해 직접 만들어봤다.

미들웨어 구조는 함수가 3겹으로 중첩된 형태다. Redux가 store → next → action 순서로 하나씩 넘겨주는 구조에 맞춰진 것이다.

const loggerMiddleware = (store) => (next) => (action) => {
  // store  : Redux가 처음에 넘겨줌 (상태 조회용)
  // next   : Redux가 두 번째로 넘겨줌 (다음 미들웨어 or Reducer로 넘기는 함수)
  // action : 실제 dispatch될 때 넘겨줌
  console.log("이전 상태", store.getState()); // 아직 안 바뀜
  console.log("액션", action);
  next(action); // ← 없으면 Reducer에 도달 안 함!
  console.log("다음 상태", store.getState()); // 바뀐 후
};

next(action)을 호출해야 다음 미들웨어 또는 Reducer로 넘어간다. 이게 없으면 액션이 거기서 멈춘다.


5. Thunk

dispatch가 호출될 때 thunk 미들웨어가 이걸 확인한다: "넘어온 게 함수야, 객체야?"

// thunk 내부 동작 (개념)
if (typeof action === "function") {
  action(dispatch); // 함수면 → thunk가 직접 실행시켜줌
} else {
  next(action);     // 객체면 → 그냥 Reducer로 보냄
}

평소엔 dispatch({ type: "INCREASE" }) 처럼 객체를 넘기면 Reducer로 직행한다.
근데 함수를 넘기면 thunk가 가로채서 실행시켜준다. 덕분에 아래와 같이 가능해진다.

// counter.js — increaseAsync를 풀어서 읽으면
export const increaseAsync = () => { // 1. increaseAsync() 호출하면
  return (dispatch) => {             // 2. 함수를 반환 (thunk가 이걸 실행)
    setTimeout(() => {
      dispatch(increase());          // 3. 1초 뒤에 dispatch
    }, 1000);
  }
};

6. connect와 useEffect

useEffect — 컴포넌트가 화면에 나타났을 때 실행할 것을 지정한다.

useEffect(() => {
  getPost(1);   // 화면 뜨자마자 API 호출
  getUsers(1);
}, []); // [] = 처음 화면 떴을 때 딱 한 번만 실행

두 번째 인자에 따라 실행 시점이 달라진다:

  • useEffect(() => {...}) — 매 렌더링마다 실행
  • useEffect(() => {...}, []) — 처음 한 번만 실행
  • useEffect(() => {...}, [count]) — count가 바뀔 때마다 실행

connect — Redux Store와 컴포넌트를 연결한다. 두 가지 일을 한다:

export default connect(
  ({ sample, loading }) => ({
    post: sample.post,                      // Store에서 꺼내서 props로
    loadingPost: loading["sample/GET_POST"],
  }),
  { getPost, getUsers }, // dispatch 연결
)(SampleContainer);

7. 전체 비동기 흐름

1. 화면 뜸
   → useEffect 실행 → getPost(1) 호출

2. dispatch(thunk 함수)
   → logger: 콘솔에 찍음
   → thunk: "함수네!" → 실행시켜줌
   → loading: true → 로딩 스피너 표시

3. API 호출 (axios)
   → 서버 응답 대기 중...

4. dispatch({ type: GET_POST_SUCCESS, payload: data })
   → Reducer가 state 업데이트
   → loading: false → 로딩 스피너 사라짐

5. connect가 변화 감지
   → 새 state를 props로 전달
   → 화면 리렌더링 → 데이터 표시 🎉


🔍 코드 스플리팅

왜 필요할까?

React 앱을 빌드하면 모든 JS가 하나의 파일로 합쳐진다.
앱이 커질수록 사용자가 첫 접속 시 받아야 하는 파일이 무거워진다.
한 번도 방문하지 않는 페이지 코드까지 미리 다운로드하는 건 낭비다.

코드 스플리팅: 필요한 코드만 그때그때 로드해서 초기 로딩 속도를 높인다.


1단계: 정적 import (스플리팅 없음)

import notify from "./notify"; // 앱 시작할 때 무조건 다운로드

버튼을 한 번도 안 눌러도 notify.js는 이미 다운로드 돼 있다. 낭비.


2단계: 동적 import

const onClick = () => {
  import("./notify").then((result) => result.default());
};


버튼을 클릭하는 그 순간 처음으로 notify.js를 다운로드한다.
import()는 Promise를 반환하므로 .then()으로 완료 후 처리한다.


3단계: React.lazy + Suspense

컴포넌트를 스플리팅할 때 사용한다.

const SplitMe = React.lazy(() => import("./SplitMe"));

React.lazy는 이 컴포넌트가 화면에 처음 나타날 때 그제야 다운로드한다.
다운로드가 끝나기 전까지 React는 "아직 준비 안 됨" 상태다. 이때 Suspense가 대신 fallback UI를 보여준다.

// 식당에 앉자마자 음식 나오기 전 물 먼저 주는 것과 같다
<Suspense fallback={<div>loading...</div>}>
  {visible && <SplitMe />}
</Suspense>

단점: SSR 미지원, Suspense로 반드시 감싸야 함.


4단계: @loadable/component

3단계의 단점을 보완한 라이브러리.

const SplitMe = loadable(() => import("./SplitMe"), {
  fallback: <div>loading...</div>,
});
  • Suspense 없이 fallback을 옵션으로 바로 넘김
  • SSR 지원
  • preload() 기능 제공
// 마우스 올리는 순간 미리 다운로드 → 클릭 시 즉각 표시
const onMouseOver = () => {
  SplitMe.preload();
};

preload: 사용자가 마우스를 올리는 순간 파일을 미리 받아놓는다. 실제 클릭 시 이미 로드되어 있어 즉각 표시 → UX 향상.


코드 스플리팅 단계 정리

단계방식특징
1단계정적 import무조건 미리 다운로드 (낭비)
2단계동적 import클릭 시 다운로드, 함수/모듈용
3단계React.lazy클릭 시 다운로드, 컴포넌트용, Suspense 필요
4단계loadableSSR 지원 + preload 가능, 실무 권장

0개의 댓글