0333

FE

[SvelteKit] Markdown Editor 구현하기

| 서론

안녕하세요! 팡일입니다.

오늘은 SvelteKit 환경에서 Markdown Editor를 구현하면서 겪은 시행착오와

결국 markdown-it 기반 커스텀 방식으로 정착하게 된 이유 및 사용방법에 대해서 공유해보려고 합니다.

처음에는 이미 존재하는 마크다운 관련 패키지를 이용해 손쉽게 구현해보려고 했지만,

실제로 적용해보니 생각보다 호락호락하지 않다는 것을 느꼈습니다.

| 시도해본 3가지 접근법

1) mdsvex - "Svelte 안에서 마크다운을 컴포넌트처럼 쓰기"

mdsvex는 마크다운 파일을 Svelte 컴포넌트처럼 사용할 수 있게 해줍니다.

즉, .svx 파일을 만들어서 import만 하면 그대로 화면에 렌더링되는 거죠.

하지만, 실시간으로 작성하고 미리보기 하는 구조에는 어울리지 않았습니다.

한마디로 사용자가 textarea에 글을 쓰고 바로 변환한 결과를 보고 싶을 때는 mdsvex가 할 수 있는 게 거의 없었습니다.

-> 결론 : 문서를 보여줄 땐 좋지만, "에디터"로는 부족함.

2) svelte-markdown-input — "편리한데, 타입과 옵션 충돌"

mdsvex의 아픔을 딛고 새로 찾게 되어 정말 기대했던 패키지였습니다.

textarea와 preview를 한 번에 제공해주는 구조라, 제가 원하는 '즉시 미리보기'에 딱 맞을 것 같았습니다.

그런데 막상 TypeScript와 함께 쓰자마자 오류가 폭탄처럼 쏟아져 나왔습니다,,

SvelteKit 빌드 중 micromark 버전 충돌, 타입 인식 오류가 계속 났습니다.

게다가 CSS나 UI를 커스터마이징하기도 어려웠습니다.

-> 결론 : 아이디어는 좋았지만, 실무에 쓰기엔 불안정함.

3) marked — "렌더링은 되지만, 너무 날것 그대로"

marked는 오래된 마크다운 렌더러라서, 기본 기능은 정말 잘 동작했습니다.

marked.parse(text)만 해도 HTML이 바로 나왔습니다.

문제는 너무 기본적이라는 점이었습니다.

코드 하이라이드, 체박스 등을 넣으려면 전부 직접 구현해야 했고, 조금 더 알아보니 XSS라는 보안 처리도 수동으로 해야 했습니다.

-> 결론 : 단순한 마크다운 변환엔 좋지만, 확장성 부족

| 최종 선택 — markdown-it 기반 직접 구현

결국 저는 markdown-it을 사용해서 직접 renderMarkdown 함수를 만들어 쓰기로 했습니다.

이 방식은 “적당한 자유도 + 안정성 + 단순함” 세 마리 토끼를 모두 잡았습니다.

1) 패키지 설치

bash
npm install markdown-it

2) src/lib/markdown.ts

TypeScript
import MarkdownIt from 'markdown-it';

const md = new MarkdownIt();

export function renderMarkdown(text: string) {
  return md.render(text);
}

3) Svelte 페이지 (예 : src/routes/write/+page.svelte)

TypeScript
<script lang="ts">
	import { renderMarkdown } from '$lib/markdown';
	let raw = '';
	let html = '';

	function updatePreview() {
		html = renderMarkdown(raw);
	}
</script>

<textarea bind:value={raw} on:input={updatePreview}></textarea>
<div class="preview">{@html html}</div>

이제 입력하는 즉시 미리보기로 반영됩니다.


원한다면 서버에서 불러온 Markdown 데이터도 별도의 {@html}로 표시할 수 있죠.

| 목업 데이터 불러오기

버튼을 누르면 아래처럼, 서버에서 받아온 것처럼 보이는 목업 Markdown을 표시할 수도 있습니다.

즉, markdown-it은 parser 역할만 하고, 렌더링 로직은 내가 완전히 제어할 수 있는 구조였습니다.

TypeScript
<script lang="ts">
	import { renderMarkdown } from '$lib/markdown';
	let raw = '';
	let html = '';

	function updatePreview() {
		html = renderMarkdown(raw);
	}

	let fetchedHtml = '';
	let isLoading = false;

	async function fetchMockMarkdown() {
		isLoading = true;
		await new Promise((r) => setTimeout(r, 800));

		const mockData = {
			content: `
# ? 서버 목업 데이터
이 데이터는 실제 서버가 아닌,  
**클라이언트 내부에서 만들어진 목업(Mock) 데이터**입니다.

- 비동기 fetch 시뮬레이션
- markdown-it으로 렌더링
- 서버 통신처럼 보이지만 로컬 실행

> ? 이후 Firestore나 API 연동 시 이 구조를 그대로 활용하면 됩니다!
			`
		};

		fetchedHtml = renderMarkdown(mockData.content);
		isLoading = false;
	}
</script>

<textarea bind:value={raw} on:input={updatePreview}></textarea>
<div class="preview">{@html html}</div>

<button on:click={fetchMockMarkdown}>? 서버 목업 불러오기</button>

{#if isLoading}
	<p>⌛ 로딩 중...</p>
{:else if fetchedHtml}
	<div class="preview secondary">
		{@html fetchedHtml}
	</div>
{:else}
	<p>아직 불러온 데이터가 없습니다.</p>
{/if}

| 결론

세 가지 패키지를 모두 사용해본 결과, markdown-it은 가장 단순하면서도 가장 유연한 선택지였습니다.

mdsvex처럼 복잡한 설정도 없고, svelte-markdown-input처럼 타입 오류에 시달리지도 않으며,
marked보다 훨씬 확장성이 높습니다.

이를 사용하여 추후 Firestore, 서버 API, 코드 하이라이팅 등 다양한 확장을 유연하게 시도해보려고 합니다.