상세 컨텐츠

본문 제목

[React] 사용자 매뉴얼? 필요없음. 튜토리얼로 대신한다! (feat. Portal)

Programming/React

by 쌩우 2022. 10. 26. 18:02

본문

우리는 종종 새로운 서비스를 처음 접하거나 기존 서비스에 신규 기능이 추가되는 경우에 튜토리얼을 진행해본 경험이 있을 것이다.
사용자는 튜토리얼을 통하여 특정 위치의, 특정 요소가 가지는 역할에 대해서 차례대로 하나씩 인지하게 된다.

 

위의 gif는 React의 Portal 기능을 이용하여 만든 튜토리얼 컴포넌트의 진행 과정이다.
Portal은 특정 DOM 엘리먼트의 자식으로써 엘리먼트, 문자열 또는 fragment 등 종류에 상관없이 React 자식이기만 하면 렌더링 할 수 있게 해준다. 때문에 Dialog나 Tooltip과 같이 시각적으로 자식 엘리먼트를 "튀어보이게" 하려는 경우에 많이 사용한다. 실제로 예시 이미지에서도 현재 설명중인 엘리먼트는 시각적으로 "튀어"보이고 있다.

 

Portals – React

A JavaScript library for building user interfaces

ko.reactjs.org

그럼, 과연 React Portal을 이용하여 어떤 방법으로 튜토리얼 컴포넌트를 구상할 수 있었을까?

튜토리얼 컴포넌트의 구상

튜토리얼의 모든 단계를 담는 배열을 하나의 "시나리오Scenario"로 정의하였다. 각각의 튜토리얼 단계는 "장면Scene"으로 정의하였다. 사용자는 Scene을 넘기면서 튜토리얼을 진행하게 된다. 각 Scene에서 가장 먼저 할 일은, 현재 장면에서 설명하려는 엘리먼트의 위치를 찾는 것이다. (편의상 이 엘리먼트를 부모 엘리먼트로 부르겠다.)

이는 getBoundingClientRect 메서드를 적용하여 쉽게 찾을 수 있었다. 해당 메서드의 결과값에 대하여 offSet 값도 뽑아내주었다.

 

Element.getBoundingClientRect() - Web API | MDN

Element.getBoundingClientRect() 메서드는 엘리먼트의 크기와 뷰포트에 상대적인 위치 정보를 제공하는 DOMRect 객체를 반환합니다.

developer.mozilla.org

const getOffset = (el: HTMLElement) => {
    let _x = 0;
    let _y = 0;
    while (el && !isNaN(el.offsetLeft) && !isNaN(el.offsetTop)) {
      _x += el.offsetLeft - el.scrollLeft;
      _y += el.offsetTop - el.scrollTop;
      el = el.offsetParent as HTMLElement;
    }
    return { top: _y, left: _x };
  };

const parentBounding = parent.getBoundingClientRect();
const parentPositionX = getOffset(parent).left;
const parentPositionY = getOffset(parent).top;
const parentPosition = {
	x: parentPositionX === 0 ? parentBounding.x : parentPositionX,
	y: parentPositionY === 0 ? parentBounding.y : parentPositionY,
	width: parentBounding.width,
	height: parentBounding.height,
};

window.scrollTo(
	positionSetter(current.direction, parentPosition, current)
);

부모 엘리먼트의 위치를 뽑아내는 이유는, 현재 사용자가 화면상에서 어느 곳을 보고 있든 (스크롤 이동을 통하여 부모 엘리먼트가 보이지 않는 곳도 ) 현재 단계의 부모 엘리먼트를 볼 수 있도록 스크롤을 이동시키기 위함이다. 또한, 부모 엘리먼트의 위치를 기준으로 하는 안내 내용(이하 툴팁)의 위치를 지정해주기 위함이다.

툴팁 컴포넌트는 부모 요소에 대하여 어느 방향으로 위치시킬 것인지를 지정할 수 있도록 고안하였다. 방향만 지정해주면, 부모 엘리먼트가 어디에 있든지 상관없이 부모 엘리먼트의 위치를 기준으로 해당하는 방향에 툴팁이 표시되도록 하였다. 처음에는 고정된 형태로 툴팁을 보여주었는데, 점점 다양한 형태로 장면을 구성하려다 보니 표시할 내용이나 이미지, 크기 등에 대한 Props도 필요하게 되었다.

// Scene이 가질 수 있는 방향과 Props 타입
export type Direction =
  | "top"
  | "bottom"
  | "left"
  | "right"
  | "topLeft"
  | "topRight"
  | "bottomLeft"
  | "bottomRight";
  
export type Scene = {
  name: string;
  contents: string;
  image: string;
  parent: string;
  width: number | string; //number 또는 string 타입인 이유는, 반응형 단위를 쓰는 경우 string으로 표기하기 위함이다.
  height: number | string;
  direction: Direction;
  shouldClick?: boolean;
  shouldLast?: boolean;
};
//Scene 예시
{
  name: "Result",
  contents: "Switch Tab to Change Results Data Type.",
  image: "",
  parent: "ID",
  width: "25rem",
  height: "18rem",
  direction: "bottom",
},


튜토리얼 컴포넌트 개선 및 보완

1. window.resize event (and debounced) listener for repositionting

