본문으로 건너뛰기
비공식 베타 번역

이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →

실행 취소 기록 구현하기

앱에 실행 취소(Undo)와 다시 실행(Redo) 기능을 구현하는 것은 전통적으로 개발자의 상당한 노력을 요구했습니다. 기존 MVC 프레임워크에서는 관련 모델 전체를 복제하여 모든 과거 상태를 추적해야 하므로 쉬운 문제가 아닙니다. 또한 사용자가 시작한 변경 사항은 실행 취소가 가능해야 하므로 실행 취소 스택을 신중하게 관리해야 합니다.

이는 MVC 애플리케이션에서 실행 취소와 다시 실행을 구현하려면 일반적으로 커맨드 패턴과 같은 특정 데이터 변형 패턴을 사용하도록 애플리케이션 일부를 재작성해야 함을 의미합니다.

그러나 Redux에서는 실행 취소 기록을 매우 쉽게 구현할 수 있습니다. 이는 세 가지 이유 때문입니다:

  • 여러 모델이 존재하지 않으며, 추적하려는 상태 하위 트리만 존재합니다.

  • 상태는 이미 불변(immutable)이며, 변이는 실행 취소 스택 개념 모델과 유사하게 개별 액션으로 설명됩니다.

  • 리듀서의 (state, action) => state 시그니처는 일반적인 "리듀서 향상기(reducer enhancer)" 또는 "고차 리듀서(higher order reducers)"를 자연스럽게 구현할 수 있게 합니다. 이는 리듀서를 가져와 시그니처를 유지하면서 추가 기능으로 강화하는 함수입니다. 실행 취소 기록이 바로 그런 경우에 해당합니다.

이 레시피의 첫 번째 부분에서는 실행 취소와 다시 실행을 일반적인 방식으로 구현 가능하게 하는 기본 개념을 설명하겠습니다.

두 번째 부분에서는 즉시 사용 가능한 이 기능을 제공하는 Redux Undo 패키지 사용법을 보여드리겠습니다.

todos-with-undo 데모

실행 취소 기록 이해하기

상태 구조 설계

실행 취소 기록 역시 애플리케이션 상태의 일부이며, 이를 다르게 접근할 이유가 없습니다. 시간이 지남에 따라 변하는 상태 유형과 무관하게 실행 취소와 다시 실행을 구현할 때는 다른 시점에서의 상태 기록 을 추적해야 합니다.

예를 들어, 카운터 앱의 상태 구조는 다음과 같을 수 있습니다:

{
counter: 10
}

이런 앱에서 실행 취소와 다시 실행을 구현하려면 다음 질문에 답할 수 있도록 더 많은 상태를 저장해야 합니다:

  • 실행 취소하거나 다시 실행할 항목이 남아 있나요?

  • 현재 상태는 무엇인가요?

  • 실행 취소 스택의 과거(및 미래) 상태는 무엇인가요?

이러한 질문에 답하기 위해 상태 구조를 변경하는 것이 합리적입니다:

{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
present: 10,
future: []
}
}

이제 사용자가 "실행 취소"를 누르면 과거로 이동하도록 변경됩니다:

{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7, 8],
present: 9,
future: [10]
}
}

더 나아가:

{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7],
present: 8,
future: [9, 10]
}
}

사용자가 "다시 실행"을 누르면 미래로 한 단계 뒤로 이동합니다:

{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7, 8],
present: 9,
future: [10]
}
}

마지막으로, 사용자가 실행 취소 스택 중간에 액션(예: 카운터 감소)을 수행하면 기존 미래 기록을 버리게 됩니다:

{
counter: {
past: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
present: 8,
future: []
}
}

여기서 흥미로운 점은 실행 취소 스택으로 숫자, 문자열, 배열 또는 객체를 유지하든 상관없이 구조는 항상 동일하다는 것입니다:

