Redux 시작하기

React 관련된 프로젝트를 살펴보면 거의 빠지지 않고 등장하는 라이브러리가 바로 Redux입니다. React를 사용하기 전에도 이름 정도는 들어봤던 라이브러리지만 구체적으로 뭘 하는 라이브러인지, 사용하면 어떤 점에서 이점이 있는지 등은 잘 모른 채로 지나갔었습니다. 최근에 프로젝트의 프론트 개발을 React로 진행하면서 다시금 관심을 갖게 되었는데, 그 과정에서 배운 점들을 남겨봅니다.

먼저 Redux가 무엇인지 살펴보고, 간단한 TODO 앱을 작성하면서 Redux를 이해하고, 어떻게 활용할 수 있을 지 보도록 하겠습니다.

이번 주제는 글을 총 2편으로 나눠서, 이번 글에서는 Redux를 활용하는 기본적인 로직을 작성하면서 Redux 자체에 대해 이해하고, 다음 글에서 Redux를 React와 함께 사용하는 방법을 다뤄보도록 하겠습니다.

또한 이 글을 작성하면서 다음과 같은 사이트를 참고하였습니다.

Redux 문서(Eng): https://redux.js.org/

Redux 문서(Kor): https://deminoth.github.io/redux/

NAVER D2: http://d2.naver.com/helloworld/1848131

대체 Redux가 뭐죠?

당연한 얘기지만 처음 Redux에 대해 들었을 때 든 생각입니다. 뭐 하는 라이브러리지?

Redux 공식 사이트의 Motivation 항목입니다.

자바스크립트 SPA(singl page application)의 요구사항들은 갈수록 복잡해지고 있습니다. 코드가 이전보다 더 많은 상태(state)를 관리해야 하죠. 이 상태에는 서버 응답과 캐시된 데이터, 당연히 로컬에서 생성됐지만 아직 서버에 저장되지 않은 데이터가 포함됩니다. 활성 라우트나 선택된 탭, 스피너, 페이지네이션 등을 관리해야 함에 따라 UI 상태 또한 복잡해지고 있습니다.

언제든지 바뀔 수 있는 이러한 상태들을 관리하는 것은 어렵습니다. 한 모델이 다른 모델을 갱신할 수 있다면, 뷰는 다른 모델을 갱신하는 모델을 차례로 갱신할 수 있게 되고, 다른 뷰를 갱신하게 만들 수 있습니다. 어떤 점에서는 상태가 언제, 왜, 그리고 어떻게 바뀌는지에 대한 통제를 잃게 되면서 더이상 앱에 대해 이해하지 못한다고도 볼 수 있겠습니다. 시스템이 불투명하고 비결정적인 경우에는 버그를 재현하거나 새로운 기능을 추가하는 것은 어려워집니다.

개발자로써, 낙관적 업데이트(optimistic updates), 서버 사이드 렌더링, 라우트 이동 전에 데이터 받아오기 등, 프론트 엔드 제품의 개발에서 새로운 요구사항들이 흔하게 변해가는 것을 생각해보면, 문제는 이뿐만이 아닙니다. 이전에는 전혀 할 필요 없었던 복잡성 관리를 시도하는 스스로를 발견하면서 질문을 던집니다. 포기할 때일까? 답은 ‘아니오’ 입니다.

이 복잡성은 변화와 비동기성이라는, 인간의 사고로는 추론하기 어려운 두 개념을 혼합하기 때문에 다루기 어려운 것입니다. 저는 이들을 멘토스와 콜라라고 부릅니다. 둘은 떨어져 있을 때는 괜찮지만, 합쳐지면 엉망진창이 됩니다. React와 같은 라이브러리는 비동기와 DOM의 직접 조작을 통해 이 문제를 뷰 계층에서 해결합니다. 하지만, 데이터의 상태를 관리하는 일은 여러분에게 달려 있죠. 이곳에 Redux가 들어갑니다.

