이 글은 React 공식 문서 중 You Might Not Need an Effect 내용을 바탕으로 작성된 글입니다.
불필요한 Effect를 제거하자
A1. 랜더링 되는 동안 모든 것을 계산하기
상황
props로 전달받은 문자열 리스트를 보여주는 컴포넌트가 있다. 그 문자열 중 'filter'라는 문자열은 화면에 보여주고 싶지 않다.
불필요한 Effects 예시
아래의 코드에서는 화면에 보여줄 리스트(filteredItems)를 따로 관리하고있다. useEffect가 initialList가 변경되었음을 감지하면 내부 state를 변경한다. 이는 불필요한 랜더링을 발생시킨다.
⚙️ props로 전달받은 initialList가 변경된다.
-> React는 랜더링하기 위해 FilteredList 컴포넌트 함수를 실행한다.
-> React는 변경된 내용을 DOM에 저장한다.
-> initialList의 변경을 감지하고 Effect가 발생한다.
-> Effect에서 새롭게 필터링된 리스트를 계산하고, setFilteredImtes를 사용하여 내부 state를 변경한다.
-> 내부 state가 변경되었기 때문에, React는 다시 함수를 호출하게 된다. (불필요한 리랜더링 발생!!)
-> 화면에 필터링된 리스트가 노출된다.
개선 방법
화면에 보여줄 데이터라면 내부 state로 관리할 필요가 없다. Effects를 사용하지 않고 top level에서 바로 보여줄 리스트를 계산한다면, initialList가 바뀌었을 때 1회의 랜더링만으로 화면에 적절한 리스트를 노출할 수 있다.
⚙️ props로 전달받은 initialList가 변경된다.
-> React는 랜더링 하기 위해 FilteredList 컴포넌트 함수를 실행한다. (이때, filteredList 도 계산된다.)
-> React는 변경된 내용을 DOM에 저장한다.
-> 화면에 필터링된 리스트가 노출된다.
A2. 이벤트 핸들러는 이벤트가 발생한 곳과 연결하기
상황
유저가 물품 구매 버튼을 클릭했을 때, /api/buy POST API를 호출한다. 성공 response를 받았다면 alert를 띄우고 싶다.
불필요한 Effect 예시
useEffect는 의존성이 있는 state의 변화를 감지하지만, 그 변화가 어디에서 일어났는지는 알 수 없다. 아래의 코드에서는 사용자 이벤트 발생과 API 호출시점이 연결되어있지 않다. 지금은 간단한 코드여서 한 눈에 보이지만, 코드가 조금만 복잡해진다면 useEffect 내부의 코드가 언제 실행될지에 대한 불확실성이 커져서 유지보수에 어려움을 줄 수 있다.
⚙️ 버튼을 클릭하면 isPurchased가 true로 바뀐다.
-> useEffect가 isPurchased의 변화를 감지하고 API를 실행시킨다.
개선 방법
사용자 이벤트와 API 호출을 바로 연결시키자. 클릭과 동시에 API를 호출한다면 해당 API가 실행되는 시점이 명확해진다.
⚙️ 버튼을 클릭하면 handleBuyClick 함수가 실행된다.
-> handleBuyClick 함수 내부의 API가 호출된다.
다양한 상황에서 불필요한 useEffect를 없애보자!
지금까지 불필요한 Effect가 발생할 수 있는 상황과 그 해결 방법을 배워보았습니다. 이제는 조금 더 구체적인 예시가 등장합니다. 주어진 상황과 나쁜 코드를 보고, 왜 나쁜 코드인지와 어떻게 코드를 개선하면 좋을지 생각해 보며 학습해 봅시다!
B1. props가 변경되었을 때, 모든 state 초기화하기
상황
userId를 props로 받는 ProfilePage 컴포넌트가 있다. 컴포넌트 안에는 input이 있고, input 값에 따라서 comment state를 갱신한다.
문제 발생!! 어느 날 사이트를 이용하며 A프로필에서 B프로필로 이동하는데, comment state가 초기화되지 않는 현상을 발견했다. 이 현상으로 인해 A프로필에 남겨야 할 댓글을 B프로필에 남기게 되는 문제가 우려된다. 이를 해결하기 위해 아래처럼 코드를 변경했다.
문제점과 개선 방법
문제점
위에서 보았던 A1. 과 동일하게 불필요한 리랜더링이 발생한다는 문제점이 있다.
⚙️ props로 전달받은 userId가 변경된다.
-> React는 랜더링 하기 위해 ProfilePage 컴포넌트 함수를 실행한다.
-> React는 변경된 내용을 DOM에 저장한다. (DOM 변경 내용 없음 -> comment state 유지)
-> userId의 변경을 감지하고 Effect가 발생한다.
-> Effect에서 comment state를 변경한다.
-> 내부 state가 변경되었기 때문에, React는 다시 함수를 호출하게 된다. (불필요한 리랜더링 발생!!)
개선 방법
기본적으로 React는 동일한 위치에 보이는 동일한 컴포넌트는 리랜더링이 되더라도 state를 유지한다. 하지만 동일한 컴포넌트라도 key값이 다르면 서로 다른 컴포넌트로 인지한다. 따라서 key값이 변경되면 React는 새롭게 DOM을 다시 생성하기 때문에, comment state는 초기화된 상태가 된다.
이를 사용하여 Profile 컴포넌트를 만들어 comment코드를 분리하고, Profile 컴포넌트에 key값을 userId로 설정해 보자. userId가 변경된다면 comment state는 자동으로 초기화가 된다
⚙️ ProfilePage가 props로 받은 userId가 변경된다.
-> React는 랜더링 하기 위해 ProfilePage 컴포넌트 함수를 실행한다.
-> React는 변경된 내용을 DOM에 저장한다. (이때, Profile을 새롭게 감지 -> DOM 변경 -> comment state 초기화 )
-> 화면에 새로운 Profile 컴포넌트를 그린다.
B2. props가 변경되었을 때, 일부 state만 변경하기
상황
items를 prod으로 받아서 item 목록을 보여주는 List 컴포넌트가 있다. List 컴포넌트는 두 개의 state( selection & isReverse )를 갖는다. prop으로 받는 items가 변경될 때, selection state만 null로 변경하고 싶다. 이를 위해 아래처럼 코드를 작성했다.
문제점과 개선 방법
문제점
이 코드의 문제점은 마찬가지로 불필요한 랜더링이 발생한다는 것이다.
⚙️ props로 전달받은 items가 변경된다.
-> React는 랜더링 하기 위해 List 컴포넌트 함수를 실행한다.
-> React는 변경된 내용을 DOM에 저장한다.
-> items의 변경을 감지하고 Effect가 발생한다.
-> Effect에서 selection state를 변경한다.
-> 내부 state가 변경되었기 때문에, React는 다시 컴포넌트 함수를 호출한다. (불필요한 리랜더링 발생!!)
개선 방법 1 : Not Bad
이전 상태를 저장하고, 새롭게 변경된 items와 비교하여 state를 변경하는 방법이다. 이전의 상태를 저장하는 것은 코드 파악과 유지보수를 어렵게 할 수 있다. 다만, 위의 코드처럼 동일한 state에서 랜더링이 두 번 발생하는 것보단 나은 코드이다.
그럼에도 여전히 불필요한 리랜더링은 발생한다. React는 랜더링 과정에서 state가 변경되면 지금까지의 계산결과는 버리고, 다시 계산을 시작하기 때문이다. 이러한 코드는 에러를 발생시키기 쉽다. 예를 들어, A state와 B state가 서로에게 영향을 주는 state라고 한다면, 랜더링이 될 때, A와 B가 계속 서로를 바꾸기 때문에 무한 랜더링이 발생할 수 있다. 따라서 이런 코드를 작성하게 된다면, 컴포넌트를 깔끔하게 유지하기 위해 다른 모든 사이드 이펙트는 event handler 혹은 Effect에서 관리해야 한다. 하지만 그렇게 하더라도 데이터 흐름을 제한시키고 디버그 하기 어렵게 만든다는 문제점이 있다.
⚙️ props로 전달받은 items가 변경된다.
-> React는 랜더링 하기 위해 List 컴포넌트 함수를 실행한다.
-> List 컴포넌트 함수를 실행하는 과정에서 state가 변경된다. (selection, prevItem)
-> state가 변경되었기 때문에, React는 지금까지의 계산 결과는 버리고 다시 컴포넌트 함수를 실행한다. (불필요한 리랜더링 발생!!)
-> React는 변경된 내용을 DOM에 저장한다.
-> 화면이 그려진다.
개선 방법 2 : Best
가장 좋은 것은 불필요한 리랜더링을 막기 위해 랜더링 되는 동안 동일한 state를 유지하는 것이다. 즉, 랜더링을 시작하기 전에 모든 state값들이 결정된 상태를 만드는 것이다. state를 조금 바꿔서, selectedId를 저장하는 state를 만들고 selection은 selectedId값에 따라 계산된다면 1번의 랜더링만으로 적절한 결과값을 얻을 수 있다. 즉, props에 따라서 state를 변경할 필요가 없게 된 것이다. 기존의 로직이 달라졌지긴 했지만, 이전의 코드들보다 좋은 것은 확실하다.
⚙️ props로 전달받은 items가 변경된다.
-> React는 랜더링 하기 위해 List 컴포넌트 함수를 실행한다. (이때, selection이 계산된다.)
-> React는 변경된 내용을 DOM에 저장한다.
-> 화면이 그려진다.
B3. 여러 이벤트 핸들러들이 로직 공유하기
상황
ProductPage 컴포넌트에 Buy 버튼과 Checkout 버튼이 있다. 버튼을 클릭하면 product.isInCart는 true로 변경되고, 변경된 내용은 DB에 저장된다. 유저가 버튼을 클릭하여 상품을 장바구니에 담았을 때, 장바구니에 담겼다는 알림을 주고 싶다. 하지만 각 이벤트 핸들러에 showNotification() 함수를 호출하는 것은 중복처럼 느껴져서 Effect를 사용하여 코드를 작성했다.
문제점과 개선 방법
문제점
A2의 상황을 참고해 보자. useEffect는 어떤 동작에 의해서 Effect가 발생하는지 알 수 없다는 것이 문제다.
만약, Buy버튼을 클릭하여 상품을 장바구니에 넣었다고 가정하자. product.isInCart는 true가 되어서 showNotification 함수가 실행된다. (변경된 product의 정보는 DB에 저장된다.) 이때, 새로고침을 한다면 어떻게 될까? Effect가 발생하고, product.isInCart는 여전히 true이기 때문에 또다시 showNotification 함수가 실행된다. 의도하지 않은 동작이다!
개선 방법
작성한 코드가 Effect에 들어가야 하는지 event handler에 들어가야하는지 잘 모르겠다면, 왜 이 코드가 필요한지 생각해 보자. 컴포넌트가 화면에 나타났을 때 실행되어야 하는 코드만 Effect를 사용하는 것이다.
생각해보자. showNotification은 컴포넌트가 화면에 나타났을 때 실행되는 코드인가? 그렇지 않다. 사용자가 버튼을 클릭했을 때 발생하는 코드이다! 그렇다면 불필요한 Effect를 지우고, event handler와 연결시키면 된다.
B4. POST 요청 보내기
상황
Form 컴포넌트는 아래 두 종류의 POST 요청을 보낸다.
- POST /analystics/event : 화면이 마운트 되었을 때, 이벤트 로그를 남기기 위한 API
- POST /api/register : 제출 버튼을 클릭했을 때, 입력란에 채워진 정보를 보내기 위한 API
두 기능을 지원하기 위해 아래처럼 코드를 작성했다.
문제점과 개선 방법
문제점
B3에서 말한 방법을 다시 되새겨보자. 코드를 Effect에 넣어야 할지, event handler에 넣어야할지 모르겠다면, 사용자 관점에서 어떤 종류의 로직인가를 생각해 보면 된다.
- 클릭과 같은 사용자의 특정 인터렉션에 의해 실행되는 로직이라면 event handler
- 화면에 컴포넌트가 그려졌을 때 실행되는 로직이라면 Effect
다시 B4 개선이 필요한 코드를 보자.
/analytics/event 는 언제 화면이 그려졌을 때 실행되는 코드이기 때문에, useEffect에 들어가는 것이 적절하다. 하지만, /api/register는 사용자가 버튼을 클릭했을 때 실행되는 코드이다. 따라서 useEffect가 아닌 event handler가 적절하다.
개선 방법
/api/register POST API를 호출하는 useEffect를 지우고, event handler에 API 호출 코드를 이동시키자.
B5. 연속된 계산하기
상황
가끔 하나의 state 변경이 다른 state 에도 영향을 줄 때, 연속된 Effects(chain Effects)를 사용하고 싶은 유혹을 느낀다. 예를 들어, 아래와 같이 card정보가 바뀜에 따라서 goldCardCount, round, isGameOver가 줄줄이 바뀌는 상황이 있을 수 있다. 이를 Effects를 사용하여 구현하면 아래와 같은 코드가 된다.
문제점과 개선 방법
문제점
이 코드는 두 가지의 문제점이 있다.
1. useEffect를 사용하면 무엇에 의해서 실행되는 코드인지 알 수 없다. 이는 코드가 조금만 더 복잡해진다면 데이터 흐름을 파악하기 어려워지고, 유지보수 또한 어려워질 것이다.
2. 너무 많은 랜더링이 발생하여 비효율적이다.
⚙️ 사용자의 액션에 의해 handlePlaceCard가 호출되어 card state가 변경된다.
-> React는 랜더링을 위해 컴포넌트를 호출하고, 변경점을 DOM에 저장한다.
-> card state변경을 감지하여 Effect가 발생하고, goldCardCount state를 변경한다.
-> React는 랜더링을 위해 컴포넌트를 호출하고, 변경점을 DOM에 저장한다.
-> goldCardCount state 변경을 감지하여 Effect가 발생하고, round state를 변경한다.
-> React는 랜더링을 위해 컴포넌트를 호출하고, 변경점을 DOM에 저장한다.
-> round state 변경을 감지하여 Effect가 발생하고, isGameOver state를 변경한다.
-> React는 랜더링을 위해 컴포넌트를 호출하고, 변경점을 DOM에 저장한다.
-> 화면에 그린다. 😨
개선 방법
반복해서 생각해 보자. 각 useEffect내부에 있는 코드는 화면이 랜더링 될 때 실행되는 코드인가? 아니면 사용자의 특정 행동에 의해 실행되는 코드인가? 사용자의 특정 행동에 의해 실행되는 코드들이다! 그러니 Effects는 지우고, 코드를 event handler로 이동시키자.
⚙️ 사용자의 액션에 의해 handlePlaceCard가 실행되어 변경이 필요한 모든 state가 변경된다.
-> React는 랜더링을 위해 컴포넌트를 호출하고, 변경점을 DOM에 저장한다. (이때, isGameOver가 계산된다.)
-> 화면에 그린다. 😊
B6. App 초기 설정
상황
사이트에 진입했을 때, localStorage에 저장된 회원 token을 불러오고, 해당 token이 유효한 토큰인지 확인하는 함수를 실행하고 싶다. 이 로직은 페이지 진입 시 1회만 실행되면 되는 코드이기 때문에, useEffect에 의존성을 비워두었다.
문제점과 개선 방법
문제점
위 코드는 1회만 실행되도록 의도하기 위해 의존성 배열을 비워두었지만, 사실 develpment 환경에서 마운트가 2번 되기 때문에 Effect도 2번 실행된다. (React는 디버깅을 위해 development 환경에서 컴포넌트가 mount 된 이후에, remount 한다. 자세한 내용은 다음에 알아보도록 하자.) 만약 checkAuthToken을 성공했을 때 토큰이 만료시키는 로직을 갖고 있다면, remount 되었을 때 checkAuthToken은 실패할 것이다.
개선 방법
React 문서에서는 로직을 1회만 실행할 방법을 찾는 것이 아니라, remount가 발생해도 괜찮은 코드로 변경하는 방법을 찾는 것이 중요하다고 말한다.
변수를 컴포넌트 바깥에 선언하면, 해당 변수는 컴포넌트가 import 되었을 때만 실행된다. 따라서 아래의 didInit처럼 1회 실행되었을 때 true로 변경시키면, 그 이후엔 [! didInit ] 조건은 항상 false이기 때문에 로그인 확인 로직은 App이 실행되고 딱 한 번만 실행되게 된다. 하지만, 성능 저하 혹은 의도하지 않은 동작을 막기 위해 이 패턴을 남용해서는 안된다. 앱 전반의 설정을 조작하기 위한 코드는 App.js 파일 혹은 root 컴포넌트와 같은 곳에 작성하도록 하자!
B7. 부모 컴포넌트에 state 변화 알리기
상황
isOn state를 가진 Toogle 컴포넌트가 있다. isOn state가 변경되면 props로 전달받은 onChange 함수를 호출하여 부모 컴포넌트에 state 변화를 알리고자 다음과 같이 코드를 작성했다.
문제점과 개선 방법
문제점
앞서 살펴보았던 케이스와 동일하게 onChange 함수는 사용자의 특정 인터렉션에 의해 실행되어야 하는 함수인데, useEffect에 들어가 있다. 이는 불필요한 랜더링을 발생시킨다.
⚙️ 사용자의 동작에 의해 handleDragEnd 함수가 호출되어 isOn state가 변경된다.
-> React는 랜더링을 위해 Toggle 컴포넌트를 호출하고, 변경점을 가상 DOM에 반영한다.
-> isOn state 변경을 감지하여 Effect가 발생하고, onChange 함수가 실행된다.
-> 부모 컴포넌트의 onChange가 실행되면 부모 컴포넌트의 state가 변경된다.
-> React는 랜더링을 위해 부모 컴포넌트를 호출하고, 변경점을 가상 DOM에 반영한다. (리랜더링 발생!!)
-> 화면에 그린다.
개선 방법
사용자의 동작에 의해 실행되어야 하는 onChange 함수를 이벤트 핸들러로 이동시키자.
⚙️ 사용자의 동작에 의해 handleDragEnd 함수가 호출된다.
-> handleDragEnd 함수에 의해 isOn state를 변경시키고, onChange 함수를 호출하여 부모 컴포넌트의 변경이 필요한 state도 변경시킨다.
-> React는 랜더링을 위해 컴포넌트를 호출하고, 변경점을 가상 DOM에 반영한다.
-> 화면에 그린다.
B8. 부모에게 데이터 전달하기
상황
Child 컴포넌트에서 API를 호출하여 응답받은 데이터를 Parent 부모에게 전달하고 싶다.
문제점과 개선 방법
문제점
React를 개발할 때 데이터 흐름은 부모 컴포넌트에서 자식 컴포넌트로 흘러야 한다. 이렇게 단방향으로 데이터 흐름을 구성했을 때, 화면에서 잘못된 데이터가 보이고 있다면 부모 컴포넌트로 타고 올라가서 그 데이터가 어디서 왔는지 빠르게 확인할 수 있다는 것이다. 반면, 자식 컴포넌트가 부모 컴포넌트의 데이터를 조작하고 변경하는 순간, 데이터가 어디서 변경되었는지 알기 어려워진다는 문제점이 발생한다.
개선 방법
데이터 흐름을 정리하기 위해, Parent 컴포넌트에서 API를 호출하고 Child 컴포넌트에 데이터를 넘겨주도록 코드를 변경한다.
B9. 데이터 불러오기
상황
API를 호출하여 데이터를 불러오기 위해 일반적으로 Effects를 사용하곤 한다. 아래의 코드를 살펴보자. 물론 이런 생각이 들 수 있다. "page나 query는 사용자의 클릭, 입력과 같은 특정 동작에 의해서 변경되고, 그 변경에 의해 데이터가 새롭게 불러와진다. 그렇다면 event handler에 fetch 코드가 들어가는 것이 적절한 것 아닌가?"
조금 더 생각해 보자. 페이지에 첫 진입할 때, query와 page의 변경 없이도 fetch 코드는 실행되어야 한다. 또, 브라우저의 뒤로 가기 혹은 앞으로 가기 버튼을 클릭해도 fetch 코드는 실행되어야 한다. 이는 사용자의 동작이 fetch 코드를 실행시키는 메인 원인이 아니라는 것을 의미한다. 따라서 Effect에 있는 것이 적절하다.
문제점과 개선 방법
문제점
이 코드에서는 의도하지 않은 결과가 나타날 수 있다. 예를 들어 query를 변경할 수 있는 input이 있는데, 여기에 사용자가 hello를 입력했다고 가정하자. 'hello'를 타이핑하면 query state가 'h' -> 'he' -> 'hel' -> 'hell' -> 'hello' 순으로 변경되면서 각 문자열마다 fetch 코드가 실행된다. 만약 'hell'을 검색한 결과보다 'hello'를 검색한 결과가 더 빠르다면, query state는 'hello'이지만 results의 state는 'hell' 검색 결과가 된다. 이런 상황을 'race condition'이라고 한다.
* race condition : 두 개의 요청이 서로 경주하게 되는 상황을 뜻한다. 그 결과 예상하지 못한 순서로 도착할 수 있다.
개선 방법
race condition을 해결하기 위해, cleanup function을 사용하여 과거의(stale) 응답을 무시해야 한다. 아래의 코드는 마지막으로 호출한 fetch를 제외하고 모두 무시한다. hello까지 입력되었을 때, 'h' 'he' 'hel' 'hell'을 호출했던 Effect들은 cleanup 코드에 의해 ignore가 true로 변경된다. 따라서 hello의 응답보다 늦게 도착했을지라도 if (!ignore) 조건에서 false가 되기 때문에 그 결괏값들은 모두 무시되는 것이다.
되돌아보기
- 특정 state 혹은 props에 따라 변경되는 state는 Effect를 사용하지 않고, top level에서 선언하여 랜더링 되는 동안 계산되도록 하자.
- 컴포넌트 전체의 state를 변경하고 싶다면, 그 컴포넌트의 key prop 값을 변경하자.
- 화면이 나타난 이후에 실행되어야 하는 코드만 Effect를 사용하자. 그 이외에는 event handler를 사용하자.
- 컴포넌트의 여러 state를 변경해야 한다면, 연속적인 Effects 대신 하나의 event에서 해결하자.
- 여러 컴포넌트에서 동일한 state를 공유하고 싶다면, state를 부모 컨테이너로 끌어올리는 것이 좋다.
- Effects를 사용하여 데이터를 호출할 수 있지만, cleanup 함수를 사용하여 race condition을 방지하자.
직접 실습해 보기
https://react.dev/learn/you-might-not-need-an-effect#challenges
마치며
회사에서 React를 처음 시작했을 때 코드리뷰에서 불필요한 useEffect, useMemo를 줄여달라는 코멘트기 종종 달렸어요. 사실 언제 어느 상황의 useEffect가 불필요한 것인지 정확하게는 몰랐는데, 이 글을 통해 여러 케이스를 만나고 나니 useEffect를 조금 더 적절하게 사용할 수 있겠다는 자신감이 생겼어요! React를 겉핥기식으로 배웠었는데, 조금은 React와 가까워진 느낌이 드네요 😊 이번 기회에 React의 다른 학습 문서들도 살펴보려고 합니다. 시험 문제도 출제자의 의도를 파악해야 문제를 잘 풀 수 있듯이, 프로그래밍 언어도 개발자의 의도를 파악해야 좋은 코드를 짤 수 있음을 다시 한번 느끼는 계기가 되었습니다
'개발 > React' 카테고리의 다른 글
[React18] 새로운 기능 : Automatic Batching (0) | 2023.12.19 |
---|---|
[React] 컴포넌트 Property type 가져오기 | Get component props type (0) | 2023.11.14 |