리액트는 어떻게 작동할까 Diffing - 3주차 회고

and.Y·2020년 8월 25일
19

우아한 테크캠프

목록 보기
4/4
post-thumbnail

2번째 후기 리액트 따라하려다 바짓가랑이 찢어진 이야기의 연계 후기로 3번째 가계부 프로젝트의 해당 Wooact를 어떻게 손봐서 조금 더 쓸 만한 수준으로 만들었는지 적어보려고 합니다. 지난 글을 요약해보자면, JSX 비스무리하게 만들기, Props를 이용한 컴포넌트화, state 변경에 따른 re-render가 wooact를 만들게 된 주요 원인이었습니다. 그리고 결론은 나는 페이스북이 아니니 만든 것을 최대한 이용하는 선에서 적정하게 타협해서 이용했다로 끝맺음을 했습니다. 사실 진짜 끝맺음은 리액트의 핵심 알고리즘인 diffing 알고리즘을 쥐꼬리만큼이라도 공부/적용 해서 조금 더 쓸만한 수준으로 만들겠다는 목표가 들어있었습니다.

사실이라고 쓰고 변명이라고 읽는다.

실제로 해당 개념을 공부하고, 그 개념들을 조금씩 차용하여 기존보다는 더 쓸 만한 형태로 wooact의 작동 방식을 더 개선했습니다. 불필요한 rerender를 줄였고, 이전에 갖고 있던 많은 문제점들을 개선했습니다. redux와 비슷한 구조로 만든Store와의 연계를 통해 확장성도 높여 개인적으로는 쬐금 만족할 만한 수준으로 끌어 올렸다고 생각합니다. 물론 아직도 개선 가능한 부분이 훨씬 많습니다. 아니 사실 시작점 부터가 리액트와는 달랐기에, 구현이 불가한 부분 또한 많습니다.

따라서 어떻게 구현했는지는 개인의 만족이지, 공유된 내용을 보는 입장에서 큰 도움이 되지 않으리라고 판단했습니다. 그래서 다음주 부터 사용할 리액트가 어떻게 작동하는지 공유해드려고 합니다. 이런 상태로 크롱님께 어떻게 쓰는지를 배우시면 더 재밌게 리액트와 더 친해질 수 있지 않을까요? 따라서 해당 글은 리액트의 가벼운 사용 방법 및 작동 원리를 다루는 글이며, 고급 기술은 크롱님을 찾아주시면 감사하겠습니다.

VirtualDOM

일단 리액트는 기본적으로 실제 DOM을 이용하지 않습니다. 자신들이 만든 VirtualDOM 혹은 React Element라고 불리우는 객체 형태를 이용합니다. 이 VirtualDOM이 ReactDOM.render( /* React Elements */, $target)에서 실제 브라우저에 DOM 형태로 렌더링 됩니다.

type Props = {
	content: string
	price: number
	categoryId: number
	paymentId: number
	...
}
// React의 class based component
class TransactionItem extends React.Component<Props> {
	constructor(props: Props){
		super(props)
		...
	}

	render(){
		// <> 은 React.Fragment와 동일
		return <> 
			<p>{this.props.content}</p>		
			...
		</>
	}
}
// React의 functional component
const TransactionItem: React.FC<Props> = (props: Props) => {
	...
	return <> 
			<p>{this.props.content}</p>		
			...
		</>
}

간단한 예시로 이번 프로젝트의 거래내역 페이지의 하나의 거래를 TransactionItem이라는 리액트 컴포넌트로 작성해 보았습니다.(타입스크립트로 작성되었었습니다. 혹 익숙치 않으시다면 : 이후에 것이 타입이구나 정도만 알고 지나가시면 되겠습니다). 클래스형 컴포넌트에서는 render 함수 내에서 리턴하는 JSX, 또한 함수형 컴포넌트에서 리턴하는 JSX는 모두 아래와 같은 형태의 virtual Dom을 리턴합니다.

{
      $$typeof: Symbol(react.element),
      type: 'p',
      key: "XXXX", // unique value
			ref: "YYYY",
      props: {
          children: [
						{
							$$typeof:Symbol(react.element),
							type: '' // tag name
							...
						}
					],
          onClick: () => { ... }
      }
  },

