Redux 한 달 사용기 (feat. 컴포넌트 상태 공유)

변영화
NAVER Pay Dev Blog
Published in
19 min readMay 10, 2023
@ LINE FRIENDS

안녕하세요.

NAVER FINANCIAL 주문 & 결제 FE팀에 합류한 지 약 한 달이 된 신입 개발자 변영화입니다.

입사하기 전에는 큰 프로젝트를 경험해 보지 못한 터라, 상태 관리 라이브러리를 적절히 활용해 본 경험이 없었는데요.

이번에 주문서 개발에 참여하며 Redux라는 툴을 접하게 되었습니다.

React + Redux + RTK 기본 개념과 활용법을 소개드리고, 아기자기한 한 달 사용 경험을 공유해 드리고자 합니다.

적절한 독자

Redux를 사용해 보지 않았고, 딱히 쓸 생각은 아닌데 키워드를 자주 듣게 되어 궁금한 사람

Redux는 왜 필요한가요?

복잡한 전체 시스템을 구성하기 위해서는 큰 문제를 작은 문제로 나눌 필요가 있습니다.

웹을 작은 문제로 나누게 되면 element group이 나오게 되는데 이를 컴포넌트라고 합니다.

컴포넌트는 자기 자신만의 데이터를 가지고 있는데, 때로는 다른 컴포넌트와 특정 데이터를 공유해야 합니다.

테스트 상품을 구매하는 주문서를 열었습니다.

주문서에는 여러 컴포넌트가 존재합니다.

  • 결제상품을 보여주는 컴포넌트
  • 포인트﹒머니를 보여주는 컴포넌트
  • 결제수단을 보여주는 컴포넌트

결제금액이 70,000원일 때,

이에 대한 정보를 결제 상품 컴포넌트도, 포인트﹒머니 컴포넌트도, 결제수단 컴포넌트도 모두 알고 있어야 합니다.

컴포넌트마다 70,000원이라는 자신만의 데이터를 가질 수 있지만, 같은 데이터를 다른 장소에서 관리할 경우 서로 동기화되지 않는 위험한 문제가 발생할 수 있습니다.

(상품가격은 70,000원인데, 사용되는 포인트의 양이 80,000원일 수는 없습니다)

위험성을 줄이기 위해 같은 데이터를 함께 관리하고 공유하는 방법을 사용해야 합니다.

결제금액 데이터를 어떻게 여러 컴포넌트가 공유할 수 있을까요?

방법은 여러 가지가 있습니다.

첫 번째 방법은 데이터를 물려받는 것(props)입니다.

70,000원이라는 데이터를 공유하고자 하는 컴포넌트들이 같은 부모 컴포넌트를 가지고 있고 이 부모로부터 70,000원을 공유받는다.

가장 근본적이고 쉬운 반면에, 치명적인 단점이 있습니다.

부모로부터 값을 물려받는 작업은 props를 통해 이루어지는데, 부모와 자식 사이에 여러 컴포넌트가 포함되어 있다면 포함된 컴포넌트에 모두 props를 전달해 주어야 합니다.

사이에 있는 컴포넌트 중 해당 props가 필요하지도 않은데, 자식 컴포넌트에 props를 전달하기 위해 전달 과정에 포함된 컴포넌트가 있을 수 있습니다.

이를 props drilling이라고 하는데, props drilling의 깊이가 깊어질수록 데이터를 추적하기가 어려워진다는 문제가 있습니다.

결제수단 보호막 > 결제수단 껍데기 > 결제수단 꼭다리 > 결제수단 머리 > 70,000원의 적절한 위치

props 전달 과정이 위와 같다고 가정했을 때,

결제수단 껍데기는 70,000원이라는 데이터가 필요하지 않음에도, 적절한 위치로 70,000원을 전달하기 위해 props 전달 과정에 포함되어 있습니다.

적절한 위치에서 70,000원이 어디서 왔는지 추적하기 위해 아무 의미 없는 결제수단 껍데기를 참고해야 할 수도 있습니다.

두 번째 방법은 Context를 사용하는 것입니다.

이 치명적인 문제를 해결하기 위해 React는 context API를 제공합니다.

70,000원이라는 데이터를 context 안에 보관하고, 해당 데이터가 필요한 컴포넌트들을 provider로 감싸서 공유한다.

