diff --git a/content/blog/2023/06/lecture-react-season2/4-4/index.md b/content/blog/2023/06/lecture-react-season2/4-4/index.md index 7012fc83c..f3fb18051 100644 --- a/content/blog/2023/06/lecture-react-season2/4-4/index.md +++ b/content/blog/2023/06/lecture-react-season2/4-4/index.md @@ -11,9 +11,9 @@ tags: [react] 이번에는 메모이제이션에 대해 알아본 뒤 리액트의 useMemo와 useCallback을 살펴볼 것이다. 각 훅을 직접 만들어 구조를 파악하고 리액트 성능 개선 방법을 이해한다. -# 4.4.1 메모이제이션 +# 메모이제이션 -n이 자연수일 때 1에서 n까지의 곱을 계승 또는 팩토리얼이라고 한다. 알고리즘 문제로 많이 사용하는 것인데 재귀 단골손님이다. +n이 자연수일 때 1에서 n까지의 곱을 계승 또는 팩토리얼이라고 한다. 알고리즘 문제로 많이 사용하는 것인데 재귀 단골 손님이다. ```js function factorial(n) { @@ -42,7 +42,7 @@ f(3) = 3 * f(2) * f(1) * f(0) → 8회 호출: f(3), f(2), f(1), f(0), f(0), f(1), f(0), f(0) ``` -재귀 특성상 함수를 여러번 호출하는데 비싼 비용을 치룬다. 최적화하기 위한 방법으로 메모이제이션이 있다. 이미 호출한 값을 미리 저장해 두어 다음 호출 시 이 값을 곧장 반환하는 것이다. +재귀 특성상 함수를 여러번 호출하는데 비용이 비싸다. 호출 횟수를 줄이기 위한 방법으로 메모이제이션을 사용한다. 호출한 값을 저장해 두어 다음에는 함수를 호출하지 않고 저장해둔 값을 사용하는 아이디어다. ```js const lookupTable = {} @@ -73,9 +73,9 @@ f(3) = 3 * f(2) * f(1) * f(0) → 4회 호출: f(3), f(2), f(1), f(0) ``` -f(n-1)을 호출하기 전에 조회 테이블에서 미리 계산한 값을 찾아 사용한다. 함수 호출 횟수가 줄어든 만큼 이로 인한 시간 비용을 줄일 수 있는 것이 메모이제이션의 장점이다. +f(n-1)을 호출하기 전에 조회 테이블에서 미리 계산한 값을 찾아 사용한다. 함수 호출 횟수를 줄인만큼 시간을 절약할 수 있다. -# 4.4.2 컴포넌트에서 메모이제이션: useMemo +# useMemo 메모이제이션 기법은 컴포넌트 안에서도 사용할 수 있다. 인자를 이용해 계산하는 함수가 컴포넌트 안에 있을 경우 이를 어딘가에 캐쉬해 사용하는 방법이다. @@ -101,17 +101,17 @@ const Board = ({ posts, tag }) => { } ``` -게시판 컴포넌트는 포스트 목록과 태그를 인자로 받는데 쿼리로 포스트 목록을 필터하는 내부 함수(filterPosts)를 가진다. 필터링된 게시물(filteredPost)로 리액트 앨리먼트를 반환하는 것이 이 컴포넌트의 역할이다. +Board는 포스트 목록과 태그를 인자로 받는데 태그로 목록을 필터하는 내부 함수(filterPosts)를 가진다. 필터링된 게시물(filteredPost)로 리액트 앨리먼트를 반환하는 것이 이 컴포넌트의 역할이다. -게시물의 양에 따라 컴포넌트는 리액트 앨리먼트를 늦게 반환하는데 바로 필터링 함수의 영향 때문이다. 포스트가 많을수록 필터 함수를 오래 실행하기 때문이다. 게다가 태그에 따라 비교 조건도 늘기 때문에 그 만큼 계산양도 늘어난다. 인자가 바뀔 때마다 계산하기 때문에 컴포넌트는 매 랜더링마다 리액트 앨리먼트를 늦게 반환할 것이다. +게시물 갯수에 따라 컴포넌트는 리액트 앨리먼트를 늦게 반환하는데 바로 filterPosts가 원인이다. 데이터가 많을수록 함수 계산양도 증가하고 인자가 바뀔 때마다 계산할 것이다. 컴포넌트는 매 랜더링마다 리액트 앨리먼트를 늦게 반환하게 된다. -매번 컴포넌트가 필터링하지 않고 어딘가 저장해 둔 값을 사용한다면 훨씬 빨리 리액트 앨리먼트를 반환할 수 있을 것이다. 성능개선한 팩토리얼 함수처럼. +컴포넌트가 매번 필터링하지 않고 어딘가 값을 저장해서 활용한다면 좀더 빨리 리액트 앨리먼트를 반환할 수 있을 것이다. 성능개선한 팩토리얼 함수처럼. -정리하면 컴포넌트가 실행될 때마다 필터링 계산을 할지 말지 정하는 문제다. 다시 렌더링하더라도 일관된 비교 로직을 만들어야하기 때문에 지금까지 해 온것처럼 훅으로 분리하게 적당해 보인다. 훅에서 인자를 기억하고 있다가 같으면 캐쉬 값을 사용하고 다르면 다시 계산하는 것이다. +정리하면 컴포넌트가 실행될 때마다 필터링 계산을 할지 말지 정하는 문제다. 리랜더 하더라도 일관된 비교 로직을 만들어야하기 때문에 지금까지 해 온것처럼 훅으로 분리하게 적당해 보인다. 훅에서 인자를 기억하고 있다가 같으면 캐쉬 값을 사용하고 다르면 다시 계산하는 것이다. 리액트에서 이러한 용도로 useMemo 훅을 제공하는데 같은 이름으로 MyReact 모듈 안에 만들어 보겠다. -```jsx{5,28} +```jsx{5-24,28} const MyReact = (() => { const memorizedStates = [] let cursor = 0 @@ -144,9 +144,9 @@ const MyReact = (() => { })() ``` -메모이제이션 관련한 값을 저장하기 위해 memorizedStates 배열을 정의했다. 컴포넌트에서 사용한 각 훅의 캐시 저장소 배열이다. 커서(cursor)로 현재 훅에서 사용할 값을 가리킬 수 있다. 팩토리얼 예제의 조회 테이이블과 같은 역할을 할 것이다. +메모이제이션 관련한 값을 저장하기 위해 memorizedStates 배열을 정의했다. 컴포넌트에서 사용한 각 훅의 캐시 저장소 배열이다. 커서(cursor)로 현재 훅에서 사용할 값을 가리킬 수 있다. 팩토리얼 예제의 조회 테이이블과 같은 역할이다. -memorizedState가 비었으면 처음 실행되는 것이다. 비싼 계산을 하고 그 값을 여기에 저장한다. 이후 훅이 호출될 경우 캐시 값 사용 여부를 정해야하는데 기준으로 삼을 의존성 배열 deps도 함께 저장했다. 첫 호출이니깐 곧장 계산한 값을 반환한다. +memorizedState가 비었으면 처음 실행되는 것이다. 비싼 계산을 하고 결과를 저장한다. 이후에 훅이 호출될 경우 캐시 값 사용 여부를 정해야하는데 기준으로 삼을 의존성 배열 deps도 함께 저장했다. 첫 호출이니깐 곧장 계산한 값을 반환한다. 다음 호출부터는 의존성이 바뀌었는지를 확인할 것이다. 이전 캐시 값(prevValue)과 이전 의존성(prevDeps)을 조회 테이블에서 가져왔다. 다음 의존성 배열과 비교해 변한 게 없다면 캐쉬를 반환한다. @@ -178,17 +178,17 @@ function Board = ({ posts, tag }) => { 필터 함수를 바로 호출하지 않고 useMemo로 메모이제이션 했다. 의존성으로 전달한 post와 tag가 변하지 않으면 필터 함수를 다시 호출하지 않고 캐시 값을 즉시 반환할 것이다. 두 값이 달라졌을 때만 필터 함수를 실행해 새로운 계싼 결과를 내놓을 것이다. -# 4.4.3 컴포넌트 렌더링 최적화: memo +# memo useMemo로 컴포넌트 내부함수를 메모이제이션 했다. 이전에는 컴포넌트를 실행할 때마다 계산했지만 이제는 단 한 번만 계산하고 이후 인자가 변하지 않는한 캐쉬 값을 사용할 것이다. -이번에는 **리랜더링 관점**에서 최적화 해보자. useMemo로 비싼 필터 함수를 메모이제이션했지만 컴포넌트의 로직에 따라서는 반환하는 리액트 앨리먼트가 달라질 수도 있는데 리액트는 이 컴포넌트를 다시 렌더링할 것이다. +이번에는 **렌더링 관점**에서 최적화 해보자. useMemo로 비싼 필터 함수를 메모이제이션했지만 컴포넌트의 로직에 따라서는 반환하는 리액트 앨리먼트가 달라질 수도 있는데 리액트는 이 컴포넌트를 다시 렌더링할 것이다. useMemo로 비싼 계산 결과를 메모이제이션 했기 때문에 이 값을 사용한 리액트 앨리먼트도 최적화했으면 좋겠다. 항상 같은 리액트 앨리먼트를 반환할 것이 뻔한데 다시 렌더링하는 것은 낭비기 때문이다. 우리가 이런 낭비를 두고 볼 수만은 없지 않은가? -이것을 재현할 수 있는 코드를 추가해 보자. +재현할 수 있는 코드를 준비하자. -```jsx{5} +```jsx{5,15-18} function Board = ({ posts, tag }) => { console.log('Board rendered') MyReact.resetCursor() @@ -213,7 +213,6 @@ function Board = ({ posts, tag }) => {
  • {content} #{tag}
  • - ))} @@ -221,13 +220,13 @@ function Board = ({ posts, tag }) => { } ``` -다크테마 여부를 기억할 상태(darkTheme)를 추가했다. 이 값을 출력한 뒤 토글할 수 있는 버튼을 두었다. 이 상태가 바뀌면 컴포넌트를 다시 그릴 것이다. +다크테마 여부를 기억할 상태(darkTheme)를 추가했다. 이 값을 표시하고 토글 버튼도 두었다. 상태가 바뀌면 컴포넌트를 다시 그릴 것이다. -리랜더링될 때 메모이제이션한 값인 filteredPosts로 리액트 앨리먼트를 만드는 부분도 다시 그려질 것이다. 비싼 비용의 필터 함수 결과를 캐시해놨는데 다른 상태의 변경에 쉽게 영향 받는 게 아깝다. +리랜더될 때 메모이제이션한 값인 filteredPosts로 리액트 앨리먼트를 만드는 부분도 다시 그려질 것이다. 비싼 비용의 필터 함수 결과를 캐시해놨는데 다른 상태의 변경에 쉽게 영향 받는 게 아깝다. **컴포넌트 자체**도 메모이제이션 하자. 인자를 받고 앨리먼트를 반환하는 함수이기 때문에 비슷한 구조로 다룰 수 있겠다. 리액트처럼 memo라는 이름의 함수를 정의하겠다. -```jsx{2,25} +```jsx{2-20,25} const MyReact = (() => { function memo(TargetComponent) { return (nextProps) => { @@ -257,9 +256,9 @@ const MyReact = (() => { })(); ``` -memo는 컴포넌트를 인자로 받아 실행하고 리액트 앨리먼트를 반환하는 함수를 반환한다. 고차함수다. 이 컴포넌트가 받을 이름을 nextProps라고 정하고 이 인자를 받는 함수를 반환했다. +memo는 컴포넌트를 인자로 받아 실행하고 리액트 앨리먼트를 반환하는 함수를 반환한다. 고차함수다. 이 컴포넌트가 받을 이름을 nextProps라 하고 인자로 받는 함수를 반환했다. -컴포넌트의 memorizedState 속성을 추가해 메모이제이션할 값을 저장했다. 컴포넌트를 처음 렌더링할 때에는 값이 비었기 때문에 컴포넌트를 실행한 리액트 앨리먼트와 인자를 캐시했다. 곧장 이 리액트 앨리먼트를 반환한다. +컴포넌트가 함수이기 때문에 함수의 memorizedState 속성을 추가해 메모이제이션할 값을 저장했다. 컴포넌트를 처음 렌더링할 때에는 값이 비었기 때문에 컴포넌트를 실행한 리액트 앨리먼트와 인자를 캐시했다. 곧장 이 리액트 앨리먼트를 반환한다. 다음 렌더링에서는 캐쉬한 값을 사용한다. 이전 인자와 현재 인자를 비교해 같다면 캐시 값을 곧장 반환한다. 이전과 같은 객체를 반환하기 때문에 리액트는 이 컴포넌트를 다시 그리지 않을 것이다. @@ -311,7 +310,7 @@ function Board = ({ posts, tag }) => { 메모이제이션한 값를 메모이제이션한 컴포넌트의 인자로 전달했다. 이 컴포넌트는 테마가 변경되어 보드 컴포넌트를 다시 그리더라도 메모이제이션한 컴포넌트만큼은 다시 그리지 않을 것이다. 메모이제이션한 인자가 바뀌지 않는한 memo 함수에 의해 항상 같은 리액트 앨리먼트를 반환하기 때문이다. -# 4.4.4 함수 메모이제이션: useCallback +# useCallback 이벤트 핸들러를 추가해 보자. @@ -328,7 +327,7 @@ const Board = ({ post, tag }) => { } ``` -useMemo와 memo로 비싼 함수와 컴포넌트를 메모이제이션 했지만 handleClick을 추가하면서 다시 불필요한 리랜더링이 발생할 수 있다. handleClick은 보드 컴포넌트가 렌더링될 때마다 새로운 값으로 할당되기 때문이다. 객체 리터럴처럼 함수 리터럴도 매번 새로운 값을 만들어 컴포넌트의 프롭으로 전달할 것이다. memo는 이를 새로운 값을로 인지하고 리액트 앨리먼트를 다시 계산할 것이다. +useMemo와 memo로 비싼 함수와 컴포넌트를 메모이제이션 했지만 handleClick을 추가하면서 다시 불필요한 리랜더링이 발생할 수 있다. handleClick은 보드 컴포넌트가 렌더링될 때마다 새로운 값으로 할당되기 때문이다. 객체 리터럴처럼 함수 리터럴도 매번 새로운 값을 만들어 컴포넌트 인자로 전달할 것이다. memo는 이를 새로운 값으로 인지하고 리액트 앨리먼트를 다시 계산할 것이다. 함수도 값이기 때문에 useMemo로 이 값을 메모이제이션할 수 있다. @@ -366,13 +365,13 @@ const MyReact = (function MyReact() { })(); ``` -이름에서 알 수 있듯이 콜백을 메모이제이션한다. 값을 반환하는 팩토리가 아니라 콜백을 바로 받았다. 캐시 값 사용여부를 비교하기 위한 의존성 배열도 함께 받았다. +이름에서 알 수 있듯이 콜백을 메모이제이션한다. 값을 반환하는 팩토리가 아니라 콜백을 바로 받았다. 캐시 값 사용 여부를 비교하기 위한 의존성 배열도 받았다. 함수 본문은 useMemo를 조합했다. 첫 번째 인자가 메모이제이션할 값을 반환하는 함수이기 때문에 콜백을 반환하는 함수 형태로 전달했다. 이 훅을 사용해 좀 더 간편하게 콜백을 메모이제이션할 수 있다. -```jsx{4} +```jsx{4-6} const Board = ({ posts, tags }) => { // ... @@ -384,9 +383,9 @@ const Board = ({ posts, tags }) => { } ``` -고차 함수를 전달하지 않고 메모이제이션할 함수를 전달했다. 한결 코드 읽기가 수월하다. +고차 함수를 전달하지 않고 메모이제이션할 함수를 전달했다. -# 4.4.5 균형 (Trade-off) +# 균형 (Trade-off) 팩토리얼 함수를 최적화하기 위해 모든 함수 호출을 기록해서 캐쉬했다. 함수를 호출할 때마다 조회 테이블에 기록해 한 번이라도 호출했던 함수와 인자는 캐쉬값을 그 결과로 사용했다. @@ -420,15 +419,15 @@ function updateMemo(nextCreate, deps) { } ``` -두 가지 이유라고 생각한다. 첫번째는 의존성 배열을 기준으로 캐시여부를 계산하는 구조 때문이다. 훅 인터페이스에 따라 의존성은 배열 리터럴을 사용하는데 컴포넌트를 렌더링 할 때마다 새로운 값을 받을 것이다. 따라서 이 값을 기준으로 캐시 여부를 판단할 수는 없다. 의존성 배열 안에 있는 값을 하나씩 비교해야 한다. +두 가지 이유라고 생각한다. 첫째는 의존성 배열을 기준으로 캐시여부를 계산하는 구조 때문이다. 훅 인터페이스에 따라 의존성은 배열 리터럴을 사용하는데 컴포넌트를 렌더링 할 때마다 새로운 값을 받을 것이다. 따라서 이 값을 기준으로 캐시 여부를 판단할 수는 없다. 의존성 배열 안에 있는 값을 하나씩 비교해야 한다. -두번째는 성능과 관련 있을 것이라고 생각한다. 메모이제이션은 속도는 빠르지만 그만큼 자원을 더 사용한다. 캐시한 값을 메모리에 저장하기 때문이다. 모든 함수 결과를 캐시한다면 메모리 공간이 크게 증가할 것이다. 속도와 메모리 사이의 균형을 맞추기 위해 캐시를 하나만 둔다는 트레이드오프의 산물이 아니었을까? +둘째는 성능과 관련 있을 것이라고 생각한다. 메모이제이션은 속도는 빠르지만 그만큼 자원을 더 사용한다. 캐시한 값을 메모리에 저장하기 때문이다. 모든 함수 결과를 캐시한다면 메모리 공간이 크게 증가할 것이다. 속도와 메모리 사이의 균형을 맞추기 위해 캐시를 하나만 둔다는 트레이드오프의 산물이 아니었을까? -# 4.4.6 주문내역 화면 활용 +# 활용: OrderStatusCard -다시 우리 프로젝트로 돌아오자. 이번에는 리액트의 메모이제이션 api를 사용해 볼 차례다. 주문 페이지에 기능을 하나 추가할 것이다. 예상 배송 시간을 추가하자. +다시 우리 프로젝트로 돌아오자. 리액트의 메모이제이션을 사용해 볼 차례다. 주문 페이지에 기능을 하나 추가할 것이다. 예상 배송 시간을 추가하자. -```jsx{2,3,5,14,16,39-42} +```jsx{2,3,5,14,16,39-47} const OrderStatusCard = ({ order }) => { const { status, orderDate, id, position = [] } = order const { openDialog, closeDialog } = MyLayout.useDialog() @@ -513,9 +512,9 @@ const OrderPage: FC = () => { }, []); ``` -5초마다 주문 정보를 조회하는 api를 호출하고 결과를 상태로 저장했다.. 컴포넌트가 사라지면 타이머도 삭제할 것이다. 컴포넌트가 마운트되면 주기적으로 주문 상태를 가져와 상태를 갱신할 것이다. +5초마다 주문 정보를 조회하는 api를 호출하고 결과를 상태로 저장했다. 컴포넌트가 사라지면 타이머도 삭제할 것이다. 컴포넌트가 마운트되면 주기적으로 주문 상태를 가져와 상태를 갱신할 것이다. -이제 문제는 컴포넌트를 렌더링할 때마다 이 값을 다시 계산하는 것이다. 계산이 길어질수록 리액트 앨리먼트 반환시점을 늦추기 때문에 UI 블록 현상이 발생할 것이다. +문제는 컴포넌트를 렌더링할 때마다 이 값을 다시 계산하는 것이다. 계산이 많을수록 리액트 앨리먼트 반환시점도 늦어지기 때문에 UI 블록 현상이 발생할 것이다. 이것을 해결하자. @@ -557,17 +556,17 @@ const ExpectedDeliveryMinutes = React.memo(({ value, onClick }) => { ``` -먼저 useMemo로 무거운 계산을 메모이제이션했다. 위치정보(position)가 변경되지 않는한 이전에 계산해 캐시한 값을 사용하기 때문에 곧장 다음 코드를 실행할 수 있을 것이다. +useMemo로 무거운 계산을 메모이제이션했다. 위치정보(position)가 변경되지 않는한 이전에 계산해 캐시한 값을 사용하기 때문에 곧장 다음 코드를 실행할 수 있을 것이다. -이 값을 사용하는 리액트 앨리먼트를 ExpectedDeliveryMinutes로 분리해 메모이제이션한 값을 전달했다. 값이 메모이제이션되었더라도 다른 렌더링인자가 변경하면 다른 앨리먼트 뿐만아니라 이 앨리먼트도 다시계산하기 때문이다. ExpectedDeliveryMinutes를 memo로 메모이제이션했다. 인자가 바뀌지 않은 이상 이전 컴포넌트의 결과값을 사용할 것이다. +이 값을 사용하는 리액트 앨리먼트를 ExpectedDeliveryMinutes로 분리해 메모이제이션한 값을 전달했다. 값이 메모이제이션되었더라도 다른 렌더링인자가 바뀌면 다른 앨리먼트 뿐만아니라 이 앨리먼트도 다시 계산하기 때문이다. ExpectedDeliveryMinutes를 memo로 메모이제이션했다. 인자가 바뀌지 않은 이상 이전 컴포넌트의 결과값을 사용할 것이다. 이 컴포넌트는 onClick 핸들러도 받기 때문에 변경되면 다시 렌더링할 것이다. 핸들러도 useCallback으로 메모이제이션 했다. 포지션 정보가 변경되지 않은이상 handleClick은 캐시 값을 사용할 것이고 이를 인자로 받는 ExpectedDeliveryMinutes도 캐시한 값을 사용해 리랜더링되지 않을 것이다. -# 4.4.7 MyRouter params 활용 +# 활용: MyRouter params -useParams는 커스텀 훅이 아니다. 내부에 리액트 훅을 사용하지 않기 때문이다. 그냥 일반 함수다. 이렇게 사용한다고 문제가 되는 것은 아니다. 다만 함수 컴포넌트가 실행될때마다 이 함수도 실행되는데 어찌보면 낭비다. 주소값은 고정되었기 때문에 다시 계산하기 때문이다. +useParams는 커스텀 훅이 아니다. 내부에 리액트 훅을 사용하지 않기 때문이다. 그냥 일반 함수다. 이렇게 사용한다고 문제가 되는 것은 아니다. 다만 컴포넌트가 렌더될 때마다 이 함수도 실행되는데 낭비다. 주소값은 고정되었는데 다시 계산할 필요가 없기 때문이다. -이를 useMemo로 호출 횟수를 줄일 수 있다. +useMemo로 호출 횟수를 줄일 수 있다. ```jsx{2} const useParams = () => { @@ -584,15 +583,13 @@ const useParams = () => { 이전과 달리 한 번만 호출될 것이다. -# 4.4.8 역할 +# 역할 메모이제이션 개념에 대해 이해했다. 입력 값이 같다면 이전에 계산한 값을 기록해 두었다가 계산하지 않고 이전값을 사용해 시간을 단축하는 기법이다. 컴포넌트에서 사용하는 함수도 메모이제이션할 수 있는데 useMemo다. 값을 반환하는 팩토리 함수와 입력값을 의존성 배열로 전달해 계산 값을 캐쉬한다. 특별히 함수를 캐시하기 위해 useCallback을 사용할 수 있다. -컴포넌트 자체도 함수라서 메모이제이션 대상이 될 수 있다. 프롭스가 같으면 다음 앨리먼트를 계산하지 않고 이전에 랜더링한 값을 사용하는 방식인데 memo함수를 사용한다. - -주문내역에 메모이제이션을 실습했다. 주기적으로 주문 정보를 조회해서 컴포넌트를 다시 그리도록했다. 그중 예상 도착시간을 계산하는 부분을 useMemo로 메모이제이션했다. 이 값을 사용하는 부분을 컴포넌트로 분리하고 memo 함수로 메모이제이션 했다. +컴포넌트 자체도 함수라서 메모이제이션 대상이 될 수 있다. 인자가 같으면 다음 앨리먼트를 계산하지 않고 이전에 랜더링한 값을 사용하는 방식인데 memo함수를 사용한다. # 중간 정리 @@ -611,7 +608,7 @@ const useParams = () => { - useParams - OrderStatusCard 도착 예상 시간 -참고 +## 참고 - [리액트 useMemo와 useCallback은 어떤 모습일까? | 김정환블로그](/2023/04/17/usememo-usecallback) - [mountMemo, updateMemo | Github](https://github.com/facebook/react/blob/dd5365878da2fe88a34dcdbb07d8297a78841da4/packages/react-reconciler/src/ReactFiberHooks.js#L2311)