이전 포스팅에서 useState & useRef 훅에 대해서 살펴봤다.
원래는 다음으로 리액트에서 많이 사용되는 useEffect 훅을 살펴보려고 했다.
하지만, 강사님께서 컴포넌트의 LifeCycle에 대해 깊게 설명해주셔서 이 부분을 정리하고 넘어가려 한다 📖
(LifeCycle과 useEffect는 밀접한 관련이 있으니 같이 알아두자)
리액트는 컴포넌트 기반의 UI 라이브러리다.
웹 애플리케이션을 개발할 때 화면에 나타나는 여러 요소들을 컴포넌트로 분리하여 관리한다.
여기서 각각의 컴포넌트들은 자신만의 생명주기(LifeCycle)를 가지고 있다.
이는 컴포넌트가 브라우저에 그려지고(Mount), 업데이트되고(Update), 사라지는(Unmount) 과정을 의미한다.
리액트는 이러한 생명주기를 통해 특정 시점에 원하는 작업을 수행할 수 있도록 해준다.
예시 상황은 다음과 같다.
1. 초기 데이터 로딩
- 컴포넌트가 처음 렌더링될 때 서버로부터 데이터를 가져와야 하는 경우
- EX) 게시판 컴포넌트가 마운트될 때 게시글 목록을 불러오기
2. 리소스 정리
- 컴포넌트가 사라질 때 구독 해제나 이벤트 리스너 제거가 필요한 경우
- EX) 채팅 컴포넌트가 언마운트될 때 웹소켓 연결 종료하기
3. 성능 최적화
- 불필요한 리렌더링을 방지해야 하는 경우
- EX) 데이터가 변경되지 않았는데도 컴포넌트가 리렌더링되는 상황 방지
이처럼 컴포넌트의 생명주기 각 단계에서 우리는 다양한 작업들을 수행해야 하기 때문에 LifeCycle의 이해는 꽤 중요하다.
지금부터는 생명주기가 구체적으로 어떤 단계들로 이루어져 있는지 자세히 살펴보자
부끄럽지만 필자는 프론트엔드 개발자로서 컴포넌트 생명주기에 대해서 나름 잘 이해했다고 생각했다.
하지만, 교육 과정에서 조금 더 깊이 있는 내용의 강의를 들을 수 있었고, 이제부터 이 내용을 정리해보려고 한다.
우선 시작하기에 앞서 다이어그램 하나를 살펴보자
컴포넌트의 생명주기는 크게 mount, update, unmount로 나눌 수 있는데, 위 사진에서 각각은 생성 될 때, 업데이트 할 때, 제거 할 때에 매핑된다.
이제부터 각각의 과정을 살펴볼 예정인데, 사진에서 호출되는 메서드들은 모두 React.Component의 내부 메서드로 정의가 되어있다.
(별도로 메서드를 오버라이딩하지 않아도 내부적으로 알아서 호출된다 👀)
이 과정을 살펴보기 위해 클래스형 컴포넌트로 예시 코드를 작성해보려고 한다.
컴포넌트가 생성되면 처음으로 constructor 메서드가 실행된다.
이때, 컴포넌트가 렌더링 되기 위한 기본적인 세팅을 수행한다.
클래스형 컴포넌트를 작성할 때 constructor를 정의할 수 있는데 이에 해당된다.
Sample Code
import { Component } from "react"; class App extends Component { constructor(props) { super(props); this.state = { count: 0, }; } handleClick = () => { this.setState({ count: this.state.count + 1, }); }; render() { return ( <div> {this.state.count} <button onClick={this.handleClick}>+1</button> </div> ); } } export default App;
해당 코드를 보면 constructor 생성자에서 super(props)로 React.component 생성자에 props를 전달하고 있다.
또한, count라는 이름의 상태 변수를 선언하고 있다.
이러한 작업이 컴포넌트 렌더링 초기에 constructor() 메서드를 호출하며 수행된다.
해당 메서드는 props의 변화에 따라 state를 업데이트해야 할 때 사용된다.
React.Component의 인터페이스에 정의된 내용은 사진과 같다.
타입을 살펴보면 nextProps와 prevState를 매개변수로 전달받는다.
실제로 해당 메서드를 사용한다면 아래와 같은 방식으로 사용할 수 있다.
Sample Code
// App.jsx import { Component } from "react"; import Example from "./Example"; class App extends Component { constructor(props) { super(props); this.state = { count: 0, }; } handleClick = () => { this.setState({ count: this.state.count + 1, }); }; render() { return ( <div> {this.state.count} <button onClick={this.handleClick}>+1</button> <Example count={this.state.count} /> </div> ); } } export default App;
// Example.jsx import { Component } from "react"; class Example extends Component { state = { exampleCount: 0, }; static getDerivedStateFromProps(props, state) { if (props.count !== state.exampleCount) { return { exampleCount: props.count + 10 }; } return null; } render() { return ( <div> <p>Example Component Count : {this.state.exampleCount}</p> </div> ); } } export default Example;
다시 언급하지만, 사진에서 확인한바와 같이 getDerivedStateFromProps의 두번째 매개변수 state는 prevState를 의미한다.
따라서, 주로 부모로부터 받은 props를 기반으로 state를 업데이트해야 할 때 사용할 수 있는 메서드이다.
위 코드를 분석해보면 Example이라는 자식 컴포넌트는 부모 컴포넌트 App에서 count값을 props로 전달받는다.
Example 컴포넌트의 getDerivedStateFromProps가 렌더링 이전에 먼저 실행되어 내부적으로 count값을 결정한 뒤 렌더링을 수행하게 된다.
실행 화면은 다음 gif을 참고하자!
(그냥 훑어만봐도 괜찮을 듯 하다. 실제로는 사용하는 경우가 없으니까..)
다음으로 render 메서드가 호출된다.
클래스형 컴포넌트에서는 다른건 몰라도 render() 메서드를 직접 정의해야 하는데, UI를 구성하는 실질적인 태그가 포함된 부분이기 때문이다.
하지만, 우리가 알아야하는 것은 이 시점에 실제 DOM이 생성되는 것은 아니라는 것이다.
render() 메서드는 UI를 구성하는 역할을 수행하며, JSX를 기반으로 Virtual DOM을 생성하는 역할을 수행한다.
다음으로 componentDidMount()가 호출된다.
리액트 내부적으로 실제 DOM이 생성된 직후 호출되는 메서드이다.
(componentDidMount()가 실제 DOM을 만드는 것은 아니다!)
이 과정에서 생성된 DOM을 기반으로 추가 작업을 수행한다.
이때 주로 비동기 작업을 통해 서버로부터 데이터를 받아오는 역할을 한다.
constructor()에서 비동기 작업을 하지 않는 이유
- constructor()가 호출되는 시점에 비동기 작업을 요청하면 render()가 호출되지 않아 Virtual DOM이 생성되지 않는다.
- 따라서, 사용자는 비동기 작업이 끝날때까지 화면을 볼 수 없다.
- 이러한 문제 때문에 componentDidMount()가 비동기 작업을 수행한다.
Q. 스켈레톤 UI는 언제 볼 수 있나요?
A. render() 호출 ->
스켈레톤 UI
-> componentDidMount()호출렌더링 과정을 생각해보면 간단하다.
- render()가 호출되어 Virtual DOM을 생성
- 리액트 내부적으로 DOM을 생성
-> 생성된 Virtual DOM을 기반으로 사용자에게 화면 렌더링!!- DOM 생성 직후, componentDidMount()가 호출되어 데이터 로딩 & UI 업데이트
호출 과정을 콘솔에 찍어보고 마무리하자!
Sample Code
import { Component } from "react"; class Example extends Component { constructor(props) { console.log("1. Constructor 실행"); super(props); this.state = { exampleCount: 0, }; } static getDerivedStateFromProps(props, state) { console.log("2. getDerivedStateFromProps 실행"); if (props.count !== state.exampleCount) { return { exampleCount: props.count + 10 }; } return null; } componentDidMount() { console.log("4. componentDidMount 실행"); } render() { console.log("3. render 실행"); return ( <div> <p>Example Component Count : {this.state.exampleCount}</p> </div> ); } } export default Example;
Console View
다이어그램을 살펴보면, mount 과정에서 살펴본 메서드 이외에 새로운 메서드는 shouldComponentUpdate, getSnapshotBeforeUpdate, componentDidUpdate이다.
이를 한번 살펴보자
shouldComponentUpdate는 이름에서 알 수 있다시피 update 과정에서 호출되는 메서드이다.
이 메서드는 불필요한 렌더링을 방지하고 성능 향상을 위한 목적으로 사용된다.
React.Component에 정의된 내용은 다음 사진과 같다.
사진과 같이 해당 메서드는 nextProps와 nextState를 매개변수로 받고, boolean 값을 리턴한다.
주석 내용을 해석하면 다음과 같다.
shouldComponentUpdate 주석
- props와 state의 변경이 리렌더링을 발생시켜야 하는지를 결정하기 위해 호출된다.
- Component는 항상 true를 반환한다.
- PureComponent는 props와 state에 대해 얕은 비교를 수행하고, props나 state가 변경된 경우 true를 반환한다.
- 만약 false가 반환되면, Component.render, componentWillUpdate, componentDidUpdate는 호출되지 않는다.
즉, 얕은 비교를 수행하여 변경사항을 판단한다.
만약 변경사항이 있을 경우, 이후의 update과정을 수행하도록 true값을 리턴하는 메서드이다!
마찬가지로 React.Component의 인터페이스를 확인해보자!
해당 메서드는 매개변수로 prevState를 전달받고 있다.
주석을 해석하면 다음과 같다.
getSnapshotBeforeUpdate 주석
- React가 render()의 호출 결과를 문서에 적용하기 전에 실행되며, componentDidUpdate에 전달될 객체 (snapshot)를 반환한다.
- render가 변경을 일으키기 전에 스크롤 위치와 같은 것들을 저장하는 데 유용하다.
즉, DOM에 반영하기 전에 가지고 있는 정보 (업데이트 내용 포함)를 기록하고 실제 업데이트에 사용될 객체를 반환하기 위한 메서드이다.
마지막으로 componentDidUpdate에 대해서 살펴보자
마찬가지로 인터페이스를 살펴보자
componentDidUpdate 메서드는 componentDidMount 메서드와 마찬가지로 DOM이 생성된 직후 호출된다.
하지만, componentDidMount의 경우 초기화 작업이 목적이었다면 componentDidUpdate는 업데이트에 따른 후속 작업에 주로 사용된다는 차이점이 있다.
또한, componentDidMount와는 다르게 3개의 매개변수를 전달받는다.
그 이유는 이전 props/state와 현재 props/state를 비교해서 필요한 작업 수행하기 위함이다.
주석에는 다음과 같은 내용이 적혀있다.
componentDidUpdate 주석
스냅샷은 getSnapshotBeforeUpdate가 존재하고 null이 아닌 값을 반환할 때만 존재한다.
snapshot을 매개변수로 전달받는 이유는 DOM 업데이트 전의 정보를 보관했다가 DOM 업데이트 후에 그 정보를 사용하기 위함이다.
마찬가지로 update 과정도 메서드 호출 순서를 콘솔에 찍어보고 마무리하자
Sample Code
import { Component } from "react"; class Example extends Component { constructor(props) { super(props); this.state = { exampleCount: 0, }; } static getDerivedStateFromProps(props, state) { console.log("[Update] 1. getDerivedStateFromProps 실행"); if (props.count !== state.exampleCount) { return { exampleCount: props.count + 10 }; } return null; } shouldComponentUpdate(nextProps, nextState) { console.log("[Update] 2. shouldComponentUpdate 실행"); return true; } getSnapshotBeforeUpdate(prevProps, prevState) { console.log("[Update] 4. getSnapshotBeforeUpdate 실행"); return null; } componentDidUpdate(prevProps, prevState, snapshot) { console.log("[Update] 5. componentDidUpdate 실행"); } render() { console.log("[Update] 3. render 실행"); return ( <div> <p>Example Component Count : {this.state.exampleCount}</p> </div> ); } } export default Example;
Console View
컴포넌트가 unmount되는 시점에 호출되는 메서드는 딱 하나 밖에 없다.
이에대해서 간단하게 살펴보자
해당 메서드는 컴포넌트가 언마운트되기 바로 직전에 호출되는 메서드이다.
componentDidMount에서 생성한 DOM 엘리먼트들을 정리하거나 네트워크 요청을 취소하는 등의 정리 작업을 이 메서드에서 수행한다.
즉, 클린업 작업 (Event Listener 제거, API 요청 취소, setInterval로 설정한 타이머 해제, 외부 라이브러리 인스턴스 제거 등등...)을 수행한다.
컴포넌트의 생명주기를 정리한 목적은 클래스형 컴포넌트에서 내부적으로 호출되는 위 메서드들을 간단히 살펴보고, 함수형 컴포넌트의 useEffect에 어떻게 녹아있는지를 설명하기 위함이었다.
정리하다보니 배보다 배꼽이 더 큰 느낌이 되었는데, 완전히 이해하지 못하더라도 가볍게 읽어보는 것만으로도 괜찮을 것 같다 😂