블로그 포스팅에 PlantUML을 사용할 수 있을까?


요약

PlantUML 다이어그램을 블로그 포스팅 안에 잘 넣어보자. 되긴 하는데 고쳐야 할 점이 많다.

v2 추가: v1에서 만들었던 구현체에 이제 inline plantuml을 넣어서 바로 렌더링할 수 있도록 했다.

목표

요렇게 넣으면 쨘 하고 나오는 걸 만들고 싶다.

```plantuml
@startuml
Bob -> Alice : hello
@enduml
```

아니면

<PlantUML>
@startuml
Bob -> Alice : hello
@enduml
</PlantUML>

배경

(이하 Copilot이 작성한 PlantUML 소개)

PlantUML은 다이어그램을 작성할 수 있는 도구이다. 다이어그램을 작성할 때는 다양한 도구를 사용할 수 있지만, PlantUML은 텍스트 기반으로 작성할 수 있어서 코드를 작성하는 것과 다이어그램을 작성하는 것을 동시에 할 수 있다. 또한, 다양한 형태의 다이어그램을 작성할 수 있어서 다양한 용도로 사용할 수 있다.

삽질의 흔적

나이브한 React SSR 컴포넌트 렌더링

<PlantUML />
export function PlantUML() {
  const [image, setImage] = useState<string | null>(null);
  useEffect(() => {
    (async () => {
      setImage(await plantUML(
        '@startuml\n' +
        'Bob -> Alice : hello\n' +
        '@enduml'
      ));
    })();
  });

  if (!image) return <div>Loading...</div>;

  return <div>
    <img src={`data:image/svg+xml;utf8,${image}`} />
  </div>;
}

결과물

Loading...

안댐. 서버 사이드 렌더링 시점엔 이미지가 생성되어 있지 않고 Loading 상태이다.

React client side rendering

<PlantUML client:only="react"/>

결과물

되겠냐;;

plantuml은 java 바이너리를 이용하기 때문에 바로 브라우저에서 실행할 수 없다.

React 18 Suspense를 이용해봄?

사실 Hooks API 이후로 그동안 내가 React 공부를 소홀히 했는데, 이번에 나이브한 구현을 하다 보니 불만이었던 점이 그릴 준비가 된 시점을 정할 수 없다는 점이었다. 기본적으로 React 컴포넌트는 이미 준비된 상태를 가정한다 - Functional component의 경우 초기 render 시점에 async 함수를 사용할 수 없고, class component 또한 render() 함수가 항상 준비되어 있는 것을 가정한다. 그러다 보니 SSR을 하는 시점에 이미지가 준비되지 않았으니 Loading... 텍스트를 그대로 보여주게 된다.

멋도 모르고 조사하다 보니 Streaming Server Rendering with Suspense라는 발표를 보게 되었는데, 원래는 SSR하는 각 컴포넌트를 개별적으로 준비해서 그릴 수 있도록 Suspense라는 컴포넌트를 사용하는 것을 제안하고 있다. 혹시나 싶어서 이 방법을 적용해 보았다.

// svg가 준비된 이후 그려줌
function PlantUMLSuspenseInner({ svg }: { svg: string; }) {
  return <div>
    <img src={`data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`} />
  </div>;
}

// svg를 생성해서 그린다. 근데 바로 그리는 건 아니고 promise로 그려준다.
export const PlantUMLWithLazy = lazy(async () => {
  const svg = await plantUML(
    '@startuml\n' +
    'Bob -> Alice : hello\n' +
    '@enduml'
  );
  return {
    default: () => <PlantUMLSuspenseInner svg={svg} />
  };
});

// PlantUMLWithLazy가 준비될 때까지 Loading...을 보여주며 존버한다.
export function PlantUMLWithSuspense() {
  return <Suspense fallback={<div>Loading...</div>}>
    <PlantUMLWithLazy />
  </Suspense>;
}
<PlantUMLWithSuspense/>

가만있어보자, Suspense 없이도 lazy component 하면 되는거 아님?

예제를 따라해서 만들어놓고 보니, 사실 내가 작성하는 플랫폼은 SSR을 얼마든지 기다려줄 수 있는 static website generator 환경이다. 그러니 번거롭게 Suspense 를 써가면서 “아이고 조금만 기다려주시면 제가 따끈한 컴포넌트 대령해드리겠습니다”할 필요 없이, 그냥 방망이 깎는 노인마냥 “기다리쇼” 해도 빌드 프로세스는 넵… 하고 얌전히 기다려줄 수 있다는 것이다.

<PlantUMLWithLazy />

이게 되네?

근데, 왜 React를 써야돼?

사실 astro 자체 템플릿은 async/await를 기본으로 제공하기 때문에 그냥 쓰면 알아서 준비되는 걸 기다려서 그려준다.

---
import { default as plantUML } from 'plantuml';

const svg = Buffer.from(await plantUML(
  '@startuml\n' +
  'Bob -> Alice : hello\n' +
  '@enduml'
)).toString('base64');
---
<img src={`data:image/svg+xml;base64,${svg}`} class="m-0" />

Inlining PlantUML

여태까지는 PlantUML을 그리는 데 급급했다. 이제 그리는 데 성공했으니 원래 목표했던 대로 PlantUML 코드를 태그 안에 바로 넣어서 그릴 수 있도록 해 보자.

---
import { default as plantUml } from 'plantuml';

const slot = (await Astro.slots.render('default')).replaceAll(
  '&gt;', '>').replaceAll('&lt;', '<').replaceAll('&amp;', '&')
  .replaceAll('&quot;', '"').replaceAll('&apos;', '\'')

const svg = await plantUml(slot);
const base64 = Buffer.from(svg).toString('base64');
---
<img src={`data:image/svg+xml;base64,${base64}`} class="m-0" />

결론

오늘의 배움: 컴포넌트가 초기 데이터가 있어야만 뭔가 제대로 그릴 수 있는 상황이라면, useEffect 대신에 lazy component로 그 초기 데이터를 준비하는 것이 더 SSR에 친화적인 구현 방안이 될 수 있다.

하지만

React를 이용한 이미지 렌더링은 svg를 data URI형태로 인코딩해서 사용하기 때문에, 결과적으로 생성된 HTML 파일에 거대한 svg blob이 임베딩되는 결과가 된다. 가능하면 svg 파일을 별도로 저장할 수 있으면 더 좋을 것 같고, 추가적으로 가능하면 webp 등으로 변환도 가능하면 더 좋을 것 같다.