fe.resolver.ts
fe.resolver.ts

Powered by Notion & Next.js

Navigate

  • 개인정보처리방침

Connect

  • GitHub

© 2026 Hanul Lee. All rights reserved.

Powered by Notion & Next.js

목록으로
Development2023년 10월 14일

"전역 상태 관리 뭐 쓰세요?"에 떳떳해지기 위해

내가 Redux보다 Recoil을 선호하는 이유

#React

개요

"전역 상태 관리 뭐 쓰세요?”

프론트엔드 개발자라면 면접에서든, 사이드 프로젝트에서든 한 번쯤은 마주치는 질문이다.

그런데 나는 이 질문을 받을 때마다 조금 불편했다. 솔직히 "왜 이걸 선택했는지"를 명확하게 설명하지 못했기 때문이다. Redux를 쓰면 "원래 다 쓰니까요" 였고, Recoil를 쓰면 "팀에서 쓰고 있으니까요" 였다.

최근 Preact, Svelte, SolidJS 같은 경량 프레임워크들이 새 프로젝트에서 선택되는 경우가 늘고 있다.

하지만 이미 운영 중인 수많은 서비스는 React 기반이고, 앞으로도 한동안은 유지보수가 필요하다. 전역 상태 관리는 그 생태계의 핵심 인프라인데, 정작 왜 이 도구가 이렇게 생겼는지를 이해하지 못한 채 사용하고 있는 느낌을 받았다.

이번 글에서는 단순히 ‘사용법’이 아니라, 각 라이브러리의 설계 철학과 멘탈 모델을 비교해보겠다. 내가 이해한 과정 그대로를 담았기 때문에 틀린 부분이 있을 수 있다.

이번 포스팅은 Redux와 Recoil, 두 라이브러리의 철학을 비교하고 내가 왜 Recoil을 선호하게 되었는지를 정리한 글이다.

Redux

https://www.npmjs.com/package/redux

faviconhttps://www.npmjs.com/package/redux

Redux를 이해하려면 먼저 왜 이게 만들어졌는지를 알아야 한다.

2014년, Facebook은 알림 카운트 버그로 유명한 사건을 겪는다. 메시지 알림이 있다고 표시되는데, 클릭하면 아무것도 없는 버그. 원인은 여러 곳에서 같은 상태를 각자의 방식으로 변경하고 있었기 때문이다. 어디서 상태가 바뀌었는지 추적이 불가능했다.

이 문제를 해결하기 위해 Facebook은 Flux 아키텍처를 발표했고, Dan Abramov가 이를 세련되게 구현한 것이 Redux다. 핵심 사상은 공식 문서 첫 줄에 그대로 나온다.

Redux is a predictable state container for JavaScript apps.

Predictable 이 단어가 Redux의 전부다.

보일러 플레이트

추가, 삭제 액션(기능)만 존재하는 TODO를 만들기 위해서 Redux로는 아래처럼 작성해야한다.

typescript
import { createStore } from "redux";

type Todo = {
  id: number;
  text: string;
};

type InitialState = {
  todos: Todo[];
};

const initialState: InitialState = {
  todos: []
};

const ADD = "ADD" as const;
const DELETE = "DELETE" as const;

// ACTION
const addTodo = (text: string) => {
  return {
    type: ADD,
    text
  };
};
const deleteTodo = (id: number) => {
  return {
    type: DELETE,
    id
  };
};
type ActionType = ReturnType<typeof addTodo> | ReturnType<typeof deleteTodo>;

// REDUCER
const reducer = (state = initialState, action: ActionType) => {
  switch (action.type) {
    case ADD:
      return {
        ...state,
        todos: [{ text: action.text, id: Date.now() }, ...state.todos]
      };
    case DELETE:
      return {
        ...state,
        todos: state.todos.filter((toDo) => toDo.id !== action.id)
      };
    default:
      return state;
  }
};
export type RootState = ReturnType<typeof reducer>;

// STORE
const store = createStore(reducer);

export const actionCreators = {
  addTodo,
  deleteTodo
};

export default store;

처음엔 단순히 보일러플레이트 많다 = 나쁘다 고 생각했는데, 한 가지 생각해볼 점이 있다. 왜 Redux는 이렇게 장황하게 설계되었을까? 단순히 설계가 구리기 때문일까? 나는 아니라고 생각한다.