context object 안에 포함된 provider 컴포넌트는 context를 구독하는 컴포넌트들에게 context 변화를 알려줍니다. context를 구독하기 위해서는 provider 컴포넌트 내부에 존재하면 됩니다.

context는 소규모 프로젝트 & 데이터 변화가 많이 없을 때 적절하게 사용될 수 있지만, 반대의 경우는 아닙니다.

context에 저장된 데이터를 가져오기 위해서 useContext API를 사용할 수 있는데, 컴포넌트가 useContext를 통해 가져온 데이터는 변경되지 않아도 context에 포함된 다른 데이터가 변경되면, 해당 컴포넌트는 re-render 됩니다.

즉, 불필요한 렌더링을 발생시킬 수 있는데, 만약 데이터 변경이 많다면 불필요한 렌더링이 다수 발생하게 됩니다.

(이를 회피하기 위해 Provider를 여러 개로 쪼갤 수 있지만, 이는 Provider Hell을 야기하는 문제를 파생시킵니다)

context는 상태 관리보다는 깊숙한 자식으로 props를 전달하는 역할만 한다고 말하는 것이 더 적절합니다.

context는 사실상 데이터를 저장하고 변화를 파악해 뿌려주는 역할을 하지, 데이터를 변경 역할은 useState 등과 같은 API에 의존하고 있습니다.

즉, 상태를 관리하는 역할이 아닌 전달하는 역할을 맡고 있습니다.

대규모 프로젝트에 데이터 변화가 많다면 공유뿐만 아니라 관리가 필요한 경우가 있습니다.

이런 경우에 사용할 수 있는 것이 상태 관리 라이브러리인데, Redux는 그중 하나입니다.

Redux 소개

Redux와 React를 세트로 생각할 수 있는데, 사실 두 친구는 세트가 아닙니다.

Redux는 React뿐만 아니라 Vue, Angular 등 여러 view 라이브러리와 사용될 수 있는 또 하나의 라이브러리일 뿐입니다.

context가 데이터를 글로벌하게 뿌려준다면, redux는 데이터를 글로벌하게 관리해 줍니다.

Redux는 Action, Store, Reducer, Dispatch를 통해 상태를 관리합니다.

각 역할은 아래와 같습니다.

  • Store : 상태를 저장하는 장소입니다.
  • Action : 상태를 변경시키고 싶을 때 생성하는 객체입니다.
  • Reducer : 현재 Store의 상태와, 발생한 Action의 payload를 받아 새로운 상태를 반환합니다.
  • Dispatch : Action을 발생시킵니다.

어떤 식으로 활용할 수 있을까요?

  1. Store를 생성하고 Reducer를 Store에 등록한다.
  2. App이 실행되면 Store를 적절하게 초기화한다.
  3. 사용자 행동에 따라 Action이 Dispatch 되고, Store가 업데이트된다.
  4. Store를 구독하는 컴포넌트는 새로운 데이터를 전달받는다.

Store를 생성하고 Reducer를 Store에 등록한다.

Store를 생성하고 Reducer를 Store에 등록할 수 있는데요.

Store는 App 당 하나 존재할 수 있지만 Reducer는 여러 개 존재할 수 있기 때문에

보통 combineReducers API를 통해 Reducer 들을 묶어 Store에 등록합니다.

const store = configureStore({
reducer: combineReducers({
...여러 가지 reducer들...
})
})
  • createStore : Store를 만드는 API
  • configureStore : createStore을 단순화하고 여러 기능을 추가한 API (redux-thunk, devtools 등)
  • combineReducers : 여러 개의 reducer를 하나의 rootReducer로 만드는 API

App이 실행되면 Store를 적절하게 초기화한다.

SPA가 등장하게 되면서 데이터가 프론트 서버, 백앤드 서버 두 군데에 존재하게 되었습니다.

화면을 구성하는 데이터를 백앤드 서버에서 요청하고 이를 프론트 서버에 저장할 필요가 생기게 되었고, 브라우저는 효율을 위해 비동기 방식으로 서버에 데이터를 요청합니다.

비동기 방식이기 때문에 응답 값이 필요한 상황에는 결과를 기다린 후 이를 사용할 수 있습니다.

