Skip to content

Commit 79e8417

Browse files
committed
[Posts] 리렌더링 방지를 위한 고찰
1 parent fd1bd01 commit 79e8417

18 files changed

Lines changed: 1202 additions & 1229 deletions

File tree

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
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+
![Image](https://github.com/user-attachments/assets/c312a3ab-f411-426a-bb9e-37b9d9ae710b)
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+
![Image](https://github.com/user-attachments/assets/b9536538-c38a-4e36-a328-3f42a4b6e0b0)
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+
![Image](https://github.com/user-attachments/assets/5f47a02b-7064-41e5-8c31-b819db154cc0)
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

Comments
 (0)