커스텀 대시보드를 위한 Scene

이원배 · 원프레딕트 프론트엔드 엔지니어
November 29, 2023

Intro.

Onepredict Frontend 팀은 설비의 상태를 예측하고 진단할 수 있는 예지보전 솔루션을 위한 대시보드 개발을 하고 있습니다.

B2B 사업의 특성상 고객사의 요청 사항에 대응하기 위한 커스터마이징 필요한데, 동일한 API를 사용하더라도 어느 고객사는 테이블로 보여주고 어느 고객사는 그래프로 보여주는 식으로 말이죠.

모든 요청 사항을 개발하다 보면 대시보드가 비대해지거나 git에서 고객사별 branch가 파편화되어 생성될 수도 있습니다. 이렇게 쌓이다 보면 결국 비대해진 프로젝트는 복잡하고 유지보수하기 어려운 코드가 만들어지고, 개발의 퍼포먼스가 줄어들게 됩니다.

저희는 이러한 문제들에 대해서 고민하였고 고객사들의 커스터마이징에 유연하게 대처할 수 있고 현장에서도 가능하다면 빠르게 대응할 방안을 고민한 끝에 Scene이라는 메타포를 만들었습니다.

Scene 이란?

Scene은 canvas처럼 빈 Box Layer로 우리가 그리고 싶은 Element를 배치하여 대시보드 화면을 만들어 낼 수 있습니다. 이 모든 Scene은 Backend로부터 전달받아 렌더링이 이루어지기 때문에 프론트엔드의 배포 없이도 대시보드 화면을 수정하고 변경하는 게 가능해집니다.

Element 란?

Scene 내부에서 렌더링할 때 필요한 Component를 Element라 부릅니다. Element는 몇 가지의 규칙으로 만들어집니다. Element는 독자적으로 렌더링할 수 있어야 하고 내부적으로 API에 대한 참조는 일어나지 않습니다. 모든 데이터는 Prop을 통해 전달받습니다.

Scene 만들어 보기

이제 간단한 Text Element를 만들어 보면서 Scene이 어떻게 구성되어 있고 Element를 어떻게 만드는지 알아보도록 하겠습니다.

Scene은 JSON으로 구성됩니다. 그리고 각 Scene은 단일 JSON 파일들로 하나의 Box Layer를 렌더링할 수 있는 메타 데이터들을 가지고 있습니다.

{
  "sceneId": 1,
  "layout": {
    "width": "100px",
    "height": "100px",
  },
  "elements": [...]
}

위의 JSON 은 1이라는 ID를 가지는 Scene의 예로 sceneId는 Scene 의 고유한 ID를 나타내며 프론트엔드에서 Scene을 구분하기 위한 Unique ID로 사용됩니다. layout은 Scene의 전체적인 layout을 설정 할 수 있으며 elements는 Scene에 렌더링 될 Element의 리스트 입니다.

각각의 Element는 고유한 type을 가지며 type을 통해 렌더링할 Element를 선택할 수 있습니다.

{
  "sceneId": 1,
  "layout": {
    "width": "100px",
    "height": "100px",
  },
  "elements": [{
    "type": "Text",
    "layout": {
      "top": "20px",
      "left": "20px",
    },
    "text": "Hello, Scene"
  }]
}

위의 JSON에서는 Text라는 type의 Element를 렌더링할 수 있습니다. Scene의 Box Layer 안에서 top: 20px left: 20px 로 Text Element의 위치를 설정하고 해당 위치에 Hello, Scene이라는 Text를 렌더링합니다.

끝입니다!

그럼, Element는 어떻게 되어 있을까요?

Text Element 만들기

Element는 Scene에서 렌더링 되는 Component입니다.

// Text.tsx
type TextProps = {
  text: string;
} & ElementProps;

const Text = (props: TextProps) => {
  const { text, layout } = props;
  
  return <div style={layout}>
    <span>{text}</span>
  </div>
}

export default Text;

Element는 위와 같이 만들 수 있습니다. 그리고 Element를 Scene에서 읽을 수 있도록 elementMap에 등록합니다.

// elementMap.ts
import Text from './Text';

export const elementMap = {
  Text,
};

Scene에서 Text 엘리먼트를 읽을 수 있는 준비가 되었습니다. 이제 Scene에서 Element를 읽고 렌더링하면 됩니다.