하나의 JSX 태그는 위와 같은 javscript 객체(object) 형태로 구성되어 있습니다. 해당 객체가 React의 virtualDOM임을 확인해주는 Symbol값과, 각각의 virtualDom을 고유하게 구분하는 key값이 기본적으로 들어가게 됩니다. 그 외에는 jsx 태그 attribute로 혹은 그 자식으로 작성한 코드가 저 형태로 변환되어 전달되는 것이죠. 리액트는 이 virtualDOM 과 브라우저에 렌더된 DOM tree와 비교(diffing)을 통해 돔 조작을 효율적으로 해내고 있습니다.

Diffing Algorithm

이제부터가 사실 진짜 이야기인데요. 지난번에도 간략히 설명드렸지만, 세상만사 자기 일도 기억하기 힘든 세상이니 지난번 보다는 조금 더 상세히 설명을 드리자면, 해당 컴포넌트 내에 스테이트가 변경된 경우에는, 리액트는 해당 컴포넌트를 dirty 하다고 표시하고 batch에 추가합니다. 물론 앞으로 쓰게 될 redux와 같은 store를 이용하게 되면, 컴포넌트 단위가 아닌 root 노드(App component)에 dirty 마크가 찍히게 됩니다. 이건 나중 얘기니 일단 pass.

https://calendar.perfplanet.com/2013/diff/

그리고 Virtual Dom 엘리먼트와 실제 브라우저에 등록되어있는 DOM 엘리먼트를 비교/순회하며 dirty 체크된 엘리먼트들을 처리합니다. 이것을 처리하는 과정에서 속성 값만 변한 경우에는 속성 값만 업데이트하고, 해당 엘리먼트의 태그 혹은 컴포넌트가 변경된 경우라면 해당 노드를 포함한 하위의 모든 노드를 언마운트(제거)한 뒤에 새로운 virtualDom으로 대체합니다. 이러한 변경 혹은 업데이트가 모두 마무리 된 후에(batch에 쌓인 모든 것들을 처리한 후에) 한 번 실제 돔에 이 결과를 업데이트 합니다.

https://calendar.perfplanet.com/2013/diff/

이렇게 자신의 가상 돔 트리를 순회하며 변경사항을 업데이트 하는 것이 리액트의 핵심 알고리즘 입니다. 여기까지 하면 용어만 추가된 것 같고 실상은 같은 얘기를 한 것 같으니 조금 더 깊이 들어가 보겠습니다.

heuristics Algorithm

O(n^3) 알고리즘이 아니라 도대체 어떻게 거의 O(n)에 이러한 돔 트리를 순회하면서 저런 일을 할 수 있을까요? 그거슨 바로 heuristics알고리즘을 이용한 순회를 하고 있습니다. 음 이름이 멋있으니 엄청난 알고리즘같지만, 사실 휴리스틱이란 모든 것을 순회하는 것은 비용이 너무 높으니, 중요하지 않은 정보들은 고려하지 말고 중요한 것들만 고려해서 최선의 값을 찾아내자는 대충 때려맞추는 방법입니다. 그렇다면 리액트는 순회과정에서 어떠한 값들을 중요하다고 판단하고 있을까요?

https://calendar.perfplanet.com/2013/diff/

가장 처음은 같은 계층입니다. 이번 프로젝트를 예로 들었을 때, 여러 개의 transaction(거래 기록)은 하나의 컴포넌트로 구성한 뒤, 이를 map 혹은 array 의 method를 통해 반복 생성하여 렌더 했을 것 입니다. 이 때를 기억해보면 특별하게 하나의 거래 기록 만을 돔 트리 상에서 더 하위에 혹은 더 상위에 두는 일은 거의 없었을 것이라고 생각합니다. 리액트는 이러한 포인트에 주목하여, 비슷한 컴포넌트는 트리 내에 동일한 계층에 위치할 것이라는 전제하고 있습니다. 그래서 비교는 같은 층에 있는 애들끼리만 하자! 라고 이 포인트를 중요하게 생각하고 있는 것이죠.