요약하자면, React에서 View 계층을 구성하는 단위인 컴포넌트는 각각 상태(state)라고 하는 값을 가질 수 있습니다. 하지만 애플리케이션이 복잡해질수록 컴포넌트 수가 증가하게 되면 이 상태들을 관리하는 일이 어려워진다는 것이 문제의 핵심입니다. Redux는 각각의 컴포넌트가 관리하던 상태를 스토어라는 객체가 한 곳에서 관리하도록 함으로써 상태 관리의 복잡도를 낮출 수 있는 라이브러리라고 할 수 있겠습니다.

Redux의 3요소

Redux는 상태를 관리함에 있어서 3가지의 핵심 철학을 가지고 있습니다. 이들 철학을 대표하는 것이 액션, 리듀서, 스토어인데, Redux의 동작에서 핵심적인 역할을 하는 구성 요소들입니다.

진실의 원천은 단 하나: 스토어

애플리케이션의 전체 상태는 하나의 스토어를 통해 객체 트리로 저장됩니다.

스토어는 다음과 같은 책임을 갖습니다.

  • 애플리케이션 상태 보관
  • getState()를 통한 상태로의 접근 허용
  • dispatch(action)을 통한 상태의 갱신 허용
  • subscribe(listener)를 통한 리스너 등록
  • subscribe(listener)에서 반환되는 함수를 통해 리스너의 등록 해제

스토어는 애플리케이션의 상태를 보관하는 객체로 애플리케이션 내에 단 하나만 존재할 수 있다는 점과, 위와 같은 함수를 제공함으로써 상태를 관리/변경하기 때문에 상태의 변화를 추적하기 용이하다는 점을 알 수 있습니다.

상태는 읽기 전용이다: 액션

상태를 바꾸는 유일한 방법은 무슨 일이 일어났는지를 기술하는 액션을 발생시키는 것입니다.

액션은 애플리케이션(컴포넌트)에서 스토어로 전달하는 데이터의 정보에 대한 페이로드입니다. 이들은 이들은 스토어에게 있어 유일한 정보의 원천입니다. store.dispatch()를 사용해서 액션을 스토어에 보낼 수 있습니다.

일종의 이벤트와 비슷한 개념으로 생각해볼 수 있을 것 같습니다. 애플리케이션의 상태를 변경하는 어떤 사건이 발생했을 때, 이를 기술하는 객체가 액션이라고 할 수 있겠네요.

상태의 변화는 순수 함수에 의해 이루어진다: 리듀서

상태 트리가 액션에 의해 어떻게 변화하는지를 명세하기 위해 순수 리듀서를 작성합니다.

리듀서는 스토어로 보내진 액션에 반응하여 애플리케이션의 상태가 어떻게 변화할지를 명세합니다. 액션은 *무슨일이 발생했다*는 사실만을 명시하고, 애플리케이션의 상태가 어떻게 변화하는지는 설명하지 않습니다.

순수 함수는 함수의 수행이 입력에 대한 계산 외에 부수적인 효과(외부 상태 변경)를 동반하지 않으면서 동일한 입력에 대해서는 언제나 동일한 출력을 내는 함수를 의미합니다.

정리하면, 리듀서는 액션을 입력으로 받아 스토어의 상태가 어떻게 변경될 지를 기술하는 함수라고 할 수 있겠습니다.

위의 내용을 종합하면, Redux 앱에는 애플리케이션의 전체 상태를 보관하는 객체(스토어)가 존재합니다. 상태가 변경되야 하는 어떤 사건이 발생하면 사건이 발생했음을 알리는 객체(액션)을 스토어에 전달하고, 스토어는 상태가 어떻게 변경될 지를 기술한 리듀서(함수)를 통해 상태가 변경되게 합니다.

일단 써봅시다

Redux가 무엇인지, 핵심 철학과 이를 대표하는 구성 요소를 간단히 살펴봤습니다. 이제, 간단한 TODO 앱을 작성해보면서 실제로 Redux가 어떻게 도움이 될 지 알아보도록 하겠습니다.

요구사항

저희가 예제로 작성할 앱은 TODO(할 일) 앱입니다. 요구사항을 간단히 정리해보면 다음과 같습니다.

  • 입력 폼을 통해 할 일을 입력받아 할 일 리스트에 추가한다.
  • 리스트의 할 일은 사용자 입력에 따라 완료한 일로 변경하거나 리스트에서 제거한다.
  • 사용자의 선택에 따라 리스트의 출력 내용을 변경한다.
    • 할 일 보기
    • 완료한 일 보기
    • 모두 보기