{
counter: {
past: [0, 1, 2],
present: 3,
future: [4]
}
}
{
todos: {
past: [
[],
[{ text: 'Use Redux' }],
[{ text: 'Use Redux', complete: true }]
],
present: [
{ text: 'Use Redux', complete: true },
{ text: 'Implement Undo' }
],
future: [
[
{ text: 'Use Redux', complete: true },
{ text: 'Implement Undo', complete: true }
]
]
}
}

일반적으로 다음과 같이 표현됩니다:

{
past: Array<T>,
present: T,
future: Array<T>
}

단일 최상위 기록을 유지할지 여부도 우리의 선택입니다:

{
past: [
{ counterA: 1, counterB: 1 },
{ counterA: 1, counterB: 0 },
{ counterA: 0, counterB: 0 }
],
present: { counterA: 2, counterB: 1 },
future: []
}

또는 사용자가 독립적으로 실행 취소와 다시 실행을 수행할 수 있도록 세분화된 여러 기록을 유지할 수도 있습니다:

{
counterA: {
past: [1, 0],
present: 2,
future: []
},
counterB: {
past: [0],
present: 1,
future: []
}
}

이후에 살펴보겠지만, 우리가 채택한 접근 방식을 통해 실행 취소(Undo)와 다시 실행(Redo)의 세분화 수준을 선택할 수 있습니다.

알고리즘 설계

특정 데이터 유형과 무관하게 실행 취소 기록 상태의 구조는 동일합니다:

{
past: Array<T>,
present: T,
future: Array<T>
}

이제 위에서 설명한 상태 구조를 조작하는 알고리즘을 살펴보겠습니다. 이 상태에서 작동하는 두 가지 액션 UNDOREDO를 정의할 수 있습니다. 리듀서에서 이러한 액션을 처리하기 위해 다음 단계를 수행합니다:

실행 취소(Undo) 처리

  • past 배열에서 마지막 요소를 제거합니다.

  • present를 이전 단계에서 제거한 요소로 설정합니다.

  • 기존 present 상태를 future 배열의 _처음_에 삽입합니다.

다시 실행(Redo) 처리

  • future 배열에서 첫 번째 요소를 제거합니다.

  • present를 이전 단계에서 제거한 요소로 설정합니다.

  • 기존 present 상태를 past 배열의 _끝_에 삽입합니다.

기타 액션 처리

  • 현재 present 상태를 past 배열의 끝에 추가합니다.

  • 액션 처리 후 생성된 새 상태로 present를 업데이트합니다.

  • future 배열을 비웁니다.

첫 번째 시도: 리듀서 작성

const initialState = {
past: [],
present: null, // (?) How do we initialize the present?
future: []
}

function undoable(state = initialState, action) {
const { past, present, future } = state

switch (action.type) {
case 'UNDO':
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
return {
past: newPast,
present: previous,
future: [present, ...future]
}
case 'REDO':
const next = future[0]
const newFuture = future.slice(1)
return {
past: [...past, present],
present: next,
future: newFuture
}
default:
// (?) How do we handle other actions?
return state
}
}

이 구현은 세 가지 중요한 문제를 해결하지 못하므로 실제로 사용할 수 없습니다:

  • 초기 present 상태를 어디서 가져와야 할까요? 사전에 이를 알 수 없습니다.

  • 외부 액션에 반응하여 presentpast에 저장하는 지점은 어디일까요?

  • 실제로 present 상태에 대한 제어를 커스텀 리듀서에 어떻게 위임할까요?

리듀서가 완벽한 추상화는 아니지만, 우리는 매우 근접해 있습니다.

리듀서 강화기(Reducer Enhancer) 소개

고차 함수(Higher Order Function)에 익숙할 수 있습니다. React를 사용한다면 고차 컴포넌트(Higher Order Components)에도 익숙할 것입니다. 여기서는 동일한 패턴을 리듀서에 적용한 변형을 소개합니다.

