State를 local storage에 쉽게 저장하자. Redux Persist를 소개합니다.

JungHoon Park
22 min readApr 18, 2021
Redux Persist

이 글은 20년 4월 것을 옮긴 글입니다.

Redux의 State값을 local storage(이하 Storage라 지칭)에 저장/관리합니다.

흔히 여러 컴포넌트를 거치지 않고 손쉽게 State를 전달하기 위해 혹은 분리해서 중앙화하기 위해 Redux를 사용합니다.

하지만 Redux에 저장된 데이터는 새로 고침 버튼을 누르거나 종료하는 순간 날아가 버리고 맙니다. 날아가는 것을 방지하기 위해서 흔히 데이터를 Javascript Web Storage API를 통해 Storage에 저장하는데요.

Redux Persist의 persistReducer를 특정 Reducer와 결합해주기만 하면 Storage에서 저장된 값을 가져옵니다. 여기서 만약 저장된 데이터가 없다면 Storage에 저장하는 rehydrate(재수화) 과정을 거칩니다.

Redux Persist는 어떤 면에서는 미들웨어와 비슷한 역할을 합니다.

Reducer의 State 값을 Storage에서 get/set을 하기 전 Reducer의 특정 State만 저장을 하게 할 수 있고 Transforms 기능을 사용해 암호/복호화를 할 수 도 있으니까요.

추가로 이후에 설명해 드릴 PURGE 액션을 호출할 때를 제외하고 Storage에서 데이터를 delete를 하지 않습니다. 그 대신 State Reconsiler라는 기능을 제공해 기존에 Storage에 저장된 오브젝트와 Application에서 오는 오브젝트가 어떻게 병합될 것인지 결정할 수 있죠.

목차

아래의 3가지로 항목을 나눠 진행하겠습니다.

1. Counter 예제에 Redux Persist 입히기

독자분들은 어느정도 React/Redux를 안다고 가정하고 예제는 따로 설명치 않겠습니다.

2. 마법같은 Redux Persist를 deep하게 알아보기

Redux Persist의 코드를 살펴보며 작동원리를 알아보겠습니다.

3. 여러 기능

Redux Persist에서 제공하는 여러 기능에 대해서 알아보겠습니다.

1. Counter 예제에 Redux Persist 입히기

Counter 예제:

counterPersistConfig를 만들어서 persistReducer의 첫 번째 인자로 넣어줍니다.

그리고 Counter 예제에 이미 구현된 counterReducerpersistReducer의 두 번째 인자로 넣어줍니다.

index.js

Persist Store을 생성해줍니다.

Provider를 연결해줍니다.

완료했습니다! 간단하죠?

이제 새로고침해도 초기화 되지 않는것을 확인할 수 있습니다.

완성본:

https://codesandbox.io/s/redux-persist-counter-wdqle?fontsize=14&hidenavigation=1&theme=dark

2. 마법같은 Redux Persist를 Deep하게 알아보기

연결만 해주면 알아서 State 값을 저장하는 게 저에게는 마치 마법과 같았습니다.

실제 코드를 뜯어보며 마법이 이뤄지는 원리를 알아보겠습니다.

다시 한번 위의 Counter 예제 완성본을 보고 어떤 일이 일어나는지 보겠습니다.

1. 콘솔 창에 loading...이 나타납니다.

2. F12를 눌러 Redux Dev Console을 확인하면 persistReducer가 감싸진 각각의 Reducer들은 REHYDRATE 액션이 호출됩니다.

3. F12를 눌러 DevTools에서 Application > Storage > Local Storage 항목을 확인하면 각 Reducer의 State 값이 저장된 것을 확인할 수 있습니다.

위의 결과를 토대로 자문자답하는 Q/A 형식으로 진행하겠습니다.

Q) 2.1. <PersistGate/>의 역할은 무엇인가요?

<PersistGate/>state.bootstraped = false 이므로 아래의 코드 처럼 props로 전달받은 Loading 컴포넌트를 보여줍니다.

react.js

이전에 persistConfig 오브젝트에 넣은 key 값이 기억나시나요?

1. Redux Persist에서 config.key 값 별로 Reducer를 등록합니다.

다른 Redux Store인 _pStoreregistry: []라는 State에 key 값을 저장하는 등록(Register) 과정을 거칩니다.

2. 아래에 함수에서 이미 Storage에 저장된 Reducer의 State 값이 있다면 그 값을 get 하고 REHYDRATE 액션을 dispatch 합니다. getStoredState(...) 함수가 호출되면 어떤 일이 발생되는지는 State Reconiler 섹션에서 좀 더 구체적으로 알아보겠습니다.

persistReducer.js

getStoredState(config).then( ... )

3. 그 후에 아래의 함수에서 특정 Reducer의 State를 Storage에 저장하는 재수화 과정을 거칩니다.

