끊임없이 검증하라

나에게 당연할지라도

React

R2_React Context API의 필요성(feat. state, props와 리렌더링)

fadet 2023. 2. 17. 23:04

이 포스트는 학습 과정에서 그 내용을 기록한 글이기에 부정확한 정보가 포함될 수 있습니다.

따라서 해당 글은 참고용으로만 봐주시고 틀린 부분이 있다면 알려주시면 감사하겠습니다. 

 

❗ 이 글의 목적은 react의 Context API나 redux의 전역 상태 관리를 사용하게 되는 과정을 알아보는 것으로 렌더링 과정, state, props 등의 react 기본 지식을 쭉 훑어보고 지나가겠지만 세부 설명은 진행하지 않습니다, 따라서 react에 완전 입문이신 분이라면 먼저 기본 지식을 학습하고 이 포스트를 보시길 권해드립니다.

* 잘 모르시는 기술은 로그인 필요 없이 이 곳에서 AI에게 물어보세요!  

 


 

이 글은 개인 프로젝트 진행 중 생긴 학습 사항을 기록한 글입니다. 해당 프로젝트에 대한 포스팅은 https://fadet-coding.tistory.com/70를 참고해 주세요. 또한 이번 포스트에서 사용된 리액트 코드 작성 방식은 클래스형이 아닌 함수형입니다.

 

Index
1 리액트의 렌더링과 state, props
2 props를 통한 컴포넌트 간 데이터 이동
3 전역 상태 관리의 필요성, Context API와 Redux
4 Context API 사용 예시 1 - Context 미사용
5 Context API 사용 예시 2 - Context 사용

 

 

사실 리액트를 공부하기 전부터 전역 상태 관리가 뭔지 대충 알고 있었고 구글링 하다 보면 보통은 거의 리액트를 쓰는 사람이면 redux 정도는 다들 사용하는 도구기에 사용을 고려했었지만 '일단 리액트 내장 라이브러리들로만 코드를 짜다가 나중에 쓰자'라는 생각에 혼자 며칠을 코드 가지고 장난을 쳤습니다. 그러다 이제는 전역 상태 관리의 필요성을 느끼게 되어 이 포스트를 작성합니다.

 

이번 포스트는 react의 state와 props를 통해 부모, 자식, 형제 컴포넌트(parent, children, sibling component) 간 데이터 공유 과정에서 생기는 불편함으로 인해 전역 상태 관리의 필요성을 언급하고  Context API(redux는 추후 포스팅)를 사용하는 것까지 훑어볼 예정입니다.

 

리액트의 렌더링과 state, props

 

우선 시작은 리액트 사용 시 화면을 그리는 과정에서 꼭 알아야 할 핵심적인 부분을 살펴볼 것입니다. 사실 리액트의 렌더링 과정을 깊게 파면 포스트 하나로는 담지 못할 만큼 깊은 내용이지만 정말 핵심만 언급하고 지나가겠습니다.

 

리액트는 기본적으로 가상 DOM을 사용해 코드의 변화를 체크해서 변화된 부분만 실제 DOM에 반영한다는 것쯤은 거의 다 아실 것이라 생각합니다. 전체 과정은 정말 쉽게 마운트 후 컴포넌트들을 호출해서 가상 DOM을 만들고경 사항을 실제 DOM에 반영하여 그를 토대로 브라우저가 paint를 하면 화면이 완성된다고 생각하시면 됩니다.

*리액트 사용 시 전체 process를 더 학습하시려면 https://narup.tistory.com/272 참고

 

이 과정을 정말 짧게 소개한 이유는 이번 포스트에선 우리가 전체 과정 중 '컴포넌트들을 호출해서 가상 DOM을 만들고'의 앞부분인 state와 props를 다룰 예정이기 때문입니다. 사실 리액트에서 렌더링은 컴포넌트 렌더링과 엘리멘트 렌더링 두 개가 존재합니다. 여기서 우리가 다루려는 것은 컴포넌트 렌더링으로 이것은 현재 state와 props에 기초하여 UI를 구성하는 React element를 만들기 위해 컴포넌트들을 호출하는 것까지를 의미합니다. 

 

react element란 type과 props를 가지는 리액트의 불변 객체인데 객체다 보니 이렇게 생겼습니다,

{ type: 'div',
  props: {
    className: 'name',
    children: 'React'
  }
}

그런데 이렇게 표현하면 엄청 낯설 것 같은데 우리에게 익숙한 JSX 문법으로 바꾸면 그냥 아래와 같습니다.

<div className='name'>React</div>

이런 react element는 함수형 컴포넌트에 인자로 props를 주면 반환됩니다. 그래서 이런 엘리먼트들을 이용해 가상 DOM을 만들고 그 변화를 업데이트하여 실제 DOM에 반영합니다.

 

이런 렌더링 과정은 최초 마운트시에 한 번 일어나고 이후에 특정 조건에 의해 일어나는 경우 리렌더링이라고 부릅니다. 우리는 이 리렌더링이 일어나는 조건을 알아보려고 합니다. 이 리렌더링이 일어나는 조건은 아래와 같습니다,

