Ray Book
상태 관리의 발전

왜 상태 관리가 필요했는가

DOM이 곧 상태이던 시대에서 Flux/Redux까지, 상태 관리라는 개념이 왜 등장했고, 어떤 문제를 해결하려 했는지를 추적합니다.

state-managementreduxfluxbackbonehistory

DOM이 곧 상태이던 시대

jQuery 시대에는 "상태 관리"라는 개념 자체가 희미했습니다. 버튼을 누르면 DOM을 직접 조작했습니다.

// jQuery 시대의 "상태 관리"
$('#count').text(Number($('#count').text()) + 1);
$('.item-list').append('<li>' + newItem + '</li>');

상태가 어디에 있느냐고 물으면, 답은 DOM 자체 였습니다. #count의 텍스트가 현재 값이고, .item-list의 자식 개수가 아이템 목록이었습니다. 앱이 작을 때는 이것으로 충분했습니다.

하지만 앱이 복잡해지면서 문제가 드러났습니다.

  • 같은 데이터를 여러 곳에서 보여줘야 할 때, DOM 요소들이 서로 다른 값을 가지는 상황이 발생합니다
  • 데이터가 변경된 원인을 추적할 수 없습니다, 어떤 이벤트 핸들러가 어떤 DOM을 바꿨는지 코드만 봐서는 알기 어렵습니다
  • 서버에서 받은 데이터, 사용자 입력, UI 상태가 전부 DOM에 흩어져 있어 테스트가 불가능합니다

핵심 문제는 상태와 뷰가 분리되지 않았다 는 것입니다. 상태를 읽으려면 DOM을 파싱해야 하고, 상태를 바꾸려면 DOM을 직접 조작해야 합니다. 앱이 커질수록 이 방식은 유지보수가 불가능해집니다.

구체적인 고통

할 일 목록을 생각해봅시다. 할 일을 추가하면 목록 DOM에 <li>를 붙이고, 카운터 DOM의 텍스트를 업데이트하고, 빈 상태 메시지를 숨겨야 합니다. 할 일을 삭제하면 그 반대를 합니다. 완료 표시를 토글하면 스타일을 바꾸고, 완료 카운터도 업데이트해야 합니다.

// 상태가 변경될 때마다 DOM 여러 곳을 직접 업데이트
const todos = [];

$('#add-btn').click(() => {
  const text = $('#input').val();
  todos.push({ text, done: false });

  $('#list').append(`<li>${text} <button class="del">삭제</button></li>`);
  $('#count').text(todos.length);
  $('#empty-msg').hide();
  $('#input').val('');
});

$(document).on('click', '.del', function () {
  const idx = $(this).parent().index();
  todos.splice(idx, 1);
  $(this).parent().remove();
  $('#count').text(todos.length);
  if (todos.length === 0) $('#empty-msg').show();
});

상태 (todos 배열) 와 DOM이 수동으로 동기화 되고 있습니다. 하나라도 빠뜨리면 화면과 데이터가 어긋납니다. 그리고 이 코드만 봐서는 #count가 어떤 이벤트 핸들러들에 의해 변경되는지 알 수 없습니다, 코드베이스 전체를 grep 해야 합니다.

Backbone, 상태와 뷰의 분리

Backbone.js (2010) 는 이 문제에 처음으로 구조적인 답을 내놓았습니다. Model 에 데이터를 두고, View 가 Model을 관찰하여 DOM을 업데이트하는 방식입니다.

const Todo = Backbone.Model.extend({
  defaults: { title: '', completed: false }
});

const TodoList = Backbone.Collection.extend({
  model: Todo
});

const TodoView = Backbone.View.extend({
  initialize() {
    this.listenTo(this.model, 'change', this.render);
    this.listenTo(this.model, 'destroy', this.remove);
  },
  render() {
    this.$el.html(this.template(this.model.toJSON()));
    return this;
  }
});

상태가 DOM에서 빠져나와 JavaScript 객체로 들어왔습니다. 이것만으로도 큰 진전이었습니다.

  • 상태를 읽을 때 DOM을 파싱하지 않아도 됩니다, model.get('title') 로 충분합니다
  • Model의 변경 이벤트를 구독하여 자동으로 View를 업데이트할 수 있습니다
  • Model은 순수한 JavaScript 객체이므로 테스트가 가능합니다

하지만 Backbone은 뷰 업데이트를 전적으로 개발자에게 맡겼습니다. Model이 바뀌면 View의 render()를 호출하지만, 그 안에서 어떤 DOM을 어떻게 바꿀지 는 직접 작성해야 했습니다. 대부분의 경우 render() 안에서 전체 HTML을 다시 그렸는데, 이는 비효율적이고 입력 필드의 포커스 같은 상태를 잃어버리는 문제가 있었습니다.