createPersistoid.js

if (keysToProcess.length === 0) {
writeStagedState()
}

4. 재수화가 완료되면 registry에서 해당 Reducer를 제거하고 나머지도 계속 진행합니다. 2, 3번을 등록된 Reducer 만큼 반복합니다.

5. registry가 빈 배열이 되면 _pStore에 저장된 또다른 state인 bootstrapped: boolean의 값이 true가 되면 <PersistGate/>bootstrapped State 값을 바꿔 로딩을 해제합니다.

정리하면 모든 Reducer의 재수화가 완료됐는지 여부를 알 수 있습니다.

Q) 2.2. REHYDRATE(재수화) 액션은 어디서부터 개시되나요? (+구조 알아보기)

이전에 Counter 예제에서 persistReducer()가 있는 것을 볼 수 있습니다.

index.js

persistReducer에 첫번째 인자로 Config 값을 넣어주고 두 번째로 Reducer를 넣어줍니다.

코드를 한번 뜯어볼까요?

persisReducer.js

아하! persistReducer는 Reducer에 이미 존재하는 액션 외에 다른 액션: PERSIST, PURGE, FLUSH, PAUSE, REHYDRATE을 추가적으로 탐지해 특정 기능을 수행하는 기능을 붙여주는군요.

그 다음으로 persistStore()를 알아보겠습니다.

Counter 예제에서 아래의 방법으로 호출되었죠?

const store = createStore(rootReducer);
const persistor = persistStore(store);

persistStore()의 코드를 뜯어보면 코드의 밑 부분에서 아래의 명령문을 발견할 수 있습니다.

persistStore.js

if (!(options && options.manualPersist)){
persistor.persist()
}

persistStore.persist() 함수를 뜯어보면 아래와 같은 명령문으로 이뤄졌네요.

persist: () => {
store.dispatch({ type: PERSIST, register, rehydrate })
},

아하! 여기서 PERSIST 액션을 호출해주고 호출되면서 시작되는군요!

해당 액션이 호출되면 persistReducer()가 래핑된 각각의 Reducer에 이전에 언급했던 등록(register)과

이미 저장된 데이터가 있을 때 Storage에서 가져오는 과정도 있고 재수화가 이뤄집니다.

persistReducer.js

_rehydrate(migratedState) 함수가 호출되면 REHYDRATE 액션도 호출됩니다.

REHYDRATE 액션이 발동될 때 어떤 로직이 실행되는지 아래의 코드로 보겠습니다.

재수화 과정에 conditionalUpdate(blacklist, whitelist), State Reconciler의 과정이 있다는 것을 볼 수 있습니다.

이 기능들이 어떤 기능인지 다음 섹션에서 알아보겠습니다.

3. 여러 기능

Redux Persist에는 여러가지 기능이 있습니다.

그 중 3가지 Purge, Blacklist & Whitelist, State Reconciler을 알아보겠습니다.

3.1. Purge

문서를 살펴보시면 PURGE라는 액션이 있습니다. 이 액션을 dispatch 함으로써 Storage에 저장된 데이터를 삭제하고 다른 값으로 초기화 할 수 있습니다.

Counter 예제에서 이 부분이 기억나시나요?

index.js

const store = createStore(rootReducer);
const persistor = persistStore(store);

persistStore.js

persistStore(store);에서 반환되는 persistor 오브젝트의 코드를 보면 이렇습니다.

간단히 특정 액션을 dispatch 해주는 것 밖에없습니다. 그러면 해당 액션은 persistReducer에서 purgeStoredState()를 호출해 처리해줍니다.

purgeStoredState.js

export default function purgeStoredState(config: PersistConfig) {
const storage = config.storage
const storageKey = `${
config.keyPrefix !== undefined ? config.keyPrefix : KEY_PREFIX
}${config.key}`
return storage.removeItem(storageKey, warnIfRemoveError)
}

이제 적용 예제를 보겠습니다.

1.원하는 부분에 purge()함수를 호출합니다.

import { persistor } from "../../stores";  // 로그인이 성공할 때 호출되는 함수
async onLoginSuccess(auth) {
auth.isAuthenticated = true;
this.props.setAuth(auth);
// redux-persist에 의해 local storage에 저장된 모든 데이터 초기화
// PURGE 액션이 호출됩니다.
await persistor.purge();
this.moveNextPage();
}

2.초기화를 원하는 Reducer에 PURGE 액션을 구현합니다.

import { PURGE } from "redux-persist";// 새로운 오브젝트를 생성합니다.
const initState = () => { item: [] }
export const itemReducer = (state = initState(), action) => {
switch (action.type) {
case PURGE: {
// Storage에 저장된 데이터가 삭제 된 후 오브젝트가 초기화 됩니다.
return initState();
}
case SET_ITEM: {
return {
...state,
item: action.payload.item
};
}
default: {
// render가 발생하지 않도록 오브젝트 그대로 반환합니다.
return state;
}
}
};

