Redux 를 이해하기 위한 useReducer

RN·2025년 5월 13일

리액트

목록 보기
6/8

이때까지 상태관리를 useStateuseReducer, React Context 혹은 Recoil 로 상태관리를 해왔다.

하지만 회사 모집요강들을 보면 Recoil 이나 Redux 혹은 Redux 상태관리를 우대하는 경우도 꽤 있었다.

그래서 Redux 에 대해서 공부해보려고 한다.


1. 왜 Redux 인가?


Redux는 전역 애플리케이션 상태를 관리하고 업데이트하는 패턴이자 라이브러리입니다. UI는 "액션"이라는 이벤트를 트리거하여 발생한 상황을 설명하고, "Reducer"라는 별도의 업데이트 로직은 이에 대한 응답으로 상태를 업데이트합니다.

Redux는 전체 애플리케이션에서 사용해야 하는 상태의 중앙 저장소 역할을 하며, 상태가 예측 가능한 방식으로만 업데이트되도록 하는 규칙을 제공합니다.

Redux가 제공하는 패턴과 도구를 사용하면 애플리케이션의 상태가 언제, 어디서, 왜, 어떻게 업데이트되는지, 그리고 이러한 변경 사항이 발생할 때 애플리케이션 로직이 어떻게 동작하는지 더 쉽게 이해할 수 있습니다

https://redux.js.org/tutorials/fundamentals/part-1-overview#what-is-redux

Redux 공식 홈페이지의 문서에서의 설명이다.

전역 상태를 관리하기 위해 사용하며, 그 상태변화를 추적할 수 있다고 한다.


1.1. 상태(state)


리액트에서 말하는 상태에는 3가지가 있다.

  1. Local state

  2. Cross component state

  3. App wide state

위에서 말한 전역상태란 2, 3 번을 말한다.


1번의 경우는 아래와 같이 하나의 컴포넌트 안에서만 다루는 상태이다.

useStateuseReducer를 사용한다.

const [buttonCount, setButtonCount] = useState(0)

<button onClick = {()=>setButtonCount(prev=>prev+1)}/>      

2번의 경우는 이름과 같이 여러 컴포넌트에서 다루는 상태이다.

export default function RootLayout({children}) {
	return(
    	<ContextProvider>
      		{children}
      	</ContextProvider>
	) 
}

export default function navbar(){
  	const { state, setState } = useContext(context)
}

리액트 Context 를 이용하여 다른 컴포넌트에서 상태를 관리할 수 있게된다.

참고로 Context 에서 상태를 관리하는 것이 아니라 결국 상태 관리는 해당 컴포넌트에서 관리하는 것이다.


3번의 경우는 모든 컴포넌트 혹은 대부분의 컴포넌트에서 사용되는 상태이다.
대표적으로 사용자 인증 정보가 있다. 이것 역시 똑같이 Context를 이용하여 관리한다.


1.2. Context 대신 Redux?


컨텍스트를 사용하면 되는데 왜 리덕스를 사용할까?

컨텍스트에는 단점이 있다.

  1. 리액트 컨텍스트를 이용한 상태관리가 복잡하다.

하지만 위의 문제는 중소형의 프로젝트에서는 문제가 되지 않을 수 있다. 하지만 대형 프로젝트라면 당연히 저 문제가 커질 수 있다.

예를 들면 아래와 같이 수 많은 컨텍스트가 사용될 수 있다.

<AuthContext>
	<LoadingContext>
    	<ModalContext>
        	<AContext>
            	<BContext>
                	{children}

위 처럼 만들면 각각의 Context가 여러 파일로 분리되어 따로 관리해줘야한다.

하나의 Context로 만들어서 관리할 수도 있지만 기존의 파일에서 관리하던 것 처럼 파일 하나가 매우 복잡해질 것이다.

하지만 리덕스는 공유하는 데이터를 한 곳에서 관리할 수 있다. 맨 위에서 리덕스에 대해 설명할 때 리덕스는 중앙 저장소 역할을 한다고 했는데 이런 역할로 상태 관리를 쉽게 만들어준다.

이것은 코드의 가독성과 유지보수성을 높여주는 역할도 한다.

