개발자를 위한 고급 에디터 만들기

김소희·2025년 11월 17일

일반적인 게시판이라면 단순한 Textarea나 Markdown 에디터로도 충분하다.
하지만 우리 서비스는 코드 중심 플랫폼이다. 개발자들이 진정으로 만족할 수 있는 에디터를 만들고 싶었다.

  • 개발자 친화적 코드 편집 UX
  • 포스트 안에서 코드가 "기능적으로" 동작
  • 언어 선택, 코드 스타일, 테마까지 사용자 제어 가능
  • 장기적으로 코드 분석/AI 기능 추가하기 좋은 구조

이를 위해 선택한 것은:

  • Tiptap - 커스터마이징 자유도가 최상급인 리치 텍스트 에디터
  • Monaco Editor - VS Code와 동일하게 구동되는 코드 편집기

두 개를 조합해 문서 속 코드, 코드 속 문서를 자유롭게 표현할 수 있는 완성도 높은 고급 에디터를 만들기로 했다.


서로 다른 세 가지 렌더링 시스템의 충돌

에디터 안에 또 다른 에디터를 넣는 구조는 생각보다 훨씬 복잡했다.

  • Tiptap → HTML 기반 리치 텍스트, Document Tree 중심
  • Monaco → 독립 UI 런타임, Canvas 기반 렌더링
  • React → Virtual DOM 기반 컴포넌트 시스템

이 세 가지가 동시에 상호작용하고 중첩 구조가 생기면서 예상하지 못한 온갖 버그들이 등장했다.

  • Tiptap은 내부에서 Selection과 NodeView 렌더링을 제어
  • Monaco는 DOM에서 완전히 독립적으로 자신의 에디터 공간을 관리
  • React는 Virtual DOM을 쓰기 때문에 Tiptap의 제어 흐름과 충돌 가능성

트러블슈팅 1: 언어 감지 실패

Monaco는 기본적으로 휴리스틱 기반 언어 감지 기능을 제공한다. 문자열의 패턴을 분석해서 "이건 Java 같고, 이건 Python 같다"고 추론하는 방식이다.

하지만 Tiptap 안에서 넘어오는 코드는 HTML 노드 구조 안에 들어 있어 Monaco가 제대로 분석하지 못했다.

  • Java 코드를 JavaScript로 감지
  • JSON 코드를 Plain Text로 감지
  • 언어를 아예 감지하지 못해 타입 에러(빨간줄) 발생

초기 구현은 Monaco의 언어 자동 감지 기능에 의존했지만, 실제 사용 시 Monaco는 Tiptap 안에서 들어오는 raw code의 문맥을 제대로 파악하지 못했다.

해결 방법

Monaco의 자동 감지에 의존하는 대신, 각 코드 블록마다 언어 선택 드롭다운을 직접 구현했다. 사용자가 명확하게 언어를 지정할 수 있도록 했다.

// monaco.editor.setModelLanguage() API로 명확하게 언어 지정
const handleLanguageChange = useCallback(
  (lang) => {
    updateAttributes({ language: lang });
  },
  [updateAttributes]
);

monaco.editor.setModelLanguage() API를 활용해 명확하게 언어를 지정해주었고, 사용자 편의성을 위해 Python, Java, JavaScript, TypeScript 등 28개 언어를 지원하도록 확장했다.


트러블슈팅 2: 여러 코드 블록의 NodeView 충돌

Tiptap NodeView는 내부에서 React 컴포넌트를 마운트하고 언마운트한다.
하지만 Monaco는 전역적으로 움직이는 에디터라서 동일 페이지에 여러 개가 존재하면:

  • focus가 갑자기 이동됨
  • 하이라이트가 꼬임
  • 특정 인스턴스가 재렌더링되면 다른 인스턴스도 영향을 받음

해결 방법

각 NodeView마다 독립적인 Monaco Model을 생성하는 방식으로 해결했다.

const handleEditorMount = (editor, monaco) => {
  setEditorInstance(editor);
  defineCustomTheme(monaco);
};

// Model을 NodeView 파괴 시 자동 dispose
useEffect(() => {
  return () => {
    if (editorInstance) {
      editorInstance.dispose();
    }
  };
}, [editorInstance]);
  1. 각 NodeView마다 독립적인 Monaco Model 생성
  2. Model을 NodeView 파괴 시 자동 dispose
  3. Tiptap의 update hook에서 Model과 EditorState를 동기화