이제 onLoginSuccess 함수가 호출될 때마다 Storage에 저장된 itemReducer의 state 값이 초기화 됩니다.

간혹, Storage가 비워지지 않는 경우가 있습니다.

예를 들어 여러번의 REHYDRATE 액션이 호출될 때 PURGE 액션 또한 호출할 경우 입니다.

이 경우 Race Condition이 발생해 비워지지 않을 수 있습니다.

3.2. Blacklist, Whitelist를 사용해 특정 값 저장되지 않게 하기/특정 값만 허용하기

Blacklist는 배열에 Redux안의 State 이름을 적어주면 해당 데이터가 Storage에 저장되는 것을 막을 수 있습니다.

Whitelist는 똑같이 적어주면 해당 데이터만 Storage에 저장되도록 합니다.

// BLACKLIST
const persistConfig = {
key: 'root',
storage: storage,
blacklist: ['navigation'] // navigation will not be persisted
};
// WHITELIST
const persistConfig = {
key: 'root',
storage: storage,
whitelist: ['navigation'] // only navigation will be persisted
};

코드를 뜯어보겠습니다.

REHYDRATE 액션이 실행되면 conditionalUpdate(newState) 이 함수가 실행됩니다.

이 함수의 코드를 보면

persistReducer.js

const conditionalUpdate = state => {
// update the persistoid only if we are rehydrated and not paused
state._persist.rehydrated &&
_persistoid &&
!_paused &&
_persistoid.update(state)
return state
}

위와 같이 되어있습니다.

_persistoid.update(state) 함수의 코드를 따라가면 아래처럼 되어있고

createPersistoid.js

여기서 passWhitelistBlacklist(key) 함수에서 우리가 원하는 Blacklist, Whitelist의 작동 방식을 볼 수 있습니다.

3.3. State Reconciler

재수화 액션이 발동될 때 Storage에 데이터가 있다면 get해온 후 이 데이터(inboundState)와 Application에서 오는 데이터(action으로 오는 값)가 어떤 식으로 합쳐질지 결정합니다.

persistReducer.js

// inboundState: Storage에 이미 존재하는 데이터
let reconciledRest: State =
stateReconciler !== false && inboundState !== undefined
? stateReconciler(inboundState, state, reducedState, config)
: reducedState
let newState = {
...reconciledRest,
_persist: { ..._persist, rehydrated: true },
}
return conditionalUpdate(newState)

위의 코드를 보시면 stateReconciler(...) 함수의 첫번째 인자 inboundState는 어디서 온 값일까요? Storage에서 저장된 값을 가져오는 getStoredState(...) 함수를 좀 더 알아보겠습니다. 복잡하므로 잘 따라와 주세요.

persistStore.js