또 다른 단점으로는 성능이 있다.

  1. 대규모 프로젝트나 상태변화의 빈도가 매우 많은 컨텍스트는 성능이 떨어진다.

물론 리덕스의 단점도 있다.

Context의 단점으로 대규모 프로젝트에서 복잡해질 수 있다고 했는데, Redux 는 역으로 소규모 프로젝트에서 복잡해질 수 있다.

그리고 Recoil 과는 다르게 꽤 복잡하기 때문에 처음에 적응이 힘들 수 있다.


위는 아주 기본적인 장단점에 대해서 이야기했는데 무조건 Redux 다, Context 다. 라는 것이 아니다.

아래의 링크에서 자세히 설명해주고 있어서 해당 글이 굉장히 큰 도움이 될 것 같다.

https://olaf-go.medium.com/context-api-vs-redux-e8a53df99b8


2. useReducer


Redux 자체가 기본적인 문법이 처음에는 꽤 복잡한걸로 알려져있었는데

사실 리액트의 useReducer 만 익히면 생각만큼 어렵진 않다. (그래도 처음엔 좀 헷갈릴수도;;)


사실 많은 경우에 useState 를 사용하다보니 useReducer 를 사용할 때마다 다시 공부하고를 반복했던 것 같다. 포스트를 작성하면서 복습도 겸해야겠다.

useReducer 에서 아주 조금이라도 복잡한 코드를 구현하는 것이 뒤에서 Redux 로 하는 것보다 더 쉽고 Redux 의 이해가 더 빨라질 것이다.


2.1. 카운터

위의 카운터를 만들어보겠다.

우선 전체 코드는 아래와 같다.

타입스크립트를 배우지 않아 interface가 뭔지 모른다면 객체를 이용해서 만든다.

const actionType = {
    PLUS : "PLUS",
    MORE_PLUS : "MORE_PLUS",
    MINUS : "MINUS",
    INIT : "INIT"
}   
// payload 는 따로 무언갈 해줄 필요가 없다.

만약 타입스크립트를 배웠다면 추가로 아래와 같이 enum 을 사용해도 된다.

enum ActionType {
	PLUS : "PLUS",
    MORE_PLUS : "MORE_PLUS",
    MINUS : "MINUS"
    INIT : "INIT"
} 

가장 간단한 코드이면서도 처음보면 이게 무슨 헛소린가 싶을 정도로 헷갈린다.

그래서 우선 쓰이는 곳에 같은 색깔의 박스로 표시했다.

코드를 먼저 보기전에 state, dispatch, reducer, initialState 가 어떻게 쓰이는지 먼저 보자.



2.1.1. dispatch, reducer ??


initialState 는 말그대로 초기상태로 useState(initialState) 를 할 때 처럼 똑같이 사용된다.

state 는 우리가 사용하는 useState 의 그 state와 같다.

dispatch 는 우리가 사용하는 const [state, setState] = useState()setState 와 같다고 생각하자.

하지만 이렇게만 하면 일반적인 useState 랑 다를 바가 없다. 여기서 reducer 로 차이를 준다.

reducerdispatch 로 상태를 set 할 때 어떻게 set 할지 설명해주는 설명서 역할을 한다.



2.1.2. action ??


설명서?? 우리는 Counter 를 만든다고 했는데 이 Counter 설명서를 만들어보자.

  1. 더하기 버튼을 누른다. : 값이 1 올라간다.
  2. 5 더하기 버튼을 누른다. : 값이 5 올라간다.
  3. 빼기 버튼을 누른다. : 값이 1 내려간다.
  4. 초기화 버튼을 누른다. : 값이 0으로 초기화된다.

이렇게 설명이 나열되어 있다.

왜 갑자기 이 얘기를 하냐면 아래의 코드를 보자.


interface ActionType {
  type : "PLUS" | "MORE_PLUS" | "MINUS" | "INIT" 
  payload : number
}

const reducer = (state : number, action : ActionType) => {
    switch(action.type){
      case "PLUS" : return state + 1;
      case "MORE_PLUS" : return state + (+action.payload);
      case "MINUS" : return state - 1;
      case "INIT" : return 0;
    }
  }

reducer 를 정의한 함수이다.

여기에 보면 action 이라는 객체를 매개변수로 받는다.