이 방식은 구현 난이도가 높지만, 전문 코드 플랫폼들이 사용하는 정석 구조다.


트러블슈팅 3: 저장된 코드의 하이라이트 문제

문제 상황

게시글 본문은 API에서 받아온 HTML 문자열dangerouslySetInnerHTML로 렌더링하는 방식이다.

HTML 문자열이기 때문에:

  • JSX처럼 className을 동적 변경할 수 없음
  • DOM 트리가 이미 확정된 상태라 다크/라이트 테마 변경 어려움
  • highlight.js가 즉시 적용되지 않음

해결 방법

실제 DOM을 스캔하고 수동으로 하이라이트를 적용하는 방식으로 해결했다.

useEffect(() => {
  if (!contentRef.current) return;

  const timer = setTimeout(() => {
    // Monaco 코드 블록 처리
    const monacoBlocks = contentRef.current.querySelectorAll(
      'pre[data-type="monaco-code-block"]'
    );
    
    monacoBlocks.forEach(block => {
      const code = block.getAttribute('data-code');
      const language = block.getAttribute('data-language');
      
      if (code) {
        // HTML 엔티티 디코딩
        const decodeHTML = (html) => {
          const txt = document.createElement('textarea');
          txt.innerHTML = html;
          return txt.value;
        };
        
        const decodedCode = decodeHTML(code);
        
        // 코드 블록 재구성
        block.innerHTML = '';
        const codeElement = document.createElement('code');
        codeElement.className = `language-${language || 'plaintext'}`;
        codeElement.textContent = decodedCode;
        block.appendChild(codeElement);
        
        // Syntax Highlighting 적용
        hljs.highlightElement(codeElement);
      }
    });
  }, 100);

  return () => clearTimeout(timer);
}, [board, isDark]);

처리 흐름 :

  1. document.querySelectorAll로 실제 DOM에서 코드 블록 스캔
  2. data-code, data-language 속성에서 코드와 언어 정보 추출
  3. HTML 엔티티 디코딩 처리
  4. 현재 다크모드 여부에 따라 highlight.js로 동적 하이라이트 적용
  5. 테마 변경 감지(MutationObserver)로 자동 재적용

이는 기술적으로 보면 "HTML 기반 뷰어에서 동적 코드 하이라이트 적용"이라는 상당히 고난이도의 React/XSS-safe 작업이다.


트러블슈팅 4: 다크모드/라이트모드 적용

Tailwind CSS의 dark: 클래스가 정상 작동하면 간단했을 것이다. 하지만 Vite + Tiptap + Shadow DOM이 얽힌 구조에서는 빌드 결과물에서 dark 클래스가 Scope 밖으로 빠지는 버그가 발생했다.

  • 다른 페이지들(SignIn 등)은 dark: 클래스가 작동
  • 하지만 FreeboardList 같은 특정 컴포넌트에서는 dark: 클래스가 작동하지 않음

원인 : @tailwindcss/vite 플러그인의 알려진 버그로, 특정 클래스 조합에서 dark 클래스가 제대로 빌드되지 않는다.

해결 방법: MutationObserver + 조건부 스타일링

// 다크모드 감지
useEffect(() => {
  const checkDarkMode = () => {
    setIsDark(document.documentElement.classList.contains('dark'));
  };
  
  checkDarkMode();
  
  const observer = new MutationObserver(checkDarkMode);
  observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: ['class']
  });
  
  return () => observer.disconnect();
}, []);

// 테마 변경 시 Monaco 테마 적용
useEffect(() => {
  if (editorInstance) {
    monaco.editor.setTheme(isDark ? "vs-dark" : "github-light");
  }
}, [isDark, editorInstance]);

Tailwind의 dark: 클래스에 의존하지 않고, MutationObserver로 다크모드 변경을 감지하고 상태 기반 조건부 스타일링으로 전환했다.

동작 방식

  1. document.documentElement의 class 변경을 MutationObserver로 감지
  2. dark 클래스가 추가/제거되면 상태(isDark) 업데이트
  3. 상태에 따라 Monaco 테마를 동적으로 변경

