⚗️ 프로젝트#Web
Notion으로 나만의 블로그 만들기
2025-01-17 | 🕒 읽는 데 0분 예상작성자
u
카테고리
⚗️ 프로젝트
작성일
‣
태그
Web
설명
상태
배포됨
최하위 정렬
최하위 정렬
forest_분류
forest_날짜
왜 Notion인가요?
개발 블로그를 운영하기로 결심했다면 선택지는 크게 두 가지이다. 첫 번째는 네이버 블로그, 티스토리와 같은 이미 잘 만들어진 블로그 플랫폼을 사용하는 것이다. SEO(검색엔진 노출 최적화)나 디자인 등 블로그 관리는 크게 신경쓰지 않고 글을 작성하는 본질에 집중할 수 있다는 점이 최대 강점이다. 두 번째는 Github Pages 등을 통해 자체 블로그를 만드는 방법이다. 어느정도의 개발 지식이 필요하긴 하지만, 자유로운 커스텀이 가능하다는 점이 장점이다.
그러나, 한 가지 문제가 있다. Jekyll로 대표되는 정적 웹사이트 생성기를 이용하여 블로그를 만들게 되는 경우가 대부분인데, 이는 Markdown 형식의 문서를 사용해야 한다. Markdown 문법을 익히는 것은 둘째치고, 새로운 글을 작성하거나 수정하기 위해서는 프로젝트 폴더를 열어 직접 markdown 파일을 고쳐야 한다는 것이다. 생각나는 것을 빠르게 작성할 수 있는 ‘접근성’이 매우 떨어진다.
Notion 기반의 블로그는 이러한 단점을 어느 정도 해소할 수 있다. 브라우저나 스마트폰의 Notion 앱을 열어 글을 작성하기만 하면, 블로그 사이트에도 자동으로 반영되도록 할 수 있는 것이다.
Notion 구조 파헤치기
노션의 기본 구조는 블록(Block)이다. 블록은 노션에서 콘텐츠를 구성하는 가장 작은 단위이다. 텍스트, 이미지, 동영상, 데이터베이스 등 모든 요소가 각각의 블록으로 취급된다.
에디터에서
/
를 누르면 notion에서 지원하는 블록 종류를 살펴볼 수 있다. 이 말은 즉슨, 특정 노션 페이지를 렌더링하는 것인 그리 단순한 일이 아니라는 것이다.
Notion 페이지 렌더링하기
Notion 페이지 정보를 불러오는 API 응답을 살펴보자.
먼저, 앞서 보았듯이 노션의 페이지는 블록들의 집합으로 이루어져 있으므로 아래의 첫 번째 사진과 같이 page를 구성하는 block들의 목록을 얻을 수 있다. 그러나 여기에는 block들의 각 id만이 있을 뿐, 내용은 담겨있지 않다. 각 블록들에 접근하기 위해서는
recordMap
을 살펴봐야 하는데, 여기에는 모든 블록들의 정보가 담겨있다.아래 사진을 예로 들어보면, 첫 번째 사진에서 가장 처음 나온 block id를
recordMap
에서 찾아보면 두 번째 사진과 같이 “K6란?”이라는 텍스트를 포함해 해당 블록이 담고 있는 세부적인 정보를 얻을 수 있는 것이다.여기까지 내용을 정리해보면, notion 페이지를 렌더링하기 위해서는
recordMap
을 통해 page를 구성하는 각 블록의 정보를 분석하고, 각 블록의 내용을 HTML 컴포넌트로 렌더링해야 한다.다행히도 이 과정을 대신 수행해주는 react-notion-x라는 라이브러리가 있다. 이 라이브러리의 핵심 컴포넌트인
NotionRenderer
는 Notion API로부터 받아온 페이지 데이터(recordMap)를 입력받아 HTML로 변환해준다.간단한 예제 코드를 통해 라이브러리 사용법을 살펴보면 다음과 같다. Notion API를 통해 page 데이터를 불러와
recordMap
을 얻고, 이 recordMap
을 NotionRenderer
에 전달하면 된다.import { NotionAPI } from 'notion-client' import { NotionRenderer } from '../packages/react-notion-x' const notion = new NotionAPI() const recordMap = await notion.getPage(pagId) return ( <NotionRenderer recordMap={recordMap}/> )
ISR 적용하기
여기서 한 가지 문제가 있다. 이렇게 Notion Page의 각 블록들을 렌더링하는 과정에서 복수의 API 호출이 발생하기 때문에 속도 측면에서 빠르지 못하다. 이를 개선하기 위해서 next.js에서 제공하는 ISR를 사용할 수 있다.
Next.js의 렌더링 방식은 크게 다음과 같이 나눌 수 있다.
CSR
(클라이언트 사이드 렌더링) : 사용자의 브라우저에서 렌더링 됨. 화면이 로딩되는 것이 사용자 눈에 보임.
SSR
(서버사이드 렌더링) : 프론트엔드 서버가 사용자가 접속할때마다 새로운 페이지를 생성해내는 방식. 최신 정보를 유지할 수 있지만, 화면 깜박임이 발생할 수 있음.
SSG
(정적 생성) : 역시 프론트엔드 서버가 렌더링을 담당하지만, build 과정에서 미리 렌더링된 페이지를 생성하기 때문에 build 시점 이후 변경사항을 보여주지 못함.
ISR(증분 정적 재생)
SSR, SSG와 마찬가지로 프론트엔드 서버가 렌더링을 담당하는 서버 사이드 렌더링 방식이다. 차이점은 ISR을 사용하면 전체 사이트를 재구성할 필요 없이 페이지 단위로 정적 생성을 사용할 수 있다. 즉, 정적생성으로 미리 생성된 페이지도 변경 사항이 있는 경우 다시 생성할 수 있는 것이다. 따라서 정적 생성의 장점을 취하면서도 최신 정보를 어느정도 유지할 수 있게 된다.
실제 블로그 페이지를 보여주는 코드를 간추린 아래 코드를 통해 ISR 사용법을 살펴보자.
import type { GetStaticPaths, GetStaticProps } from 'next' export async function getStaticPaths() { const data = await getDatabase("<notion database id>"); const paths = data.results.map((page) => ({ params: { pageId: page.id } })); return { paths, fallback: 'blocking' } } export async function getStaticProps({ params }) { const { pageId } = params; const notion = new NotionAPI() const recordMap = await notion.getPage(pageId) return { props: { pageId, recordMap }, revalidate: 10 } } export default function Page({ pageId, recordMap }) { return ( <NotionRenderer recordMap={recordMap}/> ) }
먼저,
getStaticPaths
에는 build 시에 정적 생성할 경로들을 지정해준다. 위의 예시에서는 포스팅 목록을 반환하는 API를 호출하여 각 post.id
를 params
으로 지정해주었다. params
값은 페이지 이름에 있는 파라미터와 일치해야 한다. 예를 들어 페이지를 가리키는 파일명이 pages/post/[pageId].js
라면, params
역시 pageId
값을 가지고 있어야 한다. 여기서
fallback
은 build 시에 생성하지 않은 경로로의 요청을 처리할 방법을 결정하는 값이다. 옵션은 아래와 같이 세 가지가 있다.false
: 정의된 경로만 생성, 나머지는 404 반환
true
: 정의되지 않은 경로는 fallback 페이지 제공 후 백그라운드에서 생성
blocking
: 정의되지 않은 경로는 생성 완료 후 HTML 반환, SSR과 유사
getStaticProps
에서는 해당 페이지에 대한 정적 생성을 수행한다. revalidate
값은 어떤 사용자가 페이지에 접근한 이후 몇 초 후에 정적생성을 진행할 지를 나타내는 값이다. 예를 들어 revalidate
값이 60
이고, build 타임에 이 페이지에 대한 정적 생성(”Hi”)이 이루어졌으나 이후 수정된 내용(”Bye”)이 있다고 가정해보자.- 0s: 사용자 A가 페이지 진입 → 현재 정적 생성되어 있는 “Hi”가 페이지에 표시
- 30s: 사용자 B가 페이지 진입 → 여전히 “Hi”가 페이지에 표시
- 60s: 새로운 정적 페이지가 생성되어 캐시 갱신됨
- 65s: 사용자 C가 페이지 진입 → 새롭게 생성된 “Bye”가 페이지에 표시
이렇게 하면, build 과정에서 모든 id값에 해당하는 페이지가 정적 생성되며, 이후 사용자가 페이지에 접속할 때마다 특정 시간 후 최신 정보가 반영된 새로운 정적 페이지가 생성되고 다음 사용자부터 최신 페이지를 볼 수 있을 것이다.
ISR에 대한 더 자세한 내용은 공식 문서를 참고하길 바란다.
백링크 추가하기
필자는 Forest라는 이름의 디지털 가든을 만들고 싶었다. 이 Forest에서 가장 중요한 것은 지식의 연결이다. 이를 위해서는 문서 간의 관계가 잘 표현되어야 하는데, 기존에는 단지 문서 본문에 링크를 거는 방식으로만 구현되어 있었다. 이 문서가 다른 문서에서 언급되고 있는지는 알 수 있는 방법이 없었던 것이다. 이는 단방향적인 관계만을 나타내는 것이다. 특정 문서가 언급된 다른 문서들을 나타내기 위해서는 Notion에서 제공하는 Backlink를 이용하면 된다. 그러나 이 Backlink에 대한 정보는 Notion 편집 화면에서 권한이 있는 작성자에게만 표시될 뿐, 외부에 배포된 페이지에는 표시되지 않는다. 이 블로그는 Publish된 노션 페이지를 이용하는 방식으로 구현된 비공식 라이브러리를 사용하고 있으므로, 이를 지원할리가 만무하니 직접 구현해야 했다.
우선, Notion 편집 화면에서 개발자 도구를 열어 쿠키 값들 중
token_v2
를 가져와 환경 변수로 저장해주고, 이를 fetch
요청 시 header에 넣어주는 방식으로 인증을 우회하였다. 이 token은 일정 시간이 지나면 만료되기 때문에 주기적으로 갱신해줘야 한다는 것은 한계점이다. 그래도 1년 정도로 만료 기간이 넉넉하게 설정되어 있는 것으로 추정된다. Backlink 정보를 얻기 위해서는 https://www.notion.so/api/v3/getBacklinksForBlock
주소로 POST 요청을 보내면 된다. 응답은 다음과 같은 형식이다.먼저
backlinks
에는 이 문서가 언급된 다른 문서의 Block 정보가 mentioned_from
배열에 들어있다. 상기했듯이 Block이란 Notion에서 한 페이지(문서 1개)가 아니기(단락이라 생각하면 편하다) 때문에 언급된 페이지 링크와 제목 등 정보를 얻을 수 없다. 따라서 recordMap
을 살펴봐야 한다. 여기에는 우리가 필요로 하는 블록들이 모여있는 완전한 한 페이지를 나타내는 블록 역시 들어있을 것이다. 그리고 이 정보에는 블록의 타입을 나타내는 type
과 부모 블록 id인 parent_id
가 함께 반환된다. 이제, mentioned_from
에 있는 각 block id에서 출발해 type
이 page
인 블록에 도달할 때까지 부모 블록으로 계속 이동해주면 된다.let currentBlock = block; while (currentBlock) { if (currentBlock.type === 'page') { const title = currentBlock.properties?.title?.[0]?.[0].toString(); const link = normalizeTitle(title) + '-' + currentBlock.id + '#' + block.id.replaceAll('-', ''); return res.status(200).json({ title, link }); } // 다음 parent block 찾기 const parentId = currentBlock.parent_id; currentBlock = recordMap.block[parentId]?.value; // parent block을 찾을 수 없는 경우 종료 if (!currentBlock) { return res.status(404).json({ error: 'Root block not found' }); } }
짜잔, 이렇게 해서 아래와 같이 ‘이 문서를 언급한 다른 문서들’을 표시해줄 수 있게 되었다.
문서 간의 관계 그래프로 시각화하기
우선 만들고 싶은 결과물은 이거다. Notion과 비슷한 도구인 Obsedian에서 제공하는 기능인데, 문서들 간의 연결 관계를 그래프로 시각화하여 보여주는 기능이다.
데이터 뽑아내기
기본적인 원리는 위의 백링크 구현하기와 비슷하다. 근데 이제 그걸 모든 문서에 대해 진행하며 관계를 지정해주면 되는 것이다. 이 귀찮은 작업을 구현해둔 라이브러리가 존재하여 가져다 사용하였다. graphcentral/notion이라는 라이브러리이다. Github에 나와있는 예제 코드를 실행하면 다음과 같은 형식의 JSON 파일을 얻을 수 있다.
{ nodes: Node[] links: Link[] errors: Error[] }
nodes
에는 각 페이지의 id
, 부모 노드의 id인 parentId
, spaceId
등 정보가 담겨있다. 여기서 spaceId는 workspace id이기 때문에 어차피 모든 노드가 동일하다. 따라서 우리는 parentId
만 사용할 예정이다.links
에는 source
와 target
으로 이루어진 객체들이 반환된다. source
노드와 target
노드를 잇는 선을 나타내는 데이터이다.자동화하기
이를 시각화하기에 앞서 해결해야 하는 과제가 있다. 새로운 글을 쓸 때 마다 컴퓨터를 켜서 이 node 스크립트를 실행해서 JSON 파일을 생성할 수는 없는 노릇이다. 따라서 자동화가 필요하다.
여기에는 Github Actions 기능을 활용했다. Github Action은 Github에서 공식적으로 제공하는 CI/CD 툴이다. Repository의
.github/workflows
폴더 안에 .yml
형태로 파일을 작성하여 생성할 수 있다.name: Daily Notion Graph Update on: schedule: - cron: '0 0 * * *' # Runs at 00:00 UTC every day workflow_dispatch: # Allows manual triggering jobs: update-notion-graph: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '14' - name: Install dependencies run: | cd notion-graph-generator npm install - name: Run Notion Graph script run: | cd notion-graph-generator node index.js - name: Upload JSON as artifact uses: actions/upload-artifact@v3 with: name: notion-graph path: notion-graph-generator/notion-graph.json - name: Commit and push if changed run: | git config user.name 'github-actions[bot]' git config user.email 'github-actions[bot]@users.noreply.github.com' git add notion-graph-generator/notion-graph.json git diff --quiet && git diff --staged --quiet || (git commit -m "action: update notion graph" && git push)
매일 0시(UTC)에 자동으로 작업이 실행되도록 하였다.
steps
을 살펴보면, 먼저 현재 repository에 접근하여 정해진 branch에 checkout하고, node.js를 설치하며 필요한 패키지를 설치하는 등 환경 구성을 진행한다. 이후 Run Notion Graph script
에서 node.js 스크립트를 실행해 JSON 파일을 생성한다. 생성한 파일을 artifact
기능을 사용하여 CI 서버에 업로드하고 해당 파일을 Commit, Push한다. 이렇게 하면 Github의 RAW 링크를 통해 JSON 파일에 항상 접근할 수 있고, 이 파일은 매일 자동으로 업데이트된다.시각화하기
그래프를 시각화하기 위해 d3.js라는 라이브러리를 사용하였다. 특히
d3-force
모듈을 이용하면 노드-링크 구조의 네트워크를 시각화하고, 각 노드간의 충돌과 같은 물리적 시뮬레이션을 구현할 수 있다.import { useEffect, useRef } from 'react'; import * as d3 from 'd3'; const Graph = ({ data }) => { const svgRef = useRef(null); useEffect(() => { if (!data || !svgRef.current) return; const svg = d3.select(svgRef.current); const width = window.innerWidth; const height = window.innerHeight * 0.8; svg.attr('width', width).attr('height', height); svg.selectAll('*').remove(); const simulation = d3.forceSimulation(data.nodes) .force('link', d3.forceLink(data.links).id(d => d.id).distance(100)) .force('charge', d3.forceManyBody().strength(-300)) .force('center', d3.forceCenter(width / 2, height / 2)); const link = svg.append('g') .selectAll('line') .data(data.links) .join('line') .attr('stroke', '#999') .attr('stroke-opacity', 0.6); const node = svg.append('g') .selectAll('circle') .data(data.nodes) .join('circle') .attr('r', 5) .attr('fill', '#69b3a2'); node.append('title') .text(d => d.id); simulation.on('tick', () => { link .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); node .attr('cx', d => d.x) .attr('cy', d => d.y); }); }, [data]); return <svg ref={svgRef} />; }; export default Graph;
JSON에서
node
, link
값들을 불러와 각각 circle
, line
으로 표현하도록 하였다.팟캐스트 기능 추가하기
처음에는 Elevenlabs의 Voice-cloning 솔루션을 사용하여 게시글을 내 목소리를 학습한 AI 음성으로 읽어주는 기능을 구현해두었다. 그런데 이를 위해서는 매달 5달러를 내야 한다. 방문자도 별로 없는데… 7천원이면 스타벅스 자바칩 프라푸치노를 마실 수 있다.
그래서 어느새부터는 내 목소리 클론 모델이 아닌 기본 제공 모델로 대체해 음성으로 듣기 기능을 제공하고 있었다. 그러나 내 목소리를 내는 TTS, 이거 포기하기에는 매우 재밌는 뻘짓이다. 사실 이미 Elevenlabs와 같은 외부 솔루션이 아니라 로컬에서 모델 학습부터 추론까지 내 목소리로 학습시켜 구현해둔 프로젝트가 있긴 하다. 놀랍게도 고등학교 때 생기부에 쓴 내용이다. 하지만 이게 벌써 2년 가까이 지났으니 최근 나온 zero-shot Voice Cloning 모델들에 비하면 품질이 현저히 떨어진다. 당시에 30분에 달하는 음성을 녹음하여 학습시켰는데도 말이다(이것도 권장 분량보다 적은 거였다). 따라서 새로운 오픈소스 모델을 사용해 TTS를 구현해보기로 했다.
Voice Cloning 구현하기
먼저 찾은 모델은 coqui의 XTTS-v2라는 모델이다. 10초 내외의 짧은 레퍼런스 오디오를 통해 보이스 클론이 가능하다. 다만 단점이라면 한글 기준 한 번에 생성할 수 있는 문장은 95자 이하라는 것이다. 이를 해결하기 위해 긴 텍스트를 입력받아 95자 이하의 세그먼트로 분할하고, 각 세그먼트에 대해 문장을 생성하여 다수의 음성 파일들을 생성한다. 이후 이 음성 파일들을 순서에 맞게 병합하여 하나의 오디오 파일로 만드는 방식이다.
# 텍스트를 </ttsSplit> 단위로 분리하는 함수 def split_text(text): sentences = text.split("<ttsSplit/>") return [sentence.strip() for sentence in sentences if sentence.strip()] reference_audios = ["./ref.wav"] # 출력 폴더 생성 (없으면) 및 기존 파일 삭제 # (생략) # 긴 텍스트를 문장 단위로 분리 sentences = split_text(text_to_speak) # 각 문장에 대해 음성을 합성하고 개별 파일로 저장 for idx, sentence in enumerate(sentences): output = model.synthesize( sentence, config, speaker_wav=reference_audios, gpt_cond_len=45, language="ko", ) wav_data = output['wav'] out_file_path = os.path.join("./outputs", f"output{idx + 1}.wav") wavfile.write(out_file_path, 24000, wav_data) # ./outputs 폴더에 있는 wav 파일들을 output뒤에 있는 숫자 순서대로 병합하여 하나의 wav 파일로 생성 print("오디오 병합 시작") output_files = [f for f in os.listdir("./outputs") if f.startswith("output") and f.endswith(".wav")] output_files.sort(key=lambda f: int(re.search(r"output(\d+)\.wav", f).group(1))) silence = AudioSegment.silent(duration=500) combined_audio = AudioSegment.empty() for file in output_files: audio_segment = AudioSegment.from_wav(os.path.join("./outputs", file)) combined_audio += audio_segment combined_audio += silence # 각 audio segment 뒤에 0.5초 묵음 추가 combined_audio_speedup = combined_audio.speedup(playback_speed=1.1) combined_audio_speedup.export("./outputs/combined_output.wav", format="wav")
더 좋은 품질의 음성을 합성하기 위해서는 텍스트 정규화(Normalization)이 필요하다. 예를 들어 “1984년”이라는 텍스트가 있다면 이를 “천구백팔십사년”으로 변환해주는 과정을 말한다. 이를 별도의 규칙 기반 또는 신경망 기반 모델을 이용하여 진행할 수도 있지만, 비용과 품질 등에서의 효율을 위해 LLM을 사용하기로 했다. Google AIStudio의 Gemini Experimental 1206 모델을 사용하였다. 사용한 System Prompt는 아래와 같다.
한국어 텍스트를 입력받아 다음과 같은 규칙에 따라 정규화된 결과를 반환한다: - 숫자, 영어 표기 변환: 숫자는 한글 발음으로 변환한다. 예시: "352" → "삼백오십이" - 한글 발음법에 따라, 상황에 맞는 숫자 읽기 방식을 적용한다. 단독 숫자: "하나", "둘" 등 서술적 숫자: "일", "이", "삼" 등 - 영어 표기 및 기술 용어는 한글 발음으로 변환한다. 예시: "k6" → "케이식스", "http" → "헤이치티티피" - 기술 용어: 통상적으로 사용되는 한국어 발음을 따름. - 특수문자 변환: 구두문자를 제외하고, 특수문자를 한글로 변환한다. 예시: "&" → "엔드", "%" → "퍼센트" - 소스코드로 보이는 부분은 제거한다. - 괄호 안의 텍스트는 제거한다. - 모든 문장은 완전한 형태로 재작성하며 종결 어미는 '~다'를 유지한다. - 제목이나 소제목이라 판단되는 문장에는 끝에 마침표를 추가한다. - 텍스트를 95자 이하로 나눈다: 문장 단위로 자르는 것을 기본으로 하지만, 개조식 문장이나 특수한 경우 적절히 조정한다. `<ttsSplit/>` 태그를 사용하여 TTS의 멈춤점을 명시한다. - 출력 형식 변환된 텍스트만 코드 스니펫으로 반환하며, 다음 예시에 준수한다. ``` "삼백오십이"라고 적혀 있었다. <ttsSplit/> "케이식스"는 다음으로 넘어간다. <ttsSplit/> "헤이치티티피"를 따라간다. <ttsSplit/> ``` 주의사항 - 문맥 고려: 입력 텍스트의 흐름과 의도를 해치지 않도록 자연스러운 문장을 작성한다. - 음성합성(TTS) 적합성: 발음 및 멈춤 처리의 품질을 최우선으로 한다. - 간결성: 불필요한 내용을 과도하게 추가하지 않는다. - 코드 및 괄호 제거: 비문맥적 요소는 제거한다. 위의 규칙과 형식에 맞춰 결과를 작성한다. 모든 작업 결과는 코드 스니펫 형태로 반환하도록 한다.
모델 변경 & 자동화하기
xTTS-v2 말고도 fishaudio의 fish-speech-1.5 모델도 테스트해보았다. 최대 2000 token의 긴 텍스트를 지원하고 품질도 더 좋은 것 같다. 따라서 모델을 fishaudio의 fish-speech-1.5로 변경하고, 팟캐스트 생성 및 업로드 작업을 자동화하였다. 위에서 언급한 대로 LLM을 이용해 먼저 텍스트 정규화를 진행하고, 텍스트를 나누어 순차적으로 합성한 뒤 병합하는 방식을 그대로 채택했다. 대신 모델을 fish-speech-1.5로 전환하여 더 자연스러운 결과물을 생성할 수 있게 되었다. 블로그 게시물 URL로 부터 제목과 본문 텍스트를 추출하고 정규화를 진행한 후, 음성을 합성한 뒤 병합하여 파일을 저장한다. RSS 피드에서는 최종 음성 파일이 저장된 폴더를 스캔해 목록을 반환하도록 했다. 파일들을 저장해둘 저장 공간과 상시 구동되어야 하며, Runtime에 시간 제한이 없어야 하기 때문에 Oracle Cloud VM에 배포하였다.
플레이어 UI (feat. 다이나믹 아일랜드)
그럼 이렇게 생성한 음성 파일을 각 게시글 페이지에서 재생할 수 있게 해줘야 한다. 위에서 만든 API가 반환해주는 RSS 피드에는 각 에피소드의 제목과 mp3 파일 주소 등이 포함되어 있다. 따라서 현재 게시글의 제목과 일치하는 제목을 가진 에피소드의 mp3 파일을 가져와 재생하는 방식으로 구현하였다.
해당하는 팟캐스트 파일을 찾으면, 제목 밑에 아래와 같이 재생바가 표시되어 음성을 재생할 수 있다.
아래로 스크롤하면 재생을 컨트롤할 수 있는 다이나믹 아일랜드 UI를 가진 컨트롤러가 뿅하고 튀어나온다. 다이나믹 아일랜드 구현체는 anaclumos/dynamic-island를 참고했다.