Redux의 구조를 뜯어보면, Action → Reducer → Store → View라는 단방향 흐름을 보장하기 위한 것임을 알 수 있다. Action Type을 문자열 상수로 선언하고, Action Creator 함수를 만들고, Reducer에서 switch문으로 분기하는 이 모든 과정은 — "누가 언제 왜 이 상태를 바꿨는지"를 100% 추적 가능하게 만들기 위한 장치다.

즉 보일러플레이트는 어마어마한 양의 불필요한 코드가 아니라, 상태 변화를 추적 가능하게 만들기 위한 명시적 선언이다. 물론 그 대가가 너무 크다는 데에는 동의한다.

Reducer는 순수 함수여야 한다 — 그래서 미들웨어가 필요하다

Reducer의 규칙은 명확하다.

  • 같은 입력 → 같은 출력

사이드 이펙트는 허용되지 않는다.

그런데 현실의 프론트엔드에서 사이드 이펙트 없는 앱이 존재할까? API 호출, 라우팅 변경, 로컬 스토리지 저장… 이 모든 것이 sideEffect 다.

그래서 Redux는 순수한 영역과 불순(?)한 영역을 명확히 분리하는 전략을 택했다.

이 분리가 Redux의 미들웨어 시스템이다. Reducer가 순수하기 때문에 테스트가 쉽고, 불순한 로직은 미들웨어에 격리되어 관리된다. 직관적이지는 않지만, 관심사 분리(Separation of Concerns)라는 소프트웨어 공학의 기본 원칙에 충실한 설계다.

typescript
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";

/*
 * ... ACTION 까지 동일
 */

type DispatchType = (action: ActionType) => void;

// Middleware
const addTodoAsync = (text: string) => {
  return (dispatch: DispatchType) => {
    fetch("/todo", {
      method: "POST",
      body: JSON.stringify({ text })
    })
      .then((res) => res.json())
      .then(() => {
        dispatch({
          type: ADD,
          text
        });
      })
      .catch((error) => {
        // error handling
      });
  };
};

const deleteTodoAsync = (id: number) => {
  return (dispatch: DispatchType) => {
    fetch(`/todo/${id}`, {
      method: "DELETE"
    })
      .then((res) => res.json())
      .then(() => {
        dispatch({
          type: DELETE,
          id
        });
      })
      .catch((error) => {
        // error handling
      });
  };
};

/*
 * ... REDUCER 동일
 */


// STORE
const store = createStore(reducer, applyMiddleware(thunk));

export const actionCreators = {
  addTodoAsync,
  deleteTodoAsync
};

export default store;

export type RootState = ReturnType<typeof reducer>;

생략한 부분까지 포함하면 코드샌드박스기준 104 line(…) 이라는 정신나간 보일러 플레이트를 자랑한다.

redux-thunk, redux-saga 같은 미들웨어 라이브러리를 사실상 필수로 추가해야 한다는 현실적 부담도 있다.

보일러 플레이트를 줄여보자. redux-toolkit

https://github.com/reduxjs/redux-toolkit

https://www.npmjs.com/package/@reduxjs/toolkit

faviconhttps://www.npmjs.com/package/@reduxjs/toolkit

thunk 미들웨어가 내장되어 있고, immer를 통한 불변성 관리가 기본 포함되어 있어, 사실상 Redux의 철학은 유지하면서 DX(Developer Experience)만 극적으로 개선한 형태다.

여담이지만, Redux Toolkit은 Redux 팀이 “네, 저희도 보일러플레이트가 너무 심한거 알고 있습니다" 라고 인정한 결과물이라고 생각한다.

핵심은 createSlice다. Action Type 상수 선언, Action Creator 함수 생성, Reducer switch문 — 이 세 가지를 한 곳에 자동 생성해준다:

redux-toolkit으로 줄인 코드

typescript
import {
  configureStore,
  createSlice,
  createAsyncThunk
} from "@reduxjs/toolkit";

type Todo = {
  id: number;
  text: string;
};

type InitialState = {
  todos: Todo[];
};

const initialState: InitialState = {
  todos: []
};

