블록 기반 에디터 만들기
이 글은 ‘프론트엔드 아키텍처 패턴’ 시리즈의 한 편이다. 커뮤니티/아티클 글쓰기 화면을 만들면서 정리한 “블록 기반 에디터” 설계 노트다.
들어가며
커뮤니티에 글을 쓰고 댓글을 달 수 있게 만들면서 처음엔 그냥 <textarea> 하나 놓으면 되는 줄 알았다. 실제로 시작은 그랬다. 그런데 요구가 하나씩 붙는다. 텍스트만 있으면 됐던 입력창에 이미지도 넣어야 하고, 비밀댓글 토글도 붙고, 나중엔 투표 카드 같은 것도 본문 안에 끼워야 한다.
이쯤 되면 “본문 = 문자열 하나”라는 모델이 무너진다. 문자열 안에 이미지 URL을 섞고 파싱해서 다시 그리는 식으로 버티다 보면 금방 지옥이 온다. 그래서 본문을 하나의 긴 문자열이 아니라 “블록의 배열”로 보는 관점으로 넘어갔다. 이 글은 그 이야기다.
왜 블록인가
블록 기반 에디터의 아이디어는 단순하다. 문서를 이렇게 본다.
문서 = [ 블록, 블록, 블록, ... ]
각 블록은 자기 타입과 데이터만 안다.
type Block =
| { type: 'paragraph'; text: string }
| { type: 'image'; url: string; alt?: string }
| { type: 'vote'; voteId: number };
type DocModel = Block[];
이렇게 두면 좋은 점:
- 렌더와 저장이 대칭이 된다. 저장할 땐
DocModel을 JSON으로 직렬화, 그릴 땐type으로 분기. - 새 콘텐츠 종류(투표, 임베드, 표…)가 생겨도 블록 타입 하나 + 렌더러 하나만 추가하면 된다. 기존 블록은 안 건드린다.
- 커서 위치, 삽입/삭제가 “배열 인덱스 조작”으로 환원된다.
우리 서비스의 실제 글쓰기 화면도 결국 이 방향으로 수렴했다. 지금 프로덕션의 글쓰기/댓글 폼은 “텍스트 블록 + 이미지 블록 + 옵션(비밀 여부)“의 최소 조합이다. 아래는 그 폼의 상태 모델인데, 사실상 블록 두 종류를 다루고 있는 셈이다.
const [value, setValue] = useState<string>(); // paragraph 블록
const [imageInfo, setImageInfo] = useState<IMAGE_DTO | null>(null); // image 블록
const [isSecret, setIsSecret] = useState<boolean>(false); // 문서 메타
const [isUploading, setIsUploading] = useState<boolean>(false);
이 셋을 하나의 payload로 묶어 서버로 보낸다. 텍스트와 이미지가 각자 독립된 조각이라는 게 여기서 이미 드러난다.
const payload = useMemo(
() => ({
commentId,
comment: value,
isSecret,
...(imageInfo ? { imageId: imageInfo.id, imageUrl: imageInfo.url } : {}),
}),
[value, imageInfo, isSecret, commentId],
);
렌더러: 타입으로 분기하는 스위치
블록 배열을 화면에 그리는 건 타입별 렌더러를 매핑하는 일이다. 핵심은 각 블록 렌더러를 서로 모르게 만드는 것.
function BlockRenderer({ block }: { block: Block }) {
switch (block.type) {
case 'paragraph':
return <ParagraphBlock text={block.text} />;
case 'image':
return <ImageBlock url={block.url} alt={block.alt} />;
case 'vote':
return <VoteBlock voteId={block.voteId} />;
default:
return null;
}
}
function DocumentView({ doc }: { doc: DocModel }) {
return (
<>
{doc.map((block, i) => (
<BlockRenderer key={i} block={block} />
))}
</>
);
}
Analytics 이벤트 레이어를 만들 때 sendEvent가 type으로 분기하던 것과 똑같은 구조다. “추가는 새 케이스로, 수정은 해당 케이스만”이 되는 패턴은 여기서도 그대로 쓴다.
편집 상태: 블록 배열 조작
편집은 결국 배열을 불변으로 갈아끼우는 일이다. 커스텀 훅으로 감싸두면 UI는 조작 API만 부른다.
function useBlockEditor(initial: DocModel = []) {
const [blocks, setBlocks] = useState<DocModel>(initial);
const updateBlock = (index: number, patch: Partial<Block>) =>
setBlocks(prev =>
prev.map((b, i) => (i === index ? ({ ...b, ...patch } as Block) : b)),
);
const insertBlock = (index: number, block: Block) =>
setBlocks(prev => [
...prev.slice(0, index),
block,
...prev.slice(index),
]);
const removeBlock = (index: number) =>
setBlocks(prev => prev.filter((_, i) => i !== index));
return { blocks, setBlocks, updateBlock, insertBlock, removeBlock };
}
텍스트 블록의 입력 자체는 여전히 textarea가 담당한다. 다만 스타일은 “에디터처럼” 보이게 브라우저 기본 테두리/포커스 링을 걷어냈다.
export const StyledTextArea = style([
Txt({ types: 'r14', align: 'start' }),
{
marginTop: 8,
padding: 0,
width: '100%',
lineHeight: '18px',
letterSpacing: '-0.5px',
resize: 'none',
outline: 'none',
border: 'none',
selectors: {
'&:focus': { outline: 'none', boxShadow: 'none' },
'&:focus-visible': { outline: 'none', boxShadow: 'none' },
'&::placeholder': { color: `${grey400}!important` },
},
},
]);
💡 모바일에서
textarea폰트가 16px 미만이면 iOS가 포커스 시 화면을 확대해버린다. 입력 폰트 크기와 확대 방지 설정을 같이 챙겨야 편집 경험이 안 튄다.
이미지 블록은 업로드가 비동기라 로딩 상태(isUploading)를 블록에 함께 두고, 업로드가 끝나면 URL을 채운다. 저장은 add/update를 뮤테이션으로 나눠 두고, 성공 시 목록을 갱신하고 닫는다.
const { mutate: addComment } = useMutation({
mutationFn: () => postComment(topic ?? '', contentId ?? '', payload),
onSuccess: async () => {
refreshList?.();
onClose();
},
onError: err => { throw err; },
});
렌더링 보안: sanitize는 선택이 아니다
블록을 문자열이 아니라 구조화된 데이터로 다루면 저장은 안전하다. 하지만 서버에서 내려온 HTML 콘텐츠를 그대로 그리는 순간(예: 리치 텍스트/외부 콘텐츠), XSS가 열린다. dangerouslySetInnerHTML은 이름 그대로 위험하다.
그래서 API로 받은 HTML은 화면에 넣기 전에 반드시 정화(sanitize)한다.
import DOMPurify from 'dompurify';
function HtmlBlock({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html);
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
직접 작성한 고정 코드나
JSON.stringify로 만든 구조화 데이터(예: JSON-LD)는 사용자 입력이 아니라서 sanitize 대상이 아니다. “외부/사용자에서 온 HTML”에만 적용하는 게 원칙이다. 우리 코드베이스에서도 이 기준으로 적용 범위를 나눴다.
로그인·권한 같은 부수 조건
에디터는 순수 UI만으로 끝나지 않는다. 글/댓글 작성은 로그인 상태에 묶인다. 그래서 입력창 활성화 조건에 로그인 여부를 넣고, 본인이 쓴 글에만 편집·삭제 메뉴를 노출하는 식으로 갈래를 친다.
const isLoggedIn = useLoginCheck();
// 로그인했을 때만 입력창을 열어준다
<CommentInput disabled={!isLoggedIn} placeholder={
isLoggedIn ? '댓글을 입력하세요' : '로그인 후 이용할 수 있어요'
} />
이런 조건은 블록 모델과는 직교하는 축이라, 에디터 코어(블록 조작)와 분리해서 폼 컴포넌트 쪽에 두는 게 깔끔하다.
정리
- 본문을 문자열 하나가 아니라 블록 배열로 모델링한다. 저장/렌더/편집이 전부 배열 조작으로 대칭이 된다.
- 렌더러는 타입 스위치로 분기하고, 새 콘텐츠는 블록 타입 + 렌더러 추가로 확장한다.
- 편집 로직은 커스텀 훅(
insert/update/remove)으로 가두고 UI는 API만 호출한다. - 이미지 같은 비동기 블록은 로딩 상태를 블록에 함께 둔다.
- 외부/사용자 HTML을 그릴 땐 DOMPurify로 반드시 sanitize한다. 직접 만든 고정 마크업은 예외.
- 로그인·권한 같은 부수 조건은 에디터 코어와 분리한다.
지금 우리 글쓰기 화면은 텍스트+이미지의 최소 블록 조합에서 출발했지만, 모델을 블록으로 잡아둔 덕에 투표 카드처럼 새 콘텐츠가 본문에 끼어들어도 구조가 흔들리지 않았다.
관련 작업
- 커뮤니티 댓글 작성 — 댓글 textarea UI, 등록/수정/삭제·이미지 업로드 API, 댓글 추가/수정 로직,
globalStyle('textarea')설정, 로그인 유무 판단, 모바일 iOS 확대 방지 - 댓글쓰기 창 활성화 조건(로그인 시) 추가
- DOMPurify 추가 — API로 받은 HTML 콘텐츠에 sanitize 적용, 직접 작성한 고정 코드·
JSON.stringify는 제외 - 커뮤니티 상세 이벤트를 정의된 이벤트명으로 정리(투표 카드 등 커뮤니티 콘텐츠)