안녕하세요. 질문을 사랑하는 올리브영 프론트엔드 개발자 “우문Hyun답”입니다.😁
스토어전시 스쿼드에서는 24년 2-3분기에 기존 JSP로 구현된 기획전 시스템을 Next.js로 전환하여 개편하는 작업을 진행 하였는데요.
이번 포스트에서는 다사다난했던 기획전 개편에서 제가 담당한 기획전 상세 페이지 구현에 있어 직면 했던 문제와 해결 방법에 대해 얘기해보고자 합니다.
📃 기획전의 요구사항
기획전이란 올리브영 내에서 특정 테마나 시즌에 맞춰 다양한 상품을 특별 가격에 제공하거나, 특정 브랜드 제품을 집중적으로 소개하는 페이지로 증정, 쿠폰, 할인 등 다양한 혜택을 제공합니다.
기획전 페이지는 Back-office(BO) 에서 자유롭게 수정할 수 있는 HTML 모듈, 이미지 모듈, 비디오 모듈, 쿠폰 모듈 등 기획전 생성에 필요한 모듈들을 지원하는 에디터가 있습니다. 이미지에서 볼 수 있듯이, BO 에서 에디터를 통해 생성한 기획전을 저장하고 저장된 기획전을 상세 페이지에서 유저들에게 보여줄 수 있도록 하는 것이 기획전 프로젝트의 목표였습니다.
그렇다면 BO 에서 사용자 정의를 통해 생성한 기획전을 어떻게 유저들에게 보여줄 수 있을까요?
🖥️ 렌더링 방식과 고려사항
BO 에디터에서 제공하는 대부분의 모듈은 특정한 형식이 존재해 클라이언트에서는 정해진 룰에 따라 컴포넌트에 데이터를 삽입해주기만 하면 모듈들을 렌더링할 수 있습니다. 문제는 사용자 정의가 가능한 HTML 모듈이었습니다.
HTML 과 CSS 가 문자열 형식으로 저장되고, 해당 데이터를 DOM에 삽입하기 위해 React 에서 제공하는 dangerouslySetInnerHtml
을 사용하였습니다. dangerouslySetInnerHtml
은 React 가 생성한 가상 DOM을 건너뛰고 문자열을 직접 DOM에 주입하는 역할을 합니다.
문자열을 직접 DOM 에 삽입한다면 당연히 XSS 공격에 취약할 수 있지만, 삽입하는 데이터가 BO 에서 생성된 데이터라는 점에서 신뢰할 수 있다고 판단하여, dompurify 를 사용하는 등의 안전성 검사를 진행하지 않았습니다.
그렇다면 받아온 HTML 을 dangerouslySetInnerHtml
로 DOM에 삽입만 하면 될까요?
(그렇다면 정말 좋았겠지만 테크블로그를 쓸수 있도록 상황이 만들어 졌습니다.. 😂)
고려해야 할 사항이 하나 있었는데, 사용자 정의를 통해 생성된 HTML 코드 내부에 기존 Next.js 에서 만들어져있는 상품 카드 컴포넌트를 삽입해야한다는 점이었습니다.
상품 카드 컴포넌트는 올리브영 온라인몰 전반에서 사용하는 공통 컴포넌트로, 상품에 대한 정보만 입력하면 레이아웃, 이미지, 가격 등을 표시할 수 있는 컴포넌트입니다. 이 상품 컴포넌트를 어떻게 렌더링할 것인가가 이번 기획전 개편의 큰 과제였습니다.
🧑💻 문제 해결 방법
HTML 사이에 기존 만들어져 있는 컴포넌트를 어떻게 삽입할지에 대해 프로젝트 시작 전에 여러 가지 방법을 논의했는데 ‘웹 컴포넌트로 상품 컴포넌트 재구현’과 ‘특정 영역에 상품카드를 삽입’ 두가지가 가능성이 가장 높은 방법으로 마지막까지 경쟁(?)을 하였습니다.
웹 컴포넌트를 경험해보고 싶어 제안드렸지만 복잡한 컴포넌트를 새로 만들기에는 기간이 촉박하고 결함이 많이 발생할 것이라 판단해 기존 컴포넌트를 특정 영역에 삽입하는 방식이 선택되었습니다.
그렇다면 dangerouslySetInnerHtml
을 통해 그려진 DOM 영역에 상품 카드를 삽입하는 방법이 무엇이 있을까요? 제가 선택한 방법은 createRoot
를 통해 DOM 이 생성된 이후 컴포넌트를 삽입하는 방법이었습니다.
createRoot 란?
React 18 에서 도입된 새로운 API로, 특정 요소를 루트로 지정하여 React 컴포넌트를 삽입해 주는 기능을합니다.
React 18 버전 이전의
ReactDOM.render
의 역할을 대신하며, 차이점은ReactDOM.render
는 동기적으로 렌더링하고, 성능 최적화를 위한 새로운 기능을 지원하지 못했습니다.
반면 createRoot
는 병렬 모드 기능을 기본적으로 활성화하며 우선 순위에 따라 작업을 처리하기에 사용자 경험이 향상되었습니다.
예시 코드를 통해 createRoot
의 사용법을 알아보도록 하겠습니다.
const CreateRootTestComponent = () => {
const customHtml = `
<div>
<h1>createRoot Test 컴포넌트 입니다</h1>
<!-- component 가 삽입될 곳 -->
<div class="sub-root-element"></div>
<h2>어떤 내용이 들어가도 좋습니다!</h2>
<!-- component 가 삽입될 곳 -->
<div class="sub-root-element"></div>
</div>
`;
useEffect(() => {
// B
document.querySelectorAll(".sub-root-element").forEach((element) => {
createRoot(element).render(<ExistComponent />);
});
}, []);
// A
return <div dangerouslySetInnerHTML={{ __html: customHtml }} />;
};
const ExistComponent = () => {
return <p style={{ backgroundColor: "yellow", display: "inline-block" }}>기존 만들어진 component 입니다.</p>;
};
기획전의 상황과 최대한 비슷하게 만들어보았고 핵심 동작은 다음과 같습니다.
- (A) 문자열 형식의 HTML 코드를
dangerouslySetInnerHtml
를 통해 div 요소에 삽입합니다. - (B) useEffect 를 통해 DOM이 렌더링된 이후,
createRoot
메소드를 사용하여 특정 클래스를 가진 요소를 기준으로 React 컴포넌트를 삽입합니다.
여기서 집중해야할 점은 컴포넌트가 정상적으로 렌더링 되었고 여러개의 root 요소가 생겼다는 점입니다.
React 는 기본적으로 하나의 root 와 하나의 Virtual DOM 을 가지고 동작하는데 createRoot
를 사용하면 여러개의 root 요소와 Virtual DOM 이 생성됩니다. 기획전 프로젝트에서는 이로인해 발생하는 문제가 하나 있었습니다.
상품카드 컴포넌트에는 토스트 메시지 기능을 위한 전역 상태관리 라이브러리인 Recoil 과 좋아요, 장바구니 기능 동작을 위한 서버 통신 로직이 포함되어 있었고 이는 react-query 에 의존하고 있었습니다. 그러나 기존 선언된 RecoilRoot 와 QueryClientProvider 내부가 아닌 다른 영역에 컴포넌트가 생성되면서 기존에 사용하던 Recoil 상태와 QueryClient 를 사용할 수 없었습니다.
이를 해결하는 방법은 의외로 간단 했는데요, createRoot
를 실행할 때마다 RecoilRoot 와 QueryClientProvider 로 컴포넌트를 감싸서 삽입하는 것이었니다.
하지만 이 방법은 Wrapper 컴포넌트를 여러 개 생성하여 성능 이슈를 야기할 수 있을지 모른다는 생각에 성능 분석을 진행해보았습니다.
🔍 성능 이슈 분석
발생할 수 있는 성능상의 이슈를 파악하기 위해 아래의 질문들에 대해 테스트를 진행해보았습니다.
createRoot
사용시 여래개의 Virtual DOM이 생성되는데, 이를 실제 DOM 과 비교하는 과정에서 성능상의 이슈는 없을까?
React 는 성능상 큰 비용이 드는 DOM 업데이트를 최적화하기 위해 변경 사항이 발생하면 실제 DOM 전체를 수정하지 않고, diffing 알고리즘을 사용해 변경된 부분만 실제 DOM 에 적용하여 효율적인 업데이트가 가능하도록 합니다.
diffing 알고리즘이란 Virtual DOM 과 실제 DOM 을 비교해 변경된 부분을 계산하는 과정인데, Virtual DOM이 여러 개인 경우 각각 실제 DOM 과 비교하는 과정이 있어 성능에 지장을 줄 수 있다고 생각할 수 있습니다.
하지만 Virtual DOM 이 여러개가 되어도 비교 횟수가 증가하는 것은 아닙니다. 실제로 React.Profiler 를 통해 변경을 체해본 결과, createRoot
로 생성된 컴포넌트의 변경은 기존 컴포넌트에 지장을 주지 않았습니다.
Profiler 확인 예시 코드
import { Profiler, useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
const onRenderCallback = (
id: string,
phase: "mount" | "update",
actualDuration: number, // 렌더링에 소요되는 시간 (ms, commitTime - startTime)
baseDuration: number,
startTime: number,
commitTime: number,
interactions: Set<any>
) => {
if (phase === "update") console.log(`${actualDuration.toFixed(1)} ms`);
};
const ParentComponent = () => {
const RENDER_COUNT = 100; // 100 개의 컴포넌트 렌더링
const arr = useMemo(() => new Array(RENDER_COUNT).fill(0), []);
useEffect(() => {
const wrapperList = document.getElementsByClassName("wrapper-div");
if (!wrapperList) return;
// createRoot 를 통해 100개의 child 컴포넌트 삽입
Array.from(wrapperList).forEach((wrapper) => {
createRoot(wrapper).render(<ChildComponent />);
});
}, [arr]);
return (
<Profiler id="update-test-app" onRender={onRenderCallback}>
<div>
{arr.map((_, index) => (
<div className="wrapper-div" key={index}></div>
))}
</div>
</Profiler>
);
};
const ChildComponent = () => {
const [count, setCount] = useState(0);
return (
<>
<button
onClick={() => {
setCount((prevCount) => prevCount + 1);
}}
>
count-up
</button>
<span>{count}</span>
</>
);
};
위 코드를 실행하고 count-up 버튼을 클릭했을 때, 기존 컴포넌트에 적용된 Profiler 에서는 업데이트가 감지되지 않으므로 onRenderCallback 내의 console.log 는 출력 되지 않습니다. 이는 부모 컴포넌트는 업데이트가 발생하지 않으며, 비교 횟수가 증가하지 않는다는 것을 증명해줍니다.
또한, 만약 비교 횟수가 늘어 난다고 해도 객체 형태의 비교이기 때문에, 비교하는 객체의 수가 증가하거나 객체의 크기가 커지는 경우 모두 오버헤드를 유발합니다.
따라서 Virtual DOM의 개수가 늘어나는 점에 대해서는 성능상 이슈는 무시해도 된다고 판단하였습니다.
<RecoilRoot>
, <QueryClientProvider>
가 여러개인 경우 성능에 지장을 주지 않을까?
QueryClientProvider 를 사용하면 캐시데이터가 메모리에 저장됩니다. QueryClientProvider 가 여러 개일 경우, 각 provider 별로 별도의 캐시 공간이 생성되므로 provider 간의 데이터를 공유할 수 없습니다.
따라서 같은 데이터를 요청할 경우, 데이터가 각각 메모리에 존재하게 되어 메모리 사용량이 소폭 증가할 수 있고 동일한 데이터에 대한 요청이 반복될 가능성도 있습니다.
그러나 현재 구조에서 상품 카드에 사용되는 데이터는 이미 API 를 통해 받아온 상태이며, 실제 react-query 를 사용하는 부분은 장바구니 담기, 좋아요 기능에서 mutate 가 진행되는 부분으로 기존 query 상태와 겹칠 부분이 없어 성능에 영향을 미치지 않는다고 판단하였습니다.
RecoilRoot 가 여러개인 경우도 QueryClientProvider 와 같은 문제가 존재합니다. 각 Root 간의 상태 공유가 불가능하여 동일한 상태를 여러 개 생성하게 되어 메모리 사용량이 증가할 수 있습니다. 상품 카드에서 Recoil 을 사용하는 부분은 장바구니나 좋아요 오류 발생시 알림 메시지를 토스트로 표시하기 위한 것으로, 불필요한 Recoil 상태를 생성하게 되어 메모리 사용량이 늘어나게 됩니다.
실제로 RecoilRoot 를 여러번 생성하고 Recoil 상태를 중복 생성하는 경우를 재현해 전체 메모리 사용량 스냅샷을 확인해 보았으나, 컴포넌트가 1,000 개까지 생성되는 되는 경우에도 메모리상의 유의미한 차이를 확인하긴 어려웠습니다. 일부 차이가 있었으나, 이는 provider 로 인한 것보다는 createRoot
를 사용한 것에 대한 차이로 보였습니다.
여기서 제가 내린 결론은 아래와 같습니다.
createRoot
를 사용하는 경우 초기 렌더링에 지연이 발생하지 않을까?
createRoot
를 사용하기 위해서는 렌더링 이후 Array.forEach 메서드를 통해 DOM 에 컴포넌트를 삽입하는 작업이 필요하며, 렌더링에 소요되는 시간이 다소 증가할 수 있습니다.
createRoot
가 렌더링 시간에 미치는 영향을 확인하기 위해, 동일한 컴포넌트를 10개, 100개, 1,000개씩 렌더링하고, React 에서 제공하는 Profiler 와 Performance API 를 사용해 렌더링 시간을 측정했습니다.
측정을 위해 사용한 코드는 아래와 같습니다.
-
createRoot
사용하지 않고 렌더링 하는 경우 (map 메소드를 통해 렌더링)import { Profiler, useMemo } from "react"; const onRenderCallback = ( id: string, phase: "mount" | "update", actualDuration: number, // 렌더링에 소요되는 시간 (ms, commitTime - startTime) baseDuration: number, startTime: number, commitTime: number, interactions: Set<any> ) => { console.log(`createRoot 를 사용하지 않는 경우 초기 렌더링 소요 시간: ${actualDuration.toFixed(1)} ms`); }; const ParentComponent = () => { const RENDER_COUNT = 10; const arr = useMemo(() => new Array(RENDER_COUNT).fill(0), []); return ( <Profiler id="App" onRender={onRenderCallback}> {arr.map((_, index) => ( <div className="wrapper-div" key={index}> <ChildComponent /> </div> ))} </Profiler> ); }; const ChildComponent = () => { return <div>child</div>; };
-
createRoot
를 사용해 렌더링 하는 경우import { Profiler, useEffect, useMemo } from "react"; import { createRoot } from "react-dom/client"; const onRenderCallback = ( id: string, phase: "mount" | "update", actualDuration: number, // 렌더링에 소요되는 시간 (ms, commitTime - startTime) baseDuration: number, startTime: number, commitTime: number, interactions: Set<any> ) => { console.log(`createRoot 를 사용하는 경우 초기 렌더링 소요 시간: ${actualDuration.toFixed(1)} ms`); }; const ParentComponent = () => { const RENDER_COUNT = 10; const arr = useMemo(() => new Array(RENDER_COUNT).fill(0), []); useEffect(() => { const wrapperList = document.getElementsByClassName("wrapper-div"); if (!wrapperList) return; // RENDER_COUNT 만큼 root를 생성하여 렌더링 const start = performance.now(); // 시작 시간 Array.from(wrapperList).forEach((wrapper) => { createRoot(wrapper).render(<ChildComponent />); }); const end = performance.now(); // 종료 시간 console.log(`Total block time: ${(end - start).toFixed(1)}ms`); }, [arr]); return ( <Profiler id="rendering-test-app" onRender={onRenderCallback}> {arr.map((_, index) => ( <div className="wrapper-div" key={index} /> ))} </Profiler> ); }; const ChildComponent = () => { return <div>child</div>; };
아래 코드블록은 결과를 정리한 것입니다. 초기 렌더링 시간은 createRoot
를 사용하는 경우가 자식 컴포넌트를 포함하지 않은 상태로 더 빨랐으나, useEffect 내에서 createRoot 의 동작 이후 시간은 Array.map 에 비해 오버헤드가 많이 발생하는 것을 확인할 수 있었습니다.
createRoot
를 통해 생성되는 컴포넌트의 양이 많아질수록 지연이 기하급수적으로 늘어나며, 상품 컴포넌트의 수가 증가할수록 렌더링 지연이 발생함을 확인할 수 있었습니다.
// 10개의 경우 (4배 소요)
createRoot 를 사용하는 경우 초기 렌더링 소요 시간: 0.3ms
createRoot 를 사용하지 않는 경우 초기 렌더링 소요 시간: 0.5 ms
Total block time: 2ms (createRoot 를 사용하는 경우 컴포넌트가 삽입되는 시간)
// 100개의 경우 (약 4배 소요)
createRoot 를 사용하는 경우 초기 렌더링 소요 시간: 0.9ms
createRoot 를 사용하지 않는 경우 초기 렌더링 소요 시간: 2.4 ms
Total block time: 9.5ms
// 1000개의 경우 (약 7배 소요)
createRoot 를 사용하는 경우 초기 렌더링 소요 시간: 6.5 ms
createRoot 를 사용하지 않는 경우 초기 렌더링 소요 시간: 11.6 ms
Total block time: 81.6ms
위와 같은 테스트를 통해 삽입되는 상품 컴포넌트가 많아져 성능상 이슈가 발생한다면, 이를 배포 이후 과제로 삼아해결해야 한다는 점을 파악했습니다.
🤦 예상 못한 결함
기획전에서는 상품 보러가기 버튼이 고정(fixed)으로 화면에 표시되는데, IOS에서는 해당 버튼을 클릭할 때 원하는 상품 섹션으로 한 번에 이동되지 않고, 특정 지점으로 이동한 이후에 다시 클릭해야만 이동하는 이슈가 발생했습니다.
이러한 현상이 발생하는 이유는 무엇일까요?
window.scrollTo 를 통해 이동할 때, 이동을 원하는 요소의 DOM 내에서의 위치를 계산해야 했습니다. 이 계산은어렵지 않지만, 결함을 발생시키는 원인은 바로 이 지점에 있었습니다.
특정 ref로 이동하도록 하면 요소의 위치를 계산하지 않아도 되는 것이 아닌가? 라고 생각할 수 있지만, 다른 컴포넌트에서 바인딩한 스크롤 이벤트와 겹치는 현상이 발생하여 scrollIntoView 를 사용할 수 없었습니다.
또한, window.scrollTo 는 원하는 화면으로 이동 중에 유저가 스크롤 이벤트를 발생 시키더라도 무시하고 원하는 부분으로 이동도 된다는 장점도 있습니다.
BO 에서 이미지 사이즈를 px 단위로 고정하지 않고 화면에 맞게 조정하다보니 이미지의 크기가 동적으로 결정됩니다. IOS 에서는 lazy load 시 이미지의 크기가 정해져있지 않다면 lazy load 되는 이미지는 DOM 에서 공간을 미리 차지하지 않게 되어 상품 섹션의 위치 계산이 틀어지게 됩니다.
상품 보러가기 버튼 클릭 시 화면이 자동으로 상품 섹션으로 이동할 때, 이미지가 viewport 에 들어온다면 lazy load 되는 이미지가 갑자기 영역을 차지하게되어, 상품 보러가기 버튼 클릭시 계산되었던 상품 섹션의 위치가 아닌 다른 위치로 이동하게 됩니다.
이를 해결하기 위해서는 lazy load 의 이점을 가져가지 못하는 방법밖에 없었습니다. 아쉽지만, IOS인 경우에는 lazy load 를 하지 않도록 처리하였습니다.
✏️ 앞으로의 발전 방향
Intersction Observer 를 통해 초기 렌더링시 DOM 에 불필요한 컴포넌트가 렌더링 되지 않도록 했지만, 앞서 언급한 것처럼 이미지를 lazy load 처리 하지 않거나 dangerouslySetInnerHtml
를 사용함으로 인해 layout shift 가 발생하여 Lighthouse 점수가 기존에 비해 많이 개선되지 못했습니다.
앞으로는 기획전 상세페이지의 성능을 최대한 끌어올릴 수 있는 방법을 찾고 적용시킬 계획입니다. 현재 계획하는 방향은 다음과 같습니다.
- 이미지 lazy loading을 할 수 있는 방법을 모색합니다.
dangerouslySetInnerHtml
을 사용하더라도 요소가 viewport 에 들어와 있지 않으면 이미지를 다운로드하여 DOM에 포함하지 않고, 스켈레톤을 먼저 보여준 뒤 이미지를 lazy load 할 수 있도록 합니다.
이러한 최적화를 통해 성능을 개선할 예정입니다.
🙇 마치며
기획전 개편을 진행하며 최적화에 대해 다시 한 번 생각하게 되는 시간이었습니다
많은 양의 데이터나 컴포넌트를 렌더링하는 경우에는 lazy load 나 컴포넌트 가상화를 통해 최적화를 진행하였습니다. 그러나 기획전의 경우, 컴포넌트가 많은 것보다 서버에서 받아온 데이터를 파싱해 동적으로 컴포넌트를 삽입해야 하는 상황이 발생하여, '최고의 속도'보다는 '주어진 시간 내에서 최소한의 비용으로 완전하게' 에 집중하게 되었습니다.
꽤나 무거운 렌더링 작업을 진행하면서도 유저들이 사용하기에는 느리게 보이지 않도록 구현해두었고, 성능에 문제가 생기면 그때 최적화를 진행하는 것이 프로덕트를 개발하고 관리하는 데 있어 좋은 접근법이 될 수 있겠다는 것을 배운 재미있는 프로젝트였습니다.
긴 글 읽어주셔서 감사합니다! —̳͟͞͞♥