getStoredState(config).then(
restoredState => {
const migrate = config.migrate || ((s, v) => Promise.resolve(s))
migrate(restoredState, version).then(
migratedState => {
_rehydrate(migratedState)
//...

_rehydrate(migratedState) 함수를 보시면 Storage에서 가져온 데이터를 migratedState라는 이름으로 이 함수의 인자로 넘겨줍니다. (해당 Reducer의 저장된 State값이 없으면 undefined일 수도 있습니다.) 이 함수의 코드를 보면 이렇게 생겼습니다.

let _rehydrate = (payload, err) => {
// dev warning if we are already sealed
if (process.env.NODE_ENV !== 'production' && _sealed)
console.error(
`redux-persist: rehydrate for "${
config.key
}" called after timeout.`,
payload,
err
)
// only rehydrate if we are not already sealed
if (!_sealed) {
action.rehydrate(config.key, payload, err)
_sealed = true
}
}

_rehydrate(...) 함수를 살펴보면 action.rehydrate(...) 을 호출하는 것을 볼 수 있습니다. action 오브젝트에 언제 rehydrate(...) 라는 함수가 붙여서 왔을까요? 코드를 따라가면 persistStore.js에서 최초에 PERSIST 액션을 dispatch 할 때 함수를 같이 넣어준 것을 볼 수 있습니다.

persistStore.js

store.dispatch({ type: PERSIST, register, rehydrate })

그리고 persistStore.js의 rehydrate 함수는 이렇게 생겼습니다.

let rehydrate = (key: string, payload: Object, err: any) => {
let rehydrateAction = {
type: REHYDRATE,
// 아래의 key에 Storage에서 가져온 값을 넣어줍니다.
payload,
err,
key,
}
// dispatch to `store` to rehydrate and `persistor` to track result
store.dispatch(rehydrateAction)
_pStore.dispatch(rehydrateAction)
if (boostrappedCb && persistor.getState().bootstrapped) {
boostrappedCb()
boostrappedCb = false
}
}

위의 코드를 보시면, getStoredState(...) 함수를 호출해서 Storage에서 가져온 데이터를 REHYDRATE 액션과 함께 Store에 disaptch 해줍니다. 그러면 persistReducer(...)에서 REHYDRATE 관련 로직이 실행됩니다.

그럼 이제 Storage에서 가져온 값이 action.payload라는 형태로 오게되고 inboundState로 값을 옮기는 것을 알 수 있습니다.

Storage에서 가져온 데이터가 없을 때는 Application에서 오는 값을 그대로 저장합니다.

} else if (action.type === REHYDRATE) {
// noop on restState if purging
if (_purge)
return {
...restState,
_persist: { ..._persist, rehydrated: true },
}
// @NOTE if key does not match, will continue to default else below
if (action.key === config.key) {
let reducedState = baseReducer(restState, action)
let inboundState = action.payload
// only reconcile state if stateReconciler and inboundState are both defined
let reconciledRest: State =
stateReconciler !== false && inboundState !== undefined
? stateReconciler(inboundState, state, reducedState, config)
: reducedState
let newState = {
...reconciledRest,
_persist: { ..._persist, rehydrated: true },
}
return conditionalUpdate(newState)
}
}

정리하면 PERSIST 액션이 호출되면 저장된 데이터를 가져오고 없으면 .undefined로 합니다. 그 후 REHYDRATE 액션을 다시 호출할 때 데이터를 넣어 줍니다.

3.3.1. hardSet

이미 저장된 값을 씁니다.

hardSet.js

export default function hardSet<State: Object>(inboundState: State): State {
return inboundState
}

예제:

inboundState(Storage에 저장된 값):{ foo: incomingFoo }
Application에서 오는 State 값: { foo: initialFoo, bar: initialBar }
결과: { foo: incomingFoo } // note bar has been dropped

3.3.2. autoMergeLevel1 (default)

key가 같은건 이미 저장된 값을 쓰되, 나머지는 그대로 덮어씁니다.

autoMergeLevel1.js

let newState = { ...reducedState }  //...  newState[key] = inboundState[key]

예제:

inboundState:                    { foo: incomingFoo }
Application에서 오는 State 값: { foo: initialFoo, bar: initialBar }
결과: { foo: incomingFoo, bar: initialBar } // note incomingFoo overwrites initialFoo

3.3.3 autoMergeLevel2

모든 state 값을 Application에서 오는 값으로 덮어씁니다.

autoMergeLevel2.js

let newState = { ...reducedState }//...if (isPlainEnoughObject(reducedState[key])) {
// if object is plain enough shallow merge the new values (hence "Level2")
newState[key] = { ...newState[key], ...inboundState[key] }
return
}

// otherwise hard set
newState[key] = inboundState[key]

예제:

inboundState:                    { foo: incomingFoo }
Application에서 오는 State 값: { foo: initialFoo, bar: initialBar }
결과: { foo: mergedFoo, bar: initialBar } // note: initialFoo and incomingFoo are shallow merged

여기서 autoMergeLevel2를 더 알아 보겠습니다. 만약 아래의 State 값일 때 Applicaion을 실행했고

const INITIAL_STATE = {
currentUser: null,
isLoggingIn: false,
};

이후에 오브젝트에 error를 추가했다고 가정해 보겠습니다.

const INITIAL_STATE = {
currentUser: null,
isLoggingIn: false,
error: ''
};

hardSet 이나 autoMergeLevel1 이라면 어떻게 됐을까요?

이미 저장된 값 우선이므로 error 가 사라지게 됩니다. (1)

개인적으로는 별다른 이유가 없다면 autoMergeLevel2를 쓰시는것을 추천드립니다.

완성된 Counter 예제에 error을 추가해보겠습니다.

index.js

const init = () => ({
counter: { updatedAt: new Date().toString(), name: "initial counter", cnt: 0, error: false }
});

이 값을 increasement() 함수가 호출될 때 콘솔창에 찍어보겠습니다.

App.js

<button
onClick={() => {
console.log(counter.error);
increasement();
}}
>

마치면서…

여러분들은 Redux Persist를 활용하는 방법을 배우셨고, 그 라이브러리가 어떤 원리로 작동하는 방법도 알아보았습니다.

이제 어떤 데이터를 저장할 것인지 저장하지 않을 것인지 구분해 프로젝트에 적용해보세요.

이 튜토리얼을 준비 할 수 있도록 도움을 주신 김대희님께 감사를 드립니다. 또한 이 강의를 검수해주신 정민영, 배정환, 이창훈 그리고 이미지를 제공해주신 한상진님께 감사드립니다.

참조

(1) https://blog.reactnativecoach.com/the-definitive-guide-to-redux-persist-84738167975

https://github.com/rt2zz/redux-persist

--

--