리듀서 강화기(Reducer Enhancer) 또는 *고차 리듀서(Higher Order Reducer)*는 리듀서를 입력받아 새로운 리듀서를 반환하는 함수입니다. 반환된 리듀서는 새로운 액션을 처리하거나 더 많은 상태를 보유할 수 있으며, 이해하지 못하는 액션에 대해서는 내부 리듀서에 제어를 위임합니다. 이는 새로운 패턴이 아닙니다—기술적으로 combineReducers()도 리듀서를 입력받아 새 리듀서를 반환하므로 리듀서 강화기입니다.

아무 작업도 수행하지 않는 리듀서 강화기는 다음과 같습니다:

function doNothingWith(reducer) {
return function (state, action) {
// Just call the passed reducer
return reducer(state, action)
}
}

다른 리듀서를 결합하는 리듀서 강화기는 다음과 같이 구현할 수 있습니다:

function combineReducers(reducers) {
return function (state = {}, action) {
return Object.keys(reducers).reduce((nextState, key) => {
// Call every reducer with the part of the state it manages
nextState[key] = reducers[key](state[key], action)
return nextState
}, {})
}
}

두 번째 시도: 리듀서 강화기 작성

이제 리듀서 강화기를 더 잘 이해했으므로, undoable이 정확히 이러한 형태여야 함을 알 수 있습니다:

function undoable(reducer) {
// Call the reducer with empty action to populate the initial state
const initialState = {
past: [],
present: reducer(undefined, {}),
future: []
}

// Return a reducer that handles undo and redo
return function (state = initialState, action) {
const { past, present, future } = state

switch (action.type) {
case 'UNDO':
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
return {
past: newPast,
present: previous,
future: [present, ...future]
}
case 'REDO':
const next = future[0]
const newFuture = future.slice(1)
return {
past: [...past, present],
present: next,
future: newFuture
}
default:
// Delegate handling the action to the passed reducer
const newPresent = reducer(present, action)
if (present === newPresent) {
return state
}
return {
past: [...past, present],
present: newPresent,
future: []
}
}
}
}

이제 모든 리듀서를 undoable 리듀서 강화기로 래핑하여 UNDOREDO 액션에 반응하도록 할 수 있습니다.

// This is a reducer
function todos(state = [], action) {
/* ... */
}

// This is also a reducer!
const undoableTodos = undoable(todos)

import { createStore } from 'redux'
const store = createStore(undoableTodos)

store.dispatch({
type: 'ADD_TODO',
text: 'Use Redux'
})

store.dispatch({
type: 'ADD_TODO',
text: 'Implement Undo'
})

store.dispatch({
type: 'UNDO'
})

중요한 주의사항: 현재 상태를 가져올 때 .present를 추가하는 것을 잊지 마세요. 또한 각각 실행 취소와 다시 실행 버튼의 활성화 여부를 결정하기 위해 .past.length.future.length를 확인할 수 있습니다.

Redux가 Elm 아키텍처의 영향을 받았다는 말을 들어보셨을 겁니다. 이 예제가 elm-undo-redo 패키지와 매우 유사하다는 것은 놀라운 일이 아닙니다.

Redux Undo 사용하기

지금까지 설명은 유익했지만, undoable을 직접 구현하지 않고 라이브러리를 가져다 쓸 수는 없을까요? 물론 가능합니다! Redux Undo를 소개합니다. 이 라이브러리는 Redux 트리의 어떤 부분이든 간단한 실행 취소(Undo)와 다시 실행(Redo) 기능을 제공합니다.

이번 레시피에서는 작은 "할 일 목록" 앱 로직에 실행 취소 기능을 추가하는 방법을 배웁니다. 전체 소스 코드는 Redux에 포함된 todos-with-undo 예제에서 확인할 수 있습니다.

설치

먼저 다음 명령어를 실행하세요:

npm install redux-undo

이 명령어는 undoable 리듀서 향상기(reducer enhancer)를 제공하는 패키지를 설치합니다.

리듀서 감싸기

향상시키려는 리듀서를 undoable 함수로 감싸야 합니다. 예를 들어 별도 파일에서 todos 리듀서를 내보냈다면, 기존 리듀서를 undoable()로 호출한 결과를 내보내도록 변경합니다:

