React - 리액트에서의 렌더링
렌더링? 많이 들어봤는데
프론트엔드를 공부하다보면 많이 볼수 있는 단어이다. 개발자가 직접 하는 것은 아니고 브라우저가 해주는 것이다. 렌더링이란 브라우저가 HTML과 CSS를 기반으로 웹페이지에 피요한 UI를 그리는 과정을 의미한다. 렌더링이 어떻게 이뤄지는가에 따라서 사용성이 결정되기도 하고, 성능이 결정되기도 하기 때문에 중요한 과정이다. 이런 렌더링 과정은 브라우저에서 뿐만아니라 리액트에서도 이뤄진다. 리액트를 사용하는 개발자라면 렌더링이 어떻게 이뤄지는지 알아야 성능을 개선하는데 도움이 될것이다!
리액트 렌더링
위에서도 얘기했듯이 브라우저 렌더링과 리액트 렌더링은 다른 개념이다. 리액트에서의 렌더링이란
리액트 애플리케이션 트리 안에 있는 모든 컴포넌트들이 현재 자신들이 가지고 있는 props와 state의 값을 기반으로 어떻게 UI를 구성하고 DOM 결과를 브라우저에 제공할 것인지 계산하는 과정
단순히 화면에 그리는 과정인 브라우저 렌더링과 다르게 props와 state를 통해 DOM을 계산하는 것이 리액트 렌더링이다.
렌더링이 일어나는 이유
브라우저 렌더링은 HTML과 CSS 리소스가 제공되어야 렌더링이 이뤄진다. 그렇다면 리액트는 언제 렌더링이 이뤄질까?
최초 렌더링
우선 당연히 처음 사용자가 애플리케이션에 진입하면 렌더링이 이뤄진다. 사용자가 접근하면 브라우저에 DOM 결과를 제공해야 하기 때문에 최초 렌더링이 이뤄지게 된다.
리렌더링
중요한 것은 리렌더링이다. 리액트의 렌더링은 props와 state를 기반으로 계산된 DOM 결과를 제공한다. 그렇기 때문에 props와 state의 값이 변경되면 리렌더링이 이뤄지게 된다. state값을 변경하는 방법은 전 포스팅을 보면서 봤을 것이다.
import React from 'react'; interface State { count: number; } type Props = Record<string, never>; export class Counter extends React.Component<Props, State> { private renderCounter = 0; constructor(props: Props) { super(props); this.state = { count: 1, }; } private handleClick = () => { this.setState({ count: this.state.count + 1 }); }; public render() { console.log('ReactComponent', ++this.renderCounter); return ( <> <h1>Counter: {this.state.count}</h1> <button onClick={this.handleClick}>+</button> </> ); } }
클래스 컴포넌트에서는 setState를 통해서 state값을 변경할 수 있다. 그래서 state의 값이 변경되면 리렌더링이 이뤄진다.
private isRerender = () => { this.forceUpdate(); };
추가적으로 클래스 컴포넌트에서 forceUpdate라는 메서드가 존재한다. 이 메서드는 강제로 리렌더링을 실행하는 메서드이다. 이 메서드는 개발자가 강제적으로 리렌더링이 필요하다고 간주하고 생명주기 메서드인 shouldComponentUpdate를 무시하고 건너뛰게 된다.
shouldComponentUpdate : state나 props의 변경으로 리렌더링이 되는 것을 막는 메서드이다.
이 방법으로 리렌더링을 진행할때 주의할 점은 render메서드 내에서 forceUpdate가 사용되면 무한 루프에 걸리기 때문에 render메서드 외부에서 사용해야 한다.
export function Counter2() { const [count, setCount] = useState(0); const handleClick = () => { setCount(count + 1); }; return ( <> <h1>Counter2: {count}</h1> <button onClick={handleClick}>+</button> </> ); }
함수 컴포넌트에서는 useState의 setter가 실행된 경우이다. 이 setter는 클래스 컴포넌트의 setState와 같은 기능을 하기 때문에 리렌더링이 이뤄진다.
interface State { count: number; } type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' }; const initialState = { count: 0 }; function reducer(state: State, action: Action): State { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'reset': return { count: 0 }; default: return state; } } export function Counter3() { const [state, dispatch] = useReducer(reducer, initialState); return ( <div> <p>Count: {state.count}</p> <button onClick={() => dispatch({ type: 'increment' })}>Increment</button> <button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button> <button onClick={() => dispatch({ type: 'reset' })}>Reset</button> </div> ); }
useState와 비슷하지만 더 복잡한 상태를 관리하는 useReducer의 경우도 리렌더링이 이뤄진다. useReducer의 두번째 값인 dispatch를 통해 상태를 업데이트 할 수 있다.
값이 변경되는 것과 연관지어 배열의 key값이 변경되는 경우에도 리렌더링이 실행된다. 리액트에서 배열을 다룰때에는 key값이 사용된다. 이 key값은 리액트가 리렌더링을 결정하는 중요한 요소이다.
export function ArrayKeyChangeExample() { // 초기 배열 상태 const [items, setItems] = useState<Item[]>([]); const addEnd = () => { const newItem = new Date(); setItems((prev) => [...prev, { time: newItem }]); }; const addFirst = () => { const newItem = new Date(); setItems((prev) => [{ time: newItem }, ...prev]); }; const sort = () => { const sortedList = [...items].sort((a, b) => { return a.time.getSeconds() - b.time.getSeconds(); }); setItems(sortedList); }; return ( <div> <h2>Array Key Change Example</h2> <button onClick={addFirst}>위에 추가하기</button> <button onClick={addEnd}>아래에 추가하기</button> <button onClick={sort}>정렬</button> <div> {items.map((item, index) => ( <input placeholder={item.time.toLocaleString()} key={index} /> ))} </div> </div> ); }
예시로 버튼을 클릭하면 현재 시간이 placeholder인 input이 생성된다.
배열을 렌더링하면서 key값으로 배열의 인덱스를 부여해줬다. 추가자체는 문제가 없지만, 인풋의 값이 있거나 이벤트가 실행되는 경우 정렬을 시도하면 초기화가 되지 않고 이벤트가 유지된다. key값으로 인덱스를 부여했기 때문에 배열이 변경된 것으로 인지하지 않고 리렌더링을 실행하지 않는 것이다. 그래서 위의 코드에서는 인풋은 리렌더링이 이뤄지지 않기 때문에 인풋의 값은 유지되고, 인풋 내부의 placeholder의 값만 변경된 것으로 적용되는 것이다.
이유는 간단하다. 리액트는 DOM을 구성할때 파이버를 통해 DOM을 구성하는데 파이버는 각 요소의 관계를 통해 구성을 한다. 여기에서 key값은 sibling의 관계를 명확하게 해준다. 만약 key값이 없거나 index를 부여하면 sibling 관계에 의존해서 렌더링을 진행하기 때문에 리렌더링되지 않거나 모든 배열이 변경되는 상황이 발생된다. 그래서 리액트에서는 key값은 유일한 값으로 부여해야 한다.
이렇게 state가 변경되면서 자식 컴포넌트에 전달한 props가 변경되면 자식 컴포넌트도 리렌더링이 이뤄진다. 추가적으로 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 리렌더링이 이뤄지게 된다.
렌더링 프로세스
렌더링이 이뤄지는 상황을 알았으니 어떻게 렌더링이 이뤄지는지 알아보겠다.
function Component() { return ( <Test a={10} b={'abc'}> 컴포넌트 </Test> ); }
클래스 컴포넌트는 클래스 내부의 render메서드를 통해 렌더링을 하게 되고, 함수형 컴포넌트는 JSX를 리턴한 결과를 렌더링 한다. 둘다 결국에는 JSX형태로 변환되어서 React.createElement()를 호출하는 형식으로 변환된다. 위의 코드를 변환하면
function Component() { return React.createElement(Test, { a: 10, b: 'abc' }, '컴포넌트'); }
이런 형식으로 변환되고, createElement를 통해 브라우저가 UI를 구조인 자바스크립트 객체를 생성한다.
{type:Test, props:{a:10, b:'abc', children:'컴포넌트'}}
생성 과정을 거쳐 렌더링 결과물을 수집하고 파이버를 통해 변경 사항을 수집한다. 이 과정을 재조정(reconciliation)이라고 한다. 이러한 렌더링 프로세스는 한번에 이뤄지는 것은 아니고 세가지 단계로 분리되어 실행된다.
- 트리거 단계(trigger Phase): 위에서 봤던 렌더링이 이뤄지는 상황들을 의미한다. 초기 렌더링, 리렌더링을 발생 시키는 요소를 실행했을때의 단계이다.
- 렌더 단계(render Phase): 컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 의미한다. 렌더링 프로세스를 통해 얻은 렌더링 결과를 이전 가상 DOM과 비교해 변경이 필요한 부분을 체크한다. 위에서 알아본 것과 같이 type, props, key, state가 변경되면 변경이 필요한 컴포넌트로 체크된다.
- 커밋 단계(commit Phase): 커밋 단계는 렌더 단계의 변경 사항을 실제 DOM에 적용해 사용자에게 보여주는 과정을 의미한다.

그림으로 보면 좀더 쉽다. 트리거 단계는 식당에서 주문을 하는 것과 같다. 렌더링 주문이 들어오면 컴포넌트는 주문한 결과를 주게 되고 이것은 렌더 단계이다. 이제 리액트를 주문한 결과를 브라우저에 전달하는 커밋 단계를 거치게 된다. 이렇게 세가지 단계를 거쳐야 브라우저 렌더링이 발생한다.
이것을 통해 알수 있는 사실은 리액트의 렌더링이 일어난다고 해서 무조건 DOM이 업데이트되는 것은 아니다. 렌더링을 수행했지만 커밋 단계까지 갈 필요가 없다면 커밋 단계는 생략된다.
import React, { useState } from 'react'; export default function Component() { const [count, setCount] = useState(1); const handleClick = () => { setCount(1); }; return ( <div> {count} <button onClick={handleClick}>+</button> </div> ); }
지금 이 코드는 setter를 통해 state를 변경하는 로직이 포함되어 있다. 하지만 초기값과 변경되는 값이 차이가 없기 때문에 setter를 통해 state의 값을 변경해도 변경 사항이 감지되지 않았기 때문에 렌더 단계가 이뤄지지 않는다.
렌더링 시나리오를 통해 알아보기
import React, { useState } from 'react'; export function D() { return <div>리액트 최고</div>; } export function C({ number }: { number: number }) { return ( <div> {number} <D /> </div> ); } export function B() { const [counter, setCounter] = useState(0); const handleButtonClick = () => { setCounter((prev) => prev + 1); }; return ( <> <label> <C number={counter} /> <button onClick={handleButtonClick}>+</button> </label> </> ); } export function A() { return ( <div> <h1>hello world</h1> <B /> </div> ); } export default function Component() { return <A />; }
여러개의 컴포넌트가 중첩되어 있는 상태에서 버튼을 클릭하면 어떻게 렌더링이 이뤄질지 하나씩 거슬러가보자.
- B의 setter가 실행된다.
- B컴포넌트의 리렌더링 큐에 들어간다.
- 리액트 트리 최상단부터 렌더링 경로를 검사한다.
- A는 리렌더링이 필요없음으로 체크하지 않는다.
- B는 리렌더링이 필요함으로 리렌더링한다.
- B컴포넌트에서 C컴포넌트를 반환한다.
- C컴포넌트는 props로 number를 받고있고 number의 값이 변경되었음으로 리렌더링한다.
- C컴포넌트는 D컴포넌트를 반환한다.
- D컴포넌트는 리렌더링이 필요없지만 부모 컴포넌트가 리렌더링됨으로 리렌더링된다.
이런 순서로 리렌더링이 이뤄진다. 어떤 컴포넌트에 어떤 props와 state를 관리하는지에 따라 렌더링이 결정됨으로 우리는 더욱 신경써서 상태관리를 하고 리렌더링을 고려해야한다.
마무리
리액트를 왜 쓰는가에 대한 나만의 답들이 구성되는 기분이랄까... 전에는 많이 쓰고 속도가 빠르다던지, 상태관리를 통해 리렌더링을 효율적으로 할수 있다던지등의 표면적인 이유만 알고 있었다. 하지만 조금씩 원리와 순서를 알아가면서 복잡하지만 왜 사용하는지 알것같다. state와 props를 통해 어떤 요소가 리렌더링되고, 유지되는지 한눈에 확인이 가능하다. 물론 계산을 리액트가 전부 해주지만 사람 입장에서는 편하다.
아직 책의 극초반이다. 갈길이 멀기에 좀더 부지런히 공부해보자!
개의 댓글
1
렌더링? 많이 들어봤는데
리액트 렌더링
렌더링이 일어나는 이유
최초 렌더링
리렌더링
렌더링 프로세스
렌더링 시나리오를 통해 알아보기
마무리