2.3 클래스 컴포넌트와 함수 컴포넌트
- 함수 컴포넌트는 리액트 0.14 버전부터 출시했으며, 정적으로 렌더링할 때만 쓰임
- 16.8 버전에서 hook이 등장한 이후 함수 컴포넌트가 주목을 받기 시작
- 클래스 컴포넌트와 함수 컴포넌트의 차이를 살펴보자
2.3.1 클래스 컴포넌트
- 클래스 컴포넌트는 React.Component 혹은 React.PureComponent 를 extends 하여 생성
📍클래스 컴포넌트의 구조
class Welcome extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0,
};
this.handleClick = this.handleClick.bind(this);
}
render() {
const {
props: { name },
} = this;
return <h1>Hello, {name}</h1>;
}
}
- constructor()
- 생성자 함수
- 컴포넌트가 초기화되는 시점에 호출
- state를 초기화할 수 있음
- super()는 상위 컴포넌트 , 즉 extends한 상위 클래스인 React.Component의 생성자 함수를 호출
- ES2022 기준 별도 초기화 과정 없이 클래스 내부에 state를 바로 정의할 수 있음
- props : 컴포넌트에 특정 속성 전달
- state
- 컴포넌트 내부에서 관리하는 값 (객체)
- state 값이 변경 시 리렌더링
- 메서드 : DOM에서 발생하는 이벤트와 함께 사용
- constructor에서 this 바인딩
- 화살표 함수
- 렌더링 함수 내부에서 함수 선언 (렌더링 일어날 때마다 새로운 함수 생성하기에 지양)
📍 클래스 컴포넌트의 생명주기 메서드
- 클래스 컴포넌트 내부에서는 컴포넌트가 mount,update,unmount 되면서 실행되는 여러 생명주기 메서드를 실행할 수 있음
- 생명주기 메서드가 실행되는 시점
- 마운트(mount) : 컴포넌트가 생성되는 시점
- 업데이트(update) : 컴포넌트의 내용이 업데이트되는 시점
- 언마운트(unmouont) : 컴포넌트가 더 이상 존재하지 않는 시점
✏️ LifeCycle Method 종류
- render()
- 클래스 컴포넌트의 유일한 필수 값
- UI를 렌더링하기 위해 쓰임
- mount, update
- 항상 순수 함수로 구성되어야 함 (입력값이 항상 같은 결과물을 반환)
- 이 함수 내부에서 값을 변경하는 작업이 없어야 함 (사이드 이펙트X)
- componentDidMount
- 컴포넌트가 mount된 직후 실행
- state 값을 변경할 수는 있으나 일반적인 state를 다루는 것은 생성자에서 실행
- API 호출 후 업데이트, DOM에 의존적인 작업
- 내부 의존성 배열을 공백으로 둔 useEffect()
- componentDidUpdate
- 컴포넌트가 update된 이후 실행
- state,props의 변화에 따라 DOM을 업데이트하는 경우
- 조건문을 사용하여 계속 호출되는 문제 방지
- componentWillUnmount
- 컴포넌트가 unmount 되거나 더이상 사용되지 않기 직전 실행
- 메모리 누수, 불필요한 작동 막기 위한 cleanup 함수 실행할 때 사용
- shouldComponentUpdate
- state,props의 변경으로 리렌더링이될 때 컴포넌트의 업데이트를 제한하는 메소드
- 성능 최적화 상황에서 사용
- 사용되는 예시 : 부모 컴포넌트에서 상태를 변경헀지만, 그 영향을 받지 않는 자식 컴포넌트 또한 불필요한 렌더링을 수행하는 경우 (불필요한 렌더링 발생)
- static getDerivedStateFromProps
- render()를 호출하기 직전에 호출
- 이름처럼 props를 이용해 state 값을 얻음
- 정적 메서드이기 때문에 this 호출 불가
- getSnapShotBeforeUpdate
- DOM 이 실제로 업데이트 되기 전에 실행되는 메서드
- 여기서 반환된 값은 componentDidUpdate 로 전달
- getDerivedStateFromError
- 자식 컴포넌트에서 에러가 발생할 경우 호출되는 에러 메서드
- 에러 처리 로직 구현 시 사용
- 반드시 state 값을 반환해야 함
- 에러 정보를 state에 저장해 화면에 나타내는 용도
- componentDidCatch
- 자식 컴포넌트에서 에러가 발생할 경우 getDerivedStateFromError 에서 에러를 잡고 state 를 반환한 후 실행
- 에러 정보를 서버로 전송하는 용도
- ErrorBoundary 컴포넌트 : 하위 컴포넌트 트리에서 발생한 에러를 잡아 처리할 수 있는 컴포넌트 (react-error-boundary라는 라이버르리로 함수형 ErrorBoundary 컴포넌트 구현 가능)
- 두 가지 메서드를 정의하고 있는데 하나만 정의해도 컴포넌트 자체에 에러 경계가 됨
- static getDerivedStateFromError() : 오류에 대한 응답으로 상태 업데이트, 사용자에게 오류 메세지를 표시할 수 있는 기능
- componentDidCatch() : 서비스에 대한 오류 기록
- 이벤트 핸들러, 비동기적 코드, SSR 등의 에러는 핸들링하지 않음
📍 클래스 컴포넌트의 한계
- 데이터의 흐름을 추적하기 어렵다
- 코드 순서와 상관 없이 생명주기 메서드는 순서대로 작동하기 하지만, 코드 순서가 강제되어 있는 것이 아니기 떄문에 코드를 읽는 데 어려움이 있음
- state의 흐름 파악이 어렵다 ?
- 애플리케이션 내부 로직의 재사용이 어렵다
- 컴포넌트 간 중복되는 로직을 재사용하는 경우, 래퍼 지옥에 빠질 수 있음
- 컴포넌트를 상속한다고 해도 복잡하며 코드의 흐름을 좇기 쉽지 않음
- 기능이 많아질수록 컴포넌트의 크기가 커진다.
- 생명주기 메서드 사용이 잦아지는 경우
- 함수형 컴포넌트에 비해 상대적으로 어렵다
- 자바스크립트 개발자에게 클래스가 익숙하지 않아 러닝커브가 높음
- 코드 크기를 최적화가 어렵다.
- 번들링 최적화하기 어려움
- Hot Reloading 에 불리함이 있다.
- 핫 리로딩 : 코드에 변경 사항이 발생했을 때 앱을 다시 시작하지 않고 변경 사항을 적용하는 기법
- 클래스 컴포넌트는 최초 렌더링 시에 인스턴스를 생성하고 그 내부에서 state 값을 관리하기 때문에 핫 리로딩 시 state가 초기화되기 때문
- 다시 마운트되지 않는한 인스턴스가 재사용되지 않음 !
- 함수 컴포넌트는 클로저에 저장하기 때문에 초기화되지 않음
2.3.3 함수 컴포넌트 vs 클래스 컴포넌트
📍 생명주기 메서드의 부재
- 생명주기 메서드는 React.Component 클래스 내부의 메서드이고 이를 상속해서 쓰는 클래스 컴포넌트에서만 접근할 수 있음
- 함수형 컴포넌트는 useEffect Hook을 통해 생명주기 메서드와 같은 기능 구현
📍 함수 컴포넌트와 렌더링된 값
- 함수 컴포넌트는 props를 인자로 받고, state는 컴포넌트 외부에서 클로저로 관리
- state is a snapshot
- 리액트는 컴포넌트 함수가 호출된 순간의 state값을 제공하며 이는 다음 렌더링 이전까지 변하지 않음
- 클래스 컴포넌트는 this에 바인딩되기 때문에 변경된 state와 props에 바로 접근할 수 있음
- 함수형은 렌더링이 일어날 때마다 그 순간의 값인 props, state를 기준으로 렌더링되고 클래스형은 변화하는 this를 기준으로 렌더링이 일어남
2.4 렌더링은 어떻게 일어나는가 ?
- 리액트의 렌더링 : 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정
2.4.1 리액트의 렌더링이란 ?
- 모든 컴포넌트들이 자신들의 props와 state의 값을 기반으로 어떻게 UI를 구성하고 어떤 DOM 결과를 브라우저에 제공할 것인지 계산하는 과정
2.4.2 리액트의 렌더링이 일어나는 이유
📍 리액트에서 렌더링이 발생하는 경우
- 최초 렌더링
- 처음 홈페이지에 접속
- 리렌더링
- 최초 렌더링 이후 발생하는 모든 렌더링
- 클래스 컴포넌트 : setState 실행되는 경우, forceUpdate(강제 렌더링)
- 함수 컴포넌트
- uesState()의 state를 업데이트하는 setter 함수 실행되는 경우
- useReducer의 dispatch가 실행되는 경우
- 컴포넌트에 전달된 props가 변경되는 경우
- key props가 변경되는 경우
- 부모 컴포넌트가 렌더링될 경우
✏️ 리액트에서 key를 사용하는 이유
- key는 리렌더링이 발생하는 동안 형제 요소들 사이에서 각 요소들을 구별해주는 식별자의 역할
- 리렌더링이 발생할 때 current 트리와 workInProgress 트리 간 어떤 변경사항이 있는지 구별 , key 값을 캐치해서 두 요소의 차이점이 있을 때 DOM을 변화
- index 사용을 지양해야 하는 이유
- 재배열되지 않는다면 문제 없지만, 요소들이 추가되거나 삭제되는 경우 전체적인 key 값이 변경되기 때문에 요소의 식별자로서 역할을 못함
- 만약 아이템이 추가되면 key값이 모두 변경되고 모든 sibling들을 리렌더링함 (불필요한 리렌더링 발생)
- 같은 이유로 Math.random()을 key로 지정하면 안 됨
2.4.3 리액트의 렌더링 프로세스
- 리액트의 렌더링 과정은 크게 렌더 단계와 커밋 단계로 나뉨
2.4.4 렌더와 커밋
📍 Render Phase (파악)
- 컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업
- 가상 DOM 을 비교하는 과정을 거쳐 업데이트할 컴포넌트를 계산하는 과정
- JSX 는 트랜스파일러에 의해 React.createElement 코드로 변환되고, 해당 함수는 ReactElement 객체를 반환
- 각 컴포넌트가 호출되어 반환된 ReactElement 가 Fiber 로 확장되어 Virtual DOM 에 반영되는 과정
📍 Commit Phase (적용)
- 렌더 단계에서 재조정된 가상 DOM을 실제 DOM에 적용하는 단계
- 일관된 화면 업데이트를 위해 동기적으로 실행 ( call stack에 적재 )
❗리액트의 렌더링이 일어난다고 무조건 DOM 업데이트가 일어나는 것이 아니다.
- 렌더 단계에서 변경 사항을 감지하지 않으면 커밋 단계가 생략됨
- 3단계 : 트리거 - 렌더 - 커밋
- 트리거 : 리액트가 데이터가 변경되었는지 확인하는 과정
2.4.5 일반적인 렌더링 시나리오 살펴보기
- 상위 컴포넌트에서 자식 컴포넌트로 순차적으로 일어나기 때문에 자식 컴포넌트에 변경사항이 없어도 부모 컴포넌트가 리렌더링 되면 자식 컴포넌트도 리렌더링 됨
2.5 컴포넌트 함수의 무거운 연산을 기억해 두는 메모이제이션
메모이제이션 (Memoization)
- 계산 결과를 메모리에 저장해 두고 동일한 계산이 요청될 때 다시 계산하지 않고 저장된 값을 반환하는 최적화 기법
- useMemo, useCallback 훅과 memo는 리액트의 렌더링을 최소화하기 위해 사용됨
- 그러나 언제 사용해야 하는지 정확하게 알지 못하고 있음
- 무분별하게 사용할 경우 메모리 사용량이 증가
React.memo 필요한 경우의 예시
App 컴포넌트
- count, text 2개의 state를 가짐
- count state는 자식 컴포넌트인 CountView, text state는 TextVeiw 컴포넌트로 prop으로 각각 전달
- 이때 setCount를 통해 App 컴포넌트의 state를 업데이트 해주면 해당 state를 가진 App 컴포넌트는 리렌더링 발생하며 CountView로 전달되는 prop 의 값도 바뀜
- 부모 컴포넌트가 리렌더링되면 자식 컴포넌트인 CountView,TextVeiw도 리렌더링됨
- 이 과정에서 prop의 변화가 없는 TextView도 리렌더링
- 이처럼 prop의 변화가 없어도 리렌더링되는 성능의 낭비를 방지하기 위해 업데이트 조건을 부여할 수 있음
- 자식 컴포넌트에 React.memo를 사용하여 해당 문제 방지
React.memo
- 고차 컴포넌트로, 함수형 컴포넌트의 렌더 결과를 메모이징
- props의 얕은 비교를 통해 이전과 현재의 props과 같은지 확인함, props가 변경되지 않았다면 이전에 렌더링된 결과를 재사용
- 주의점
- 얕은 비교만 수행하기에 객체나 배열 같은 참조 타입의 props가 변경될 때 문제 발생할 수 있음
useMemo
- 계산된 값을 메모이징, 값의 재계산을 방지
- 메모이제이션을 통해 연산의 결과값 저장, 의존성 배열이 변경되지 않으면 이전에 계산한 값을 재사용
- 주의점
- 의존성 배열이 너무 자주 변경되는 경우에는 지양
- 컴포넌트가 리렌더링될 때마다 실행되는 로직이나 사이드 이펙트에 사용되어선 안 됨. 외부 상태에 의존하지 않는 순수한 계산, 순수 함수에만 사용해야 함
- 비용이 많이 드는 계산 : 리액트 공식문서에서 수천개의 객체를 만들거나 반복해야 하는 일을 비용이 많이 드는 계산이라고 하고 있다.
useCallback
- 함수 자체를 메모이징, 특정 함수를 재생성하지 않고 재사용
- 함수와 의존성 배열을 인자로 받으며 의존성 배열의 값이 변경되지 않으면 이전에 생성된 함수를 재사용 함
- 의존성 배열이 비어있으면 컴포넌트가 mount될 때 생성한 함수를 재사용
- 의존성 배열 값이 변경되면 새로운 함수를 생성
- 사용 예시
- name state가 변경되어 리렌더링이 일어나면 onSave 함수가 새로 만들어짐. onSave를 prop으로 받고 있는 Profile 자식 컴포넌트에서도 onSave가 매번 새로 생성되는 문제
- onSave에 useCallback을 사용하여 성능 낭비 방지할 수 있음
- function App() { const [name, setName] = useState(''); const onSave = () => {}; return ( <div className="App"> <input type="text" value={name} onChange={(e) => setName(e.target.value)} /> <Profile onSave={onSave} /> </div> ); }
2.5.3 결론 및 정리
책의 결론
- 성능에 대해 깊이 연구해 볼 여유가 안 된다면, 우선 메모이제이션을 다 적용해라
- 메모이제이션으로 인한 비용보다 리렌더링 작업이 더 무겁고 비용이 비싸다.
- 성능에 대해 지속적으로 모니터링, 관찰보다 메모이제이션의 이점이 더 크다 !
🤔 나의 생각
- 개발자가 성능 최적화가 필요한 부분이 어딘지 제대로 파악하지 못한 채로 무분별하게 메모이제이션을 해버린다면 그 결과로 의도하지 않은 결과가 나올 수도 있다. (반드시 실행되어야 하는 리렌더링이 발생하지 않는다던지 등의 버그 발생 .. ? )
- 성능 이슈가 있는 곳을 파악하고 적재적소에 맞게 메모이제이션을 적용하는 것이 개발자로서 해야 할 일 아닐까? 무작정 다 적용하는 것보다 성능을 파악하며 점진적으로 적용해나가는 것이 바른 방향같다.
'React > 모던 리액트 Deep Dive' 카테고리의 다른 글
[4-1 ~ 4-2] 모던 리액트 Deep Dive 스터디 8회차 (0) | 2024.06.24 |
---|---|
[3장] 모던 리액트 Deep Dive 스터디 7회차 (0) | 2024.06.17 |
[2-1 ~ 2-2] 모던 리액트 Deep Dive 스터디 5회차 (1) | 2024.06.12 |
[1-6 ~ 1-7] 모던 리액트 Deep Dive 스터디 4회차 (0) | 2024.06.12 |
[1-3 ~ 1-5] 모던 리액트 Deep Dive 스터디 3회차 (0) | 2024.06.12 |