
평일 내내 배가 아파서 병원을 갔더니 "응급실 가야겠는데요?"
...?
병원 간 당일에 바로 수술을 하였다..
해야 할 일이 산더미인데 회복과 함께 일주일이 날아갔다.
건강하게 지내는 게 참 중요한 것 같다. 다들 건강한 날들 보내길!
이번 글에서는 직접 텍스트 에디터를 구현하면서 겪은 시행착오, 그리고 다음 글에서 다룰 Tiptap 라이브러리 사용기의 배경까지 정리해 보려고 한다.
이전 포스팅과 같은 외주사에서 블로그와 비슷한 텍스트 에디터 개발을 부탁하셨다.
전달받은 디자인을 기반으로 하되, 각각의 텍스트 스타일 기능들은 네이버 블로그를 참고하여 만들면 좋겠다고 하셨다.
텍스트 에디터 라이브러리를 살펴보고, 네이버 블로그를 간단히 훑어보았다.
텍스트 에디터 라이브러리를 찾아보자
이번 개발에서 중요한 것은 텍스트 에디터와 더불어, 다른 기능을 넣을 수 있도록 커스텀이 용이한 라이브러리가 필요했다.
문서 프로그램처럼 글자 사이에 사진 첨부 및 크기 조절, 그리고 영상, 투표, 지도와 같은 커스텀 블록을 배치해야 했다
살펴본 라이브러리는 크게 세 가지였다.
1. Toast UI
Toast UI는 과감하게 사용하지 않았다. Github 마지막 업데이트가 무려 4년 전.. NPM 에는 무려 7년 전에 마지막 버전이 올라왔다.
2. Tiptap
처음에는 25년 초중순 기준으로 레퍼런스가 많지 않아, 러닝커브가 길어질 것을 우려해 도입을 보류했다. 그래서 한동안은 직접 텍스트 에디터를 구현하면서 스타일 토글, 이미지 리사이즈, 대표 이미지 처리 같은 로직을 하나씩 만들어 갔다.
이후 다시 Tiptap을 살펴보니, 직접 구현해 둔 로직들을 Tiptap의 extension으로 녹여 넣을 수 있다는 걸 확인했다. 완전히 처음부터 갈아엎기보다는, 이미 만든 로직을 살리면서 에디터의 기본기와 생태계를 가져올 수 있겠다는 판단이 들어 최종적으로 Tiptap으로 전환했다.
3. react-quill-new
개발 초기에는 react-quill-new를 도입을 시도하였다. 레퍼런스가 많진 않지만 당시에 다른 라이브러리에 비해 많다고 판단하였고, React 환경에서 쉽게 구현할 수 있다는 점이 주된 도입 이유였다.
하지만, 에디터 주변에 영상·투표·지도 같은 커스텀 블록을 함께 배치하기가 어려웠다. 즉, react-quill-new는 텍스트 에디터 기능 외에 다른 기능을 커스텀할 수 없는 문제가 있었다.
따라서 당시에는 라이브러리에 의존하지 않고, 직접 텍스트 에디터를 구현해 보기로 했다.
네이버 블로그는 어떤 구조를 가지고 있을까
직접 에디터를 구현하기에 앞서, 오래되었고 인지도가 있는 네이버 블로그의 에디터를 조사했다.
조사한 블로그에서는 에디터 제작에 있어서 중요한 힌트를 얻을 수 있었다.
1. <div> <p> <span> 구조

블로그 입력란에 위와 같이 세 가지 문장을 입력하고, 개발자 모드(DevTools)에 접속하여 html 코드가 어떻게 이루어져 있는지 확인하였다.
<div id="..." class="...">
<p id="..." class="...">
<span id="..." class="...">
안녕하세요
</span>
</p>
<p id="..." class="...">
<span id="..." class="...">
반갑습니다
</span>
</p>
<p id="..." class="...">
<span id="..." class="...">
어서오세요
</span>
</p>
</div>
코드를 보면 크게 div에 감싸져 있고, 한 라인 (ex. 안녕하세요)은 <p>와 <span>, 그리고 텍스트로 이루어져 있는 걸 확인할 수 있었다.

여기서 의문점은 왜 <p> 안에 <span>이 있는가였다. <p>와 <span> 중 한 가지만 사용하지 되지 않을까 라는 생각이었다.
하지만 의문점은 스타일을 적용하고 난 후 해결되었다.
2. 스타일 적용에 따라 <span>으로 분리

