
실시간 마크다운 프리뷰 에디터 구현기
실시간 마크다운 프리뷰 에디터 구현기
블로그에 마크다운 글을 작성하면서 실시간으로 렌더링 결과를 확인할 수 있는 프리뷰 기능을 구현했습니다. Notion에서 글을 작성하기 전에 마크다운 문법과 커스텀 마커(퀴즈, 광고 등)가 어떻게 렌더링되는지 미리 확인할 수 있습니다.
기능 소개
접근 방법
프리뷰 페이지는 두 가지 방법으로 접근할 수 있습니다:
- 블로그 목록 페이지 - 상단의 "글쓰기 프리뷰" 버튼
- 블로그 상세 페이지 - 각 포스트 상단의 "글쓰기 프리뷰" 버튼 (해당 글 내용이 자동으로 로드됨)
주요 기능
- 실시간 프리뷰: 좌측에서 마크다운을 작성하면 우측에서 즉시 렌더링 결과 확인
- 줄 번호 표시: 에디터에 GitHub 스타일의 줄 번호 표시
- 스크롤 동기화: 에디터와 프리뷰 영역의 스크롤이 연동
- URL 상태 유지: 작성 중인 내용이 URL에 저장되어 새로고침해도 유지
- 커스텀 마커 렌더링: 광고(🅱️), 퀴즈(❓...❓) 등 특수 마커도 실제와 동일하게 렌더링
기술적 구현
1. URL 파라미터로 상태 관리
작성 중인 제목과 내용을 URL 파라미터에 저장하여 브라우저를 새로고침해도 내용이 유지되도록 했습니다. 데이터 크기를 줄이기 위해 lz-string 라이브러리로 압축합니다.
import LZString from "lz-string"; // URL 파라미터에서 초기값 로드 useEffect(() => { const dataParam = searchParams.get("d"); if (dataParam) { try { const decompressed = LZString.decompressFromEncodedURIComponent(dataParam); if (decompressed) { const data = JSON.parse(decompressed); if (data.t) setTitle(data.t); if (data.c) setContent(data.c); } } catch { // 파싱 실패시 무시 } } }, []); // 내용 변경시 URL 업데이트 (500ms debounce) useEffect(() => { const timer = setTimeout(() => { if (title || content) { const data = JSON.stringify({ t: title, c: content }); const compressed = LZString.compressToEncodedURIComponent(data); setSearchParams({ d: compressed }, { replace: true }); } }, 500); return () => clearTimeout(timer); }, [title, content]);
2. lz-string ESM 호환 이슈
SSG(Static Site Generation) 빌드 시 lz-string이 CommonJS 모듈이라 named import가 동작하지 않는 문제가 있었습니다.
// ❌ SSG 빌드 실패 import { compressToEncodedURIComponent } from "lz-string"; // ✅ default import 사용 import LZString from "lz-string"; LZString.compressToEncodedURIComponent(data);
3. 원본 글로 돌아가기 기능
블로그 상세 페이지에서 프리뷰로 이동할 때 from 파라미터에 원본 글의 slug를 전달합니다. 이를 통해 프리뷰에서 "글로 돌아가기" 버튼을 표시할 수 있습니다.
// BlogPost.tsx - 프리뷰 링크 <Link to={`/blog/preview?d=${LZString.compressToEncodedURIComponent( JSON.stringify({ t: post.title, c: post.content }) )}&from=${encodeURIComponent(post.slug)}`} > 글쓰기 프리뷰 </Link> // BlogPreview.tsx - 조건부 버튼 렌더링 const initialFromRef = useRef<string | null>(null); useEffect(() => { initialFromRef.current = searchParams.get("from"); // ... }, []); // 렌더링 {initialFromRef.current ? ( <Link to={`/blog/${initialFromRef.current}`}>글로 돌아가기</Link> ) : ( <Link to="/blog">블로그로 돌아가기</Link> )}
from 파라미터는 useRef로 초기값만 저장하여 URL이 업데이트될 때 덮어씌워지지 않도록 했습니다.
4. 스크롤 동기화
에디터와 프리뷰 영역의 스크롤 위치를 비율로 동기화합니다. 동시 업데이트로 인한 무한 루프를 방지하기 위해 스크롤 중인 영역을 추적합니다.
const isScrollingRef = useRef<"editor" | "preview" | null>(null); const syncScroll = useCallback((source: "editor" | "preview") => { if (isScrollingRef.current && isScrollingRef.current !== source) return; const editor = editorRef.current; const preview = previewRef.current; if (!editor || !preview) return; isScrollingRef.current = source; const sourceEl = source === "editor" ? editor : preview; const targetEl = source === "editor" ? preview : editor; const scrollRatio = sourceEl.scrollTop / (sourceEl.scrollHeight - sourceEl.clientHeight || 1); const targetScrollTop = scrollRatio * (targetEl.scrollHeight - targetEl.clientHeight); targetEl.scrollTop = targetScrollTop; // 스크롤 잠금 해제 setTimeout(() => { isScrollingRef.current = null; }, 50); }, []);
5. 줄 번호 동기화
textarea는 CSS로 줄 번호를 표시할 수 없어서 별도의 div로 줄 번호를 렌더링하고, 스크롤 시 transform으로 위치를 동기화합니다.
// 줄 번호 영역 <div ref={lineNumbersRef} className="font-mono text-xs"> {lines.map((_, index) => ( <div key={index} style={{ height: '24px', lineHeight: '24px' }}> {index + 1} </div> ))} </div> // 스크롤 동기화 const handleEditorScroll = (e: React.UIEvent<HTMLTextAreaElement>) => { if (lineNumbersRef.current) { lineNumbersRef.current.style.transform = `translateY(-${e.currentTarget.scrollTop}px)`; } };
6. 광고 플레이스홀더
프리뷰 페이지에서는 실제 광고 대신 플레이스홀더만 표시합니다. preview prop으로 제어합니다.
// AdPlaceholder.tsx const AdPlaceholder = ({ type, preview = false }: AdPlaceholderProps) => { return ( <div className="w-[728px] h-[90px] ..."> {/* 플레이스홀더 - 항상 표시 */} <div className="absolute inset-0 ..."> <img src="/images/google_ads_logo_icon.png" alt="AD" /> </div> {/* 실제 광고 - preview가 아닐 때만 */} {!isDev && !preview && ( <ins className="adsbygoogle" ... /> )} </div> ); }; // BlogPreview.tsx <AdPlaceholder type="horizontal" preview />
화면 제한
에디터와 프리뷰를 나란히 배치하는 레이아웃 특성상 최소 화면 너비 1500px이 필요합니다. 작은 화면에서는 안내 메시지를 표시합니다.
const MIN_WIDTH = 1500; const [isSupported, setIsSupported] = useState(true); useEffect(() => { const checkWidth = () => setIsSupported(window.innerWidth >= MIN_WIDTH); checkWidth(); window.addEventListener("resize", checkWidth); return () => window.removeEventListener("resize", checkWidth); }, []); if (!isSupported) { return ( <div> <Monitor className="w-24 h-24" /> <h1>더 큰 화면이 필요합니다</h1> <p>글쓰기 프리뷰는 {MIN_WIDTH}px 이상의 화면에서 지원됩니다.</p> </div> ); }
마무리
이 기능을 통해 Notion에 글을 작성하기 전에 마크다운이 어떻게 렌더링되는지 미리 확인할 수 있게 되었습니다. 특히 퀴즈나 이미지 캡션 같은 커스텀 문법을 사용할 때 유용합니다.
URL에 상태를 저장하는 방식 덕분에 작성 중인 내용을 링크로 공유하거나 나중에 다시 불러올 수도 있습니다.