Yess 웹앱에서 문서 편집은 가장 중요한 기능이다. 사용자들은 Yess를 통해 클라이언트와의 미팅을 준비하거나 클라이언트와 함께 미팅 노트를 편집하고, 클라이언트에게 프로젝트를 수주하기 위해 제안서를 작성하기도 한다. 이는 세일즈 단계에서 굉장히 중요한 작업들인 만큼 Yess 프론트엔드 코드베이스에서 에디터 라이브러리에 대한 의존도는 높은 편이다.
Yess 팀은 피벗하기 전 Additor라는 노트 서비스를 운영하기도 했는데, 그 당시의 quill 최신 버전을 포크하여 코드베이스 내부에 가지고 와서 여러가지 커스텀 코드를 추가했다고 한다. 그리고 이것이 Yess 웹앱을 구현할 때 그대로 이관되었다. quill 라이브러리 자체로 스타일 커스터마이즈가 유연하지 않고 유지보수가 활발하지 않은 등 단점들이 있었지만 이를 차치하더라도 포크 버전 위에서 기능을 구현한 바람에 정상적인 버전 관리에 의한 혜택마저도 받기 어려운 형태가 되어 버린 것이다. 더군다나 포크 버전의 코드는 type strict하지 않았고 이미 알고 있는 버그가 많음에도 불구하고 근속 연수가 오래 된 팀원들조차 빠르게 해결하기 어려울 만큼 복잡하게 얽혀 있었다.
솔직히 말하자면 그래서 더욱 처음부터 다시 만들고 싶다는 바람도 있었다. 무엇보다도 앞으로는 문서 편집에 생성형 AI를 적극적으로 도입할 것이고 더욱 인터랙티브한 UI/UX 요구사항에 대응해야 하는 상황을 고려했을 때 보다 유연하고 확장성 좋은 최신 에디터 라이브러리를 도입하면 좋겠다고 생각했다. 하지만 개인적인 바람만으로 일을 추진할 수는 없다. 다시 만드는 게 정말 더 나은 건지, 아니면 그저 개발자로서 문제를 해결하기보다 기피하고 싶어 흔히 착각하는 바와 같은 건지, 현실적인 판단이 먼저 필요했다. 이에 새로운 라이브러리를 대상으로 POC를 진행하고 적절한 요구사항이 있을 때 시험적으로 도입해 보기도 하면서 그 답을 찾아 나섰던 과정에 대해 정리해 보고자 한다.
quill vs tiptap
유지보수성이나 확장성 등의 고차원적인 문제는 둘째로 하고, 우선 quill 라이브러리 사용이 공식적으로 시한부 선고를 받는 문제가 한 가지 있었다. 바로 quill 메이저 버전 1점대에서 사용중인 크롬 브라우저의 DOMNodeInserted
mutation event가 2025년 3월 25일 부로 지원 종료된다는 것이었다. 만약 정상적으로 버전 관리를 할 수 있었다면 이는 quill의 메이저 버전을 2로 업데이트하면 해결되기 때문에 문제될 일이 아닐 수도 있다. 하지만 상기되었듯 Additor가 개발될 당시 quill을 1.3.7 버전으로 포크한 버전에서 자체적으로 커스텀해 온 세월이 이미 길었기 때문에 단순히 패키지 매니저로 버전을 업데이트할 수 있는 상황이 아니었다. quill을 계속해서 사용한다는 전제로 이 문제를 해결하고자 한다면 세 가지 옵션이 있다.
- 포크 버전의 코드를 백업한 후 quill을 최신 메이저 버전으로 설치한다. 이후 백업했던 코드의 커스텀 로직들을 재이식한다.
- 포크 버전의 코드를 유지한 채 별도 패키지에서 quill의 최신 메이저 버전으로 에디터 모듈을 빌드한다. 포크 버전의 커스텀 로직들을 해당 모듈 위에 재이식한다.
- 포크 버전의 코드를 유지한 채 quill의 최신 메이저 버전 코드를 분석한다. 분석한 코드 중 지원 종료된 API 이슈를 해결할 수 있는 부분만 골라서 반영한다.
하지만 어떤 것도 현실적으로 실행하기는 어려운 옵션으로 생각되었다. 워낙 문서 편집 기능이 방대하기 때문에 모든 기능을 안정적으로 유지하면서 작업을 진행하는 것은 난이도가 높고 시간도 오래 걸릴 것이다. 무엇보다도 기존에 서비스되고 있는 코드에서도 이미 버그가 꽤 많았기 때문에 이를 안정적으로 유지한다는 말조차 어폐가 있다. 그 버그들을 전부 해결하면서 작업을 진행한다면 인과관계를 파악하기도 복잡해질 것이고 시간은 더욱 오래 걸릴 것이다. 더군다나 3번 옵션처럼 포크 버전을 계속해서 유지하게 되면 버전 관리를 정상적으로 할 수 없는 현재의 상황을 더욱 고착화하기 때문에 문제를 해결한다기보다는 오히려 부풀리는 결과로 이어지는 셈이다.
quill을 대체하기 위해서 검증해 보고자 했던 라이브러리는 tiptap이었다. 오픈소스이며 헤드리스 라이브러리로 커스터마이즈에 유연하고, 실시간 동시 편집이나 생성형 AI 등 새로운 기술 스택과도 호환이 되는 것으로 보여 긍정적인 인상을 받았기 때문이다. tiptap에 대해서 처음 알게 된 건 배리 재직 당시에 CKEditor로 텍스트 편집 기능을 구현하고 나서 더 좋은 에디터는 없을까 찾아볼 때였다. 그때는 너무 신생 라이브러리였고 플러그인도 지금만큼 다양하지는 않았으며 안정성도 검증되지 않았기 때문에 지켜만 보고 있었지만 이후 몇 년 동안 꾸준히 업데이트되는 것을 지켜보면서 어느 정도 신뢰할 수 있겠다는 기대가 생겼다.
물론 새로운 라이브러리를 도입한다고 해서 당장 모든 문서 편집 기능을 갈아 엎을 수 있는 것은 아니다. 그렇다고 해서 포크 버전의 quill로 문제를 해결하기 위해 현실적이지 않은 방법을 시도하며 시간을 허비하는 것보다는, 문제를 보다 근본적이고 장기적인 관점에서 해결하는 것이 더 중요하다고 판단했다.
1차 POC: 파라미터 플러그인
1차 POC 주제로 파라미터 플러그인을 시범 구현해 보기로 했다. 테스트 환경으로는 모노레포에 앱 패키지를 하나 생성하고 tiptap 노트를 띄웠다. POC가 목적이므로 스타일링은 따로 하지 않았다.
tiptap의 데이터 구조
테스트 환경에서 기본적인 텍스트 편집을 통해 데이터 구조를 먼저 파악해 보았다. tiptap의 데이터는 트리 구조로 이루어져 있으며, ProseMirror의 Schema를 기반으로 형성된다. 최상단의 doc
하위로 heading
또는 paragraph
가 나열되며, 각 heading
과 paragraph
하위로 text
가 나열된다.
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "new note poc"
},
]
},
{
"type": "heading",
"content": [
{
"type": "text",
"text": "this is title"
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "here are contents"
}
]
}
]
}
여기서 doc
, heading
, paragraph
, text
등의 type을 가진 하나의 단위를 Node
로 지칭한다. 각 Node
는 marks와 attrs를 지정할 수 있다. Node
가 구조를 이루는 빌딩 블럭이라면 marks는 스타일 요소이다. marks가 적용되는 경우 데이터의 구조가 변경되지는 않지만 별도의 Node
를 형성하여 분리된다. attrs는 Node
의 데이터 요소이다. 예를 들어 heading
의 attrs에 level이 1로 지정되면 이 Node
는 h1 레벨의 html 태그로 렌더링된다. 다만 이 attrs가 html의 attributes로 바로 매칭되는 것은 아닌데, 만약 매칭을 하고 싶다면 Node
선언시 renderHTML
메서드를 사용하면 된다.
{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "new "
},
/**
* marks가 지정되며 별도 Node로 분리되었다.
*/
{
"type": "text",
"marks": [
{
"type": "bold"
},
{
"type": "italic"
}
],
"text": "note"
},
{
"type": "text",
"text": " poc"
}
]
},
/**
* heading의 attrs를 지정하였다.
*/
{
"type": "heading",
"attrs": {
"level": 1
},
"content": [
{
"type": "text",
"text": "this is title"
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "here are contents"
}
]
}
]
}
파라미터 노드 구현
데이터 구조 확인 후 커스텀 노드로 파라미터를 구현해 보았다. 파라미터는 일반 텍스트와 다르게 버튼 형식의 UI가 입혀진 요소이다. 즉, 마크다운 문법으로 대응할 수 없는 요소이기 때문에 별도의 커스텀 노드를 선언하여 기능을 구현해야 한다. tiptap에서는 이에 대응할 수 있도록 Node View API를 제공한다. Node.create
함수로 새 노드를 선언할 때 addNodeView
메서드를 사용하면 된다.
여기서 ReactNodeViewRenderer
의 인자로 넘겨지는 Parameter
는 NodeViewRendererProps
를 prop으로 받는 일반적인 react 컴포넌트이다. 이후 파라미터를 편집할 수 있는 기능을 구현한다면 Parameter
컴포넌트 내부에 Popover UI를 적용하여 구현할 수 있을 것으로 예상하며 1차 POC를 마쳤다.
export default Node.create({
name: "parameter",
group: "inline*",
content: "inline*",
addAttributes() {
return {
title: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "button",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["button", mergeAttributes(HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(Parameter);
},
});
function Parameter({ node }: NodeViewRendererProps): ReactElement {
const key = node.content.child(0).text;
return (
<NodeViewWrapper className="parameter">
<button type="button" style={{ cursor: "pointer" }}>
{key}
</button>
</NodeViewWrapper>
);
}
프로덕트 기능 구현: 이메일 편집기
실제로 프로덕트에 tiptap을 적용한 첫 사례를 소개하고자 한다. Yess의 Automation Workflow 기능은 자동화된 워크플로우를 생성할 수 있도록 해주는 기능이다. 여기에 새로 추가되는 워크플로우 요소로 조건에 따라 이메일을 전송하는 기능을 구현해야 했다. 이때 이메일 주소와 내용을 입력하는 텍스트 편집기에서 파라미터도 사용할 수 있어야 한다는 요구사항이 있었다. 기존의 quill 모듈로 구현된 파라미터와는 또 다른 독립적인 UI/UX였다. 해당 요구사항을 tiptap으로 빠르게 구현해 보기로 했다.
tiptap으로 새로운 텍스트 에디터 모듈을 구현하기 전 가장 중요하다고 생각한 목표는 유지보수성 및 확장성을 최대한 보장하는 것이었다. 어렵게 시간을 들여 만든 새 모듈이 quill 모듈과 같은 한계를 벗어나지 못한다면 의미가 없기 때문이다. 다행히도 tiptap에서는 이같은 개발자의 니즈를 충족시켜 주는 유연한 API를 제공한다. 스타일 의존성이 없는 헤드리스 UI를 표방하며 편집 기능은 익스텐션으로 사용자별 조합하여 사용할 수 있게 되어 있기 때문이다. 이 장점을 활용하는 차원에서 UI를 담당하는 react 컴포넌트와 기능을 담당하는 익스텐션의 두 가지 역할로 모듈을 구분하였다.
UI Component
tiptap 에디터를 확장한 컴포넌트는 디자인 시스템 기획에 맞는 스타일을 구현하고, 비즈니스 로직에 의존성이 없는 필수 익스텐션만을 포함하고 있다. 마지막으로, 사용처에서 에디터 동작을 제어해야 하는 경우를 대비하여 useImperativeHandle
훅을 사용해 몇 가지 데이터 및 메서드를 정의하도록 했다. 구현된 컴포넌트를 단순화하면 다음과 같다.
export const RichTextarea = forwardRef<Refs, Props>(function RichTextarea(
{
content,
extensions,
maxLength = DEFAULT_MAX_NOTE_TEXT_LENGTH,
minHeight = 210,
maxHeight = 400,
placeholder = "Write something...",
...props
},
ref,
) {
const editor = useEditor(
{
content,
extensions: [
Document,
Paragraph,
Text,
History,
CharacterCount.configure({
mode: "textSize",
limit: maxLength,
}),
Placeholder.configure({
placeholder,
emptyEditorClass: "is-editor-empty",
}),
...(extensions ?? []),
],
...props,
},
[editable],
);
useImperativeHandle(ref, () => {
return {
html: editor?.getHTML() ?? "<p></p>",
json: isTipTapDocumentContent(editor?.getJSON() ?? {})
? (editor?.getJSON() as TipTapDocumentContent)
: getDocFromNodes({ nodes: [] }),
text: editor?.getText() ?? "",
insertContentAtCurrentSelection: (...newContent) => {
insertContentAtCurrentSelectionUtil(newContent, editor);
},
replaceContent: (newContent, callback) => {
replaceContentUtil(newContent, editor, callback);
},
};
});
return (
<RichTextareaBase
{...props}
$minHeight={minHeight}
$maxHeight={maxHeight}
editor={editor}
/>
);
});
const RichTextareaBase = styled(EditorContent)<RichTextareaBaseProps>`
cursor: text;
padding: 6px 8px;
border-radius: 8px;
transition:
border-color,
background-color ease-in-out 200ms;
border: 1px solid #EBEBEC;
background-color: #FFFFFF;
:hover {
border-color: #B8BBBF;
}
:focus,
:focus-within {
border-color: #0358FC;
}
.tiptap {
font-size: 14px;
font-weight: 500;
line-height: 1.6;
letter-spacing: -0.01em;
min-height: ${({ $minHeight }) => $minHeight}px;
max-height: ${({ $maxHeight }) => $maxHeight}px;
overflow-y: auto;
p {
flex-wrap: wrap;
word-break: break-word;
min-width: 1px;
width: 100%;
margin: 0;
}
}
`;
Custom Extension
파라미터 기능의 경우 커스텀 익스텐션으로 구현하였다. 참고로 tiptap에서 Node
와 Extension
이라는 용어가 서로 혼용되기도 하는데 정확히 구분하자면 다음과 같다.
Node
- 스키마에 포함되며 편집할 수 있는 요소 (e.g. 파라미터, 멘션, 이미지 등)
Mark
- 스타일 및 포맷팅 로직으로
Node
에 반영될 수 있음 (e.g. Bold, Italic, Highlight 등)
- 스타일 및 포맷팅 로직으로
Extension
- 단순 기능적으로 에디터 로직을 확장하는 모듈 (e.g. 키보드 이벤트 핸들러 등)
- 에디터 컴포넌트에 prop으로 넘기는
extension
의 경우Node
,Mark
,Extension
을 모두 포함
우선 1차 POC에서 작성했던 코드를 기반으로 보다 자세한 요구사항을 반영했다. addAttributes
메서드에서 실제로 HTML에 속성을 추가하는 renderHTML
예시를 확인할 수 있다. 또한 inline
, selectable
, atom
속성이 true
로 지정되는 부분이 있는데 이는 노드를 하나의 편집 단위로 취급하도록 하기 위한 속성들이다. 아래 이미지에서 키보드의 화살표 및 백스페이스 동작에 대한 예시를 확인할 수 있다.
관련해서 한가지 주의해야 할 사항은, Node View를 통해서 생성되는 노드는 해당 컴포넌트(아래 예시에서는 AutomationParameterNode
)의 contenteditable
속성이 기본적으로 true
로 설정된다. 처음에 이 사실을 모르고 addNodeView
에 따로 속성을 지정하지 않았다가 셀렉션이 의도대로 되지 않는 버그가 있어서 한참을 헤맸다. 결국 개발자 도구로 DOM을 직접 확인해 보고 나서야 contenteditable
로 인해서 발생하는 이슈임을 알게 되어 false
로 명시하는 구문이 추가되었다.
구현된 익스텐션을 단순화하면 다음과 같다.
export const AutomationParameter = Node.create<AutomationParameterOptions>({
name: TipTapCustomContentType.AUTOMATION_PARAMETER,
group: "inline",
content: "inline*",
inline: true,
selectable: true,
atom: true,
addAttributes() {
return {
fallback: {
default: null,
parseHTML: (element) =>
element.getAttribute("data-automation-parameter-fallback"),
renderHTML: (attrs: AutomationParameterTipTapContentAttributes) => {
return {
"data-automation-parameter-fallback": attrs.fallback,
};
},
},
};
},
parseHTML() {
return [
{
tag: TipTapCustomContentType.AUTOMATION_PARAMETER,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
TipTapCustomContentType.AUTOMATION_PARAMETER,
mergeAttributes(HTMLAttributes),
0,
];
},
renderText({ node }) {
if (isTipTapAutomationParameterContent(node)) {
return `$\{${node.attrs.fallback}}`;
}
return `$\{Invalid Parameter}`;
},
addNodeView() {
return ReactNodeViewRenderer(AutomationParameterNode, {
attrs: {
contenteditable: "false",
},
});
},
});
function AutomationParameterNode({
node,
extension,
editor,
}: NodeViewRendererProps): ReactElement {
const attrs = node.attrs as AutomationParameterTipTapContentAttributes;
return (
<NodeViewWrapper>
<Popover.Root>
<Popover.Trigger>
<AutomationParameterChip>
{displayName}
</AutomationParameterChip>
</Popover.Trigger>
<Popover.Content
width={CONTENT_WIDTH}
sideOffset={4}
align="start"
side="top"
>
<Popover.CloseButton hidden />
<Popover.Body>
<TextInput
defaultValue={attrs.fallback ?? ""}
placeholder="Enter fallback option"
/>
</Popover.Body>
</Popover.Content>
</Popover.Root>
</NodeViewWrapper>
);
}
최종적으로 구현된 모듈을 조합하여 사용하는 곳에서의 모습은 다음과 같다. UI와 비즈니스 로직이 효과적으로 분리된 것을 알 수 있다. 예를 들어 기존과 다른 에디터 UI가 필요한다면 컴포넌트만 새로 구현하고, 특정 기능을 추가해야 한다면 익스텐션만 새로 구현하면 된다. 사용처에서는 필요에 따라 컴포넌트 및 익스텐션의 조합을 자유롭게 구성할 수 있다.
<RichTextarea
placeholder="Enter Content.."
content={content}
ref={richNote}
extensions={[AutomationParameter]}
/>
남은 과제
실시간 동시 편집
tiptap에서 자체적으로 제공하는 Hocuspocus를 사용하면 생각보다 간단하게 실시간 동시 편집을 테스트 구현해 볼 수 있다. Hocuspocus는 Y.js라는 CRDT 프레임워크를 기반으로 tiptap의 문서 편집 로직과 서버를 연동하는 역할을 해준다. 그러나 기존의 Yess 웹앱에서는 OT 기반으로 자체 구현된 라이브러리를 사용하고 있었다. 이로 인해 실시간 동시 편집에도 tiptap을 도입할지 여부는 단순하게 결정할 수 없겠다고 의견이 모였다. 백엔드에서 동일한 기능을 다루는 데 완전히 다른 두 개의 패러다임에 대해 유지보수를 병행해야 하는 점을 감안하고 이에 대한 전략도 필요할 텐데, PMF를 찾기 전까지는 이런 종류의 비용은 아끼는 것이 더 경제적이라고 판단하였다.
AI 스트리밍
tiptap에서는 자체 구현한 Content AI 기능을 제공하는데, 단 요금제가 적용된다. PMF를 찾기 전이기 때문에 요금을 지불해서 해당 기능을 이용하는 것보다는 자체적으로 구현하거나 기존의 구현 방식을 유지하는 것이 낫겠다는 의견이 모였다. 가능한 전략들을 정리해 보면 다음과 같다.
- 서버에서 AI로부터 받은 스트리밍 응답을 tiptap 데이터 구조로 변환하여 내려준다.
- 서버에서 AI로부터 받은 스트리밍 응답을 기존의 방식대로 TextDelta 구조로 내려주고 프론트엔드에서 tiptap 에디터에 전달할 때 해당 데이터 구조로 변환하여 사용한다.
우선 1번 전략은 선택하게 되면 백엔드 개발자가 유지보수해야 할 부분이 두 배로 늘어난다. 기존에 quill의 TextDelta 구조를 다루는 로직들이 복잡하게 구현되어 있는 상황에서 동일한 기능을 위해 또 다른 구조를 다루어야 하기 때문이다. 실시간 동시 편집에 tiptap을 도입하는 것과 동일한 우려가 있는 것이다. 반대로 2번 전략은 프론트엔드에서만 구현하면 되기 때문에 더 빠르게 실행해 볼 수는 있다. 하지만 들어가는 공수에 비해서 장점이 크지 않다고 판단하였다. tiptap의 API를 사용할 수 있는 것은 장점이지만 서버에서의 데이터를 바로 사용할 수 없고 변환이 필요한 것은 성능이나 안정성 측면에서 리스크가 있기 때문이다. 오히려 가능한 시점에 서버에서 문서 편집 데이터를 다루는 방식을 변경하는 것이 보다 궁극적인 해결책이라고 생각된다.