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가 어떻게 더 단순한 모델을 제시했는지를 살펴봅니다.