컴포넌트의 state를 생성하고 관리하기 위해 React에서는 기본 Hook인 useState와 useState로부터 파생된 추가적인 Hook인 useReducer를 제공하고 있습니다. 두 Hook은 같은 역할을 하지만 useState는 사용자가 직접 상태에 접근하는 것이 가능했다면 useReducer는 action 객체와 reducer 함수를 통해 상태에 접근한다는 차이점을 가지고 있습니다.
useReducer를 쉽게 이해하기 위해서 그림을 통해 useState와 useReducer의 차이를 살펴보겠습니다.
그림 5-1
위 그림은 useState를 은행에 빗대어 설명한 것입니다. 100원을 입금할 것이라는 요구사항을 가진 사용자와 사용자의 계좌인 state가 존재합니다. 사용자는 입금을 하기 위해서 직접 계좌에 입금 내역을 작성해야 합니다. 이때 직접 state를 변경하는 것이 setState가 됩니다.
그림 5-2
위 그림은 useReducer의 흐름을 은행으로 표현한 것입니다. 사용자는 ‘입금을 할 것이다’라는 요구사항을 가지고 있으며, useState와 달리 창구 직원과 은행 시스템이라는 중간자 역할이 추가되었습니다. 이때, 사용자는 ‘입금’이라는 action을 창구 직원에게 보내면 이를 은행 시스템에 전달하고 사용자 계좌의 state를 업데이트하게 됩니다.
이렇게 사용자의 요구 사항이 action이며 dispatch의 역할을 하는 창구 직원이 reducer의 역할을 하는 은행 시스템에 전달하면 계좌의 state를 업데이트할 수 있는 것입니다. useReducer를 활용하면 입금이나 출금 등 다양한 일을 사용자가 직접 처리하지 않고 action으로 간단하게 처리할 수 있기 때문에 복잡한 state를 다루어야 한다면 useReducer를 사용하는 것이 좋습니다.
그림 5-3
useReducer의 흐름에 대해 살펴보겠습니다. state를 업데이트하기 위해서 dispatch 함수의 인자로 action을 넣어서 reducer에 전달합니다. reducer는 우리가 넣은 action에 맞춰 state를 업데이트하게 됩니다.
💡
dispatch, action, reducer 정리
dispatch는 state 업데이트를 위한 요구이며, action은 요구의 내용, reducer는 state를 업데이트 해주는 역할이며 컴포넌트의 state를 변경하고 싶다면 reducer를 활용하여 변경하는 것입니다.
그렇다면 useReducer와 useState를 어떤 상황에서 어떻게 사용해야 하는지 알아보겠습니다. useReducer는 useState보다 더 복잡한 작업을 처리하는 것이 가능합니다. state가 단순할 경우에는 useReducer를 사용하는 것이 코드를 더 복잡하게 만들 수 있기 때문에 useState를 사용하는 것이 적합합니다. 하지만 객체나 배열같이 여러 개의 하위 값을 포함하는 복잡한 state를 가지거나 확장성이 있을 때는 useReducer를 사용하여 state를 관리한다면 코드를 간결하게 해주고 유지보수를 편리하게 할 수 있게 됩니다.
5.1.1. useReducer 기본 구조
useReducer의 기본 형태입니다. state는 컴포넌트에서 사용하는 상태, dispatch는 reducer 함수를 실행시키는 함수입니다. useReducer의 첫 번째 인자로는 컴포넌트 외부에서 state 업데이트를 하는 함수인 reducer, 두 번째 인자는 state의 기본값, 세 번째 인자는 선택사항으로 초기함수를 전달합니다.
간단한 예제를 통해 useReducer의 사용법을 살펴보겠습니다.
그림 5-4
우리에게 익숙한 useState로 증감 기능과 초기화 기능이 있는 간단한 카운터를 구현하였습니다. 각 버튼을 누를 때마다 해당 event에 맞게 count를 업데이트하며 콘솔에 count 값을 출력합니다.
useState로 구현한 카운터를 useReducer를 사용하여 바꿔보았습니다. ⓵에서 useReducer를 정의합니다. useReducer의 사용 방법은 아래와 같습니다. 따라서 위 예시에서 count는 0으로 초기화 됩니다.
⓶에서 각 버튼의 이벤트에 대한 onClick 함수를 정의합니다. onClick 이벤트가 발생하면 dispatch는 action 값을 담아 state와 함께 ⓷으로 전달합니다. reducer에서는 전달받은 action과 일치하는 값을 찾는 조건문을 실행하여 새로운 state를 반환합니다.
useState를 사용한 카운터에서는 setCount로 직접 state를 변경하였지만, useReducer에서는 setState의 기능을 세분화하여 직접 state에 접근하지 않으며 state를 변경하는 것은 reducer 함수에서 집중적으로 처리하게 됩니다. 이러한 방식을 사용하게 되면 reducer 함수 내부에 state를 변경하는 과정을 은닉할 수 있으며 이것이 useReducer를 사용하는 이유 중 하나입니다.
5.2 reducer 함수란?
reducer 함수는 현재 state와 action 값을 전달받아 새로운 state를 반환하는 함수입니다.
5.2.1 action과 type
action은 현재 state의 업데이트를 위해 필요한 정보를 담은 값으로, 보통 객체의 형태를 띱니다. 하지만, action의 값은 문자열이나 숫자여도 상관이 없습니다.
reducer의 로직은 action이 새로운 state를 계산하는 데 쓰이는지 확인하기 위해 action.type을 체크합니다. 이 때, action들이 분명한 의미를 가지고 유용한 정보를 주도록 설명하는 방식의 type 필드를 작성해야 합니다. 하지만, useReducer에서 사용하는 action 객체에 type 필드를 명시하는 것 또한 자유입니다.
위의 코드는 switch문을 사용하여 action 객체의 type 필드에 매핑되어 있는 값, 즉 “LOGIN_SUCCESS” case에 따라 state를 업데이트합니다.
5.2.2 reducer 함수의 조건 - 순수 함수
reducer를 순수 함수로 작성하는 이유는 예상대로 동작하도록 보장하는 것에 목적이 있습니다. 부가적으로, Redux의 변경 감지 알고리즘에 의해서도 reducer를 반드시 순수 함수로 작성해야 합니다.
따라서, reducer 함수 내에서 reducer 함수 스코프 바깥의 변수를 수정하거나 사용해서는 안됩니다. 비동기 로직인 AJAX 호출, Promise 객체 사용이 불가능하고, 추가적으로 Date.now(), Math.random()과 같이 무작위 값을 반환하는 함수를 사용할 수 없습니다.
💡
Redux 공식 문서에서 reducer 함수가 순수 함수여야 하는 이유에 대해 설명하고 있습니다.
순수 함수(pure function) VS 비순수 함수 (impure function)
동일한 매개변수가 주어지면 항상 동일한 결과를 반환하는 함수를 일컫습니다. 순수 함수를 사용하여 부수 효과(side effect)가 발생하는 것을 방지할 수 있습니다.
pureAdd 함수는 외부 환경에 의한 영향을 받지 않고, 주어진 매개변수가 같을 때 같은 결과를 반환하므로 순수 함수라 할 수 있습니다.
impureAdd 함수 스코프 외부에서 선언된 sideNumber 변수가 impureAdd 함수에서 다뤄지고 있고, 주어진 매개변수가 같아도 다른 결과를 반환하므로 비순수 함수라 할 수 있습니다.
5.2.3 reducer 함수의 조건 - 상태 변이(state mutation) 지양
state는 불변의 데이터 구조를 띠어야 합니다. 하지만 빈번히 state의 원본을 수정하거나 추가, 혹은 덮어쓰는 방식을 사용할 경우 state 변이가 발생하게 됩니다. 이는 여러가지 문제를 야기합니다. 먼저, 기존의 state를 사용하던 다른 컴포넌트에서 에러를 발생시켜 리렌더링 시에 예기치 않은 문제가 생길 수 있습니다. 이를 디버그하더라도, state가 어디서 변경되었는지 알기 어렵습니다.
이를 방지하기 위해, 깊은 복사를 사용하여 업데이트한 state를 반환하여 기존 state를 대체합니다. 구체적인 방법으로는 Object.assign() 또는 spread 연산자를 사용하여 깊은 복사를 수행합니다. 이를 통해 state의 불변을 유지할 수 있습니다.
💡
깊은 복사(deep copy)?
데이터(값) 자체를 복사하여 다른 주소값을 가지는 새로운 변수(또는 객체)로 생성하는 것을 일컫습니다.
5.3 useReducer 사용해보기
앞서 간단한 예제들을 통하여 useReducer와 reducer 함수에 대해 살펴보았습니다. 이제 학습한 내용을 이용하여 간단한 로그인 기능을 같이 만들어보도록 하겠습니다.
5.3.1 예제 설명
아래의 예제는 2장 useState에서 살펴본 “useState와 이벤트를 사용한 로그인 폼” 예제를 조금 응용하여 로그인 기능을 구현한 것입니다. 그리고 useState로 구현된 이 기능을 useReducer로 변환하는 연습을 해보도록 하겠습니다.
5.3.2 src 폴더 구조
src 폴더의 구조는 다음과 같습니다.
(context와 reducer 폴더는 useReducer로 변환하는 과정에서 사용됩니다.)
5.3.3 useState로 구현된 로그인 기능
아래의 코드는 2장 useState에서 살펴본 로그인 폼 예제를 약간 수정한 코드입니다. 아래 useState로 구현된 로그인 폼의 동작 내용을 간단히 살펴보겠습니다.
아래의 App 컴포넌트는 isLogin이라는 로그인 상태가 가진 값에 따라 true이면 “환영합니다~ 라이캣님!” 화면이 나타나고, false이면 로그인 폼이 나타나게 됩니다. 그리고 isLogin의 상태를 변경하는 setIsLogin 함수를 LoginForm 컴포넌트에 props로 전달하여 LoginForm 컴포넌트에서도 isLogin의 상태를 변경할 수 있도록 해줍니다.
아래의 LoginForm 컴포넌트는 id와 password, 그리고 message를 useState로 선언하고 있습니다. 아이디와 비밀번호는 form 요소 안에 있는 input 요소를 통해 값을 전달 받고 있으며, setId, setPassword를 통해 id와 password 값을 갱신 받고 있습니다.
사용자가 input 내용을 모두 입력한 뒤, “로그인 하기” 버튼을 클릭하면 handleLoginForm 함수가 동작하며 id와 password가 모두 일치한 경우에만, App 컴포넌트로부터 전달 받은 setIsLogin 함수를 이용하여 isLogin의 값을 true로 변경하여 줍니다. 그 이외의 경우에는 setMessage 함수를 통하여 “로그인 실패!”라는 문구를 “로그인 하기” 버튼 아래에 표기하여 줍니다.
앞에서 useState를 이용한 간단한 로그인 기능 구현 내용을 살펴보았습니다. 하지만, 조금 더 세부적으로 살펴보면 로그인 과정은 총 4가지 경우의 수로 나누어 생각할 수 있습니다.
id와 password가 모두 일치하는 경우 ⇒ 로그인 성공
id만 일치하는 경우 ⇒ 로그인 실패
password만 일치하는 경우 ⇒ 로그인 실패
id와 password 모두 불일치하는 경우 ⇒ 로그인 실패
id와 password 중 적어도 하나만 불일치하여도 로그인은 실패하게 됩니다. 하지만, 우리는 조금 더 각각의 상태를 명확히 구분하기 위하여 각각의 경우에 대해 서로 다른 message를 반환하여 주도록 하겠습니다.
아래는 앞서 살펴본 LoginForm 컴포넌트의 handleLoginForm 함수에서 조건문을 추가하여 로그인 과정을 조금 더 세분화한 것입니다.
5.3.4 useReducer로 구현한 로그인 기능
5.1과 5.2을 통하여 알게 된 것들을 토대로 앞선 useState로 구현한 로그인 기능을 useReducer로 바꿔보겠습니다.
위에서 App, LoginForm으로 컴포넌트들을 각각의 파일로 분리하였지만, 코드 구현사항을 명확히 파악하기 위해 잠시 App.jsx 파일 안에 모든 컴포넌트의 내용을 넣어 살펴보도록 하겠습니다.
가장 먼저 살펴볼 사항은 App 컴포넌트입니다. App 컴포넌트는 LoginForm을 분리하지 않았기 때문에 앞서 살펴본 LoginForm의 익숙한 코드가 보이게 됩니다. 여기서 달라진 점은 userInfo라는 사용자 정보가 담긴 객체와 useReducer Hook이 이용된 점, 그리고 handleLoginForm의 조건문에 dispatch가 추가된 점입니다. 이제 각각 추가된 새로운 부분들을 살펴보도록 하겠습니다.
먼저 userInfo는 사용자 정보인 id와 password를 가진 객체입니다. 보통은 서버로부터 사용자 정보를 받아오지만, 우리의 간단한 예제에서는 받아왔다고 가정하고 진행하기 위해 선언한 내용입니다.
다음으로 useReducer Hook은 나중에 살펴볼 reducer 함수를 첫 번째 인자로, 두 번째 인자로 초기 상태를 받았습니다. 초기 상태에서 isLogin은 false, 표기될 message는 빈 문자열로 되어 있습니다. 그리고 상태(state)와 dispatch라는 변수와 함수로 useState와 같이 구조 분해 할당 문법을 이용하여 useReducer Hook을 할당합니다.
마지막으로 handleLoginForm 함수의 조건문에서는 input 요소로부터 입력 받은 id와 password가 userInfo의 id와 password와 일치하는지 여부를 판단하여 각각의 경우에 맞추어 dispatch를 이용하여 reducer 함수가 반환해야할 값을 action의 type으로 reducer 함수에게 전달하여 주고 있습니다.
추가로, 로그인이 성공한 경우(type이 “LOGIN_SUCCESS” 인 경우)를 살펴보면 dispatch 함수에 payload라는 값을 type 이외에 추가로 전달하여 주고 있음을 확인할 수 있습니다. 여기서 payload는 action과 같이 전달하고자 하는 값 또는 데이터를 담는 그릇과 같습니다.
이제 reducer 함수의 내용을 살펴보도록 하겠습니다. reducer 함수는 상태(state)와 행동(action)을 파라미터로 전달 받아 action의 type에 따라 state를 업데이트하여 반환시켜주는 함수입니다. 여기서는 switch 문을 사용하여 사용자의 로그인 상태 및 로그인하기 위한 경우의 수를 모두 action의 type으로 받아 구분하였습니다.
이제 위의 코드가 잘 동작하는지 살펴보도록 하겠습니다. 다음은 각각의 경우에 대한 브라우저 화면을 캡쳐한 모습입니다.(참고: 입력내용을 보이기 위해 password 입력창인 input 요소의 type을 text로 변환하여 진행하였습니다.)
앞서 5.3.4에서는 useReducer로의 변환 과정과 그 흐름을 명확히 파악하기 위하여 App.jsx 하나의 파일에 모든 내용이 작성되어 있었습니다. 하지만, 실제로 기능을 구현하는 과정에서는 다양한 컴포넌트들이 서로 분리됩니다. 그리고 이로 인해 useState에서 마주하였던, props drilling 현상을 다시 마주하게 됩니다. 이는 useReducer 이용 시, 다른 컴포넌트에게 state와 dispatch를 전달하려면 불가피하게 나타나는 일입니다.
아래는 App.jsx에서 작성하였던 5.3.4의 코드를 컴포넌트 별로 분리한 것입니다.
위와 같이 파일을 분리하여도 LoginForm 컴포넌트에서 App 컴포넌트의 state와 dispatch를 props로 전달 받았기 때문에 App.jsx 파일 하나에서 작성한 것과 동일하게 작동하고 있음을 확인할 수 있습니다. 하지만, 폴더 구조가 복잡해지고 부모와 자식 컴포넌트 간의 관계가 깊어질수록 또 다시 props drilling의 문제는 해결해야할 과제가 됩니다. 때문에 useReducer는 useContext와 함께 사용하면 더 좋은 효율적으로 활용할 수 있게 됩니다. 이 부분은 이어지는 5.4 useReducer와 useContext 함께 사용하기에서 보다 자세히 살펴보도록 하겠습니다.
5.4 useReducer와 useContext 함께 사용하기
5.4.1 useReducer의 문제점
useReducer를 사용하면 상태 관리에 대한 로직들이 컴포넌트에서 분리되어 쉽게 재사용 할 수 있다는 장점이 있습니다. 하지만 dispatch와 reducer를 통해 변경할 데이터는 최종적으로 변경할 컴포넌트까지 전달해 줘야 하므로 props drilling이 생긴다는 단점이 발생합니다.
작은 규모의 프로젝트일 경우 props를 전달하는 깊이가 깊지 않지만, 규모가 있는 프로젝트를 진행할 경우에는 props를 넘겨주는 번거로움을 느낄 수 있을 것입니다.
그림 5-11
앞에서 살펴본 예제에는 LoginForm 컴포넌트에서 state와 dispatch를 사용하기 위해 App 컴포넌트에서부터 props로 값을 내려줘야 했습니다. 그렇다면, 상태 관리도 하면서 props drilling을 피하기 위해서는 어떻게 해야 할까요? 바로 useContext를 함께 사용하면 됩니다. 앞에서 살펴본 로그인 예제를 이용하여 props drilling을 해결해 봅시다.
💡
props drilling
중첩된 여러 계층의 컴포넌트에 props를 전달해 주는 것입니다. 단계적으로 일일이 props를 넘겨줌으로써 해당 props를 사용하지 않는 컴포넌트들에도 데이터가 제공되는 문제가 있습니다.
5.4.2 props drilling 해결하기
Context.jsx
먼저, 초깃값으로 들어갈 상태를 INITIAL_STATE라는 변수 이름으로 다음과 같이 선언해 준 후, createContext 안에 넣어 Context 객체를 생성합니다. 그다음 ContextProvider 컴포넌트를 생성하고 useReducer 안에 첫 번째 인자로는 reducer 함수, 두 번째 인자로는 INITIAL_STATE를 넣어 전달합니다. 이 ContextProvider 컴포넌트는 Context.Provider 를 반환하는데, value 속성을 통해 하위 컴포넌트에 전달할 값을 지정해 줍니다.
index.jsx
Context.jsx에서 작성한 ContextProvider 컴포넌트를 import 시켜줍니다. 그다음, ContextProvider 컴포넌트로 App 컴포넌트를 감싸줍니다. 이제 ContextProvider 컴포넌트에서 작성한 value 값을 App에서도 전달받아 사용할 수 있게 되었습니다.
App.jsx
useContext(Context)를 통해 Context를 호출하고 컨텍스트 내의 변수 state와 dispatch를 사용할 수 있도록 선언합니다.
만약, 컨텍스트를 통해 받아온 state.isLogin이 true 이면 로그인이 된 상태이므로 사용자를 환영하는 화면을 보여주고 false 이면 LoginForm 컴포넌트를 실행합니다. 이때, 기존에는 state와 dispatch를 LoginForm 컴포넌트에게 props로 값을 전달해 줘야 했으나 이제는 그럴 필요가 없게 되었습니다. 왜 그런 건지 LoginForm 컴포넌트에서 살펴봅시다.
LoginForm.jsx
그림 5-13
LoginForm 컴포넌트에서도 useContext(Context)를 통해 컨텍스트를 호출하고 컨텍스트 내의 변수 state와 dispatch를 사용할 수 있도록 선언하면 되기 때문입니다. LoginForm 컴포넌트에서 state와 dispatch를 사용하기 위해 props drilling이 일어나는 일을 없애줄 수 있습니다.
그림 5-14
form의 input에 아이디와 비밀번호를 입력하면 useState의 setId와 setPassword 함수를 통해 id와 password 값이 업데이트 됩니다. 또한, 값을 입력 후 form을 제출하면 내가 입력한 아이디/비밀번호가 userInfo와 일치한 지 여부에 따라 각각 다른 action의 type이 reducer로 전달되고 상태가 업데이트 됩니다.