reducers/todos.js

import undoable from 'redux-undo'

/* ... */

const todos = (state = [], action) => {
/* ... */
}

const undoableTodos = undoable(todos)

export default undoableTodos

실행 취소와 다시 실행 액션의 타입 설정처럼, 다양한 옵션으로 undoable 리듀서를 구성할 수 있습니다.

combineReducers() 호출은 정확히 그대로 유지되지만, todos 리듀서는 이제 Redux Undo로 향상된 리듀서를 참조합니다:

reducers/index.js

import { combineReducers } from 'redux'
import todos from './todos'
import visibilityFilter from './visibilityFilter'

const todoApp = combineReducers({
todos,
visibilityFilter
})

export default todoApp

리듀서 구성 계층 구조의 어느 수준에서든 하나 이상의 리듀서를 undoable로 감쌀 수 있습니다. 상위 결합 리듀서 대신 todos를 감싸면 visibilityFilter 변경 사항이 실행 취소 기록에 반영되지 않습니다.

셀렉터 업데이트

이제 todos 상태 부분은 다음과 같이 표시됩니다:

{
visibilityFilter: 'SHOW_ALL',
todos: {
past: [
[],
[{ text: 'Use Redux' }],
[{ text: 'Use Redux', complete: true }]
],
present: [
{ text: 'Use Redux', complete: true },
{ text: 'Implement Undo' }
],
future: [
[
{ text: 'Use Redux', complete: true },
{ text: 'Implement Undo', complete: true }
]
]
}
}

즉, 상태에 접근할 때 state.todos 대신 state.todos.present를 사용해야 합니다:

containers/VisibleTodoList.js

const mapStateToProps = state => {
return {
todos: getVisibleTodos(state.todos.present, state.visibilityFilter)
}
}

버튼 추가하기

이제 실행 취소와 다시 실행 액션을 위한 버튼을 추가하기만 하면 됩니다.

먼저 UndoRedo라는 새 컨테이너 컴포넌트를 만듭니다. 아주 작기 때문에 프레젠테이션 부분을 별도 파일로 분리하지 않겠습니다:

containers/UndoRedo.js

import React from 'react'

/* ... */

let UndoRedo = ({ canUndo, canRedo, onUndo, onRedo }) => (
<p>
<button onClick={onUndo} disabled={!canUndo}>
Undo
</button>
<button onClick={onRedo} disabled={!canRedo}>
Redo
</button>
</p>
)

React Reduxconnect()를 사용해 컨테이너 컴포넌트를 생성합니다. 실행 취소와 다시 실행 버튼 활성화 여부는 state.todos.past.lengthstate.todos.future.length를 확인하면 됩니다. 실행 취소와 다시 실행을 수행하는 액션 생성자는 Redux Undo가 이미 제공하므로 별도로 작성할 필요가 없습니다:

containers/UndoRedo.js

/* ... */

import { ActionCreators as UndoActionCreators } from 'redux-undo'
import { connect } from 'react-redux'

/* ... */

const mapStateToProps = state => {
return {
canUndo: state.todos.past.length > 0,
canRedo: state.todos.future.length > 0
}
}

const mapDispatchToProps = dispatch => {
return {
onUndo: () => dispatch(UndoActionCreators.undo()),
onRedo: () => dispatch(UndoActionCreators.redo())
}
}

UndoRedo = connect(mapStateToProps, mapDispatchToProps)(UndoRedo)

export default UndoRedo

이제 UndoRedo 컴포넌트를 App 컴포넌트에 추가합니다:

components/App.js

import React from 'react'
import Footer from './Footer'
import AddTodo from '../containers/AddTodo'
import VisibleTodoList from '../containers/VisibleTodoList'
import UndoRedo from '../containers/UndoRedo'

const App = () => (
<div>
<AddTodo />
<VisibleTodoList />
<Footer />
<UndoRedo />
</div>
)

export default App

이게 전부입니다! 예제 폴더에서 npm installnpm start를 실행하고 직접 확인해보세요!