1 내부 상태(state) 변경 시
2 부모에게 전달받은 값(props) 변경 시
3 부모 컴포넌트가 리렌더링 되는 경우(부모 > 자식)
4 중앙 상태값(Context value 혹은 redux store) 변경 시

이 조건은 다음 항목에서도 언급되니 계속 기억해 주시길 바랍니다.

 

props를 통한 컴포넌트 간 데이터 이동

 

이 항목은 포스팅이 잘 이해가 안 가신다면 생활 코딩님의 유튜브 강의인 https://www.youtube.com/watch?v=yjuwpf7VH74&t=629s 를 처음부터 10분 정도 보시기를 추천드립니다. 제가 설명하는 내용 거의 그대로 영상으로 잘 풀어주시는 것 같습니다.

 

먼저 알아야 할 것은 이번 항목에서 다룰 props로 데이터 전달의 경우 기본적인 사용 방법은 숙지하신 상태에서 넘어가시길 추천드립니다. props를 통해 컴포넌트 간 데이터 이동을 할 줄 모른 채로 전역 상태 관리를 사용하신다면 후에 컴포넌트 설계나 가벼운 프로젝트를 할 때 애먹으실 확률이 꽤 있기 때문입니다.

 

props를 다루실 수 있다는 전제 하에 이번 항목에선 컴포넌트 간 데이터 이동에 대해서 살펴볼 예정입니다. 사실 앞에서 살펴본 렌더링 관련 언급은 이 항목을 보다 이해하기 쉽도록 한 것이라 생각하시면 됩니다. 보통 리액트에선 컴포넌트 간 관계가 이렇게 됩니다.

그런데 프로젝트가 커지면 이런 컴포넌트들 간에 반드시 데이터가 이동해야 하는 경우가 발생합니다. 이런 경우들은 보통 1. 부모 > 자식 2. 자식 > 부모 3. 형제 > 형제 이렇게 3가지입니다. 하지만 리액트에선 3. 형제 > 형제 의 경우를 구현하는 것은 쉽게 이뤄지지 않습니다.

 

리액트의 함수형 디자인에서 컴포넌트들은 기본적으로 함수기 때문에 일반적으로 state나 변수들을 선언해도 다른 컴포넌트에서 참조할 수 없다는 것을 아실 겁니다. 그래서 컴포넌트들이 데이터를 다른 컴포넌트로 전달해 줄 땐 props를 사용합니다. 여기서 이 props를 사용할 수 있는 대상은 부모 <> 자식 간이라는 것이 핵심입니다.

 

따라서 형제 컴포넌트 간 데이터 전달은 서로 직접 direct가 아닌 부모 컴포넌트의 중개가 이루어져야 합니다.

보시면 예상하시겠지만 데이터 전달 방식은 컴포넌트가 한두 개면 크게 문제가 되지 않을 수도 있지만 여러 컴포넌트가 중첩된다면 꽤 골치 아픈 경우가 생길 수 있겠죠?

위와 같은 컴포넌트 구조(first Child를 1, second child를 2로 칭하겠습니다.)에서 만약 1-3에 담긴 데이터를 2-2에 전달해야 한다면 어떻게 될까요? 아마 1-3 > 1-2 > 1-1 > parent > 2-1 > 2-2의 순서로 props를 서로 작성해줘야 할 겁니다.

 

첫째로 일단 작성자가 일일이 컴포넌트마다 props를 다 작성해줘야 하기에 코드가 지저분해질 수 있고 가독성이 떨어질 수도 있지만 가장 중요한 건 매우 귀찮습니다. 또 이렇게 코드를 작성하다가 잘못하면 순환 참조 오류로 무한 리렌더링이 일어날 가능성도 있구요. 이게 가장 큰 문제지만 이것 말고도 다른 문제도 발생합니다.

 

아까 리렌더링 되는 조건 기억하시나요? 방금 예시인 1-3 > 2-2에서 좀 더 생각해 봅시다. 이번엔 단순히 데이터를 전달하는 것이 아니라 1-3에서 변경된 상태가 2-2에 반영돼야 한다고 생각해 봅시다. 아마 코드로 짜게 되면 부모와 1-3 컴포넌트가 state를 가져야 할 겁니다. 앞서 살펴본 리렌더링의 조건은 state 변경, props 업데이트, 부모 > 자식 리렌더링이 있었습니다. 

 

1. 1-3 > 1-2 > 1-1 > parent에서 각 props 업데이트로 인해 순차적 리렌더링
2. parent의 state 변경으로 parent 컴포넌트 리렌더링 > parent의 모든 자식(1, 2) 리렌더링

props는 불변, read only로 알고 계실겁니다. 근데 이게 업데이트된다는게 사실 아이러니할 수 있습니다. 사실 이 내용은 참조값과 immutable에 대해 선행 지식이 있어야 쉽게 이해하실텐데 자세한건 refer를 참고하시고 짧게 말하면 props가 변경되지 않더라도 부모 컴포넌트가 리렌더링되면 자식은 새로운 props를 전달받게 됩니다.

 