const todosSlice = createSlice({
  name: "todos",
  initialState: initialState,
  reducers: {
    addTodo: (state, action) => {
			// 불변성을 유지시켜준다.
      state.todos.push({ text: action.payload, id: Date.now() });
    },
    deleteTodo: (state, action) => {
			// 마찬가지로 이렇게 사용해도 문제없음
      state.todos = state.todos.filter((todo) => todo.id !== action.payload);
    }
  }
});

export const { addTodo, deleteTodo } = todosSlice.actions;

// 비동기 작업을 위한 Thunk
export const addTodoAsync = createAsyncThunk(
  "todos/addTodoAsync",
  async (text: string, thunkAPI) => {
    const response = await fetch("/todos", {
      method: "POST"
      body: JSON.stringify({ text })
    });
    try {
      const data = await response.json();
			// pure action			
      thunkAPI.dispatch(addTodo(text));

    } catch (err) {
			// error handling
    }
  }
);

// Store
const store = configureStore({
  reducer: todosSlice.reducer
});

export const { dispatch } = store;

export default store;

절반 가량의 line을 줄여버릴 수 있다.

프로젝트의 규모가 클수록, 관리하는 state들이 많을수록, 빛을 발하며, Reducer의 로직도 간편하게 작성할 수 있다는 장점이 있어, Redux를 사용한다면 많은 사람들이 redux-toolkit을 채용한다.

Recoil

https://github.com/facebookexperimental/Recoil

https://www.npmjs.com/package/recoil

faviconhttps://www.npmjs.com/package/recoil

2025년 1월, Recoil 은 공식 archived 되면서 역사의 뒤안길로 사라지게 되었다.

Atom — 상태의 최소 단위

Recoil의 핵심 개념은 Atom이다. Atom은 상태의 가장 작은 조각이다.

typescript
import { atom } from 'recoil';

const todoListState = atom({
  key: 'todoListState',
  default: [],
});

이게 전부다. Redux에서 Store 설정, Action Type 선언, Action Creator, Reducer, combineReducers... 이 모든 것이 필요했던 TODO 리스트의 상태가, Recoil에서는 이 네 줄이다.

컴포넌트에서 사용 할 때도 useState와 거의 동일하다:

typescript
import { useRecoilState } from 'recoil';

function TodoList() {
  const [todoList, setTodoList] = useRecoilState(todoListState);
  
  const addTodo = (text: string) => {
    setTodoList((prev) => [
      ...prev, 
      { id: Date.now(), text }
    ]);
  };
  const deleteTodo = (id: number) => {
    setTodoList((prev) => prev.filter((todo) => todo.id !== id));
  };
  return (
    // ...
  );
}

Redux에서 104줄이 필요했던 코드가, Recoil에서는 컴포넌트 코드 포함해서 이 정도로 끝난다.

미들웨어? 필요 없다. async/await를 그냥 쓰면 된다.

Selector — 전역 파생 상태

Recoil에서 내가 가장 마음에 드는 부분은 Selector다.

typescript
import { selector } from 'recoil';

const filteredTodoListState = selector({
  key: 'filteredTodoListState',
  get: ({ get }) => {
    const todos = get(todoListState);
    const filter = get(todoFilterState);
    
    switch (filter) {
      case 'completed':
        return todos.filter((todo) => todo.completed);
      case 'uncompleted':
        return todos.filter((todo) => !todo.completed);
      default:
        return todos;
    }
  },
});

Selector는 Atom에서 파생되는 계산된 값이다. 여기서 핵심은 get(todoListState)나 get(todoFilterState)를 호출하는 순간, 의존성이 자동으로 추적된다는 것이다.

todoListState가 변하면 이 Selector도 자동으로 재계산된다. todoFilterState가 변해도 마찬가지.

이 패턴을 이해하는 가장 쉬운 비유는 스프레드시트 라고 한다.

셀(Atom)에 값을 쓰면, 그 셀을 참조하는 수식(Selector)이 자동으로 재계산된다. Excel에서 =SUM(A1:A10)를 쓰면 A1~A10 이 변할 때 알아서 업데이트되는 것이다.

구독 모델 — 진짜 필요한 것만

Redux에서 가장 조심해야 하는 것 중 하나가 불필요한 리렌더링이다.

typescript
// Redux — Store 전체를 구독하면 안 됨
const state = useSelector((state) => state); // 🚫
const todos = useSelector((state) => state.todos); // ✅