Redux 로직 구현

이를 React앱으로 구현하기 전에, 상태 변경 로직들을 Redux만 이용해서 구현해보도록 하겠습니다.

언어는 TypeScript를 사용했고, 기본적인 프로젝트 생성이나 설정 부분은 따로 다루지 않았습니다.

본 글에서 작성한 예제는 Github에서 확인하실 수 있습니다.

디렉터리 구조는 다음과 같습니다

/
├ src/			# TypeScript 소스코드
│ ├ lib/		# 각종 객체와 enum 타입 정의
│ ├ action/		# 액션
│ ├ reducer/		# 리듀서
│ └ store/		# 스토어
├ package.json		# dependency 정보
└ tsconfig.json		# TypeScript 컴파일 정보

먼저 Todo 클래스를 정의합니다.

src/lib/todo.ts

export enum TodoState { 
	TODO = "TODO",
	COMPLETED = "COMPLETED",
}

export class TodoItem {

	public constructor(
		private id: number,
		private text: string,
		private state: TodoState,
	) { }

	public getId() { return this.id; }
	public getText() { return this.text; }
	public getState() { return this.state; }
}

액션

이제 액션을 정의해보겠습니다.

요구사항에 따라 정의한 액션은 총 4가지입니다.

  • Todo 추가
  • Todo 제거
  • Todo 완료
  • Visibility 변경

각 액션은 액션 객체의 type값을 통해 구별하게 됩니다. type 값을 구분할 수 있으면 자료형은 큰 상관 없는 것으로 보이는데, 일반적으로 string을 사용한다고 합니다.

아래와 같이 문자열 상수로 액션의 type값을 정의했습니다.

src/action/todo.ts

export enum todoActionTypes {
	ADD_TODO = "TODO_ADD",
	REMOVE_TODO = "TODO_REMOVE",
	COMPLETE_TODO = "TODO_COMPLETE",
	CHANGE_VISIBILITY = "SET_VISIBILITY",
}

Visibility 상태도 문자열로 정의해서 구분할 수 있도록 합니다.

export enum todoVisibility {
	SHOW_ALL = "SHOW_ALL",
	SHOW_TODO = "SHOW_TODO",
	SHOW_COMPLETED = "SHOW_COMPLETED",
}

그리고 각 액션에 대한 인터페이스도 정의해봤습니다.

export interface ITodoActionAdd {
	type: todoActionTypes;
	text: string;
	id: number;
}

export interface ITodoActionRemove {
	type: todoActionTypes;
	id: number;
}

export interface ITodoActionComplete {
	type: todoActionTypes;
	id: number;
}

export interface ITodoActionSetVisibility {
	type: todoActionTypes;
	filter: todoVisibility;
}

이제 상태를 변경할 때에는 각 액션의 종류에 맞는 인터페이스를 구현한 객체를 store.dispatch(action)에 넘겨주면 됩니다.

각각의 상황에서 인터페이스를 직접 구현해 사용해도 되지만, 여기서는 action creator를 작성해서 액션 객체를 생성해주는 함수를 함께 작성했습니다.

export class TodoActionCreator {

	public static addTodo(text: string, id: number): ITodoActionAdd {
		return {
			type: todoActionTypes.ADD_TODO,
			text,
			id,
		};
	}

	public static removeTodo(id: number): ITodoActionRemove {
		return {
			type: todoActionTypes.REMOVE_TODO,
			id,
		};
	}

	public static completeTodo(id: number) {
		return {
			type: todoActionTypes.COMPLETE_TODO,
			id,
		};
	}

	public static changeVisibility(visibility: todoVisibility) {
		return {
			type: todoActionTypes.CHANGE_VISIBILITY,
			filter: visibility,
		};
	}
}

리듀서

다음은 리듀서입니다. 순수함수를 처음 접하는 경우에는 리듀서가 어렵게 느껴질 수 있는데 몇 가지 제약사항이 있긴 하지만 근본적으로 리듀서는 함수라는 점을 기억하시면 될 것 같습니다.