// Scene.tsx
import { elementMap } from './elementMap';

type SceneProps = {
  // Scene JSON
  scene?: SceneType;
} & ElementProps;

const Scene = (props: SceneProps) => {
  const { scene } = props;
  const { sceneId, elements, layout } = scene ?? {};

  return <div style={layout}>
    {elements.map(({ type, ...elementProp }, i) => {
      const key = `${sceneId}_${type}_${i}`;
      
      const Component = elementMap[type];
      
      return <Component key={key} {...elementProp} />
    })}
  </div>
}

export default Scene;

위에서 Scene은 어떤 Element를 렌더링해야 할지 알지 못합니다. 그저 전달받은 elements를 렌더링 하는 역할만 할 뿐입니다. 이로써 간단한 Text를 렌더링하는 Scene을 만들었습니다.

그렇다면 Element로 데이터 연결은 어떻게 하는 걸까요?

여기에 대해서 저희는 Channel이라는 메타포를 생각했습니다. 모든 데이터는 Channel Id를 가지고 Channel의 Signal을 통해 데이터를 조회할 수 있습니다. Channel에 대한 더 자세한 설명의 이 글의 범위를 벗어나는 것 같아 여기까지 하고 Element에서 Channel을 연결하여 데이터를 보여주는 방법에 대해서 알아보도록 하겠습니다.

간단한 예로 Channel을 연결한 Element를 만들어 보겠습니다.

아래는 데이터를 보여주기 위해서 RPM주1이라는 Element 가 있습니다.

// RPM.tsx
type RPMProps = {
  // RPM Channel
  rpm: ChannelType;
} & ElementProps;

const RPM = (props: RPMProps) => {
  const { rpm: rpmChannel, signals } = props;
  
  const rpm = signals[rpmChannel.channelId];
  
  return <div>
    {rpm}
  </div>
}

export default RPM;

위의 RPM의 prop은 ChannelType의 rpm 값을 prop으로 전달받습니다. 이 ChannelType은 channelId property를 가지고 있습니다. 여기서 channelId를 가지고 signals prop에서 우리가 필요한 Channel의 Signal을 찾아 렌더링합니다. 그렇다면 이 signals는 어디서 오는 걸까요?

그건 위의 Scene에서 signals 데이터를 그대로 하위로 전달 해주고 있습니다.

// Scene.tsx
import { elementMap } from './elementMap';

// ...

const Scene = (props: SceneProps) => {
  const { scene, signals } = props;
  const { sceneId, elements, layout } = scene ?? {};

  return <div style={layout}>
    {elements.map(({ type, ...elementProp }, i) => {
      const key = `${sceneId}_${element.type}_${i}`;
      
      const Component = elementMap[type];
      
      return <Component key={key} {...elementProp} signals={signals} />
    })}
  </div>
}

export default Scene;

위의 Scene 구현체에서 달라진 건 props에서 signals를 받아 이 signals를 하위 Element로 전달하고 있습니다. 즉, Scene을 구현하는 Parent Component로부터 모든 Scene 정보와 Signal 정보를 받아 렌더링할 모든 Element로 전달합니다. 이렇게 되면 Element에서는 어떤 Channel Id에서 데이터를 가져와야 하는지만 알 뿐 거기서 어떤 데이터가 나오는지는 알지 못합니다. 그저 전달받은 Channel Id로 렌더링할 뿐입니다.

마무리.

간단하게 이번 저희 Frontend 팀에서 진행했던 프로젝트에 적용한 Scene에 대해서 알아봤습니다. Scene을 작업하며 느꼈던 장점은 Element 간의 의존성이 낮으므로 Element의 변경이 쉽다는 장점이 있었습니다. 하지만 Scene 은 대시보드 전체 영역에서 하나의 영역을 그리기 위한 용도이고 전체적인 레이아웃을 책임지지는 않습니다. 이 문제에 대한 해답을 또 고민하고 해결해야 할 수도 있습니다.


  1. revolutions per minute의 약자입니다.
원프레딕트는 더 나은 제품을 고민하며 기술적인 문제를 함께 풀어낼 동료를 찾고 있습니다.
자세한 내용은 채용 사이트를 참고해 주세요.