서버와의 통신을 통해 주문서를 그리기 위한 데이터를 가져오는데요.

서버로부터 가져온 데이터를 그대로 사용하게 되면 서버 데이터 스펙과 App 인터페이스 간 의존성이 커지게 됩니다.

서버 데이터와 App 데이터가 높은 의존성을 갖게 되면 BE 데이터 스펙이 변경되었을 때, FE 쪽 대응이 어려울 수 있기 때문에 가져온 데이터를 그대로 사용하지 않고 App에 알맞게 가공을 진행합니다.

가공되어야 하는 시점은 데이터가 서버로부터 전달된 이후여야 하므로 서버 데이터 fetching이 어떻게 되어가고 있는지 알고 있어야 합니다.

RTK(Redux-Toolkit)에서 제공하는 API를 통해 fetching 상태를 파악할 수 있습니다.

RTK는 Redux 구성을 단순화해 주는 패키지입니다.

React 프로젝트를 생성할 때 CRA를 활용할 수 있는 것처럼, 개발에 도움을 줄 수 있는 설정을 자동으로 해줍니다.

한 가지 예시로, Redux는 Reducer를 작성하기 위해 immutable 하게 코드를 작성해야 합니다.

RTK를 활용하면 immer 라이브러리를 통해 개발자는 mutable 하게 코드를 작성하지만, immutable 하게 동작하도록 할 수 있습니다.

들어가기 전에 thunk에 대해서도 말씀드리겠습니다.

RTK는 Redux에 redux-thunk라는 middleware를 기본적으로 제공합니다.

thunk란 작업을 나중에 실행하기 위해 함수로 한번 감싼 것을 의미하는데,

비동기 작업의 결과값은 응답이 도착할 때까지 기다려야 합니다. 여기서 응답을 기다리고 추후에 작업을 하기 위해 thunk를 사용합니다.

페이지에 들어와서, 주문서 데이터를 서버에 요청하여 화면을 초기화하는 과정을 생각해 봅시다.

주문서 데이터를 서버에서 가져오는 예시는 아래와 같습니다.

// 주문서 정보를 가져오는 thunk action creator
const getOrderSheetInfo = createAsyncThunk(
'getOrderSheetInfo',
async ({orderSheetId}, {rejectWithValue}) => {
// 주문서 정보를 fetch 합니다.
const {response, code} = await fetchInfo({
orderSheetId,
})

// rejected
if (code === FAILED) {
return rejectWithValue({
errorCode: code,
})
}

// fulfilled
return response
},
)

...

// 주문서 정보를 가져오는 action을 dispatch합니다.
dispatch(getOrderSheetInfo({orderSheetId}))
  • createAsyncThunk : Promise를 반환하는 함수를 인자로 받고 thunk action creator를 반환합니다.
  • 첫 번째 매개변수 typePrefix : Action Type을 나타내는 문자열로, 상태에 따른 Action Types를 만들어 냅니다. (pending, fulfilled, rejected)
  • 두 번째 매개변수 payloadCreator : 비동기 결과를 포함한 Promise를 반환하는 콜백함수 입니다. 첫 번째 매개변수로 payload(arg)를 받고, 두 번째 매개변수로 thunkAPI를 받습니다.
  • thunkAPI : dispatch, getState 등 thunk 함수에 전달되는 추가 옵션

createAsyncThunk는 4가지 함수를 만들어 내는데,

pending action을 dispatch 할 수 있는 thunk action creator,

fulfilled action을 dispatch 할 수 있는 thunk action creator .. 등이 있습니다.

dispatch를 통해 thunk action의 life cycle을 시작할 수 있고,

각 life cycle에 맞는 action을 순서대로 dispatch 합니다.

주문서 정보를 불러오는 getOrderSheetInfo가 dispatch 되면, pending action이 dispatch 되고, fetchInfo (payloadCreator)가 실행됩니다.

payloadCreator promise가 성공적으로 resolve 된 경우 fulfilled action을 dispatch하고 실패한 경우 rejected action을 dispatch 합니다.

주문서 정보를 불러오는 비동기 작업이 성공적으로 fulfilled 되면 App에 알맞게 데이터 가공 작업을 진행해야 합니다.