처음 튜토리얼 컴포넌트를 구현하고 시나리오를 진행시켰을 땐, 브라우저의 크기도 건드리지 않고 "다음 장면"으로 이동할 수 있는 버튼만 클릭해댔다. 덕분에 부차적인 이슈에 대해서 파악을 하지 못했다. 각 툴팁 컴포넌트가 처음 렌더링되는 순간에만 부모 엘리먼트의 위치를 파악했기 때문에, 만약 브라우저의 크기가 바뀌거나 레이아웃이 함께 변경되는 경우에는 툴팁이 제대로 따라가질 못했다. 결국 window 크기가 변경될 때 마다 이를 알 수 있는 방법이 필요했다. windowSize가 변경되면 throttling이 적용된 상태로 다시 부모 엘리먼트의 위치를 찾도록 해주었다.

useEffect(() => {
	let resizeTimer: number | undefined;
    let windowSizer = () => {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        setWindowSize({
          width: document.body.clientWidth,
          height: document.body.clientHeight,
        });
      }, 0);
    };
    window.addEventListener("resize", windowSizer);
    return () => {
      window.removeEventListener("resize", windowSizer);
    };
}, [])


2. fixed size => flexible size by rem units for resizing and responsive design

앞에서 언급한 Scene의 width / height의 타입에 string이 포함된 이유이다. 처음엔 고정된 단위값인 px만 사용하기 위하여 number 타입만 지원했다. 하지만 전체적으로 rem 단위를 적용한 반응형 프로젝트에서 문제가 발견되었다. 레이아웃은 media query breakpoints에 따라 크기가 유동적으로 변경되는데, 툴팁은 그렇질 못했기 때문이다. 때문에 상대적으로 작은 viewport에서, 레이아웃은 축소되었는데 툴팁만 화면을 전부 차지할만큼 커다랗게 남는 문제가 발생하였었다.


3. 튜토리얼 단계로 설명하려는 parent 밑으로 포탈을 열 경우의 문제점

여기까지 글을 읽고, 따라서 구현을 해보았다면 무언가 잘못되고 있음을 인지할 것이다. 대부분은 위의 내용에 따라 Portal로 부모 엘리먼트를 찾고, 부모 엘리먼트의 자식으로써 바로 툴팁을 렌더링했을 것이다. 물론 이렇게 하더라도 어느 정도 원하는만큼은 제 기능을 할 지도 모른다. 하지만 문제는 "부모 엘리먼트의 자식으로써" 툴팁이 렌더링되는 것이다. 이 때 툴팁은 부모 엘리먼트의 스타일 컨텍스트에 포함이 되버린다. 우리는 툴팁이 내부에 어떠한 요소를 지니게 될 지 아무도 예측할 수 없다. 누구도 부모 엘리먼트의 스타일이 얼만큼 상속되어 영향을 끼치게 될 지 예측 불가능한 툴팁을 만들고 싶진 않을 것이다.

나는 이를  "React 트리의 최상위 컴포넌트 외부에 툴팁을 렌더링"시키는 것으로 해결했다. 흔히들 React 트리를 root라는 id 값을 지닌 div 엘리먼트에 렌더할 것이다.

ReactDOM.createRoot(document.getElementById("root")).render(
	<React.StrictMode>
    	<App />
   	</React.StrictMode>
)

Portal의 놀라운 점 중 하나는, "DOM 트리의 어디에서든 존재할 수 있다"는 사실이다.

React 트리 바깥에 React 컴포넌트를 렌더링해도 문제가 없나요..?
네! 아무 문제없이, 정확히 동일하게 동작합니다.

DOM트리에서의 위치에 상관없이, Portal은 여전히 React 트리에 존재하기 때문이다. 따라서, 나는 부모 엘리먼트위 위치에 대한 정보를 획득하고 툴팁 컴포넌트를 modal이라는 id를 가진 div의 자식으로써 렌더링 시켜주었다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Tutorial</title>
  </head>
  <body>
    <div id="root"></div>
    <div id="modal"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

덕분에 CSS 상속으로 인한 표시의 어려움을 피할 수 있었다. 3번의 내용은 다음 글을 통해 더 이해할 수 있을 것 같다.

 

리액트 Portal

jeonghwan-kim.github.io

4. 튜토리얼 컴포넌트의 디자인

튜토리얼 컴포넌트의 경우, 기획 및 디자인부터 개발까지 모두 스스로 구현하였다. 디자인 단계에서 사용자의 시선을 사로잡고, 집중 가능한 디자인은 어떤 것이 있는지 많이 리서치했었다. 그 중, 가장 인상깊게 본 글이 바로 neubrutalism 에 대한 글이었다. 굵은 선과 강렬한 고대비의 조합이, 어느 곳에 두어도 강조되는 느낌을 주어 튜토리얼 컴포넌트로는 아주 제격이라는 생각이 들었다.

 

Neubrutalism is taking over the web | Hype4Academy

A new design style merges chaotic visuals with good typography.

hype4.academy

(튜토리얼을 통해서, 모든 프론트엔드 개발자가 기능 업데이트 때 마다 여기저기 불려다니면서 일일이 설명하지 않는 세상이 오기를..!)

관련글 더보기

댓글 영역