더 심각한 문제는 데이터 흐름이었습니다. Model과 View가 서로를 양방향으로 업데이트할 수 있었기 때문에, 앱이 커지면 어떤 변경이 어떤 연쇄 반응을 일으키는지 추적하기 어려워졌습니다.

Flux, 단방향이라는 원칙

Facebook은 이 문제를 직접 겪었습니다. Facebook의 채팅 시스템에서 읽지 않은 메시지 카운터가 실제 메시지 목록과 불일치하는 버그가 반복적으로 발생했습니다. 여러 View가 여러 Model을 양방향으로 업데이트하면서, 상태가 어디서 꼬였는지 찾을 수 없었습니다.

2014년 F8 컨퍼런스에서, Facebook의 Tom Occhino와 Jing Chen은 Flux 아키텍처를 발표했습니다. 핵심 원칙은 하나입니다, 데이터는 한 방향으로만 흐른다.

Action → Dispatcher → Store → View

                              Action → ...
  • Action , "무슨 일이 일어났는지"를 기술하는 객체입니다. { type: 'ADD_TODO', text: '할 일' } 같은 형태입니다
  • Dispatcher , 모든 Action을 받아서 등록된 Store들에 전달하는 중앙 허브입니다
  • Store , 상태를 보관하고, Action에 따라 상태를 업데이트합니다. View에서 직접 상태를 변경할 수 없습니다
  • View , Store의 상태를 읽어 화면을 렌더링합니다. 사용자 인터랙션이 일어나면 Action을 발행합니다

양방향 바인딩이 사라졌습니다. View는 Store의 상태를 읽기만 하고, 변경하고 싶으면 반드시 Action을 발행해야 합니다. 이 제약 덕분에 상태 변경의 원인을 추적할 수 있게 되었습니다.

Redux, Flux의 단순화

2015년 React Europe 컨퍼런스에서, Dan Abramov는 Redux 를 발표했습니다. Flux의 아이디어를 유지하되 몇 가지를 단순화했습니다.

Flux에서 Redux로 바뀐 것.

FluxRedux
여러 Store하나의 Store
Store가 직접 상태 변경순수 함수 (Reducer) 가 새 상태를 반환
Dispatcher 필요Dispatcher 불필요
// Redux의 Reducer, 순수 함수
function todosReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { text: action.text, done: false }];
    case 'TOGGLE_TODO':
      return state.map((todo, i) =>
        i === action.index ? { ...todo, done: !todo.done } : todo
      );
    default:
      return state;
  }
}

Reducer는 순수 함수입니다. 현재 상태와 Action을 받아 새 상태를 반환할 뿐, 외부에 부수효과를 일으키지 않습니다. 이 덕분에 강력한 기능이 가능해졌습니다.

  • Time-travel 디버깅 , 상태 변경 이력을 되감고 다시 재생할 수 있습니다
  • 상태 직렬화 , 전체 앱 상태를 JSON으로 저장하고 복원할 수 있습니다
  • 테스트 , Reducer에 이전 상태와 Action을 넣으면 새 상태가 나옵니다. 순수 함수이므로 테스트가 간단합니다

보일러플레이트 지옥

하지만 대가가 있었습니다. 간단한 카운터 하나를 만들려면 이만큼의 코드가 필요했습니다.

// 1. Action 타입 상수
const INCREMENT = 'INCREMENT';

// 2. Action Creator
function increment() {
  return { type: INCREMENT };
}

// 3. Reducer
function counterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case INCREMENT:
      return { ...state, count: state.count + 1 };
    default:
      return state;
  }
}

// 4. 컴포넌트 연결 (react-redux의 connect 사용, Counter는 별도 정의)
import { connect } from 'react-redux';

const mapStateToProps = state => ({ count: state.count });
const mapDispatchToProps = { increment };
export default connect(mapStateToProps, mapDispatchToProps)(Counter);

카운터 하나에 Action 타입, Action Creator, Reducer, connect 설정, 코드량이 실제 로직 대비 과도합니다. 비동기 작업을 추가하면 Redux-Thunk나 Redux-Saga 같은 미들웨어까지 필요해집니다.

Redux는 "예측 가능성"이라는 가치를 분명히 달성했습니다. 하지만 그 대가로 "개발 속도"를 희생했고, 이 보일러플레이트에 대한 피로감이 다음 세대 상태 관리 라이브러리들의 등장을 촉발했습니다.

다음 단계

Redux의 보일러플레이트 문제를 인식한 후, 각 프레임워크 생태계는 각자의 방식으로 해결책을 찾아갔습니다. 다음 글에서는 Vue가 Vuex의 mutation을 왜 버렸는지, Pinia가 어떻게 더 단순한 모델을 제시했는지를 살펴봅니다.