바로 다음에 설명하겠지만 자식 > 부모 컴포넌트 간 데이터 전달은 props를 통한 함수 호출로 이뤄집니다. 따라서 props가 호출되면 부모가 리렌더링되기에 #1처럼 엮여있다면 쓸데 없는 리렌더링이 발생하게됩니다.

 

또한 앞서 살펴봤듯 부모 컴포넌트가 리렌더링 되면 그 자식들은 같이 리렌더링 됩니다. 그런데 #2의 경우 분명 1-3에서 순차적으로 parent로 올라오며 리렌더링이 되었는데 다시 parent부터 1-3까지 리렌더링 되는 것도 모자라 이번 경우에서 변경 사항이 없는 1-4 컴포넌트도 리렌더링 됩니다. 게다가 #1에서 이미 리렌더링을 한번 순회했는데 다시 중복하여 이 과정이 일어납니다. 또 거기서 끝나는 것이 아니라 second child 쪽의 2-3, 2-4도 마찬가지로 불필요한 리렌더링이 발생합니다.

 

물론 이런 불필요한 리렌더링 발생 문제를 단순히 1-3과 parent 컴포넌트에 각각 state를 만든 것 때문에 생겼다고 할 수는 없습니다. state를 사용하지 않더라도 부모 컴포넌트의 렌더링으로 인해 모든 자식들이 리렌더링 되는 과정에서 불필요한 렌더링이 생길 수도 있기 때문입니다,

 

물론 이렇게 불필요한 부모 > 자식 리렌더링의 경우 useMemo와 useCallback 등을 사용하여 props가 변경되지 않으면 새로운 props를 전달시키지 않도록하여 리렌더링을 막도록하여 최적화시킬 수 있습니다. 하지만 이 과정에서 불필요한 state들이 사용되는 경우는 props가 아닌 state 변경으로 인한 리렌더링이기 때문에 문제가 발생할 여지가 생깁니다.

 

리렌더링은 일어나는 경우 당연히 메모리 소모를 수반합니다. 따라서 이렇게 불필요한 렌더링이 일어나도록 코드를 짜다 프로젝트가 커지면 체감할만한 성능 저하에 직면할 수도 있습니다. 

useCallback, useMemo 등 렌더링 최적화를 위한 방법들은 분량상 다음 포스트에서 소개할 예정입니다. 따라서 이번 포스트를 읽으며 렌더링 최적화에 대해 더 자세히 알고 싶으시다면 refer에 링크된 포스트를 참고해 주세요.

 

 

전역 상태 관리의 필요성, Context API와 Redux

 

앞서 살펴본 부모 자식 컴포넌트 간에 props를 통한 데이터 전달을 마치 하나의 전선으로 연결되어 있다고 비유하곤 합니다.  또한 전선줄처럼 이런 props를 전달하기 위해서만 존재하는 중간 컴포넌트를 사용하는 것을 prop drilling이라고 합니다. 형제 컴포넌트 간 데이터 전달 시엔 이 회로를 통해 이루어져 매우 불편합니다. 그렇다면 형제 컴포넌트 간에 데이터를 이런 전선이 아닌 와이파이처럼 무선으로 전달할 수 있다면 얼마나 좋을까요?

 

이를 위해 전역 상태 관리가 필요합니다. 그 대표적인 도구로 Context API와 Redux, MobX가 있는데, 엄밀히 말하면 Redux, MobX는 전역 상태 관리 도구가 맞지만 Context API는 전역 상태 관리 도구가 아닙니다. 단지 Context란 그 자체로 상태 관리 도구의 기능을 하지 않고 상태 관리 훅인 useState, useReducer와 조합될 경우 상태 관리 도구로써의 기능을 할 뿐입니다. 하지만 이번 포스트에선 그냥 전역 상태 관리라고 통칭하려 합니다. 다시 말하지만 Context API는 '전역'도 '상태 관리 도구'도 아닌 상태 관리를 위한 매개체임을 말하고 지나가겠습니다.

 

과거 Context API는 지금처럼 사용성이 편하지 않아 주로 쓰이지 않았고 그 때문에 Context API 개선 전부터 존재하던 Redux가 지금의 위상을 가진 거라 볼 수 있습니다. 따라서 전역 상태 관리가 필요해진 개발자라면 이런 도구들 중 무엇이 지금 내 프로젝트와 맞을지 선택하여 사용할 수 있어야 합니다.

 

정말 단순히 얘기하면 Redux는 큰 프로젝트에, Context API는 작은 프로젝트에 어울린다고 할 수 있고 저 역시도 Context API를 사용할 예정이긴 하지만 사용하기 전 장단점을 그래도 파악해둬야 합니다,

 

Context API는 리액트의 내장 라이브러리기 때문에 다른 훅들과 조합하기도 편하고 상대적으로 가볍다는 이점이 있습니다. 하지만 리렌더링 항목에서 본 것처럼 컴포넌트 간 리렌더링을 고려하여 코드를 작성해야 하는데 그러한 최적화를 Context API 사용 시 개발자가 직접 해줘야 합니다. 또한 Context를 사용하면 컴포넌트 간 의존도가 높아서 리액트의 장점인 컴포넌트의 재사용이 어려워질 수 있습니다.

 