action 에는 type 이라는 속성이 있다. 즉 우리가 실행할 행동의 타입이다.

type 을 내가 interface로 설정해놓은 거 처럼 본인이 직접 설정한다.

설명서를 만든 사람이 직접 설명서를 작성해야하니까.

위의 설명서로 action 객체에 대해 다시 설명한다면 아래와 같다.


interface ActionType {
  type : "더하기 버튼을 누른다." | "5 더하기 버튼을 누른다." | "빼기 버튼을 누른다." | "초기화 버튼을 누른다." 
  payload : number
}

const reducer = (state : number, action : ActionType) => {
    if(action.type === "더하기 버튼을 누른다.")
    	return 값이 1 올라간다.
    else if(action.type === "5 더하기 버튼을 누른다.") 
    	return 값이 5 올라간다.
    else if(action.type === "빼기 버튼을 누른다.")
    	return 값이 1 내려간다.
    else if(action.type === "초기화 버튼을 누른다.")
    	return 값이 0으로 초기화된다.
  }

위의 코드를 아래와 같이 변환한 것이 Counter 코드이다.

더하기 버튼을 누른다. === "PLUS"
5 더하기 버튼을 누른다. === "MORE_PLUS"
빼기 버튼을 누른다. === "MINUS"
초기화 버튼을 누른다. === "INIT"

그리고 if문들을 switch 문으로 변경

마지막으로 이 액션을 전달하는 방법은 dispatch 에 전달하면 된다.

dispatch가 결국 state 를 변경해주므로 어떻게 변경할지에 대한 정보(액션. 즉, 우리가 취할 행동으로 state가 어떻게 변하는지)를 전달해주는 것이다.

dispatch({ type : "PLUS" }) 

마지막으로 actiontype은 대문자로 작성하고 _ 로 구분하는게 좋다.
ex) MORE_PLUS


2.1.3. payload ??


다시 한 번 reducer 함수를 보자.


const reducer = (state : number, action : ActionType) => {
    switch(action.type){
      case "PLUS" : return state + 1;
      case "MORE_PLUS" : return state + (+action.payload);
      case "MINUS" : return state - 1;
      case "INIT" : return 0;
    }
  }

case MORE_PLUS에서 action.payload가 있다.

(+action.payload) 라고 해놓은 건 payload가 문자열로 전달되었기에 + 를 붙여 숫자로 바꾸기 위함이다.

만약에 저 payload가 없다고 가정해보자.

우리가 1~10 까지 랜덤한 숫자만큼 올려주는 카운터가 있다고 했을 때, 아래처럼 작성할 수 밖에 없다.

const reducer = (state : number, action : ActionType) => {
    switch(action.type){
      case "PLUS" : return state + 1;
      case "2_PLUS" : return state + 2;
      case "3_PLUS" : return state + 3;
      case "4_PLUS" : return state + 4;
      case "5_PLUS" : return state + 5;
      case "6_PLUS" : return state + 6;
        ...
      case "10_PLUS" : return state + 10;
      case "MINUS" : return state - 1;
      case "INIT" : return 0;
    }
  }

const random = Math.floor(Math.random()*10) + 1

dispatch({ type : `${random}_PLUS`})

이렇게 하드 코딩을 할 수 밖에 없다.

이러한 문제를 해결해주는 것이 payload 이다.

const reducer = (state : number, action : ActionType) => {
    switch(action.type){
      case "PLUS" : return state + 1;
      case "MORE_PLUS" : return state + (+action.payload);
      case "MINUS" : return state - 1;
      case "INIT" : return 0;
    }
 }

const random = Math.floor(Math.random()*10) + 1

