- Published on
메모리를 절약하면서 효율적으로 React Portals를 사용해 보자
프로젝트를 진행하다 보니 많은 모달을 사용하게 되었고 자연스럽게 모달 관리에 대한 고민을 하게 되었다. 조사 결과 친절하게도 React에서 이미 Portals라는 좋은 기능을 제공하고 있었다.
사용 방법 또한 매우 간단하다. 그냥 createPortal 함수를 사용하면 된다. 이미 많은 포스팅이 존재하기 때문에 기본 개념은 자세하게 다루지 않으려고 한다. 그렇기 때문에 이번 포스팅으로 Portals를 주제로 선정하기에 애매한 감이 있었다.
그렇지만, 포스팅을 하지 않더라도 프로젝트에 적용할 계획이 있었기 때문에 공식 문서 외에 많은 블로그 게시물을 참고하게 되었는데 외국인 개발자분이 작성한 Portals 관련 흥미로운 내용을 보고 포스팅을 결심하게 되었다.
🤔 Portals?
게임 혹은 영화에서 포탈을 생각해 보면, 보통 어딘가로 이동하는 경우가 많다. 관련 공식 문서를 읽으며 내가 기존에 알고 있던 포탈과 얼추 비슷하다는 생각이 들었다.
Portal은 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링하는 최고의 방법을 제공합니다.
<main>
) 밖에 존재
🔍 부모 컴포넌트 DOM 계층(Portal을 사용하는 컴포넌트는 React의 가상 DOM 트리 내에서는 특정 위치에 논리적으로 존재하면서, 실제 브라우저의 DOM 트리에서는 완전히 다른 위치에 물리적으로 렌더링 된다.
모달 컴포넌트를 사용할 경우 모달 컴포넌트가 상위 컴포넌트 내부에 존재하지만, 모달 컴포넌트는 다른 위치에 존재하고 있는 것이다.
게시물의 썸네일 이미지 선정을 고민하며 가장 적절한 이미지가 무엇일까에 대한 고민을 하게 되었다. 곰곰이 고민해 봤을 때 유체이탈이 가장 적합하다는 생각이 들었다.
유체이탈을 하게 될 경우 영혼은 육체를 벗어난 독립적인 상태(물리적 분리)가 되지만, 육체와 영혼은 같은 존재(논리적으로 존재)라고 다소 의역이 존재하지만, 이렇게 이해해도 무방할 것 같다.
💡 완전하게 분리되는 것은 아니기 때문에 state, props에 의한 리렌더링이 발생한다.
독립적인 컴포넌트 관리, 이벤트 버블링 등 Portals를 사용했을 때 얻을 수 있는 많은 특징이 존재하지만, 이번에는 Portals를 메모리 최적화시켜 사용하는 부분을 중점적으로 다룰 예정이다.
성능 최적화를 몇 번 진행해 본 경험은 있었지만 메모리와 관련된 내용과 React에서 제공하는 기능을 최적화 했던 경험은 이번이 처음이었다. 🤔
📌 메모리 측정과 관련해서 개발자 도구에 메모리와 성능 탭을 이용 가능하지만, 이번 측정에서는 성능 탭을 사용했다.
우선 최적화 진행 전 공식 문서에서 제공하는 방식을 사용해 createPortal
함수를 사용했다.
import { createPortal } from 'react-dom';
// ...
{showReviewModal &&
createPortal(
<ReviewModal
action="create"
itemData={itemInfo}
onReviewModal={setShowReviewModal}
/>,
document.body
)
}
모달 열기 버튼을 클릭하면 정상적으로 모달이 열리고 부모 컴포넌트의 자식 위치가 아닌 body 태그의 하위 계층으로 포함되게 된다.
이제 앞서 언급했던 성능 탭으로 측정을 진행하고, 조금 극단적이지만 보다 수월한 시각적 수치 파악을 위해 모달 열고 닫기를 5번 진행했다.
Portals가 적용된 모달에서 열기/닫기 버튼을 클릭할 경우 개발자 도구 요소 탭의 DOM 트리 구조에서 모달 엘리먼트가 추가되고 사라지는 동작을 반복하게 된다.
뒤에 추가적으로 언급하겠지만, 성능 탭에서 보이는 DOM 트리에서 모달을 닫았을 때 사라지는 것처럼 보이지만 완전하게 사라지지 않고 메모리 상에 존재하게 된다.
따라서 메모리 상에서 Portals를 사용해 모달을 사용하지 않을 경우에 발생하는 메모리 누수를 막기 위해 최적화를 진행해 보자.
최적화 전 (기존) 메모리 (JS Heap) 사용 측정
💡 보다 수월한 시각적 수치 파악을 위해 모달 열고 닫기를 5번 진행한 결과
💡 JS Heap:
- 브라우저가 JS 객체를 저장하기 위해 사용하는 메모리의 양
- 메모리 누수가 있을 경우, Heap 크기가 계속해서 증가하는 경향을 보인다.
최적화 전 (기존) 에는 JS Heap 수치가 보기 이쁜 계단식 형태로 우상향 되고 있다. 일단은 형태만 기억한 뒤에 최적화 작업을 진행한 결과물을 비교하면 더 쉽게 이해가 가능하다.
🔍 본격적으로 메모리를 최적화 시켜보기
공식 문서를 살펴보면 클래스형 컴포넌트로 해당 방법을 제시하고 있다.
const appRoot = document.getElementById('app-root');
const modalRoot = document.getElementById('modal-root');
class Modal extends React.Component {
constructor(props) {
super(props);
this.el = document.createElement('div');
}
componentDidMount() {
modalRoot.appendChild(this.el);
}
componentWillUnmount() {
modalRoot.removeChild(this.el);
}
render() {
return ReactDOM.createPortal(
this.props.children,
this.el
);
}
}
...
생략된 코드가 있지만, 주목해야 할 부분은 생명주기 메서드 componentDidMount
와 componentWillUnmount
다.
특정(Modal) 컴포넌트를 만들고 컴포넌트가 생성될 때 특정 자식 노드를 추가하고, 컴포넌트가 더 이상 필요하지 않을 때(모달 닫기) 정리를 위해 추가했던 자식 노드를 삭제하는 것이다.
클래스 컴포넌트 형태로 구현해도 되지만, 함수형 컴포넌트에서도 생명 주기를 관리할 수 있는 useEffect
hook이 존재하기 때문에 이 훅을 사용해 Portal이라는 컴포넌트를 따로 만들어 주었다.
Portal.tsx
import { ReactNode, useEffect } from 'react'
import { createPortal } from 'react-dom'
interface PropsType {
title: string
children: ReactNode
}
export default function Portal({ title, children }: PropsType) {
const el = document.createElement('div')
el.id = title
useEffect(() => {
document.body.appendChild(el)
return () => {
document.body.removeChild(el)
}
})
return createPortal(children, el)
}
부모 컴포넌트.tsx
{showReviewModal && (
<Portal title="review-modal">
<ReviewModal
action="create"
itemData={itemInfo}
onReviewModal={setShowReviewModal}
/>
</Portal>
)}
- 여러 개의 모달을 관리하기 위해 각
div
태그에props
로title
을 입력받아id
를 구분 useEffect
내에서return
키워드를 사용해componentWillUnmount()
메서드 역할 수행
📌 결과 비교하기
💡 보다 수월한 시각적 수치 파악을 위해 모달 열고 닫기를 5번 진행한 결과
최적화 전(기존) 메모리(JS Heap) 사용 측정
최적화 후 (Portal 컴포넌트 적용) 메모리(JS Heap) 사용 측정
두 결과를 비교해 보면 앞서 언급했던 형태의 차이가 쉽게 식별된다. 최적화 후 메모리 사용 형태가 증가와 감소를 반복하는 이유는 바로 가비지 컬렉터의 존재 때문이다.
가비지 컬렉터는 사용되지 않는 메모리를 감지하고 회수하는 역할을 수행한다. 이 과정은 프로그램이 동작하는 동안 주기적으로 이루어지며, 메모리 사용량을 효율적으로 관리하게 해 준다.
그렇다. 모달을 열었을 때, createPortal
으로 생성되는 body 하위 계층의 모달 컴포넌트를 생성하고 모달을 닫을 때 사용되는 메모리를 반납하는 것이다.
모달을 닫을 때 (unmount) 더 이상 필요하지 않은 모달 (DOM Node)을 정리하는 작업을 진행하는 것이다.
💡 개발자 도구 요소 탭에 DOM 트리에서 사라지는 것처럼 보이지만, 메모리 상에서는 정리되지 않아 가비지 컬렉터에 감지되지 않았던 것이다.
요약
- Portals를 모달 컴포넌트에 적용할 경우 모달 컴포넌트가 unmount 됐을 때 DOM Node가 메모리 상에서 완벽하게 정리되지 않는다.
- useEffect hook, componentWillUnmount 생명 주기 메서드를 사용해 DOM Node를 반납할 경우 가비지 컬렉터 수집 대상이 되기 때문에 메모리 최적화가 가능하다.
마치며
처음 Portals를 찾았을 때 단순하게 기술을 적용하면 좋을 것 같다는 생각에 큰 생각 없이 도입을 계획하고 있었는데 Portals도 useCallback
, memo
와 같이 무작정 사용하는 것은 큰 차이가 없거나 오히려 독이 될 수도 있는 상황이 발생할 수도 있다는 점을 배웠다.
마찬가지로 앞으로도 Portals를 비롯해 여러 라이브러리, 함수 등을 사용할 때 여러 측면에서 고민해 보고 사용할 수 있도록 꼼꼼하게 측정, 분석, 관찰, 판단할 필요성을 다시금 느낄 수 있었다.