하지만 Redux의 경우 기본적으로 한 곳에서 모든 상태를 관리하기 때문에 Context를 매번 새로 만들어줄 필요도 없고 컴포넌트간 최적화를 덜 신경 써도 됩니다. 또한 미들웨어를 사용하여 비동기 작업을 하는데도 용이합니다. 그리고 추가로 유용한 함수와 훅들을 사용하기에 더 사용성이 좋습니다.

 

이렇게 적고 보니까 저도 당장 Redux를 배워야 할 것 같지만 당장은 Context API로도 충분할 것 같고 이후에 필요성이 조금이라도 생기면 배워도 되니까요.

 

 

Context API 사용 예시 1 - Context 미사용

 

좀 더 와닿을 수 있게 예시를 들어보고자 합니다. 예시로는 실제 제가 프로젝트에 넣을 Dial 컴포넌트를 보여드리겠습니다. 구현할 기능은 마운트시엔 도움말이 켜지고 '닫기' Button을 눌렀을 때는 도움말이 꺼집니다. 이후 '도움말 다시 열기' 버튼을 누르면 다시 도움말이 켜지는 간단한 기능입니다.

이 기능을 구현하기 필요한 컴포넌트의 구조는 이렇습니다.

일반적으로 리액트 프로젝트의 App.js에서 App 컴포넌트는 모든 컴포넌트들의 조상이 되기 마련입니다. 마찬가지로 이번 예시에서도 App 컴포넌트는 Root(Parent)의 지위를 갖습니다. 그다음에 자식 컴포넌트로 위의 과정에서 '도움말 창'이 될 CustomDial 컴포넌트'도움말 다시 열기'가 될 DialOnButton 컴포넌트가 있습니다. '닫기' 버튼의 경우 CustomDial 버튼 내부에 존재하게 작성합니다.

 

우선 기본적인 컴포넌트의 틀은 MUI 라이브러리를 사용할 예정입니다. Material-UI(MUI)는 기본적으로 디자인되어 있는 UI 컴포넌트를 제공하는 리액트의 라이브러리입니다. html/css만 쓰시던 분은 기존의 부트스트랩과 비슷하다고 생각하시면 됩니다.

 

우선 도움말 기능을 할 CustomDial 컴포넌트를 먼저 만들겠습니다. 

 

# CustomDial

// #1
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material';

function CustomDial(props){
  // #2
  return <Dialog Mod={}>
  <DialogTitle>사용법</DialogTitle>
      <DialogContent> 
          <DialogContentText>
              1. ai에게 할 질문이 한글이라면 번역을 위한 한글 질문을 입력해주시거나 추천 질문 버튼을 눌러주세요.<br />
              2. 번역된 질문 또는 직접 입력한 영어 질문이 'ai에게 질문하기' 버튼을 누르시면 아래에 답변이 출력됩니다.
          </DialogContentText>
          <DialogActions>
           	  // #3
              <Button variant='contained' onClick={() => {}}>닫기</Button>
          </DialogActions>
      </DialogContent>
</Dialog> }

#1은 MUI 라이브러리부터 필요한 컴포넌트들을 import 하는 문장입니다. 중괄호 안은 디자인된 Button과, 도움말 기능을 할 Dialog, DialogText 등 Dialog 내부를 채울 컴포넌트들입니다. 잘 모르겠으신 분은 어렵게 생각하지 마시고 기존에 다른 사람이 만들어 놓은 Custom 컴포넌트를 가져 다 와서 쓴다고 생각하시면 됩니다.

 

#2는 Dialog 컴포넌트를 사용하겠다고 선언하는 문장인데 props로 Mod를 갖습니다. 이 Mod는 도움말 창이 true면 켜지고 false면 꺼지도록 넣어준 props입니다. 위 기능에서 도움말이 켜지고 꺼지는 건 다른 컴포넌트와의 상호작용으로 이루어지기 때문에 부모의 props를 받을 예정인데 이는 이후 다시 설명할 테니 비워두겠습니다.

 

 #3은 도움말을 닫을 '닫기' 버튼을 넣어준 문장입니다. Button 컴포넌트는 variant, onClick 이 두 가지 props를 갖는데 variant는 그냥 버튼의 디자인이니 신경 쓰지 않으셔도 되고 onClick만 보시면 됩니다. 클릭 이벤트가 발생하면 onClick에 지정해 둔 함수를 실행하여 앞서 Mod props를 바꿔주면 기능이 작동할 겁니다. 다시 설명할 예정이니 이 또한 비워두겠습니다.

 

# DialOnButton

 

function DialOnButton(props){
  return <Button variant='outlined' onClick={() => {}}>도움말 다시열기</Button> }

다음으로 DialOnButton 컴포넌트입니다. 이 컴포넌트는 우리가 구현할 기능의 '도움말 다시 열기' 버튼을 담당합니다. 마찬가지로 props들 중 variant는 신경 쓸 필요 없고 onClick만 아시면 됩니다. 마찬가지로 비워두겠습니다.

 

# App

 

export default function App() {
  return (
    <div>
      <CustomDial DialMod={} DialOff={() => {}}></CustomDial>
      <DialOnButton DialOn={() => {}}></DialOnButton>
    </div> ); }

