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로 바뀐 것.
| Flux | Redux |
|---|---|
| 여러 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가 어떻게 더 단순한 모델을 제시했는지를 살펴봅니다.