리듀서는 스토어의 현재 상태와 액션을 매개변수로 받아 변경된 상태의 스토어를 반환하는 순수함수입니다. 여기서 중요한 점은

  • 매개변수로 받은 스토어를 직접 변경해선 안됩니다: 스토어의 복사본을 만들고 액션에 대한 스토어의 변경 사항을 이 복사본에 적용한 뒤, 복사본을 반환합니다.
  • 스토어의 다음 상태를 알 수 없는 경우 즉, 정의된 액션이 아니거나 리듀서가 수행할 수 없는 기타 등등의 경우에는 매개변수로 받은 현재 스토어의 상태를 그대로 반환합니다.

때문에 스토어가 상태를 갖지 않은 초기에는 스토어 매개변수가 undefined로 전달됩니다. 이때 초기화된 스토어 객체를 반환하면 되는데, 이를 위해 스토어의 인터페이스를 먼저 작성하도록 하겠습니다.

src/store/index.ts

import * as redux from "redux";

import { TodoActionCreator, todoVisibility } from "../action/todo";
import { TodoItem } from "../lib/todo.obj";
import { todoApp } from "../reducer/todo";

export interface IStore {
	todos: TodoItem[];
	visibility: todoVisibility;
}

스토어의 초기 상태로 반환할 객체를 먼저 선언하였습니다.

src/reducer/todo.ts

import * as todoAction from "../action/todo";
import { TodoItem, TodoState } from "../lib/todo.obj";
import { IStore } from "../store/index";

const initialState: IStore = {
	todos: [],
	visibility: todoAction.todoVisibility.SHOW_ALL,
};

이제 리듀서를 작성합니다.

export function todoApp(state = initialState, 
	action: todoAction.ITodoActionAdd | todoAction.ITodoActionComplete | 
	todoAction.ITodoActionRemove | todoAction.ITodoActionSetVisibility) {

	// Copy current state to new state object
	const newState = Object.assign({}, state);

	switch (action["type"]) {
		case todoAction.todoActionTypes.ADD_TODO:
			newState.todos = [
				...state.todos,
				new TodoItem(
					(action as todoAction.ITodoActionAdd).id, 
					(action as todoAction.ITodoActionAdd).text,
					TodoState.TODO),
			];
			return newState;
			
		case todoAction.todoActionTypes.REMOVE_TODO: {
			// Find index to remove and splice it
			const idx = newState.todos.findIndex(
				(obj: TodoItem) => obj.getId() === 
				(action as todoAction.ITodoActionRemove).id);
			if (idx < 0) { return state; }
			newState.todos.splice(idx, 1);
			return newState;
		}
		case todoAction.todoActionTypes.COMPLETE_TODO: {
			const idx = newState.todos.findIndex(
				(obj: TodoItem) => obj.getId() === 
				(action as todoAction.ITodoActionComplete).id);
			if (idx < 0) { return state; }
			newState.todos[idx] = new TodoItem(
				newState.todos[idx].getId(),
				newState.todos[idx].getText(),
				TodoState.COMPLETED);
			return newState;
		}
		case todoAction.todoActionTypes.CHANGE_VISIBILITY:
			newState.visibility = (action as todoAction.ITodoActionSetVisibility).filter;
			return newState;
		default:
			return state;
	}
}

매개변수를 보시면, state 매개변수에 기본값을 지정해서 undefined로 전달된 경우에 initialStatestate에 대입됩니다. action의 경우, 앞서 액션에서 함께 작성한 인터페이스들을 type guard로 지정하여 정해진 액션만 받도록 하였습니다.

const newState = Object.assign({}, state);

새로 반환될 상태는 현재 상태 객체가 아닌, 현재 상태 객체의 복사본이 됩니다. 액션에 의해 필요한 상태의 변경은 state가 아니라 newState 객체에서 이뤄집니다.

그 아래의 switch문에서 action["type"]에 따라 상태를 적절하게 변경하고, 변경된 상태를 반환하도록 작성하였습니다.

현재는 액션의 종류가 많지 않고 로직도 단순하기 때문에 하나의 리듀서에 모두 작성했지만, 실제 production에서는 하나의 리듀서에 전체 로직을 담는 건 좋지 않다고 생각합니다. 때문에 여러 리듀서를 여러개로 나눠서 구조화 하는 요령이 필요한데, 이에 대한 자세한 내용은 링크를 참조하시기 바랍니다.