마지막으로 앞서 본 두 컴포넌트의 root(parent)인 App 컴포넌트입니다. 부모 컴포넌트이니 일단 두 컴포넌트 모두 포함합니다. 비워둔 props는 차차 설명하겠습니다.

 

이제 사용할 세 컴포넌트의 골격을 만들어 두었으니 본격적으로 기능을 구현하겠습니다. 우선 처음으로 해야 할 것은 도움말이 켜지고 꺼지는 기본적인 토글 여부를 체크하는 것입니다. 이를 우리는 mod가 true면 도움말이 켜지고 mod가 false면 꺼지도록 구현할 겁니다. 이를 위해 다음과 같은 과정이 필요합니다.

1 CustomDial 컴포넌트가 state인 dialMod를 알아야 도움말 기능이 켜지고 꺼짐
> App 컴포넌트(부모)의 dialMod state를 props를 통해 CustomDial 컴포넌트(자식)로 전달
2 '닫기' 버튼을 누르면 state인 dialMod가 setDialMod를 통해 true에서 false로 바뀌어야 함 
> CustomDial 컴포넌트(자식)의 Button에서 onClick을 추가하고 onClick시 props를 통해 App 컴포넌트(부모)로 setMod를 하도록 하는 함수를 전달
3 '도움말 다시 열기' 버튼을 누르면 state인 dialMod가 setDialMod를 통해 false에서 true로 바뀌어야 함
> DialOnButton 컴포넌트(자식 2)에서 onClick을 추가하고 onClick시 props를 통해 App 컴포넌트(부모)로 setMod를 하도록 하는 함수를 전달한 다음 App 컴포넌트(부모)의 dialMod state를 props를 통해 CustomDial 컴포넌트(자식 1)로 전달

 

일단 이 과정들을 설명하기 전에 부자 컴포넌트 간 props 전달을 잠깐 얘기하고 가겠습니다. 부모 > 자식의 경우 props에 값을 전달하는 것으로 끝입니다. 하지만 자식 > 부모의 경우 자식 컴포넌트에서 받은 props로 특정 함수로 지정해 두고 이벤트 발생 시 지정해 둔 특정 함수를 실행하도록 해서 부모 컴포넌트에서 props인 특정 함수를 통해 값을 변화하도록 하는 과정을 거쳐야 합니다.

 

# 과정 1

부모 > 자식 간 데이터 전달이므로 App 컴포넌트 내 코드에 있는 CustomDial 컴포넌트에 DialMod라는 props를 넣어줍니다. 그리고 해당 props의 값은 상호작용으로 인해 변할 테니 dialMod라는 state를 사용해 주겠습니다.

// App 컴포넌트
...
<CustomDial DialMod={dialMod}

따라서 이 state를 사용하기 위해 App 컴포넌트에 dialMod라는 state를 선언해 줍니다. 초기화는 true로 해서 마운트시에 기본적으로 도움말이 켜지도록 해줍니다.