"안녕하세요"라는 문장에서 "안녕"에는 볼드체와 기울임체, "요"는 볼드체로 바꾸었다.
<p id="..." class="...">
<span id="..." class="..."><i><b>안녕</b></i></span>
<span id="..." class="...">하세</span>
<span id="..." class="..."><b>요</b></span>
</p>
스타일을 적용하고 나서 코드를 다시 보면, 스타일이 들어간 "안녕"과 "요", 그리고 스타일이 없는 "하세"가 각각 하나의 <span>으로 쪼개져 있는 걸 알 수 있다.
즉, 텍스트 일부에 스타일을 주면 해당 구간이 자동으로 <span>으로 감싸지며 다른 구간과 분리된다는 점을 확인할 수 있었다.
이렇게 2가지의 힌트를 가지고 에디터를 직접 만들기 시작했다.
스타일 토글 로직을 만들면서 부딪힌 문제들
스타일 적용 코드를 만드는 과정에서 꽤나 많은 수정 단계를 거쳤다.
예상보다 많은 예외처리를 나누는데 많은 시간을 소비하였지만, 결국 코드를 완성하긴 하였다.
간략하게 아래와 같이 요약할 수 있을 것 같다.
- 먼저 선택 영역에 포함된 텍스트 노드들을 전부 수집하고,
각 노드에 대해 현재 해당 스타일이 이미 적용되어 있는지 검사한다. - 이때 전체가 적용할 스타일을 가지고 있는 경우(allHaveStyle === true)에는 해당 스타일을 제거
- 반대로 전체가 적용할 스타일을 가지고 있지 않은 경우(allHaveStyle === false)에는 해당 스타일을 추가
- 아직 스타일이 없는 텍스트 조각에만 아래와 같이 스타일을 추가
- style.type === "tag" → <b>, <i> 등 태그를 감싸서 추가
- style.type === "inline" → style[key] = value 형태로 인라인 스타일 추가
- style.type === "class" → classList.add(...)로 폰트 클래스 등 추가
- 아직 스타일이 없는 텍스트 조각에만 아래와 같이 스타일을 추가
또한, 드래그된 텍스트를 앞 / 드래그된 부분 / 뒤의 3구간으로 나눈 뒤, 드래그된 부분에만 스타일을 적용하거나 제거하였다. 그리고 앞·뒤 구간은 원래 가지고 있던 스타일을 그대로 유지하도록 텍스트를 다시 조합해 선택 범위 밖의 기존 스타일이 깨지지 않도록 처리하였다.
스타일 적용 로직이 담긴 전체 코드는 Gist에 정리했다.
👉 https://gist.github.com/jaqwe2301/074639d6ac94f5c246f7e565a8e0539b
스타일 적용 로직
스타일 적용 로직. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
스타일 적용 코드에서
- 선택된 텍스트를 앞 / 선택 / 뒤로 쪼개고
- 선택된 구간에만 스타일을 추가/제거하고
- 새 span들로 원래 span을 갈아 끼우는 부분
정도의 세 가지를 나타낸 코드는 아래와 같다.
textNodesInSpan.forEach((textNode) => {
const fullText = textNode.nodeValue ?? "";
const isStart = textNode === range.startContainer;
const isEnd = textNode === range.endContainer;
const startOffset = isStart ? range.startOffset : 0;
const endOffset = isEnd ? range.endOffset : fullText.length;
const before = fullText.slice(0, startOffset);
const selected = fullText.slice(startOffset, endOffset);
const after = fullText.slice(endOffset);
const fragments: Node[] = [];
// 1) 앞부분은 기존 스타일 그대로 감싸서 유지
if (before) {
fragments.push(createWrappedSpan(before, textNode, originalSpan));
}
// 2) 선택된 부분만 스타일 토글
if (selected) {
const spanForSelected = createWrappedSpan(selected, textNode, originalSpan);
if (allHaveStyle) {
// 모두 스타일이 적용되어 있던 경우 → 선택 구간에서만 스타일 제거
removeStyleFromSpan(spanForSelected, style);
} else if (!hasStyleApplied(textNode, style)) {
// 스타일이 없던 경우 → 선택 구간에만 스타일 추가
applyStyleToSpan(spanForSelected, style);
}
fragments.push(spanForSelected);
}
// 3) 뒷부분도 기존 스타일 유지
if (after) {
fragments.push(createWrappedSpan(after, textNode, originalSpan));
}
// 4) 기존 span을 잘라낸 조각들로 교체
const parent = originalSpan.parentNode;
if (!parent) return;
fragments.forEach((node) => parent.insertBefore(node, originalSpan));
parent.removeChild(originalSpan);
});
이미지 크기 조절, 대표 이미지 선정 구현