스토어

스토어 코드는 많지 않습니다.

src/store/index.ts

const store = redux.createStore(todoApp);

createStore(reducer)로 스토어 객체를 생성한 뒤, 이를 사용하는 게 다입니다.

이제 작성한 로직을 테스트하기 위한 코드를 추가해보겠습니다.

let id = 1;

console.log(store.getState());

const unsubscribe = store.subscribe(() => {
	console.log(store.getState());
});

store.dispatch(TodoActionCreator.addTodo("Learn React", id++));
store.dispatch(TodoActionCreator.addTodo("Learn Redux", id++));
store.dispatch(TodoActionCreator.addTodo("Learn React-Redux", id++));
store.dispatch(TodoActionCreator.completeTodo(1));
store.dispatch(TodoActionCreator.completeTodo(3));
store.dispatch(TodoActionCreator.changeVisibility(todoVisibility.SHOW_COMPLETED));

unsubscribe();

store.subscribe(listener) 함수는 위에 설명하지 않았는데, 스토어의 상태가 변경될 때마다 호출되는 함수를 등록할 수 있습니다. 여기서는 변경된 상태를 알아보기 위해 스토어 객체를 출력하도록 했습니다. 이때 subscribe 함수는 구독을 취소하는 함수를 반환합니다.

그 아래에는 store.dispatch(action) 함수에 앞서 작성한 액션 생성자 함수를 활용하여 액션 객체를 생성하여 넘겨줍니다.

빌드한 뒤, 실행 결과를 확인해보겠습니다.

tsc
node build/store/index.js
{ todos: [], visibility: 'SHOW_ALL' }
{ todos: [ TodoItem { id: 1, text: 'Learn React', state: 'TODO' } ],
  visibility: 'SHOW_ALL' }
{ todos:
   [ TodoItem { id: 1, text: 'Learn React', state: 'TODO' },
     TodoItem { id: 2, text: 'Learn Redux', state: 'TODO' } ],
  visibility: 'SHOW_ALL' }
{ todos:
   [ TodoItem { id: 1, text: 'Learn React', state: 'TODO' },
     TodoItem { id: 2, text: 'Learn Redux', state: 'TODO' },
     TodoItem { id: 3, text: 'Learn React-Redux', state: 'TODO' } ],
  visibility: 'SHOW_ALL' }
{ todos:
   [ TodoItem { id: 1, text: 'Learn React', state: 'COMPLETED' },
     TodoItem { id: 2, text: 'Learn Redux', state: 'TODO' },
     TodoItem { id: 3, text: 'Learn React-Redux', state: 'TODO' } ],
  visibility: 'SHOW_ALL' }
{ todos:
   [ TodoItem { id: 1, text: 'Learn React', state: 'COMPLETED' },
     TodoItem { id: 2, text: 'Learn Redux', state: 'TODO' },
     TodoItem { id: 3, text: 'Learn React-Redux', state: 'COMPLETED' } ],
  visibility: 'SHOW_ALL' }
{ todos:
   [ TodoItem { id: 1, text: 'Learn React', state: 'COMPLETED' },
     TodoItem { id: 2, text: 'Learn Redux', state: 'TODO' },
     TodoItem { id: 3, text: 'Learn React-Redux', state: 'COMPLETED' } ],
  visibility: 'SHOW_COMPLETED' }

결론

Redux에 대한 소개와 Redux의 3대 구성요소를 간단히 살펴보고, 다음 글에서 작성할 Todo 앱의 로직을 Redux로 작성해봤습니다.

예제 앱을 작성하면서 Redux 코드가 API와 유사하다고 느꼈습니다. REST API가 클라이언트로부터 정해진 파라미터로 요청을 받아 처리한 뒤 그 결과를 데이터베이스 등에 영속화하는 과정이 Redux에서 액션을 정의하고 그 액션에 대한 스토어의 변경 로직을 작성하는 부분과 상당히 닮았다고 느꼈습니다. 마치 클라이언트 안의 REST API 서버같은 느낌이었습니다.

목록으로