React Portal을 통해 OpenSeadragon Overlay 만들기

React Portal을 사용해서 OpenSeadragon Overlay를 Reactive하게 개발할 수 있다.

React Portal을 통해 OpenSeadragon Overlay 만들기

공식 문서에서 소개하는 대로, OpenSeadragon은 공식적으로 HTML 엘리먼트의 오버레이 기능을 지원한다.  이 것을 사용하기 위해서는 OpenSeadragon을 초기화하는 시점에 overlays 속성에 엘리먼트 정보를 담거나, Viewer.addOverlay 메소드를 사용하면 쉽게 오버레이를 적용할 수 있다.

당연하지만, 이 메소드로는 임의의 React 컴포넌트를 OpenSeadragon 오버레이로써 띄울 수는 없다. OpenSeadragon이 React를 염두에 두고 개발된 라이브러리가 아니기도 하고, OpenSeadragon이 HTML 엘리먼트를 오버레이로 띄우는 방식을 보면 알 수 있다.

OpenSeadragon의 Overlay 핸들링

Overlay 예제: https://codesandbox.io/s/osd-overlay-simple-q7qv9l
Overlay를 추가하기 전의 초기 상태

위 스크린샷 캡쳐에서 보이는 것과 같이, Overlay 예제의 초기 상태는 osd-viewer 엘리먼트, overlay 엘리먼트, 그리고 하나의 버튼 엘리먼트가 존재한다. 세 엘리먼트가 특별한 계층 구조를 가지지 않고, 루트 엘리먼트 밑에 바로 위치해있는 형태이다.

이 상태에서 버튼을 클릭해서 viewer.addOverlay 메소드를 실행시켜주면 다음 스크린샷과 같이 DOM 계층 구조가 변화하게 된다.

Overlay를 추가한 이후의 상태

이전과 달리, overlay 엘리먼트는 더 이상 루트 밑에 존재하지 않는다. 해당 엘리먼트는 원래 위치에서 제거되고 OpenSeadragon의 오버레이 영역으로 이동해있게 된다. 이렇게 구현되어 있는 이유는 정확하게 알 수는 없지만, OpenSeadragon이 오버레이에 발생하는 다양한 이벤트를 효과적으로 처리하기 위함과 연관되어 있지 않을까 추측한다.

즉, OpenSeadragon은 Overlay 엘리먼트를 관리하기 위해 DOM을 직접적으로 조작하는데, 이런 방식은 React 컴포넌트를 Overlay로 쓰고 싶을 때 걸림돌이 된다. React 또한 컴포넌트의 라이프사이클 관리를 위해서 DOM을 직접적으로 조작하는데, OpenSeadragon의 DOM 조작은 React 입장에서 잘못된 동작을 야기할 수 있기 때문이다.

실제로 React 컴포넌트를 OpenSeadragon overlay로 등록한 뒤에 컴포넌트가 언마운트될 경우, 다음과 같은 에러가 발생한다: React가 제거하려고 한 컴포넌트가 있어야 할 위치에 없다는 것이다.

React Portal로 포탈을 뚫어주자

HTML 엘리먼트를 오버레이로 사용할 수 있다는 점에서 충분히 훌륭하다. 하지만, 프론트엔드 엔지니어의 입장에서는 React 라이프사이클과 동시에 Overlay의 라이프사이클을 신경쓰고 있어야 한다는 점이 불편했다.

Overlay와 관련된 기능이 추가될 수록 코드가 복잡해지고 있었기 때문에, 해당 기능과 관련해서 동료 직원들의 불만도 상당한 상황이었다.

"horrible and needs urgent refactoring" 이라는 평가가 나왔다

여러가지 방법 중에서 우리 팀이 사용하기로 결정한 방법은 React Portal을 사용하는 것이다. Portal의 활용 방법은 무궁무진하지만, React 컴포넌트가 아닌 HTML 엘리먼트에 React 컴포넌트를 렌더링하기 위해 Portal을 사용하는 것을 고려할 수 있다. 공식 예제에서는 지도 엘리먼트에 React 컴포넌트를 렌더링하는 예제를 소개하고 있다.

실제로 Portal을 적용하는 과정은 상당히 간단하다. createPortal을 임포트하고, Overlay 엘리먼트를 렌더링하는 코드 위치에 적용해주면 proof-of-concept이 끝난다:

import { createPortal } from "react-dom";
  
... // 위 예제 코드와 동일하다
  
  return (
    <>
      <div id="osd-viewer" style={{ width: 400, height: 300 }} />
      {viewer &&
        createPortal(
          <div
            id="overlay"
            style={{
              position: "absolute",
              width: 100,
              height: 100,
              backgroundColor: "gray",
            }}
          >
            Hello, world!
          </div>,
          viewer.canvas
        )}
    </>
  );
}