export default function App() {
  const [dialMod, setDialMod] = useState(true)

이제 받은 props를 사용할 수 있도록 CustomDial 컴포넌트를 수정해 줍니다. CustomDial 내에서 도움말 창의 전체 골격을 담당하는 Dialog에 open이라는 속성을 추가하고 open의 값은 위에서 받은 props인 DialMod로 해줍시다.

* MUI의 Dialog 컴포넌트는 open이라는 속성이 true냐 false냐에 따라서 창이 열리고 닫힘을 정합니다.

function CustomDial(props){
  return <Dialog open={props.DialMod}>

 

# 과정 2

자식 > 부모 데이터 전달이므로 조금 더 과정이 깁니다. 우선 우리가 구현할 기능이 닫기 버튼을 누르면 dialMod가 변하도록 해야 하므로 자식인 CustomDial의 Button이 부모인 App의 dialMod를 변화시켜야 합니다. 따라서 우선 CustomDial의 Button에 onClick을 추가하고 함수를 추가해 줍니다. 추가해 주는 함수는 App에 있는 CustomDial로 전달된 props 중 DialOff()를 호출합니다. props로 함수를 추가했으니 작동할 수 있도록  App에 있는 CustomDial의 props로  DialOff()라는 props를 선언해 주러 갑시다.

//CustomDial
<DialogActions>
              <Button variant='contained' onClick={() => {props.DialOff()}}

아래처럼 CustomDial에 props로 DialOff()를 추가해 주고 이 함수는 dialMod state를 변경하는 setDialMod로 작성해 줍니다. 

//App
return (
    <div>
      <CustomDial DialMod={dialMod} DialOff={() => {setDialMod(false)}}></CustomDial>

 

이렇게 코드 작성이 끝났으니 간단히 정리해 봅시다. App 컴포넌트에서 CustomDial 컴포넌트로 props를 통해 DialOff()라는 함수를 전달해 주고 CustomDial 컴포넌트에서 onClick이 발생하면 해당 DialOff() 함수를 호출하여 SetDialMod()를 실행해서 App 컴포넌트의 dialMod state를 변경하는 과정이 이루어집니다. 코드는 간단한데 풀어쓰자니 좀 기네요.

 

# 과정 3

과정 2를 거쳤으니 자식 > 부모 데이터 전달은 이제 아실 것이라 생각합니다. 따라서 DialOnButton 컴포넌트(자식 2)에서 값을 CustomDial 컴포넌트(자식 1)로 전달하는 과정 3인 형제 > 형제 데이터 전달이고 이것은 자식>부모>자식 형태로 코드를 작성하시면 됩니다.

 

우리가 과정 3에서 구현할 기능은 DialOnButton 컴포넌트에 작성된 '도움말 다시 열기' 버튼을 누르면 App을 거쳐 CustomDial에 위치한 속성 open이 바뀌도록 하면 됩니다. 따라서 우선 DialOnButton를 작성합니다.

 

DialOnButton컴포넌트에 있는 Button에 onClick을 추가하고 클릭 시 호출하는 함수는 App에 있는 DialOnButton컴포넌트로 전달된 DialOn()이라는 props를 실행하도록 작성해 줍니다. 과정 2와 마찬가지로 이 DialOn()이는 props를 작성해 주러 App으로 가겠습니다.

function DialOnButton(props){
  return <Button variant='outlined' onClick={() => {props.DialOn()}}>도움말 다시열기</Button>
}

과정 2와 마찬가지로 DialOn() props를 추가하고 setDialMod()로 dialMod state를 변경토록 작성해 줍니다.

//App
<DialOnButton DialOn={() => {setDialMod(true)}}></DialOnButton>

이제  false에서 true로 바뀐 App의 dialMod state를 CustomDial이 알 수 있도록 props를 작성해 주면 마무리됩니다. 부모 > 자식 데이터 전달이므로 함수가 아닌 값 전달만 해도 됩니다. 원래라면 같은 App 컴포넌트에 위치한 CustomDial에 props를 추가해줘야 하지만 이미 과정 1에서 작성한 내용이니 보고만 넘어가시면 됩니다.

//App 
<CustomDial DialMod={dialMod} DialOff={() => {setDialMod(false)}}></CustomDial>

마찬가지로 원래라면 내용을 추가로 작성해야 하지만 기존에 작성해 둔 CustomDial 컴포넌트의 open 속성은 이미 props로 전달된 DialMod를 따라가므로 과정 3도 구현이 완료되었습니다. 

function CustomDial(props){
  return <Dialog open={props.DialMod}>

 

Context API 사용 예시 2 - Context 사용

 

위에서 작성한 예시를 직접 보시면서 어떤 느낌이 드셨나요? 보면서도 props를 너무 많이 만지는 기분이 들지 않나요? 따라서 우리는 전역 상태 관리를 사용하여 이 코드를 수정해보려 합니다. 만약 위 코드가 Context API를 사용하게 된다면 props를 과연 몇 번 사용하는 것으로 줄일 수 있을까요?

 

우선 Context API를 사용하기 위해서 import 해줍니다. 

import React, {createContext, useContext} from 'react';

이후에 이 context를 사용하기 위해 createContext()를 사용하여 ModContext 하나를 만듭니다. Context는 App 컴포넌트에 종속될 이유가 없으니 App.js 파일 아무 데나 작성하셔도 됩니다.

//App.js
const ModContext = React.createContext();

function App() { ...

우리가 컴포넌트에서 만든 Context를 사용하기 위해서는 이 Context를 provide 해줄 Provider가 필요합니다. 원래 간단하게 사용하려면 App 컴포넌트를 ModContext.Provider로 감싸주기만 해도 괜찮지만 이렇게 하면 상태 관리가 아닌 Context만 사용하는 꼴입니다. 우린 계속 상태를 변경해 줄 예정이니 상태 관리를 해줄 수 있도록 Provider에 state를 넣어 커스텀으로 만들어줍니다.

function ModProvider({ children }) {
  const modState = useState(true);
  return <ModContext.Provider value={modState}>
         {children}
        </ModContext.Provider>; }

커스텀으로 만든 ModProvider는 {children}이라는 props를 받으며 return을 보시면 이 props는 ModContext.Provider를 통해 둘러 쌓여 있으며 ModContext는 props로 value를 가집니다.

 

이 코드를 위에 들었던 예시와 비교해 보면 변수인 modState기존 App의 dialMod에 해당합니다. 기존에 컴포넌트의 개별 props를 통해 dialMod state를 전달받아 과정을 여러 번 거쳤다면 변경하는 코드에선 ModState가 props로 Povider에 한 번 전달해 주면 우리는 이 Provider만 컴포넌트에 전달해주면 더 이상 props drilling을 할 필요가 없어지는 것입니다.

 

마지막으로 우리가 만든 Context를 사용하기 쉽도록 커스텀 훅 하나를 만들어줍니다. 이 useModeState를 통해 각 컴포넌트들은 공통으로 관리되는 state를 주입받습니다.

function useModState() {
  const value = useContext(ModContext);
  if (value === undefined) {
    throw new Error('error') }   
    return value; }

이제 Context를 사용하여 상태 관리를 할 준비를 모두 끝냈습니다.

 

# App

이제 기존 코드를 수정해 주겠습니다. 먼저 App 컴포넌트입니다. 수정 전에는 아래처럼 App내부에 dialMod state를 선언해야 했고 컴포넌트들에 props들을 여러 개 작성해줘야 했습니다.

//기존 App 컴포넌트 코드
export default function App() {
  const [dialMod, setDialMod] = useState(true)
  return (
    <div>
      <CustomDial DialMod={dialMod} DialOff={() => {setDialMod(false)}}></CustomDial>
      <DialOnButton DialOn={() => {setDialMod(true)}}></DialOnButton>
    </div> );}

하지만 수정 후 App 컴포넌트는 state도 props도 모두 삭제했습니다. 단지 provider로 감싸주면 끝입니다. return문이 훨씬 깔끔해졌습니다.

//수정 후 App 컴포넌트 코드
export default function App() {
	// dialMod state 삭제
  return (
    <div>
      {/* provider 추가 */}
      <ModProvider> 
        {/* props 삭제 */}
        <CustomDial></CustomDial> 
        <DialOnButton></DialOnButton>
      <ModProvider>
    </div> ); }

 

# CustomDial

기존 CustomDial은 props를 통해 데이터를 전달받았습니다.

//기존 CustomDial 컴포넌트
function CustomDial(props){
  return <Dialog open={props.DialMod}>
...
              <Button variant='contained' onClick={() => {props.DialOff()}}>닫기</Button>
...

변경 후의 CustomDial 컴포넌트의 경우 provider를 통해 useModState()로 Context가 관리하는 modState를 전달받습니다. 전달받은 state를 컴포넌트 분리 없이 평소 App에서 모든 코드를 작성하듯 사용해 주면 됩니다.

//변경 후 CutomDial 컴포넌트
function CustomDial(props){
	// useModeState 추가
    const [mod, setMod] = useModState();
    return <Dialog open={mod}>
...
				 {/* provider 추가 */}
                <Button variant='contained' onClick={() => setMod(false)}>닫기</Button>
...

 

# DialOnButton

//기존 DialOnButton 컴포넌트
function DialOnButton(props){
  return <Button variant='outlined' onClick={() => {props.DialOn()}}>도움말 다시열기</Button> }

마찬가지로 변경 후엔 useModState()로 modState를 전달받고 이를 그냥 사용해 주면 됩니다.

//변경 후 DialOnButton 컴포넌트
function DialButton(props){
    const [mod, setMod] = useModState();
    return <Button variant='outlined' onClick={() => setMod(true)}>도움말 다시열기</Button> }

 

이 항목 시작 때 props를 얼마나 줄일 수 있는지 여쭤봤었습니다. 보셨듯이 Context API 사용 후 코드에선 단지 povider에 props로 modState를 한 번 전달해 주고 이후엔 props를 전혀 사용하지 않았습니다.

 

더 나아가서 하나 더 살펴보겠습니다. 만약 사용할 state가 하나면 상관 없지만 여러 개라면 각각의 state마다 Context에 넣어줘서 사용해야할 겁니다. 이러면 개발자가 제일 싫어하는 쓸데 없는 중복 코드가 발생하겠죠? 어떻게 바꾸면 될까요?

 

바로 context 하나를 여러 번 사용하기 위해서 입력 값을 변수 하나가 아닌 배열로 바꾼다면 해결됩니다. 예를 들어 내가 1과 2라는 state 두 개를 전역 상태 관리하기 위해선 기존의 방식으론 비슷한 context 코드를 두 번 사용해야 하는데 배열을 사용한다면 하나의 context에서 1이라는 state를 사용하려면 그냥 배열의 첫 번째 요소를 사용하고 2를 사용하려면 두 번째 요소를 사용하면 되니 같은 코드를 두 번 쓰지 않아도 됩니다.

 

기존 코드는 ModeProvider와 useModState는 다음과 같았습니다.

# 기존 ModProvider
function ModProvider({ children }) {
  const modState = useState(true);
  return <ModContext.Provider value={modState}>
         {children}
        </ModContext.Provider>; }

# 기존 useModState
function useModState() {
  const value = useContext(ModContext);
  if (value === undefined) {
    throw new Error('error') }   
    return value; }

여기서 변수 modState에 true라는 값을 넣는게 아니라 [true, false]라는 배열을 넣으면 내가 기본값이 true인 state를 사용하고 싶다면 배열의 첫 번째, false인 state를 사용하고 싶다면 배열의 두 번째 요소를 사용하면 됩니다.

 

// 수정 후 ModProvider
function ModProvider({ children }) {
  // 이 부분 수정
  const modState = useState([true, false]);
  return <ModContext.Provider value={modState}>
         {children}
        </ModContext.Provider>;
}

그런데 위 처럼 ModProvider는 단순히 배열로 바꿔주면 되지만 useModState도 아래처럼 단순히 바꿔주면 작동하지 않습니다.

// 잘못된 useModState 수정
function useModState(putIndex) {
  const index = putIndex;
  const value = useContext(ModContext);
  if (value === undefined) {
    throw new Error('error') }   
    return value[index]; }

이 useModState는 parameter로 사용할 배열의 요소의 Index를 putIndex로 받습니다. 코드만 보면 작동할 것 같지만 이러면 우리는 state로 배열을 사용했기 때문에 리액트는 setState() 함수에 대한 정보를 알지 못합니다. 기존에 state 훅 같은 경우 라이브러리에서 import해오면 useState를 단순 사용시 자동으로 [state, setState()] 배열이 생성되며 setState()에 대한 정보도 리액트가 알 수 있습니다. 하지만 우리가 useState를 배열로 사용했기 때문에 리액트가 아는 setState()와 다른 의도로 작동합니다.

 

예를 들어 useState[first, second]를 사용했을 때 리액트는 1. state가 first고 setState가 second(우리가 의도한 로직 아님) 2. 사용할 state[0]이 first고 state[1]이 second(우리가 의도한 로직) 둘 중에 뭐가 맞는지 알 수 없습니다. 그렇기 때문에 위에 수정한 코드대로 사용하면 우리 의도는 2번인데 리액트는 본인이 알던대로 1번으로 인식하므로 이 ModState를 App에서 사용한다면 아마 출력되는 결과는 first의 첫 글자인 f일 것입니다. 이 부분이 잘 이해가 가지 않으신다면 state에 대한 기본 학습을 더 하고 오신 후 다시 보시길 추천합니다.

 

따라서 useModState를 아래처럼 변경 해주어야 합니다.

// 1 param으로 putIndex를 받음
function useModState(putIndex) {
	const index = putIndex;
	const value = useContext(ModContext);
      if (value === undefined) throw new Error('error'); 
        // 2 useState의 리턴 값으로 배열 [ , ]이 반환
        return [ value[0][index],
          (newValue) => {
            const newArray = [...value[0]];
            newArray[index] = newValue;
            value[1](newArray);
          },];
}

위 코드를 보면 1처럼 putIndex를 받는 것은 동일하지만 2를 보면 return 자체가 value[index]인 단일 값이 아니라 배열입니다. 위 코드에서 value를 콘솔로 출력해보면 아래와 같으며 아마 [[ture, false], setState()]인 이중 배열일겁니다.

 

따라서 return 역시도 위처럼 첫 번재 요소를 value[0][index]로 바꿔주고 두 번째 요소는 setState() 함수를 직접 정의해 주면 됩니다. 이렇게 변경하면 아래처럼 사용했을 때 정상 작동합니다.

 


이번 포스트는 Context API를 사용해서 예시로 코드를 작성해서 꽤 길어진 감이 있네요. 하지만 어떠셨나요? Context API를 사용해서 prop drilling을 획기적으로 개선할 수 있었습니다. 이로 인해 props를 사용하도록 설계해야 하는 시간과 코드를 깔끔하게 관리할 수 있는 등 여러 장점이 와닿네요. 물론 앞서 설명했듯 재사용이나 다른 단점도 있긴 하지만 모든 리팩토링에는 장단점이 존재할 수밖에 없고 그중 장점이 단점보다 훨씬 크다면 사용하지 않을 이유가 없습니다. 

 

이번 포스트에선 전역 상태 관리를 언급했는데 정확히 Context API는 '전역' 상태 관리 도구가 아니라 상태 관리를 구현할 수 있는 매개체일 뿐이지만 용이한 설명을 위해 크게 둘을 다르게 여기진 않은 점 다시 알아주시길 바라고 이후 진짜 '전역 상태 관리 도구'인 redux, mobX를 사용해 보고 포스트 할 수 있도록 하겠습니다. 또한 글이 길어져서 잘라냈던 Context 사용 시 리렌더링 최적화나 useCallback, useMemo 등 리렌더링 최적화만 따로 떼서 그 내용을 포스트 할 예정입니다.

 

 

 

 

refer

 

https://velog.io/@shin6403/React-%EB%A0%8C%EB%8D%94%EB%A7%81-%EC%84%B1%EB%8A%A5-%EC%B5%9C%EC%A0%81%ED%99%94%ED%95%98%EB%8A%94-7%EA%B0%80%EC%A7%80-%EB%B0%A9%EB%B2%95-Hooks-%EA%B8%B0%EC%A4%80

 

[React] 렌더링 성능 최적화하는 7가지 방법 (Hooks 기준)

오늘은 그동안 React를 공부하고 알아왔던, class기반이 아닌 hooks 기반의 성능 최적화에 대한 방법들을 포스팅 하고자 한다.먼저 컴포넌트의 리렌더링 되는 조건은 아래와 같다.부모에서 전달받은

velog.io

https://yceffort.kr/2022/04/best-practice-useCallback-useMemo

 

Home

yceffort

yceffort.kr

https://tecoble.techcourse.co.kr/post/2020-05-18-immutable-object/

 

불변객체를 만드는 방법

이번 글에서는 불변 객체로 만들어야 할 때 어떠한 방법으로 만들 수 있는지에 대해 이야기해보고자 합니다. 주로 클래스를 불변 클래스로 만드는 방법에 관해서 이야기 할 예정입니다. Immutable

tecoble.techcourse.co.kr

 

'React' 카테고리의 다른 글

R1_React와 가상DOM(feat. 새로고침과 렌더링)  (0) 2023.02.11