useSelector에 정확한 selector를 넣지 않으면 Store의 아무 값이 바뀔 때마다 리렌더링된다. 이걸 실수하면 성능 문제로 직결된다.

Recoil은 이 문제가 원천적으로 발생하지 않는다. Atom 자체가 구독 단위이기 때문이다.

typescript
// Recoil — todoListState가 변할 때만 이 컴포넌트가 리렌더
const todos = useRecoilValue(todoListState);
// todoFilterState가 변해도 이 컴포넌트는 리렌더되지 않음

todoListState Atom을 구독한 컴포넌트는 그 Atom이 변할 때만 리렌더된다. 다른 Atom이 아무리 변해도 영향을 받지 않는다.

물론 단점도 있다

  1. 문자열 Key 관리
typescript
const todoListState = atom({
  // 이게 겹치면 콘솔에 거슬리게 경고가 뜨고, 의도치 않은 상태 공유가 일어날 수 있다.
  key: 'todoListState', 
  default: [],
});

모든 Atom과 Selector에 고유한 문자열 key를 지정해야 한다.

프로젝트가 커지면 key 이름 충돌을 방지하는 것이 은근히 신경 쓰인다. 컨벤션을 잘 정하지 않으면 나중에 고통받을 수 있다.

2. Predictable 하지 않다

Redux가 그 많은 보일러플레이트를 감수하면서까지 지키려 했던 것은 추적 가능성 이었다.

Recoil은 이 제약이 없다. 어떤 컴포넌트에서든 setTodoList를 호출하면 그냥 바뀐다.

자유롭지만, 그만큼 상태 변경의 흐름을 추적하기 어려워진다.

프로젝트가 커지고 팀이 커질수록, "이 상태 어디서 바뀐 거야?"라는 질문에 답하기가 점점 어려워질 수 있다. (이것이 정확히 Redux가 탄생한 배경이기도 하다.)

3. 관심사 분리가 약하다

Redux에서는 상태 변경 로직이 리듀서에, 비동기 로직은 미들웨어에, 읽기는 셀렉터에 강제로라도 분리된다.

Recoil은 이런 강제가 없다. setTodoList 안에 비즈니스 로직을 넣어도 되고, 컴포넌트 안에서 직접 상태를 조작해도 된다.

자유도가 높다는 건, 뒤집으면 컨벤션 없이는 코드가 쉽게 산만해진다는 뜻이다.

끝맺음

단점을 짚긴 했지만, 솔직하게 말하겠다.

지금 시점에서 나는 Recoil이 더 매력적이다.

프론트엔드 개발에서 내가 가장 많이 하는 일은

사용자 인터랙션에 따라 UI를 바꾸는 것 이다.

폼 입력, 필터 변경, 모달 열기/닫기, 리스트 정렬…

이런 일상적인 작업에 Redux의 Action → Reducer → Store를 매번 거치는 건 과잉 설계라고 느낀다.

Recoil은 이 작업을 useState만큼이나 가볍게 처리하면서도, 파생 상태 자동 계산, Atom 단위 정밀 구독, 비동기 내장 같은 강력한 기능을 함께 제공한다.

물론 Redux가 나쁜 도구라는 게 아니다.

Redux가 해결하려는 문제 대규모 팀에서 상태 변경을 추적 가능하게 만들기 는 진짜이고 중요하다.

다만 내가 다루는 규모의 프로젝트에서는,

그 추적 가능성의 대가가 너무 크다고 판단했다.

도구는 문제를 해결하기 위해 존재한다.

좋은 도구를 고르는 기준은 “무엇이 최고인가", “무엇이 트렌디한가” 가 아니라,

"내 문제에 무엇이 맞는가" 이지 않을까.

Loading image...
Notion Image

  • 개요
  • Redux
  • 보일러 플레이트
  • Reducer는 순수 함수여야 한다 — 그래서 미들웨어가 필요하다
  • 보일러 플레이트를 줄여보자. redux-toolkit
  • redux-toolkit으로 줄인 코드
  • Recoil
  • Atom — 상태의 최소 단위
  • Selector — 전역 파생 상태
  • 구독 모델 — 진짜 필요한 것만
  • 물론 단점도 있다
  • 끝맺음