이 방식의 장점

  • Tailwind dark 버그에 의존하지 않음
  • Tiptap 내부에서도 안정적으로 다크모드 반영
  • Monaco theme 전환도 즉시 반영 가능
  • 다크모드에서는 VS2015 테마, 라이트모드에서는 GitHub 테마 자동 적용

다만 다크모드와 같은 공통 부분을 페이지마다 구현해야 하는 것이 아직 성에 차지 않아서
추후에 수정할 것 같다.


트러블슈팅 5: atom 노드의 Content Hole 에러

문제 상황

링크 프리뷰나 Monaco 코드 블록을 저장하려고 하면 다음과 같은 에러가 발생했다.

RangeError: Content hole not allowed in a leaf node spec

원인 분석

Tiptap에서 atom: true로 설정된 노드는 자식 노드를 가질 수 없다. 하지만 HTML 렌더링 시 content를 표시하기 위한 "구멍(hole)"을 만들고 있어서 충돌이 발생했다.

Monaco 코드 블록과 링크 프리뷰 모두 atom: true 노드로 설정되어 있었는데, renderHTML 메서드에서 content hole을 반환하고 있었다.

// 문제가 있던 코드
renderHTML({ HTMLAttributes }) {
  return ["div", mergeAttributes(HTMLAttributes, { 
    "data-type": "link-preview" 
  }), 0]; // 이 0이 문제!
},

해결 방법

content hole을 제거하고, 필요한 데이터는 data 속성으로 저장하도록 수정했다.

// LinkPreview.js
renderHTML({ node, HTMLAttributes }) {
  return [
    "div", 
    mergeAttributes(HTMLAttributes, { 
      "data-type": "link-preview",
      "data-title": node.attrs.title,
      "data-description": node.attrs.description,
      "data-image": node.attrs.image,
      "data-site": node.attrs.site,
      "data-url": node.attrs.url,
    })
  ]; // content hole 제거
},

// MonacoCodeBlock.js
renderHTML({ node, HTMLAttributes }) {
  return [
    "pre",
    {
      ...HTMLAttributes,
      "data-type": "monaco-code-block",
      "data-language": node.attrs.language,
      "data-code": node.attrs.code,
      class: "monaco-code monaco-code-block-wrapper",
    },
  ]; // content hole 제거
},

수정 내용

  • renderHTML에서 content hole (0) 제거
  • 노드의 속성들을 data-* 형태로 HTML 속성에 저장
  • parseHTML에서 data-* 속성을 읽어서 노드 복원

이렇게 하면 atom 노드의 규칙을 지키면서도 필요한 데이터를 모두 저장하고 불러올 수 있다.


회고

이번 에디터 작업을 하면서 느낀 점은 하나였다.

아무리 많은 에러가 나도 결국 해결되지 않는 문제는 없다.

다크모드를 고치면 코드블록이 안 보이고,
라이트모드를 맞추면 언어 선택 UI가 따라오지 않아서 다시 구조를 뜯어보고,
하나를 해결하면 다른 문제가 튀어나오는, 정말 두더지 잡기 같은 반복이었다.

그런데 신기하게도, 그 과정이 전혀 두렵지 않았다.
오히려 이것도 결국 해결할 수 있다는 확신이 계속 있었고,
버그가 생길 때마다 에디터의 구조가 더 명확하게 이해되는 느낌이 들었다.
어떻게 렌더링되고, 어떤 단계에서 충돌하고, 왜 특정 옵션이 먹히지 않는지…
문제를 하나씩 잡아갈 때마다 퍼즐이 맞춰지듯 머릿속 구조가 더 선명해졌다.

그리고 무엇보다 정말 재미있었다.

내가 매일 쓰던 Velog 에디터보다 더 강력하고,
더 개발자 친화적인 글쓰기 도구를 내가 직접 만들고 있다는 사실이
너무 신기했고, 너무 보람찼다.

이 에디터는 단순히 글을 쓰는 기능을 만드는 게 아니라,
사용자가 더 보고 싶고 더 쓰고 싶어지는 글쓰기 경험을 만드는 작업이었다.
그래서 그 과정이 힘들기보다는 오히려 창작에 가깝게 느껴질 정도로 즐거웠다.

앞으로 이 툴로 글을 쓰는 사람들이
편하고, 재밌고, 또 쓰고 싶다고 느낀다면, 그걸로 충분히 가치 있는 작업이었다고 생각한다.


참고 자료

profile
개발자 소희의 노트

0개의 댓글