호스트 앱이 직접 제어할 수 있는 조합형 마크다운 에디터 패키지
chaeditor는 chaen 프로젝트 내부 markdown editor를 독립 패키지로 꺼내는 작업에서 출발했습니다.
가장 신경 쓴 부분은 편집 기능을 패키지로 꺼내면서도 호스트 앱의 제어권을 얼마나 남겨둘 수 있는가였습니다.
기존 마크다운 에디터들은 둘 중 한쪽으로 치우치는 경우가 많았습니다. 직접 조합해야 할 만큼 비어 있거나, 반대로 기능은 많지만 UI와 동작 방식이 강하게 고정되어 서비스에 자연스럽게 녹이기 어려웠습니다. 실제로는 호스트마다 원하는 기능도 다르고, 링크 미리 보기 · 파일 업로드 · 이미지 렌더링처럼 기존 API와 바로 연결돼야 하는 지점도 달랐습니다.
chaeditor는 velog · 네이버 블로그 · 디스코드 · Notion에서 유용하다고 느낀 작성 경험을 하나의 기본 구현으로 묶되, UI shell · 메타데이터 · 업로드 · 렌더링 책임은 host app이 계속 가져갈 수 있게 설계했습니다. 완전히 headless로 가기보다는, 기본값만으로도 바로 쓸 수 있지만 필요하면 기능을 빼고, 교체하고, 기존 서비스 구조에 맞게 연결할 수 있는 에디터를 목표로 했습니다.
기능 추가에 그치지 않고, 실제로 다른 앱이 가져다 쓸 수 있는 공개 surface와 운영 흐름까지 함께 정리했습니다.
내부는 FSD(Feature-Sliced Design) 레이어로 구성했습니다. 작성 UI, 편집 기능, 순수 모델, 기본 primitive를 레이어별로 분리해 두면 package extraction 이후에도 공개 API를 어디서 끊을지 판단하기 쉬웠기 때문입니다.
이 구조를 먼저 잡아둔 덕분에 core, react, panda-primitives 같은 entrypoint를 자연스럽게 나눌 수 있었습니다.
작성 기능은 유지하면서도, React 의존이 없는 로직과 기본 UI 구현을 분리해 공개 패키지로 관리하기 쉬운 형태를 만들 수 있었습니다.
업로드 API, 링크 미리보기, 이미지 렌더러처럼 서비스마다 정책이 다른 부분은 adapter로 분리해 패키지 안에서 고정하지 않도록 했습니다.
어댑터를 제공하지 않으면 해당 툴바 액션은 자동으로 비활성화됩니다. chaeditor/default-host entrypoint는 , , 를 호출하는 기본 구현체를 제공하고, 업로드 전 이미지 최적화(1600×1600px / 84% 품질)도 내장되어 있습니다.
MarkdownRenderer는 server rendering에 맞게, editor surface는 client component로, 업로드와 링크 미리보기는 route handler나 server action 쪽 정책과 연결하는 식입니다. chaen에 다시 통합할 때도 에디터 surface만 package로 교체하고, 업로드 API·next/image 기반 렌더링·링크 미리보기·primitive shell은 host 쪽에서 그대로 유지할 수 있었습니다.
host app의 디자인 시스템을 editor 내부까지 일관되게 적용하기 위한 구조입니다. , , , , , 을 슬롯으로 열어두고 host app이 자신의 컴포넌트로 교체할 수 있게 합니다.
미주입 슬롯은 panda-primitives 기본 구현으로 폴백합니다. MarkdownPrimitiveProvider로 트리 최상단에 주입하고, 내부 어디서든 useMarkdownPrimitives()로 소비합니다. primitive를 교체하면 editor 내부의 링크/이미지/수식 helper와 overlay UI까지 같은 디자인 시스템 위에서 동작합니다.
스타일 교체 지점은 CSS custom property로 열어두었습니다. Panda CSS 토큰만 열어두면 host app이 Tailwind, Emotion, styled-components, vanilla-extract 같은 환경에서 재사용하기 어렵기 때문에, CSS variables를 바깥 contract로 두고 Panda는 기본 구현체로만 남겼습니다.
build 성공만으로는 충분하지 않습니다. @/ alias가 빌드 후 해석되지 않거나, 타입 선언 파일이 누락되거나, treeshaking으로 내부 의존성이 의도치 않게 제거되는 경우가 있기 때문입니다. 실제 npm tarball을 풀어 임시 consumer가 import하는 단계까지 확인해야 package exports, 타입 선언, CJS/ESM surface가 살아 있는지 알 수 있습니다.
파일: scripts/verify-package-surface.mjs
테스트 그룹은 두 개로만 운영합니다.
| 그룹 | 기준 | workers |
|---|---|---|
node | 파일 상단 @vitest-environment node 주석, 순수 로직 | 기본 설정 |
dom-ui | *.test.tsx, React UI와 DOM wiring | 기본 설정 |
SVG 목업 3종:
순수 markdown helper와 selection util은 node 테스트로 보내고, editor · toolbar · viewer처럼 DOM 상호작용이 필요한 영역만 dom-ui에 남겼습니다.
Storybook 10 + Chromatic을 연결해 PR 단위 visual diff로 CSS 변경이 다른 surface에 번지는지 확인합니다. 버전 업 시 version 스크립트에서 Storybook build와 Chromatic 배포가 자동으로 이어집니다.
Storybook은 "컴포넌트 모음"이 아니라, editor / toolbar / renderer / embed workflow에서 무엇이 달라지고 어떤 계약은 유지되는지를 비교하는 reference로 구성했습니다. README, wiki, host preset 문서도 같은 흐름으로 나눴습니다. Storybook은 시각적 상태와 상호작용을 빠르게 확인하는 자리로 두고, wiki는 통합 가이드와 레퍼런스를 영어/한국어로 함께 정리했으며, host preset 문서는 실제 연결 예시를 바로 가져다 쓸 수 있게 구성했습니다.
chaeditor는 React 앱과 Next.js 앱 양쪽에서 쓸 수 있게 열어두는 방향으로 설계했습니다. 그래서 SVG 아이콘도 특정 번들러 전용 컴포넌트에 기대지 않고, raw SVG 문자열을 받아 런타임에 정리한 뒤 인라인 SVG로 렌더링하는 방식을 택했습니다. 소스 환경에서는 유연했지만, 이 선택 때문에 dev 경로와 dist 경로가 다를 때만 드러나는 버그가 생겼습니다.
chaeditor를 chaen에 연결한 직후, 툴바의 Quote 아이콘이 보이지 않는 현상이 생겼습니다. Storybook에서는 정상인데 실제 배포 환경에서만 아이콘이 사라지는 패턴이었습니다.
Storybook은 Vite dev server 위에서 소스 파일을 직접 읽고, 실제 host app은 dist/를 npm 패키지로 참조합니다. 아이콘 컴포넌트는 SVG 파일을 ?raw로 문자열로 가져온 뒤 런타임에 normalizeSvgMarkup()을 거쳐 인라인 SVG로 렌더링합니다.
normalizeSvgMarkup에는 SVG를 아이콘 크기에 맞게 스케일하는 정규화 단계가 있었습니다.
이 regex는 전역(g 플래그)으로 SVG 문자열 전체에 적용됩니다. quote.svg에는 Figma에서 내보낸 clipPath 정의가 있었습니다.
전역 regex가 <rect width="24" height="24">에서 와 를 제거해 로 만들었고, clip 영역이 0×0이 되면서 아이콘 전체가 가려졌습니다.
clipPath를 포함한 SVG는 quote.svg와 github.svg 두 개뿐이었습니다. github.svg도 같은 이유로 망가져 있었지만 링크 아이콘으로 쓰여서 발견이 늦었습니다.
수정은 외부 매칭을 <svg> 여는 태그 하나로 좁히는 것이었습니다.
이 수정을 계기로 clipPath 케이스를 다루는 회귀 테스트도 추가했습니다.
chaen에서 기존 아티클을 불러와 편집하면 toolbar 기능 한 개만 적용해도 UI 변화가 눈에 띄게 지연되는 문제가 있었습니다.
원인 1 — execCommand가 문서 전체를 교체
툴바 액션은 선택 영역에만 마크다운 포맷을 적용하는 단순한 작업입니다. 하지만 내부 구현이 항상 0부터 끝까지 선택하고 전체 문서를 insertText로 넘기고 있었습니다.
document.execCommand('insertText')는 deprecated API지만 브라우저의 native undo 스택에 변경을 등록할 수 있는 유일한 방법입니다.
5만 자짜리 아티클에서 볼드 처리를 누르면 브라우저가 undo 스택에 5만 자를 기록하고, 5만 자를 다시 쓰는 DOM mutation을 동기적으로 처리합니다.
이전 값과 새 값의 공통 prefix와 suffix를 계산해 변경된 구간만 교체하는 방식으로 수정했습니다.
5만 자 아티클에서 볼드 처리를 누르면 이제 **text** 10자 내외만 교체합니다. execCommand 비용이 O(n) → O(변경량)으로 줄고, undo도 실제로 바뀐 부분만 되돌립니다.
원인 2 — 프리뷰 렌더가 매 입력마다 동기 실행
가 호출되면 프리뷰 패널도 동기적으로 재렌더됩니다. (전체 문서 스캔), (커스텀 블록 파싱), (코드 하이라이팅)가 모두 변경마다 동기적으로 실행되는 구조였습니다.
React.useDeferredValue로 프리뷰 전용 값을 분리했습니다.
React 19에서 useDeferredValue는 사용자 입력 같은 긴급한 업데이트를 먼저 처리하고, 프리뷰 렌더를 낮은 우선순위로 미룹니다. textarea는 즉각 반응하고, 프리뷰는 React가 여유 시간에 따라잡습니다.
핵심은 "작은 포맷팅 동작에 비해 너무 큰 비용을 쓰고 있었다"는 점이었습니다. 문서가 길어질수록 이런 종류의 문제는 더 쉽게 드러나기 때문에, 툴바 transform과 프리뷰 렌더 경로를 먼저 가볍게 만드는 쪽으로 패치했습니다. 지금도 더 줄일 여지는 남아 있지만, 실제 편집 흐름을 막는 급한 병목은 이 단계에서 빠르게 정리하여 배포했습니다.
패키지를 오픈소스로 공개하면서 가장 신경 쓴 건 경계를 명확하게 그어두는 것과 문서화였습니다. 확장적인 구조에서 패키지와 host app의 책임이 불분명하면, 통합하는 쪽에서는 어디를 손대야 할지 알기 어렵습니다. host adapter, primitiveRegistry, CSS variable contract는 모두 그 경계를 코드로 표현한 것이고, README와 Storybook은 그 경계를 문서로 설명한 것입니다.
chaen에 다시 붙여보면서 이 경계가 실제로 유지되는지 확인할 수 있었고, 그 과정에서 나온 버그와 성능 문제를 패키지 수준에서 고쳤습니다.
src/
├── widgets/editor/ # MarkdownEditor (최상위 조합)
├── features/edit-markdown/ # toolbar / image / file / link / math / video / formatting
├── entities/editor-core/ # 순수 모델 유틸리티 (React 없음)
├── shared/ # UI 프리미티브 + 공유 라이브러리
├── core/ # 공개 API 배럴 (entities 재익스포트)
├── react/ # 공개 API 배럴 (features + widgets 재익스포트)
└── panda-primitives/ # 기본 Panda CSS 구현체type MarkdownEditorHostAdapters = {
uploadImage?:(args: { file: File; contentType; imageKind }) => Promise<string>;
uploadFile?:(args: { file: File; contentType }) => Promise<string>;
uploadVideo?:(args: { file: File; contentType }) => Promise<VideoEmbedReference>;
fetchLinkPreviewMeta?:(url: string) => Promise<LinkPreviewMeta>;
resolveAttachmentHref?:(href: string, contentType) => Promise<string>;
renderImage?:HostImageRenderer; // Next.js Image 등 커스텀 이미지 컴포넌트
};/api/files/api/videosButtonInputTextareaPopoverModalTooltipexport type MarkdownPrimitiveRegistry = Partial<{
Button: MarkdownButtonPrimitive; // forwardRef<HTMLButtonElement, ButtonProps>
Input: MarkdownInputPrimitive; // forwardRef<HTMLInputElement, InputProps>
Modal: MarkdownModalPrimitive; // ComponentType<ModalProps>
Popover: MarkdownPopoverPrimitive; // ComponentType<PopoverProps>
Textarea: MarkdownTextareaPrimitive; // forwardRef<HTMLTextAreaElement, TextareaProps>
Tooltip: MarkdownTooltipPrimitive; // ComponentType<TooltipProps>
}>;export const CHAEDITOR_THEME_VARIABLES = {
primary: '--chaeditor-color-primary',
primaryHover: '--chaeditor-color-primary-hover',
primaryContrast: '--chaeditor-color-primary-contrast',
primaryMuted: '--chaeditor-color-primary-muted',
primarySubtle: '--chaeditor-color-primary-subtle',
surface: '--chaeditor-color-surface',
surfaceMuted: '--chaeditor-color-surface-muted',
surfaceStrong: '--chaeditor-color-surface-strong',
text: '--chaeditor-color-text',
textSubtle: '--chaeditor-color-text-subtle',
muted: '--chaeditor-color-muted',
border: '--chaeditor-color-border',
borderStrong: '--chaeditor-color-border-strong',
focusRing: '--chaeditor-color-focus-ring',
error: '--chaeditor-color-error',
success: '--chaeditor-color-success',
overlayBackdrop: '--chaeditor-color-overlay-backdrop',
sansFont: '--chaeditor-font-sans',
sansJaFont: '--chaeditor-font-sans-ja',
monoFont: '--chaeditor-font-mono',
} as const;import { createChaeditorThemeVars } from 'chaeditor/core';
const myTheme = createChaeditorThemeVars({
primary: '#3b82f6',
surface: '#ffffff',
text: '#18181b',
});
// → { '--chaeditor-color-primary': '#3b82f6', ... }
// → <div style={myTheme}> 으로 적용npm pack 으로 실제 배포될 .tgz 생성/.tmp/package-surface-smoke/) 생성core, react, default-host, panda-primitives)를 실제 importtsc --noEmit)*.svg → 더미 React 컴포넌트 (svg-component.tsx)*.svg?url → 더미 URL 문자열 (svg-mock-url.ts)*.svg?raw → 인라인 SVG 문자열 (Vite inline 플러그인)// app-icons.tsx
import QuoteSvg from '@/shared/assets/icons/quote.svg?raw';
const rawSvgMarkup = resolveRawSvgMarkup(svgSource); // 모듈 평가 시 고정
const svgMarkup = React.useMemo(
() => scopeSvgMarkupIds(normalizeSvgMarkup(rawSvgMarkup), iconScopeId),
[assetSvgMarkup, iconScopeId],
);// 수정 전
.replace(/\s(width|height)="[^"]*"/gi, '')<g clip-path="url(#clip0_403_3386)">
<path d="M0 7.99939V13.9994..." fill="currentColor"/>
<path d="M18.0002 3.99939..." fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_403_3386">
<rect width="24" height="24" fill="white"/> ← 여기가 문제
</clipPath>
</defs>height<rect/>// 수정 후
.replace(/<svg\b[^>]*>/, svgTag => svgTag.replace(/\s(width|height)="[^"]*"/gi, ''))it('Under raw SVG markup with a clipPath, normalizeSvgMarkup must preserve clipPath element dimensions', () => {
const input = [
'<svg width="24" height="24" viewBox="0 0 24 24">',
'<g clip-path="url(#clip0)"><path fill="currentColor" /></g>',
'<defs><clipPath id="clip0"><rect width="24" height="24" fill="white"/></clipPath></defs>',
'</svg>',
].join('');
const result = normalizeSvgMarkup(input);
expect(result).toContain('width="24" height="24"'); // clipPath rect 치수 보존
expect(result).toContain('width="100%"'); // SVG 루트는 100%로 교체됨
});// 수정 전
textarea.setSelectionRange(0, textarea.value.length); // 전체 선택
document.execCommand('insertText', false, nextValue); // 전체 문서 교체// 수정 후
const prevValue = textarea.value;
const minLength = Math.min(prevValue.length, nextValue.length);
let changeStart = 0;
while (changeStart < minLength && prevValue[changeStart] === nextValue[changeStart]) {
changeStart += 1;
}
let changeEndPrev = prevValue.length;
let changeEndNext = nextValue.length;
while (
changeEndPrev > changeStart &&
changeEndNext > changeStart &&
prevValue[changeEndPrev - 1] === nextValue[changeEndNext - 1]
) {
changeEndPrev -= 1;
changeEndNext -= 1;
}
textarea.setSelectionRange(changeStart, changeEndPrev);
document.execCommand('insertText', false, nextValue.slice(changeStart, changeEndNext));onChangecollectMarkdownImagesparseRichMarkdownSegmentsrehype-pretty-codevalue// 수정 전
const markdownOptions = useMemo(
() => getMarkdownOptions({
adapters,
items: collectMarkdownImages(value), // O(n) 이미지 수집
}),
[adapters, value],
);// 수정 후
const deferredValue = React.useDeferredValue(value);
const markdownOptions = useMemo(
() => getMarkdownOptions({
adapters,
items: collectMarkdownImages(deferredValue),
}),
[adapters, deferredValue],
);