네이버 블로그는 이미지의 크기를 조절할 수 있고, 포스팅의 대표 이미지를 선정하는 기능을 제공한다.
또한, 텍스트만 편집하는 것이 아니라, 네이버 블로그처럼 본문 중간에 이미지를 넣고 크기를 조절할 수 있어야 했다. 특히 이미지를 선택하면 모서리에 리사이즈 핸들이 생기고, 드래그로 비율을 유지한 채 크기를 조절할 수 있도록 구현하는 것을 목표로 하였다.
이를 위해 각 이미지 블록을 data-id로 식별하고, 현재 선택된 이미지(selectedImageId)에만 리사이즈 핸들을 동적으로 붙이는 방식으로 구현했다. 핸들 자체는 React 컴포넌트가 아니라, DOM에서 직접 <div>를 만들어 position: absolute로 이미지 컨테이너 위에 올리는 구조다.
리사이즈 로직은 크게 다음과 같은 흐름으로 동작한다.
- 드래그를 시작할 때 마우스 위치와 이미지의 현재 크기를 기준점으로 저장해 두고, 이후 mousemove 이벤트에서 기준점 대비 마우스 이동량을 계산해 새로운 width를 구한다.
- height는 기존 가로·세로 비율(aspect ratio)을 유지하도록 newHeight = newWidth / aspectRatio로 계산한다.
- 계산된 크기를 img.style.width/height에 반영하고, 핸들과 버튼 위치를 다시 계산해 모서리를 따라오도록 업데이트한다.
- mouseup 시점에는 mousemove/mouseup 리스너를 제거해 이벤트 누수를 막는다.
아래 코드는 한 모서리용 리사이즈 핸들을 생성하는 핵심 부분이다.
const createResizeHandle = (
position: "nwse" | "nesw",
img: HTMLImageElement,
updateOverlay: () => void // 핸들/버튼 위치 다시 잡는 함수
) => {
const handle = document.createElement("div");
handle.classList.add("resize-handle");
handle.style.position = "absolute";
handle.style.cursor = `${position}-resize`;
handle.contentEditable = "false";
handle.onmousedown = (event) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = img.clientWidth;
const startHeight = img.clientHeight;
const aspectRatio = startWidth / startHeight;
const onMouseMove = (moveEvent: MouseEvent) => {
const dx = moveEvent.clientX - startX;
// 드래그 방향에 따라 너비 계산
let newWidth =
position === "nwse" || position === "ne"
? startWidth + dx
: startWidth - dx;
// 최소/최대 크기 제한 (예: 100px ~ 800px)
newWidth = Math.max(100, Math.min(800, newWidth));
const newHeight = newWidth / aspectRatio;
img.style.width = `${newWidth}px`;
img.style.height = `${newHeight}px`;
// 핸들, 대표/삭제 버튼이 항상 모서리를 따라오도록 위치 업데이트
updateOverlay();
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
return handle;
};
실제 코드에서는 네 방향(좌상단, 우상단 등)에 대한 핸들을 생성하고, 모바일 환경에서의 리사이즈를 다르게 처리했다. 전체 구현 코드는 아래 링크를 통해 확인할 수 있다.
👉 https://gist.github.com/jaqwe2301/a4bdbf180a33503444a6f664af95ba6f
이미지 리사이즈 전체 코드
이미지 리사이즈 전체 코드. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
이외에도 많은 로직과 코드를 만들었지만, 결과적으로 라이브러리 없이 텍스트 에디터를 만드는 것은 중단했다. 대신, 직접 구현하면서 쌓은 스타일/이미지/편집 로직을 그대로 살릴 수 있는 그릇으로 Tiptap을 선택해 전환했다.
Tiptap으로 옮기면서 어떤 부분을 재사용했고, 어떻게 extension으로 분리했는지는 다음 포스트에서 조금 더 자세히 정리해 보겠다.
다음 포스트: https://mini-frontend.tistory.com/14
[React] 텍스트 에디터 제작기 2 (Tiptap에 다양한 로직 결합하기)
Tiptap 텍스트 에디터 개발 코드와 배포 링크는 아래에서 확인할 수 있습니다.배포 링크: https://text-editor.bio/Github 코드: https://github.com/jaqwe2301/text-editor 이전 포스팅에 이어서 아래 세 가지에 관하여
mini-frontend.tistory.com
'문제 해결 (개발)' 카테고리의 다른 글
| [React] 텍스트 에디터 제작기 2 (Tiptap에 다양한 로직 결합하기) (3) | 2025.12.04 |
|---|---|
| Next.js 에서 NICE 본인인증 구현하기2 (Route Handlers) (0) | 2025.11.18 |
| Next.js 에서 NICE 본인인증 구현하기 1 (iOS, 카카오톡 인앱 브라우저 환경에서는 왜 안될까) (0) | 2025.11.18 |
| AWS 서버 비용 줄여 보자! (EC2) (1) | 2024.04.25 |