https://calendar.perfplanet.com/2013/diff/

그 다음으로 그 계층에 존재하는 노드 들의 수 일치 혹은 불일치에 따라 다른 비교를 합니다. 수가 일치한다는 것은 추가되거나 삭제된 노드가 없다는 것을 추론할 수 있습니다. 조금 더 디테일 하게 추론해보자면, 해당 노드가 state가 변경되어 내부의 값이 변경된 컴포넌트라면 dirty체크가 되어 있을 것이고, 그것의 하위 컴포넌트의 속성 값을 업데이트 하는 것으로 처리할 수 있습니다. 혹은 전혀 다른 컴포넌트 혹은 엘리먼트로 대체 되었을 가능성도 있습니다. 이러한 경우라면 리액트는 해당 DOM을 완전히 버리고 새로운 Virtual DOM으로 이것을 대체합니다. 이런 경우에는 이 것들의 자식 노드들을 검색하지 않아도 되겠죠? 물론 수가 일치하지 않는 경우에도 위와 같은 순회가 필요한 경우가 있습니다.

https://calendar.perfplanet.com/2013/diff/

그렇다면 해당 계층의 수가 다른 경우는 어떤 것이 있을까요? 다시 한번 이번 프로젝트의 거래 내역 페이지를 생각하면, 새로운 거래 내역을 등록한 경우에 새로운 컴포넌트를 하나 추가해 주었어야 합니다. 혹은 삭제한 경우에는 해당 거래 내역을 전체 리스트에서 삭제 했어야 하죠. 같은 계층이라고 하면 트리와 같은 복잡한 구조가 아닌 queue혹은 기본적인 list 형태의 선형적인 구조로 되어있을 텐데, 누가 추가, 삭제 되었는지를 체크하는 것 또한 적지 않은 비용이 듭니다. 심지어 완전히 같은 정보를 가진 컴포넌트가 하나 더 추가 되었다면? 아니면 가장 앞에 새로운 아이템이 추가되었다면?

이러한 경우를 대비해 리액트에서는 리스트 형태로 컴포넌트를 렌더할 때, key값을 입력하기를 강제합니다. 그렇게 하면 이 컴포넌트 집단은 하나의 리스트 형태로 렌더 되어있음을 알고, 이 키값을 기반으로 Map 자료 구조를 이용해 쉽게 추가 삭제를 확인할 수 있게 해 둔 것이죠. 같은 컴포넌트가 다른 위치에 있다면 새로운 렌더가 없이 그저 위치만 바꿔주는 효율적인 연산이 가능해집니다.

이러한 휴리스틱 알고리즘을 기반으로 Diffing 알고리즘은 DOM tree를 빠르게 순회하며 그 변화를 반영시켜, 브라우저에 렌더링 시켜줍니다. 물론 이것은 아주 간소화된 이야기이며, life cycle method들만 추가 되어도 이 로직은 훠어어얼씬 복잡해집니다. 그런데 혹시 그거 아시나요? 리액트는 16버전부터 이러한 과정을 총체적으로 관리하는 Fiber라는 새로운 친구를 도입했습니다. 도입의 이유는 지금까지 설명한 diffing(Reconciliation) 알고리즘이 구려서라네요. ㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎㅎ

Fiber

좌측과 우측의 차이는 해민갓이 fiber가 있는 것과 없는 것이라고 합니다. 우측히 확연히 부드러워 보이죠? 자 그럼 지금부터 해민갓이란 fiber란 무엇이고, 어떠한 변화를 가져왔는지 찾아보시길 바랍니다. 는 너무 갑작스러운 결말 같으니 변명을 또 한번 하자면, 아직 제가 다 이해를 못했...어요 ㅎㅎㅎ 열심히 공부해서 2탄으로 돌아오겠습니다. 언제가 될지는 모르겠지만...

참고문서

1개의 댓글

comment-user-thumbnail
2024년 2월 11일

2020년의 작성자님이 가진 정보가 지금 저에게 큰 도움을 주고 있습니다..ㅜㅜ 이해가 잘 되는 글 감사드립니다!

답글 달기