[10분 테코톡] 마루의 리액트 컴포넌트 LIFECYCLE
무사히 테코톡 발표를 끝마쳤다!!! 페어 프로그래밍 주간이라 페어에게 조금 미안해서 발표시간 외에는 최대한 페어 프로그래밍에 집중하려고 노력했다. 페어 주간인 것을 알고 나름 미리 준비한다고 했는데 피피티를 전날까지 수정했다. 그리고 발표준비가 완전하지 않다고 느껴 전날까지 살짝 불안했다. 발표 준비로 인해 4시에 자서 아침에 매우 피곤한 상태였는데 발표 자체는 꽤나 만족스러웠다. (실전파일지도)
컴포넌트 생명주기
를 발표 주제로 선택하였는데, 그 이유는 useEffect가 마법처럼 동작한다고 느껴지고, useEffect를 꼭 사용해야 하는 시점을 구분하지 못했다. 이를 발표 준비를 통해 해소하고 싶었고, 많은 사람들이 헷갈리는 부분이라고 생각해서 발표 주제로 정하게 되었다.
준비하면서 렌더링에 대해 많은 지식을 얻을 수 있었다. 어느 시점에 렌더 단계, 커밋 단계, 브라우저 렌더링이 일어나는지를 좀더 명확하게 구분할 수 있었다. 발표를 준비하면서 React가 어떻게 동작하는지도 궁금해져서 코드 레벨로 분석해보고 싶다는 생각도 들었다. useEffect를 쓸 때마다 약간의 불안감과 거부감을 가지며 사용하게 되는데 어떤 상황에서 써야하는지, 쓰면 안되는지 명확하게 알 수 있었다.
컴포넌트의 생명주기를 온전히 이해하기 위해 렌더링 과정을 살펴보았다.
정의를 먼저 내리면 리액트에서의 렌더링이란 브라우저가 렌더링에 필요한 DOM 트리를 만드는 과정
이다.
이를 리액트 공식문서에서는 렌더링을 컴포넌트 호출
이라고 표현한다.
렌더링 프로세스는 render phase, commit phase 2가지로 나눠져 있다.
render phase
에서는 컴포넌트를 호출하여 변경사항 계산하고, commit phase
에서는 render phase에서 계산한 변경 사항을 실제 DOM에 적용한다.
함수 컴포넌트는 return문으로 JSX를 반환
하는데, JSX는 Babel에 의해 React.createElement로 변환
되어 실제로 반환되는 것은 ReactElement
다.
// 작성 코드 (React 17 이전)
import React from 'react';
function App() {
return <h1>Hello World</h1>;
}
// Babel에 의해 변환 (React 17 이전)
import React from 'react';
function App() {
return React.createElement('h1', null, 'Hello world');
}
그러한 이유로 함수 컴포넌트를 작성할 때 항상 React를 import 해줘야했지만, React 17 이후로는 컴파일러가 ReactElement로 변환시키는 코드를 자동으로 넣어준다.
// 작성 코드 (React 17 부터 React import 안해도 됨)
function App() {
return <h1>Hello World</h1>;
}
// 변환될 때 필요한 함수가 자동으로 import 된다.
// Inserted by a compiler (don't import it yourself!)
import {jsx as _jsx} from 'react/jsx-runtime';
function App() {
return _jsx('h1', { children: 'Hello world' });
}
아래 그림과 같이, 컴포넌트가 반환한 정보들을 바탕으로 이전 렌더링 결과와 비교하여 변경된 부분만 실제 DOM에 적용한다.
여기서 기억해야할 부분은 React의 렌더링과 브라우저 렌더링은 다르다.
리액트의 렌더링(render)이 완료되고 React가 DOM을 업데이트(commit)한 후 브라우저 렌더링이 발생한다.
이렇게 하나의 렌더링 사이클을 확인하였는데, 추가된 정보들을 바탕으로 렌더링을 한번더 정의해보자.
앞에서 렌더링을 컴포넌트 호출
이라고 표현하였는데, 이를 좀더 단계별로 자세하게 표현할 수 있다.
렌더링 : 컴포넌트를 호출하여 반환한 JSX를 ReactElement로 변환하고, VDOM(Virtual DOM)을 재조정(reconciler)하는 과정(fiber로 확장)
컴포넌트 호출, DOM 삽입, 브라우저 렌더링 각각을 구분하여 생각해야 한다.
reconciler가 컴포넌트를 호출하고, VDOM 재조정 작업 후 renderer를 이용하여 DOM에 마운트한다.
commit 단계는 보통 매우 빠르지만, 렌더링은 느릴 수 있음
concurrent mode
는 렌더링 작업을 여러 조각으로 나누어 브라우저 차단을 방지하기 위해 작업을 일시 중지했다가 다시 시작한다.
렌더링이 길어지면 브라우저 렌더링이 발생하는 데에 오래걸리기 때문에 성능 저하가 발생할 수 있다. 그래서 브라우저 차단을 방지하기 위해 렌더링 작업을 나누어 처리하는 방식이다.
React가 커밋하기 전에 render 단계 생명 주기를 두 번 이상 호출할 수도 있고, 커밋하지 않고 호출할 수도 있기 때문에 느릴 수 있다.(오류 또는 더 높은 우선 순위의 중단으로 인해)
https://legacy.reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects
컴포넌트가 생성, 변경, 제거되는 사이클
마운트 시점, 업데이트 시점, 언마운트 시점으로 크게 3가지로 구분
각각의 단계에서 컴포넌트를 생성하고, 업데이트하고, 제거한다.
💡 mount(마운트) :
lazy initialize
→ render phase → commit phase → layoutEffect 실행 → 브라우저 렌더링 → effect 실행update(리렌더링) : render phase → commit phase →
layoutEffect 정리
→ layoutEffect 실행 → 브라우저 렌더링 →effect 정리
→ effect 실행unmount(언마운트) : layoutEffect 정리 → effect 정리
lazy initialize
→ render phase → commit phase → layoutEffect 실행 → 브라우저 렌더링 → effect 실행
1. lazy initialize (게으른 초기화)
컴포넌트가 마운트되면 lazy initializer를 수행한다.
마운트시에만 실행되고, 리렌더링될 때는 함수가 실행되지 않는 것을 확인할 수 있다.
코드 | 콘솔 실행 |
---|---|
2. render phase
함수 컴포넌트 내부 코드를 실행하며, VDOM에 발생할 변경사항을 기록한다.
3. commit phase
render phase에서 기록된 변경사항들을 VDOM에 적용한다.
4. useLayoutEffect 실행
useLayoutEffect 훅에 전달된 콜백 함수 실행
커밋 단계와 브라우저 렌더링 사이에 실행
5. 브라우저 렌더링
가상 DOM에 발생한 변경 사항들을 브라우저 DOM에 적용하는 시점 (브라우저 렌더링)
페인팅이 끝나면 사용자는 화면에 컴포넌트가 그려진 것을 볼 수 있음
6. useEffect 실행
useEffect에 전달된 콜백함수가 호출되는 시점
보통 이때 서버에 API 요청을 통해 데이터를 불러옴
render phase → commit phase →
layoutEffect 정리
→ layoutEffect 실행 → 브라우저 렌더링 →effect 정리
→ effect 실행
state가 업데이트되어 렌더링이 트리거된 상황
이 업데이트에 해당
변경된 값으로 렌더링 프로세스를 다시 진행
나머지는 마운트 시점과 동일하고, 추가적으로 정리함수가 실행되며, 정리함수는 각각 effect가 실행되기 전에 실행된다.
1. useLayoutEffect 정리 함수
useLayoutEffect에 전달된 정리함수가 호출되는 시점
정리 함수가 실행된 후 useLayoutEffect 실행
2. useEffect 정리 함수
useEffect에 전달된 정리함수가 호출되는 시점
정리 함수가 실행된 후 useEffect 실행
layoutEffect 정리 → effect 정리
useLayoutEffect 정리 함수와 useEffect 정리 함수만 실행
방금 설명한 과정을 콘솔로 출력한 결과다.
앞에서 설명드린 이미지와 비교해보면 동일하게 동작한다는 것을 확인할 수 있다.
- 마운트 시에 게으른 초기화를 진행하고, 컴포넌트를 호출하여 render phase 진행
- 이후 useLayoutEffect와 useEffect가 차례로 실행
- 버튼을 클릭하여 setCounter로 렌더링을 트리거하면 업데이트 과정을 거침
- 리렌더링되면서 render phase를 진행하고, effect 함수가 실행되기 전 정리함수가 먼저 실행
코드 | 콘솔 실행 |
---|---|
외부 시스템과 컴포넌트를 동기화
하는 React Hook
외부 시스템 : React에 의해 제어되지 않는 모든 코드
ex) 네트워크, 브라우저 API, DOM, setTimeout, addEventListener 등
useEffect(setup, dependencies)
브라우저 렌더링 이후
에 실행
cleanup 함수는 이전 렌더링에 사용된 값으로 cleanup 함수를 실행
한 후, 새로운 값으로 setup 함수 실행
setup 코드 내에서 참조된 모든 반응형 값
의 배열
반응형 값
: props, state, props 또는 state로 계산된 모든 변수나 함수 등
의존성 배열은 선택할 수 없음
→ effect에서 읽은 모든 반응형 값이 포함되어야함 → 안정성
- 이벤트 핸들러와 구분하기
- render 단계에 계산하기
컴포넌트 내부에는 2가지 유형의 로직 존재
- 렌더링 코드
- 컴포넌트의 최상단에 위치하며, 결과만 계산하는
순수 함수
여야함- 이벤트 핸들러
사용자와 상호작용
으로 인해 발생하는 부수 효과 포함컴포넌트가 사용자에게 표시되었기 때문에 실행되어야 한다 →
Effect
사용자가 버튼을 클릭했기 때문에 실행되어야 한다 →이벤트 핸들러
렌더링을 위해 데이터를 변환하는 데 Effect 필요 ❌
state를 조합해서 만들 수 있는 계산은 상태로 선언하지 않는다. 상태를 최대한 줄이고 render 단계에 계산할 수 있다면 useEffect로 처리하지 않는다.
Before | After |
---|---|
props가 변경될 때 일부 state 를 바꿀 때 Effect 필요 ❌
Effect를 사용하지 않고 render 단계에 이전 state와 비교하여 상태를 변경한다. 또는 id를 저장하여 해당 상태를 렌더링 중에 계산할 수 있다.
Before |
---|
After (1) | After (2) |
---|---|
useEffect에 대해 조사하면서 미션에 적용해보았다.
ex) 입력 최대 길이가 줄어들었을 때 입력값 업데이트하기
이벤트 핸들러의 event.target.value 로 validation을 하여 카드 브랜드를 판단하는 로직이다. 카드 브랜드마다 유효 최대 길이가 다르기 때문에, cardBrand 상태가 변했을 때 input 상태도 유효 최대 길이에 맞게 바꿔야 한다. 그렇다면 이때 2가지 방법을 선택할 수 있다.
나는 첫번째 방식으로 구현했다가 사용자가 버튼을 클릭해서 동작하는 이벤트인데 Effect를 사용하는 것이 찝찝했다. 그래서 공식문서를 찾아보던 중 2번째 방식을 발견하여 렌더링 과정을 줄일 수 있었다.
- useEffect를 활용하여 cardBrand 상태가 변경된 후 카드 번호를 제어하는 input 상태를 갱신한다.
- render 단계에 유효최대길이를 계산하여 input 상태를 갱신한다.
1번(useEffect)
: setCardBrand(이벤트 핸들러)로 렌더링 트리거 → render → commit → 브라우저 렌더링 → effect → set카드번호로 렌더링 트리거 → render → commit → 브라우저 렌더링 → effect
// ❌ before
useEffect(() => {
const 현재_유효_최대_길이 = 브랜드정보 ? 유효_최대_길이 : 16;
if (카드번호_길이 > 현재_유효_최대_길이) {
set카드번호(카드번호.substring(0, 현재_유효_최대_길이), 'first');
}
}, [cardBrand]);
2번(render phase update)
: setCardBrand(이벤트 핸들러)로 렌더링 트리거 → render → render phase update → commit → 브라우저 렌더링 → effect
🚨 주의 : render phase가 따로 분기처리가 되어 바로 commit으로 넘어가는지, setState를 하니까 다시 render phase를 거치는지 명확하게 알고 싶다면 코드를 뜯어봐야 한다. 하지만 useEffect를 사용했을 때보다 빠른건 명확하다.
// ✅ after
const useCardNumbers = () => {
const 현재_유효_최대_길이 = 브랜드정보 ? 유효_최대_길이 : 16;
if (카드번호_길이 > 현재_유효_최대_길이) {
set카드번호(카드번호.substring(0, 현재_유효_최대_길이), 'first');
}
...
}
브라우저가 화면을 다시 그리기 전에 실행되는 useEffect
브라우저 렌더링 이전
에 실행
useLayoutEffect의 실행이 종료될 때까지 기다린 다음 브라우저 렌더링 수행 → 성능 문제 발생 위험
DOM은 계산됐지만 이것이 화면에 반영되기 전에 필요한 작업이 있을 때 사용
ex) 특정 요소에 따라 DOM 요소를 기반으로 한 애니메이션, 스크롤 위치 제어, 팝업창 위치 제어 등
위와 같은 상황에 사용하면 useEffect보다 자연스러운 사용자 경험(UX) 제공
value가 0이 되었을 때 setValue를 호출하는 로직을 useEffect와 useLayoutEffect에서 실행하여 차이를 비교하였다.
const App = () => {
const [value, setValue] = useState(0);
// 1번 예시 (깜박임 O)
useEffect(() => {
console.log(value);
if (value === 0) {
setValue(Math.random() * 200);
}
}, [value]);
// 2번 예시 (깜박임 X)
useLayoutEffect(() => {
console.log(value);
if (value === 0) {
setValue(Math.random() * 200);
}
}, [value]);
return (
<button onClick={() => setValue(0)}>value : {value}</button>
)
}
useEffect는 브라우저 렌더링이 된 후에 실행되므로, 사용자에게 0이 되었다가 랜덤값으로 바뀌는 로직이 보여진다. 이와 달리 useLayoutEffect는 브라우저 렌더링 이전에 실행되므로, 사용자에게는 결과값만 보여지게 된다.
useEffect (브라우저 렌더링 2번)
useLayoutEffect (브라우저 렌더링 1번)
useEffect
1. 버튼을 클릭하면 setValue(0)이 실행되어 렌더링이 트리거된다.
2. 새로운 value값인 0으로 리렌더링 된다.
3. value가 0으로 브라우저가 렌더링되어 사용자에게 보여진다.
4. 브라우저 렌더링이 끝난 후 useEffect의 setup함수가 실행되어 setValue 호출
5. 랜덤값으로 리렌더링 된다.
6. 랜덤값이 사용자에게 보여진다.useLayoutEffect
1. 버튼을 클릭하면 setValue(0)이 실행되어 렌더링이 트리거된다.
2. 새로운 value값인 0으로 리렌더링 된다.
3. 브라우저 렌더링 이전에 useLayoutEffect setup함수가 실행되어 setValue 호출
4. 랜덤값으로 리렌더링 된다.
5. 랜덤값이 사용자에게 보여진다.
https://ko.react.dev/
https://goidle.github.io/react/in-depth-react-preview/
https://velog.io/@hyunjine/리액트-렌더링에-대한-이해
[10분 테코톡] 마루의 리액트 컴포넌트 LIFECYCLE