여기서 slice를 활용할 수 있는데요. slice는 store initialState와 action creator, reducer를 한 번에 묶은 것을 의미합니다.

(state가 초기에는 어떤지, 이를 action에 따라 어떻게 변화시킬지에 대한 방법이 묶여 있다고 보시면 됩니다.)

createSlice는 인자로 CreateSliceOptions를 받습니다.

  • name : slice의 이름이며, action types를 생성할 때 namespace로 사용됩니다.
  • initialState : slice reducer가 반환할 초기 state입니다.
  • reducers : action type과 mapping 되는 reducer 들을 등록해 줄 수 있습니다.
  • extraReducers : action creator에 접근할 수는 있지만, 생성하지는 않는 reducer 들을 등록합니다.

다시 예시로 돌아와 보겠습니다.

주문서에서는 결제를 위한 결제수단이 렌더링 되어야 합니다.

서버에서 response를 받은 이후에, 결제수단 컴포넌트를 화면에 그리기 위해 데이터를 가공한다고 가정해 봅시다.

// 결제수단 관련 slice
const slice = createSlice({
name: 'slice', // 1️⃣
initialState: {
...
},
reducers: { // 2️⃣
somethingReducer: (state) => { // 3️⃣
...
}
},
extraReducers: (builder) => { // 4️⃣
builder
.addCase(getOrderSheetInfo.fulfilled, () => { // 5️⃣
// 서버에서 받아온 데이터를 가공합니다.
return process(state, payload) // 6️⃣
})
},
})

실제 코드를 단순화하여 가져와 보았습니다.

서버에서 받아온 데이터를 가공하는 작업은 6️⃣ process 함수 안에서 진행하게 됩니다.

이는 4️⃣ extraReducers에 등록되어 있는데요.

2️⃣ reducers와 extraReducers의 차이부터 먼저 알아봅시다.

reducers에 등록된 함수들은 action도 제작하고, 이에 대응하는 역할도 합니다.

reducers에 3️⃣ somethingReducer 함수가 등록된 것을 볼 수 있는데요.

createSlice에 전달된 option 객체의 1️⃣ name 프로퍼티는 slice의 namespace가 됩니다.

reducers에 등록된 함수의 action type은 함수의 이름과 slice의 namespace를 합쳐 slice/somethingReducer와 같이 생성됩니다.

이와 다르게 extraReducers에 등록된 함수들은 action을 따로 만들지는 않습니다.

대신 다른 곳에서 생성된 action creator에 접근할 수 있는데요.

서버에서 주문서 정보를 가져오는 thunk action creator인 5️⃣ getOrderSheetInfo의 fulfilled action creator를 참조하고 있는 것을 확인할 수 있습니다.

extraReducers에서 builder에 case reducer를 추가하면, promise 진행 상황에 따라 reducer를 실행할 수 있습니다.

위 코드를 참고하면, getOrderSheetInfo가 fulfilled 되면 addCase의 두 번째 인자로 전달된 콜백함수를 실행합니다.

즉, 주문서 데이터가 서버로부터 성공적으로 가져와지면 가공 작업을 진행합니다.

사용자 행동에 따라 Action이 Dispatch 되고, Store가 업데이트된다.

네이버 웹툰을 누구보다 빠르게 보기 위해 쿠키를 구매하는 상황을 가정해 봅시다.

쿠키를 결제하기 위해 NPay 머니 충전결제를 결제수단으로 사용할 것인데요.

하필이면 전체 은행이 점검 중인 상황이라 계좌에서 돈을 뺄 수 없는 상황입니다.

사용자는 충전결제를 포기하고 카드 간편결제를 결제수단으로 선택합니다.

카드 간편결제 라디오 버튼을 누르는 순간 결제수단을 변경하는 Action이 Dispatch 됩니다.

// 결제수단을 선택하는 thunk action creator
const selectPaymentMethod = createAsyncThunk(
'payMethod/selectPayMethodTab',
async (paymentMethod) => {
...

// 정책에 따라 결제수단을 골라냅니다.
const selectedPaymentMethod = choosePaymentMethod(paymentMethod)
...

return {
selectedPaymentMethod,
...
}
}
)

...

// 결제수단을 변경하는 Action을 Dispatch 합니다.
dispatch(selectPaymentMethod(CARD_EASY_PAY))