컨테이너 엘리먼트를 viewer.canvas로 설정하면 거의 동일한 위치에 엘리먼트가 삽입되어 있음을 확인할 수 있다. 한 가지 문제점은 zoom이나 pan과 같은 인터랙션이 발생해도 Overlay 엘리먼트가 계속 동일한 자리에 고정되어 있다는 점인데, 이 것은 OpenSeadragon의 핸들러를 사용해서 엘리먼트의 위치를 계속 갱신해주어야 한다.

Viewport Change 인터랙션 대응

앞서 말한 zoom 혹은 pan과 같은 동작을 OpenSeadragon에서는 viewport-change 이벤트로 핸들링한다. 따라서 Overlay 엘리먼트의 위치가 viewport-change 이벤트가 발생했을 때마다 수정하는 코드를 작성하면 된다.

우선 작업을 편하게 진행하기 위해, 아래와 같이 코드를 살짝 수정하였다.

Initial Version

function App() {
  ...

  return (
    <>
      <div id="osd-viewer" style={{ width: 400, height: 300 }} />
      {viewer && (
        <Portal container={viewer.canvas}>
          <Overlay viewer={viewer} />
        </Portal>
      )}
    </>
  );
}
App.tsx
function Portal({ children, container }) {
  return createPortal(children, container);
}
Portal.tsx
function Overlay({ viewer }) {
  return (
    <div
      style={{
        position: "absolute",
        width: 100,
        height: 100,
        backgroundColor: "gray"
      }}
    >
      Hello, world!
    </div>
  );
}
Overlay.tsx

Overlay 컴포넌트의 viewer는 이벤트 핸들러를 등록하고, 컴포넌트의 위치를 계산하는데 사용되는 메소드에 접근하기 위해 필요하다. 가장 먼저 이벤트 핸들러를 등록하는 것부터 적용하면 다음과 같다.

Applying Event Handler

function Overlay({ viewer }) {
  useEffect(() => {
    const handleViewportChange = () => {
      // handler contents
    };
    viewer.addHandler("viewport-change", handleViewportChange);

    return () => {
      viewer.removeHandler("viewport-change", handleViewportChange);
    };
  }, [viewer]);

  return (
    <div
      style={{
        position: "absolute",
        width: 100,
        height: 100,
        backgroundColor: "gray"
      }}
    >
      Hello, world!
    </div>
  );
}
Overlay.tsx

컴포넌트가 마운트되었거나 viewer 오브젝트가 변경되었을 때, useEffect 훅을 통해서 handleViewportChange 핸들러가 OpenSeadragon에 등록된다. 다음으로, 이벤트가 발생할 때마다 컴포넌트의 위치를 갱신할 수 있도록 코드를 추가해야 한다.

Handling Viewport-Change Event

function Overlay({ viewer, top, left }: OverlayProps) {
  const [windowCoord, setWindowCoord] = useState<OpenSeadragon.Point>(() =>
    viewer.viewport.viewportToViewerElementCoordinates(new Point(left, top))
  );

  useEffect(() => {
    const handleViewportChange = () => {
      setWindowCoord(
        viewer.viewport.viewportToViewerElementCoordinates(new Point(left, top))
      );
    };
    viewer.addHandler("viewport-change", handleViewportChange);

    return () => {
      viewer.removeHandler("viewport-change", handleViewportChange);
    };
  }, [viewer, top, left]);

  return (
    <div
      style={{
        position: "absolute",
        top: `${windowCoord.y}px`,
        left: `${windowCoord.x}px`,
        width: 100,
        height: 100,
        backgroundColor: "gray"
      }}
    >
      Hello, world!
    </div>
  );
}
Overlay.tsx

컴포넌트 외부에서 top 값과 left 값을 받아오되, 이 값들은 그대로 사용되는 것이 아니라 Viewport.viewportToViewerElementCoordinates라는 복잡한 이름의 메소드를 통해 변환 과정을 거치게 된다. 이 것은 OpenSeadragon이 여러 종류의 coordinates를 내부적으로 관리하고 있기 때문이다.

최종적으로 적용이 완료된 코드의 형태는 다음 예제 링크와 같다:

Portal 예제: https://codesandbox.io/s/osd-overlay-portal-5cpzyf
최종적으로 React Portal을 적용한 상태

이제 Overlay 엘리먼트는 React 라이프사이클을 따르면서도, OpenSeadragon에서 인터랙션이 발생할 때마다 위치 및 크기가 자유자재로 변화한다. 이번 예제에서 엘리먼트의 크기를 바꾸는 것까지 다루지는 않았지만, 이것 또한 앞선 코드를 약간 수정하는 것으로 쉽게 응용할 수 있다.

또한 Overlay 엘리먼트는 임의의 React 컴포넌트일 수 있기 때문에, Material UI와 같은 디자인 시스템을 쉽게 엘리먼트에 적용할 수 있다. 가장 큰 장점은, 이 방법을 통해 쌓여만 가던 기술 부채를 해결할 수 있었다는 점 아닐까 싶다.