|
| 1 | +--- |
| 2 | +title : "[Trouble Shooting] 리렌더링 방지를 위한 고찰" |
| 3 | +author: potato |
| 4 | +date : 2025-01-17 10:00:00 +0900 |
| 5 | +categories : [React, TroubleShooting] |
| 6 | +tags: [react, hooks, javascript, troubleshooting,] |
| 7 | +image: |
| 8 | + path: https://github.com/user-attachments/assets/49c1a910-a255-4467-a10c-95d5143df690 |
| 9 | +description: 나만의 책장 만들기를 통해 컴포넌트의 리렌더링 방지에 대한 고찰 |
| 10 | +--- |
| 11 | + |
| 12 | +나만의 책장 만들기라는 작은 실습을 진행했습니다. mock 데이터인 book들을 나열하고, 읽기를 클릭하면 현재 읽고 있는 책에 해당 책이 보여집니다. |
| 13 | + |
| 14 | +React에서 **리렌더링** 이라는 개념은 중요하다고 생각해서, 컴포넌트들의 리렌더링 방지에 대한 고찰을 시작했습니다. |
| 15 | + |
| 16 | +## 구현기능 |
| 17 | +구현 기능은 아래와 같습니다. |
| 18 | + |
| 19 | +1. 현재 읽고있는 책 : 아래 책 리스트 중 읽고싶은 책에 읽기 버튼 클릭 시, 해당 책을 검색창 위에 노출 |
| 20 | +2. 읽고있는 책을 localStorage에 저장하여 페이지 새로고침 시에도 노출 |
| 21 | +3. 책 검색창 : 책 검색 시 실시간 검색 가능 |
| 22 | +4. 책 리스트 : 내가 가진 모든 책들을 나열하여 표기 |
| 23 | +5. 페이지 이동 : Link를 사용하여 페이지 이동 |
| 24 | +6. 상세 페이지 : 책 리스트에서 책을 클릭하면 해당 책의 상세 페이지로 이동 |
| 25 | +7. 고객 센터 페이지 : Footer의 고객센터 클릭 시 고객센터 페이지로 이동 |
| 26 | +8. NotFound : 유효하지 않은 URL 접근 시 NotFound 페이지 표시 |
| 27 | + |
| 28 | +## 구조 |
| 29 | +폴더 구조는 아래와 같이 나눴습니다. |
| 30 | +``` |
| 31 | +./src |
| 32 | +├── App.css |
| 33 | +├── App.jsx |
| 34 | +├── components |
| 35 | +│ ├── BookList.jsx |
| 36 | +│ ├── BookShelves.jsx |
| 37 | +│ ├── Container.jsx |
| 38 | +│ ├── Creator.jsx |
| 39 | +│ ├── Footer.jsx |
| 40 | +│ ├── Header.jsx |
| 41 | +│ └── SearchInput.jsx |
| 42 | +├── context |
| 43 | +│ ├── BookContext.jsx |
| 44 | +│ └── SearhContext.jsx |
| 45 | +├── hooks |
| 46 | +│ ├── useCurrentBook.jsx |
| 47 | +│ └── useSearchBook.jsx |
| 48 | +├── index.css |
| 49 | +├── layout |
| 50 | +│ └── layout.jsx |
| 51 | +├── main.jsx |
| 52 | +├── mock |
| 53 | +│ └── book.js |
| 54 | +├── pages |
| 55 | +│ ├── Details.jsx |
| 56 | +│ ├── Help.jsx |
| 57 | +│ ├── Home.jsx |
| 58 | +│ └── NotFound.jsx |
| 59 | +└── shared |
| 60 | + └── Router.jsx |
| 61 | +``` |
| 62 | + |
| 63 | +## 📌 문제 |
| 64 | +### 1. 검색어 입력 시 입력 폼까지 리렌더링 되는 현상 |
| 65 | +저는 검색어 입력 시엔 `BookList`만 리렌더링 하고 싶었습니다. `<input>`을 입력 시마다 리렌더링 하는 것은 불필요하다고 생각했기 때문입니다. |
| 66 | + |
| 67 | +#### 시도 및 해결 |
| 68 | +이 문제는 간단하게 `memo`를 사용해서 방지해줄 수 있었습니다. |
| 69 | + |
| 70 | +```jsx |
| 71 | +export default memo(SearchInput) |
| 72 | +``` |
| 73 | + |
| 74 | +또는 이런 방법도 있더군요. |
| 75 | + |
| 76 | +```jsx |
| 77 | +import { SearchContext } from '@/context/SearhContext' |
| 78 | + |
| 79 | +const SearchInput = () => { |
| 80 | + |
| 81 | + return ( |
| 82 | + <> |
| 83 | + <SearchContext.Consumer> |
| 84 | + {(context) => ( |
| 85 | + <input placeholder='검색' onChange={(e) => context.searching(e.target.value)} /> |
| 86 | + )} |
| 87 | + </SearchContext.Consumer> |
| 88 | + </> |
| 89 | + ) |
| 90 | +} |
| 91 | + |
| 92 | +export default SearchInput |
| 93 | +``` |
| 94 | +<br /> |
| 95 | + |
| 96 | +### 2. 읽기 버튼으로 상태 변경 시 부모 컴포넌트 리렌더링 |
| 97 | + |
| 98 | +제가 의도한 바와 다르게 동작되는 부분은 `Context`로 감싸준 `BookShelves` 컴포넌트에서 발생했습니다. |
| 99 | + |
| 100 | +```jsx |
| 101 | +const Home = () => { |
| 102 | + return ( |
| 103 | + <BookProvider> |
| 104 | + <SearchProvider> |
| 105 | + <Container type='bookshelves'></Container> |
| 106 | + </SearchProvider> |
| 107 | + </BookProvider> |
| 108 | + <Container type='creator'></Container> |
| 109 | + ) |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +제 파일 구조를 보면 `BookShelves` 컴포넌트 안에 `BookList`와 `SearchInput` 컴포넌트가 존재합니다. |
| 114 | + |
| 115 | +그리고 `BookProvider`는 `currentBook` 현재 읽고 있는 책의 상태를, `SearchProvider`는 `searchBook` 검색어의 상태를 관리합니다. |
| 116 | + |
| 117 | +`Home` 페이지에서, `BookShelves`와 `Creator` 컴포넌트를 Provider로 감싸다 보니, 자식인 `BookList`에서 읽기 버튼을 클릭 했을 때, currentBook의 상태가 변경되며 상태를 공유받고 있는 부모인 `BookShelves` 까지 리렌더링 되는 현상이 일어났습니다. |
| 118 | + |
| 119 | +제가 원하는 것은 `BookShelves` 컴포넌트의 리렌더링이 아닌 |
| 120 | +1. 읽기 버튼 클릭 시 `현재 읽고 있는 책` 부분만 리렌더링 되는 것 |
| 121 | + |
| 122 | +혹은 |
| 123 | + |
| 124 | +2. `현재 읽고 있는 책` + `BookList` 리렌더링이었습니다. |
| 125 | + |
| 126 | +일단 `BookShevles` 컴포넌트만 리렌더링 되지 않으면 된단 생각이었습니다. 제가 원하는 건 읽기 버튼을 클릭했을 때 현재 읽고 있는 책 부분의 변경뿐이니까요. |
| 127 | + |
| 128 | +#### 시도 1 |
| 129 | +_Consumer로 해당 부분만 구독시키면 되지 않을까?_ |
| 130 | +SearchInput 컴포넌트를 Consumer로 감싸줬던 것처럼 시도해봤습니다. |
| 131 | + |
| 132 | +```jsx |
| 133 | +<BookContext.Consumer> |
| 134 | + {(saved) => <div>현재 읽고 있는 책 : {saved.currentBook?.title || '없음'}</div>} |
| 135 | +</BookContext.Consumer> |
| 136 | +``` |
| 137 | + |
| 138 | +
|
| 139 | +하지만 여전히 리렌더링이 일어나는 걸 확인할 수 있었습니다. |
| 140 | +
|
| 141 | +
|
| 142 | +#### 시도 2 |
| 143 | +뭐가 문제일까... Consumer는 그대로 둔 채 BookList의 코드를 한참 들여다 봤습니다. |
| 144 | +
|
| 145 | +```jsx |
| 146 | +const { filteredBooks } = useContext(SearchContext) |
| 147 | +const { setCurrentBook } = useContext(BookContext) |
| 148 | + |
| 149 | +function savedCurrentBook(book) { |
| 150 | + setCurrentBook(book) |
| 151 | + localStorage.setItem('currentBook', book.title) |
| 152 | +} |
| 153 | +``` |
| 154 | +
|
| 155 | +버튼 클릭을 하면 savedCurrentBook이 호출 → setCurrentBook으로 curretBook 상태를 변경 → 상태 변경을 감지한 Context가 구독하고 있는 모든 컴포넌트를 리렌더링 |
| 156 | +
|
| 157 | +이 방식이 부모 컴포넌트까지 리렌더링 하고 있구나! 싶어 변경했습니다. |
| 158 | +
|
| 159 | +setCurrent를 바로 호출하는 방식이 아니라, 해당 savedCurrentBook을 BookContext로 옮겨주고, 상태 변경하는 함수로 전달하게 했습니다. |
| 160 | +
|
| 161 | +```jsx |
| 162 | +// BookContext.jsx |
| 163 | +const savedCurrentBook = (book) => { |
| 164 | + const savedBookTitle = localStorage.getItem('currentBook') |
| 165 | + |
| 166 | + // 이미 같은 책이 저장되어 있다면 상태 변경 및 리렌더링 방지 |
| 167 | + if (savedBookTitle === book.title) return |
| 168 | + |
| 169 | + setCurrentBook(book) |
| 170 | + localStorage.setItem('currentBook', book.title) |
| 171 | +} |
| 172 | +``` |
| 173 | +
|
| 174 | +여전히 리렌더링이 일어납니다. |
| 175 | +
|
| 176 | +
|
| 177 | +#### 시도 3 |
| 178 | +이번엔 리렌더링이 일어나지 말아야할 부분인 BookShelves를 봤습니다. |
| 179 | +
|
| 180 | +BookShelves 컴포넌트 안에 `useEffect`로 눈을 돌렸습니다. |
| 181 | +
|
| 182 | +```jsx |
| 183 | +useEffect(() => { |
| 184 | + const savedBookTitle = localStorage.getItem('currentBook') |
| 185 | + if (savedBookTitle) { |
| 186 | + const book = books.find((b) => b.title === savedBookTitle) |
| 187 | + if (book) setCurrentBook(book) |
| 188 | + } |
| 189 | +}, []) |
| 190 | +``` |
| 191 | +
|
| 192 | +처음 BookShelves가 그려진 후, 로컬스토리지에서 아이템을 가져와 DOM을 업데이트 합니다. |
| 193 | +
|
| 194 | +해당 로직을 currentBook의 useState 초기 값으로 이동시켰습니다. |
| 195 | +
|
| 196 | +이 부분이 문제였던 것 같습니다. //왜인지는 나중에 |
| 197 | +
|
| 198 | +
|
| 199 | +```jsx |
| 200 | +// BookProvider.jsx |
| 201 | +const [currentBook, setCurrentBook] = useState(() => { |
| 202 | + const savedBookTitle = localStorage.getItem('currentBook') |
| 203 | + return savedBookTitle ? { title: savedBookTitle } : null |
| 204 | +}) |
| 205 | +``` |
| 206 | + |
| 207 | +_읽기 클릭 시에도 BookList만 리렌더링_ |
| 208 | +
|
| 209 | +
|
| 210 | +### 3. 버튼 클릭 시 왜 BookList가 리렌더링 될까? |
| 211 | +`BookList`가 리렌더링되는 이유는 `useCurrentBook` 훅을 통해 `BookContext`를 구독하고 있기 때문입니다. |
| 212 | +
|
| 213 | +현재 구조에서, |
| 214 | +
|
| 215 | +1. 버튼 클릭 → `savedCurrentBook` 호출 |
| 216 | +2. BookContext의 currentBook 값 변경 |
| 217 | +3. 이 Context를 구독하는 모든 컴포넌트 리렌더링 |
| 218 | +- BookShelves의 Consumer 부분 |
| 219 | +- `useCurrentBook`을 사용하는 BookList 컴포넌트 |
| 220 | +
|
| 221 | +#### 시도 |
| 222 | +현재 BookList가 BookContext와 SearchContext 모두 구독하고 있기 때문에, 리렌더링이 일어날 수 밖에 없습니다. |
| 223 | +
|
| 224 | +➡️ 따라서 BookContext를 구독하는 부분과 SearchContext를 구독하는 부분을 나누고자 했습니다. |
| 225 | +`BookList`는 useSearch 훅의 filteredBooks만 받아오고, `BookItem`은 useCurrentBook의 savedCurrentBook만 받아오도록요. |
| 226 | +
|
| 227 | +하지만 그렇게 나누고 나니, BookList와 BookItem n개가 좌르륵 리렌더링 됐습니다. 여전히 BookItem에서 useCurrentBook을 사용하고 있기 때문입니다. |
| 228 | +
|
| 229 | +그래서 Parent와 Children에 관해 생각하는 도중, [해당 블로그](https://velog.io/@jingjing2222/Children-Component-톺아보기)를 보게 됐습니다. |
| 230 | +
|
| 231 | +해당 글에서는, |
| 232 | +
|
| 233 | +``` |
| 234 | +<Parent>{children}<Parent/> = <Parent children={<Child />} = <Parent><Child /></Parent> |
| 235 | +``` |
| 236 | +children으로 `<Child />`을 전달하면, 이 `<Child />`의 React Element는 `Parent`가 리렌더링 되더라도 새로운 객체로 생성되지 않고, 기존 객체를 재사용한다. |
| 237 | +> children으로 전달된 컴포넌트가 재렌더링되지 않는 이유는 React가 JSX 내부에서 생성된 React Element를 메모이제이션(Memoization)하기 때문 |
| 238 | +{: .prompt-info } |
| 239 | +
|
| 240 | +사진을 인용하여 보자면 이렇습니다. |
| 241 | +
|
| 242 | + |
| 243 | +
|
| 244 | +결론은 Parent의 상태 변경으로 인한 리렌더링이라도, **Child를 props로 받는다면 Child의 속성이나 상태가 변경되지 않는 한 Child의 리렌더링은 일어나지 않습니다.** |
| 245 | +
|
| 246 | +#### ❓❓ 해결인가? |
| 247 | +
|
| 248 | +다는 아니었습니다. 제 BookList는 BookItem을 return 하고 있었기 때문에, `<Parent><Child/></Parent>`의 구조가 아니었기 때문입니다. |
| 249 | +
|
| 250 | +그래서 전체를 렌더링하고 있는 BookShelves 컴포넌트로 갔습니다. |
| 251 | +```jsx |
| 252 | +{% raw %} |
| 253 | +// BookShelves.jsx |
| 254 | +const BookShelves = () => { |
| 255 | + console.log('[BookShelves] - rerender') |
| 256 | + |
| 257 | + return ( |
| 258 | + <> |
| 259 | + <h3>나만의 책장</h3> |
| 260 | + <BookContext.Consumer> |
| 261 | + {(saved) => <div>현재 읽고 있는 책 : {saved.currentBook?.title || '없음'}</div>} |
| 262 | + </BookContext.Consumer> |
| 263 | + <SearchInput /> |
| 264 | + <BookList> |
| 265 | + <BookItem /> |
| 266 | + </BookList> |
| 267 | + </> |
| 268 | + ) |
| 269 | +} |
| 270 | + |
| 271 | +export default BookShelves |
| 272 | +{% endraw %} |
| 273 | +``` |
| 274 | +```jsx |
| 275 | +{% raw %} |
| 276 | +// BookList.jsx |
| 277 | +const BookList = ({ children }) => { |
| 278 | + console.log('[BookList] -rerender') |
| 279 | + |
| 280 | + return <div>{children}</div> |
| 281 | +} |
| 282 | + |
| 283 | +export default BookList |
| 284 | +{% endraw %} |
| 285 | +``` |
| 286 | +```jsx |
| 287 | +{% raw %} |
| 288 | +// BookItem.jsx |
| 289 | +const BookItem = () => { |
| 290 | + console.log('[BookItem] -rerender') |
| 291 | + const { savedCurrentBook } = useCurrentBook() |
| 292 | + const { filteredBooks } = useSearch() |
| 293 | + |
| 294 | + return ( |
| 295 | + <> |
| 296 | + {filteredBooks.map((book) => ( |
| 297 | + <div key={book.id}> |
| 298 | + <Link to={`/details/${book.id}`}> |
| 299 | + <span> |
| 300 | + {book.title} - {book.author} |
| 301 | + </span> |
| 302 | + </Link> |
| 303 | + <button |
| 304 | + style={{ padding: '0.2rem 0.4rem', marginLeft: 4 }} |
| 305 | + onClick={() => savedCurrentBook({ title: book.title })} |
| 306 | + > |
| 307 | + 읽기 |
| 308 | + </button> |
| 309 | + </div> |
| 310 | + ))} |
| 311 | + </> |
| 312 | + ) |
| 313 | +} |
| 314 | + |
| 315 | +export default memo(BookItem) |
| 316 | +{% endraw %} |
| 317 | +``` |
| 318 | +이렇게 해주니 버튼 클릭시 해당 Item 컴포넌트만 리렌더링 되었습니다! |
| 319 | +
|
| 320 | +#### 다른 방법 (useCallback, memo) |
| 321 | +```jsx |
| 322 | +{% raw %} |
| 323 | +// BookList.jsx |
| 324 | +const BookList = () => { |
| 325 | + console.log('[BookList] -rerender') |
| 326 | + const { filteredBooks } = useSearch() |
| 327 | + const { savedCurrentBook } = useCurrentBook() |
| 328 | + |
| 329 | + const handleSave = useCallback(savedCurrentBook, []) |
| 330 | + |
| 331 | + return ( |
| 332 | + <> |
| 333 | + {filteredBooks.map((book) => ( |
| 334 | + <BookItem key={book.id} book={book} savedCurrentBook={handleSave} /> |
| 335 | + ))} |
| 336 | + </> |
| 337 | + ) |
| 338 | +} |
| 339 | + |
| 340 | +//BookItem.jsx |
| 341 | +const BookItem = ({ book, savedCurrentBook }) => { |
| 342 | + console.log('[BookItem] -rerender') |
| 343 | + return ( |
| 344 | + <div> |
| 345 | + <Link to={`/details/${book.id}`}> |
| 346 | + <span> |
| 347 | + {book.title} - {book.author} |
| 348 | + </span> |
| 349 | + </Link> |
| 350 | + <button |
| 351 | + style={{ padding: '0.2rem 0.4rem', marginLeft: 4 }} |
| 352 | + onClick={() => savedCurrentBook({ title: book.title })} |
| 353 | + > |
| 354 | + 읽기 |
| 355 | + </button> |
| 356 | + </div> |
| 357 | + ) |
| 358 | +} |
| 359 | + |
| 360 | +export default memo(BookItem) |
| 361 | +{% endraw %} |
| 362 | +``` |
| 363 | +이렇게 currentBook 값을 바꾸는 savedCurrentBook 함수와 BookItem 컴포넌트를 메모제이션 하는 것입니다. |
| 364 | +
|
| 365 | +BookList는 리렌더링 되지만, BookItem은 리렌더링 되지 않습니다. (Props가 변경되지 않으므로) |
| 366 | +
|
| 367 | +## 마무리 |
| 368 | +기능 동작은 잘 하던 걸 **최적화 시켜볼까? 리렌더링을 좀 방지해볼까? 메모는 언제 써야할까? 아, 이 기능도 써보고 싶은데!** 라며 리팩토링을 시작했었습니다. |
| 369 | +
|
| 370 | +고민을 너무 깊게 하다보니 오히려 더 꼬이는 기분이었습니다. ~~리렌더링 어려워~~ |
| 371 | +코드 리뷰 및 질문을 하려는데, 커밋도 기능단위로 나누지 않고 싹 바꿔놓곤 헤헤! 다했다! 하고 커밋해버려서... 보기에도 어렵고 질문하기에도 어려운 커밋이 되어버렸습니다... |
| 372 | +
|
| 373 | +하지만 공부를 더 깊게 한 것 같아 재미는 있었습니다... 이렇게 배워가는 거겠죠... |
| 374 | +
|
| 375 | +
|
| 376 | +전체 코드는 해당 링크에서 보실 수 있습니다. |
| 377 | +[나만의 책장 만들기](https://github.com/oding01/react-bookshelves) |
0 commit comments