Store가 성공적으로 업데이트되고,

결제 수단이 변경된 것을 devtools로 확인할 수 있습니다 😮

(충전결제 → 카드 간편결제)

Store를 구독하는 컴포넌트는 새로운 데이터를 전달받는다.

이제 화면에서 결제수단으로 충전결제가 선택된 모습이 아닌, 카드 간편결제가 선택된 모습을 보여주어야 합니다.

Store 데이터는 useSelector hook을 통해 가져올 수 있는데요.

단순화하여 표현하자면,

const SomeComponent = () => {
const {payMethods} = useSelector(selectedPaymentMethodsSelector)

const isEasyCardSelected = payMethods.some((payMethod) => payMethod === CARD_EASY_PAY)

...

return (
isEasyCardSelected && <EasyCard />

...
)
}

위와 같이 간편결제가 선택되었을 때 카드가 렌더링 될 수 있도록 코드를 작성할 수 있을 것 같습니다.

(아기자기한) 한 달 사용 후기

Redux 공식 문서에서 대문짝만하게 광고하고 있는 만큼 Redux는 디버깅/테스팅에 강력함을 가지고 있는 것 같습니다.

디버깅/테스팅에 도움이 되는 기능으로 redux devtools와 middleware가 있습니다.

Redux devtools

redux devtools에서는 어떤 Action들이 발생해 왔는지, 현재 Store의 상태는 어떠한지 한눈에 파악할 수 있습니다.

Store의 상태는 오직 Action을 통해 변경되기 때문에 로깅 된 Action들을 통해 투명한 상태 변화를 파악할 수 있었습니다.

이전 Action으로 시간 여행이 가능할뿐더러, 코드를 따로 작성하지 않고 custom action을 devtools을 통해 직접 dispatch 할 수 있습니다.

입사한 지 얼마 되지 않아 로깅 툴을 잘 다루지 못할 때, 여러 이슈를 redux devtools 만으로 해결할 수 있었을 만큼 App에서 어떤 일들이 발생하고 있는지 잘 파악할 수 있었습니다.

이전 예시에서 보았듯이 state의 diff 또한 devtool로 파악할 수 있었는데요.

버그가 발생했을 때 어떤 action으로 인해 state에 문제가 생겼는지 한눈에 파악할 수 있었습니다.

가장 많이 사용한 기능은 custom action을 dispatch 하는 부분인데요.

input에 발생시키고 싶은 action을 작성하고 우측 하단 dispatch 버튼을 누르면, custom action을 발생시킬 수 있습니다.

버그 제보가 오면 이를 재현할 수 있는 action만 가져와 직접 dispatch하여 쉽게 버그를 재현할 수 있었습니다.

또, 기능 구현/변경을 위해 데이터를 조작해야 하는 경우가 있는데, 이 경우 조작 action만 따로 작성하여 손쉽게 테스팅 환경을 구축할 수 있었습니다.

Middleware

middleware는 Action이 Store에 도착할 때까지 여러 행동을 추가로 할 수 있도록 도와줍니다. (보통 로깅에 많이 쓰이고 있습니다)

저희 주문서에서는 테스팅 용도의 middleware가 하나 존재하는데요.

은행 점검 중 상태를 가정해야 할 때, 이를 FE 측에서 재현하기가 까다로울 때가 있습니다.

middleware를 통해 getOrderSheetInfo.fulfilled가 되기 전, 서버 response를 조작하여 마치 ‘은행이 점검 중이다’라는 데이터가 서버에서 내려온 것처럼 설정해 줄 수 있었습니다.

const middleware = () => (next) => (action) => {
const {type} = action

if (type === getOrderSheetInfo.fulfilled.type) {
// 서버 response를 원하는 대로 조작해줍니다.
...
}

next(action)
}

위와 같이 작성하여 여러 가정이 실제로 발생한 것처럼 설정해 줄 수 있었습니다.

지금까지 Redux의 기본적인 개념과 활용법, 한 달 사용 후기 관련하여 서술해 보았습니다.

Redux에 대해 아직 잘 모르지만, 호기심이 있는 분들이 흥미롭게 봐주셨으면 좋을 것 같습니다.

긴 글 읽어주셔서 매우 감사합니다 🙇🏻‍♀️

참고 자료

--

--