dispatch({ type : MORE_PLUS`, payload : random })

dispatch 에 보내는 액션 객체에 payload 를 담아서 보내면 된다.

액션이라는 행동에 필요한 재료를 같이 전달해준다고 보면 된다.


2.1.4. 주의! 상태 직접 변경 X


useState 의 state도 마찬가지지만, useReducer 에서도 상태를 직접 변경해서는 안된다.

아래의 예시에서도

const reducer = (state : number, action : ActionType) => {
    switch(action.type){
      case "PLUS" : return state + 1;
      case "MORE_PLUS" : return state + (+action.payload);
      case "MINUS" : return state - 1;
      case "INIT" : return 0;
    }
 }

return state + 1 을 사용하지 return state++ 로 상태를 직접 변경하지 않는다.

만약 객체를 상태로 사용할 때에도

const initialState = {
	이름 : "상문",
    나이 : "50"
}
const reducer = (state : number, action : ActionType) => {
    switch(action.type){
      case "나이먹음" : {
          state.나이++;    // state의 나이를 먼저 바꾼 후

          return state    // 그 state 반환. 
      }
 	}
}  // 이렇게 사용하면 안됨 ㅠㅠ

const reducer = (state : number, action : ActionType) => {
    switch(action.type){
      case "나이먹음" : {
          return {
           	...state,
            나이 : state.나이 + 1
          }
      }  // 이렇게 사용하거나 혹은 아래처럼 사용함.
        
      case "나이먹음" : {
       	const copy = { ...state };  // 복사를 만들어서 얘를 조작함.
        
        copy.나이++;
        
        return copy;
      }
 	}
}

2.2. 토글


조금 더 복잡한 상태를 다뤄보자.


import React, {  useReducer } from 'react'
import WebtoonImage from './Image.tsx';
import { IoIosHeart } from "react-icons/io";
import { IoIosHeartEmpty } from "react-icons/io";

enum Actions {
    LIKE = 'LIKE',
    DISLIKE = 'DISLIKE'
}

interface WebtoonStateType {
    id : string,
    isLike : boolean,
    imgSrc : string
}

interface WebtoonActionType {
    type : Actions
    payload : string
}

export default function PracContainer() {
    
    const initailState : WebtoonStateType[] = [
        {
            id : "0",
            isLike : false,
            imgSrc : "webtoon.jpg"
        },
        {
            id : "1",
            isLike : false,
            imgSrc : "webtoon2.jpg"
        },
        {
            id : "2",
            isLike : false,
            imgSrc : "webtoon3.jpg"
        }
    ]

    function reducer(state : WebtoonStateType[], action : WebtoonActionType) {
        switch(action.type){
            case Actions.LIKE : {
                return state.map((webtoon)=>{
                    if(webtoon.id === action.payload) 
                      return { ...webtoon, isLike : !webtoon.isLike }
                    else return webtoon
                })
            }
            case Actions.DISLIKE :{
                return state.map((webtoon)=>{
                    if(webtoon.id === action.payload) 
                      return { ...webtoon, isLike : !webtoon.isLike }
                    else return webtoon
                })
            }
            default : return state
        }
    }

    const [ webtoonState, webtoonDispatch ] = useReducer(reducer, initailState)

    const handleLike = (id : string) => {
        webtoonDispatch({ type : Actions.LIKE, payload : id });
    }

    const handleDisLike = (id : string) => {
        webtoonDispatch({ type : Actions.DISLIKE, payload : id });
    }

    return (
        <>
            <div className='container'>
            {
                webtoonState.map((webtoon)=>(
                    <div className='image-container' key = {webtoon.id}>
                        {
                            webtoon.isLike 
                            ? <IoIosHeart className='icon' onClick={() => handleDisLike(webtoon.id)}/> 
                            : <IoIosHeartEmpty className='icon' onClick={() => handleLike(webtoon.id)}/>
                        }
                        <WebtoonImage img = {webtoon.imgSrc} />
                        <span className = "description" >웹툰 이미지 입니다.</span>
                    </div>
                ))
            }
            </div>
        </>
    )
}



참고로 WebtoonImage 컴포넌트는 그냥 아무것도 없이 img = {image} 라는 이미지 경로만 props로 받으며,

컴포넌트는 <img src = "" alt = "" /> 로만 만들어져있기에 그대로 저 자리에 넣어도 된다.


Reducer 를 보면 LikeDisLike 가 완전히 똑같다. 사실 저 행동들이 전부 토글이기때문에 TOGGLE 이라고만 만들어서 하나로 묶어도 되지만 여러 액션이 있는게 괜찮을 거 같아서 일부러 분리했다.


상태가 단순히 객체도 아니고 객체배열이라 코드가 길어보이지만 읽어보면 전혀 어렵지 않다.




0개의 댓글