TipTap 에디터 적용 과정에서 겪은 문제와 정리
개인 프로젝트의 글 작성 화면을 개선하면서 기존 Toast UI Editor 중심의 구조를 TipTap 기반 에디터로 정리했다.
처음 목표는 단순했다.
에디터 화면이 전체 디자인과 어울리지 않았고, 툴바와 본문 영역이 투박해 보였다. 그래서 Apple 스타일에 맞춰 더 깔끔한 작성 경험을 만들고 싶었다.
하지만 작업을 하다 보니 단순한 디자인 문제가 아니라, 에디터 구현체와 기존 저장 로직 사이의 인터페이스 문제도 함께 드러났다.
기존 구조
작성 페이지에서는 에디터 값을 저장할 때 다음과 같은 코드를 사용하고 있었다.
const html = editorRef.current.getInstance().getMarkdown();이 코드는 Toast UI Editor를 기준으로 작성된 방식이다.
Toast UI Editor는 getInstance()를 통해 내부 인스턴스에 접근하고, getMarkdown()으로 작성된 내용을 가져올 수 있다.
하지만 실제 화면에서는 TipTap을 사용하고 있었다.
TipTap은 기본적으로 이런 API를 제공하지 않는다.
editor.getHTML();
editor.getText();
editor.getJSON();즉, 작성 페이지는 Toast UI Editor의 인터페이스를 기대하고 있는데, 실제 에디터 컴포넌트는 TipTap 방식으로 동작하고 있었다.
문제 1. Hook 호출 순서 오류
처음에는 useImperativeHandle을 사용해서 기존 인터페이스를 맞추려고 했다.
React.useImperativeHandle(
ref,
() =>
({
getInstance: () => ({
getMarkdown: () => editor.getHTML()
})
}) as unknown as Editor,
[editor]
);하지만 다음과 같은 오류가 발생했다.
React has detected a change in the order of Hooks called by DynamicEditor.원인은 editor가 아직 생성되지 않았을 때 먼저 return을 했기 때문이다.
if (!editor) {
return <p>에디터 정보를 불러오고 있어요.</p>;
}
React.useImperativeHandle(...);첫 렌더에서는 useImperativeHandle이 호출되지 않고, 다음 렌더에서 editor가 생긴 뒤에 호출되면서 Hook 순서가 바뀌었다.
React Hook은 조건문이나 early return 뒤에 위치하면 안 된다.
항상 같은 순서로 호출되어야 한다.
수정 후에는 useImperativeHandle을 early return보다 위로 이동했다.
React.useImperativeHandle(
ref,
() =>
({
getInstance: () => ({
getMarkdown: () => editor?.getHTML() ?? ''
})
}) as unknown as Editor,
[editor]
);
if (!editor) {
return <p>에디터 정보를 불러오고 있어요.</p>;
}이렇게 하면 editor가 아직 없어도 Hook은 항상 호출되고, 값이 없을 때는 빈 문자열을 반환한다.
문제 2. ref가 덮이는 문제
다음으로는 저장 시점에 아래 오류가 발생했다.
editorRef.current.getInstance is not a function원인은 같은 ref를 두 군데에 연결했기 때문이다.
<EditorContent editor={editor} ref={props.innerRef} />부모 컴포넌트에서는 같은 ref를 ref와 innerRef로 넘기고 있었다.
<DynamicEditor
ref={editorRef}
innerRef={editorRef}
initialValue={...}
/>useImperativeHandle은 editorRef.current에 getInstance()를 가진 객체를 넣고 있었지만, EditorContent에도 같은 ref가 전달되면서 값이 덮일 수 있었다.
그래서 EditorContent에는 ref를 넘기지 않도록 수정했다.
<EditorContent editor={editor} />이제 부모의 editorRef.current는 useImperativeHandle이 만든 API 객체만 가리킨다.
문제 3. 코드 입력 기능이 보이지 않음
에디터 디자인을 정리한 뒤, 코드 작성 기능이 화면에 보이지 않는 문제가 있었다.
원인은 단순했다.
툴바에 코드 버튼이 없었다.
TipTap의 StarterKit에는 code, codeBlock 기능이 포함되어 있지만, 사용자가 누를 수 있는 버튼을 만들지 않으면 화면에서는 사용할 수 없다.
그래서 툴바에 인라인 코드와 코드블록 버튼을 추가했다.
<button
type="button"
title="Inline code"
onClick={(e) => {
e.preventDefault();
editor.chain().focus().toggleCode().run();
}}
>
<Code />
</button>
<button
type="button"
title="Code block"
onClick={(e) => {
e.preventDefault();
editor.chain().focus().toggleCodeBlock().run();
}}
>
<SquareCode />
</button>또한 코드블록이 잘 보이도록 CSS도 보정했다
.editor-content :global(pre) {
overflow-x: auto;
margin: 24px 0;
padding: 18px;
border-radius: 12px;
background: #272729;
}
.editor-content :global(pre code) {
display: block;
min-height: 24px;
padding: 0;
color: #f5f5f7;
background: transparent;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 15px;
line-height: 1.7;
white-space: pre;
}최종 구조
최종적으로 작성 페이지는 기존 저장 로직을 유지한다.
const html = editorRef.current.getInstance().getMarkdown();하지만 내부 에디터는 TipTap을 사용한다.
getMarkdown: () => editor?.getHTML() ?? ''이 방식은 완벽한 이름은 아니다.
getMarkdown()이라는 이름이지만 실제로는 HTML을 반환하기 때문이다.
장기적으로는 다음처럼 더 명확한 이름으로 바꾸는 것이 좋다.
getContent: () => editor.getHTML()또는 저장 로직 자체를 TipTap 기준으로 정리할 수도 있다.
const content = editor.getHTML();다만 이번 작업의 목적은 에디터 교체가 아니라 디자인과 작성 경험 개선이었다.
그래서 기존 호출부를 크게 바꾸지 않고, 에디터 컴포넌트 내부에서 호환 계층을 두는 방식으로 마무리했다.
정리
이번 작업에서 배운 점은 세 가지다.
에디터를 바꿀 때는 UI보다 먼저 저장 인터페이스를 확인해야 한다.
React Hook은 early return보다 위에서 항상 같은 순서로 호출되어야 한다.
ref는 한 가지 책임만 가지게 해야 한다.
에디터는 단순한 입력창이 아니다.
작성, 저장, 수정, 출력 흐름이 모두 연결되어 있다.
그래서 디자인을 개선할 때도 데이터 흐름과 컴포넌트 경계를 함께 확인해야 한다.

댓글 0