안녕하세요!
개발자라면 깃허브(GitHub)나 벨로그(Velog)에서 글을 쓸 때, 왼쪽 창에 마크다운 기호를 입력하면 오른쪽 창에 즉시 예쁘게 변환된 화면이 나타나는 경험을 해보셨을 겁니다. 오늘은 외부 라이브러리 없이 순수하게 자바스크립트 마크다운 프리뷰어를 제작하며, 그 이면에 숨겨진 텍스트 파싱과 정규표현식의 원리를 깊이 있게 파헤쳐 보려고 합니다. 특히 단순히 글자를 바꾸는 것을 넘어, 브라우저가 텍스트를 어떻게 구조적으로 이해하게 만드는지 저의 실제 구현 경험을 바탕으로 상세히 공유해 드릴게요.

라이브러리를 버리고 정규표현식을 선택한 이유
처음 에디터 기능을 구상했을 때, 사실 marked.js 같은 훌륭한 라이브러리를 가져다 쓰면 5분 만에 끝날 일이었습니다. 하지만 “어떻게 ‘#’ 기호가 <h1>태그로 변하는 걸까?”라는 근본적인 호기심이 저를 멈춰 세우더군요.
수많은 if 문으로 일일이 조건을 따지기엔 코드가 너무 지저분해질 것이 뻔했습니다. 이때 정규표현식(Regex)은 구세주와 같았습니다. 복잡한 텍스트 패턴을 단 한 줄의 식으로 정의하고, 이를 원하는 HTML 태그로 치환하는 과정은 마치 암호를 해독하는 것 같은 짜릿함을 주었습니다. 이번 자바스크립트 마크다운 프리뷰어 프로젝트는 정규표현식이 단순히 ‘이메일 형식 검사’용이 아니라, 강력한 텍스트 처리 엔진이라는 것을 깨닫게 해준 소중한 경험이었습니다.
파싱 엔진의 심장: 정규표현식 패턴 설계
마크다운 파싱에서 가장 중요한 것은 ‘우선순위’였습니다. 예를 들어, 볼드체(text)를 먼저 처리할지, 제목(#Title)을 먼저 처리할지에 따라 결과가 완전히 달라질 수 있거든요. 저는 이 문제를 해결하기 위해 규칙들을 배열로 정의하고 순차적으로 실행하는 구조를 선택했습니다.
가장 먼저 맞닥뜨린 난관은 ‘제목’ 처리였습니다. 줄 바꿈 뒤에 오는 ‘#’ 기호를 찾아야 하는데, 단순히 문자열 전체에서 ‘#’을 찾으면 문장 중간에 있는 해시태그까지 태그로 변해버리는 문제가 생기더군요.
// 핵심 로직: 줄 시작 부분의 # 패턴을 <h1>~<h6>로 변환
const parseHeaders = (text) => {
return text.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^### (.*$)/gim, '<h3>$1</h3>');
};
위 코드처럼 ^ 앵커와 m(multiline) 플래그를 조합해 “줄의 시작”을 명확히 정의함으로써 문제를 해결했습니다. 이 과정은 제가 이전에 작성한 [객체 매핑 설계로 코드 줄이기: 자바스크립트 단위 변환기] 포스팅에서 다뤘던 ‘데이터 구조화’의 논리를 텍스트 패턴으로 확장한 것과 같았습니다.
실시간 렌더링과 성능의 균형
사용자가 타이핑을 할 때마다 화면을 갱신하는 ‘실시간성’은 자바스크립트 마크다운 프리뷰어의 생명입니다. 하지만 매 입력마다 수천 줄의 텍스트를 정규표현식으로 다시 훑는다면 브라우저는 비명을 지를지도 모릅니다.
저는 이 부하를 줄이기 위해 두 가지 전략을 세웠습니다. 첫 번째는 input 이벤트의 최적화입니다. 사용자가 쉼 없이 타이핑할 때는 잠시 기다렸다가, 입력이 멈춘 찰나에만 렌더링을 수행하는 ‘디바운싱(Debouncing)‘ 개념을 도입하려 고민했습니다. 이는 지난번 [인피니트 스크롤 구현하기] 포스팅에서 성능 최적화를 위해 브라우저의 부하를 줄였던 것과 일맥상통하는 접근이었죠.
두 번째는 DOM 조작의 효율성입니다. 변환된 HTML을 innerHTML로 매번 갈아끼우는 것은 비용이 많이 들지만, 실시간 에디터의 특성상 피할 수 없는 선택이기도 합니다. 그래서 최대한 렌더링 범위를 좁히거나 비동기 처리를 활용해 사용자가 느끼는 ‘버벅임’을 최소화하는 데 집중했습니다.
자바스크립트 마크다운 프리뷰어 사용해보기
실시간 마크다운 프리뷰어 실습
결과가 여기에 나타납니다.
마크다운 문법의 중첩 문제 해결하기
구현하다 보니 가장 까다로운 부분은 ‘중첩된 문법’이었습니다. 예를 들어 # 제목 같은 입력이 들어왔을 때, 이를 굵은 제목으로 처리할지 아니면 일반 텍스트로 둘지에 대한 규칙이 필요했죠.
저는 정규표현식의 replace 함수에 콜백을 전달하는 고급 기법을 사용해 이 문제를 해결했습니다. 패턴이 매칭될 때마다 추가적인 로직을 실행해 하위 문법을 한 번 더 검사하는 식이죠. 이 과정에서 정규표현식이 단순한 치환 도구를 넘어 하나의 ‘함수’처럼 작동할 수 있다는 점이 놀라웠습니다. 마치 [자바스크립트 스톱워치 만들기]에서 밀리초 단위의 정밀한 로직을 구현하며 조건문 하나하나에 신경 썼던 것처럼, 텍스트 파싱 역시 아주 미세한 규칙 하나가 전체 결과의 완성도를 결정짓더군요.
완성도를 결정짓는 한 끗: 보안 이슈와 사용자 편의성 최적화
- 보안을 위한 필수 관문: XSS(교차 사이트 스크립팅) 방지
사용자가 입력한 마크다운을 HTML로 변환해 화면에 뿌릴 때 가장 위험한 것이 바로 스크립트 주입 공격입니다. 만약 누군가 같은 문장을 마크다운 창에 넣는다면 어떻게 될까요?
단순히 innerHTML로 렌더링하면 브라우저는 이를 코드로 인식해 실행해 버립니다. 이를 방지하기 위해 텍스트를 HTML로 변환하기 전, 위험한 태그들을 무력화하는 ‘Sanitization(세정)’ 과정이 반드시 필요합니다. 제가 [LocalStorage를 활용한 할 일 목록] 포스팅에서 사용자 입력값을 다룰 때 강조했던 것처럼, “모든 사용자 입력은 믿지 않는다”는 보안 원칙을 여기서도 적용해야 합니다.
// 간단한 HTML 이스케이프 함수 예시
function sanitize(html) {
const map = { '&': '&', '<': '<', '>': '>' };
return html.replace(/[&<>]/g, (s) => map[s]);
}
- 사용자 경험을 위한 ‘자동 스크롤 동기화(Sync Scroll)’ 구현
진짜 실용적인 에디터라면 왼쪽 입력창을 내릴 때 오른쪽 미리보기 창도 함께 내려가야 합니다. 이를 ‘스크롤 동기화’라고 부르는데요.
두 창의 높이 비율을 계산하여 스크롤 위치를 맞추는 이 로직은 자바스크립트의 scrollTop과 scrollHeight 속성을 깊이 있게 이해해야 구현 가능합니다.
오늘 제작해 본 자바스크립트 마크다운 프리뷰어는 단순히 텍스트를 변환하는 도구를 넘어, 우리가 매일 사용하는 수많은 서비스의 핵심 원리를 직접 체험해보는 시간이었습니다.
정규표현식의 난해한 기호들이 하나하나 제 자리를 찾아가며 완벽한 HTML로 변할 때의 쾌감은 이루 말할 수 없습니다. 여러분도 완벽한 라이브러리를 찾기 전에, 가끔은 이렇게 날것의 코드로 기초 원리를 직접 만져보시길 바랍니다. 그런 고민의 시간들이 모여 결국 어떤 프레임워크 앞에서도 당당한 실력 있는 개발자로 만들어줄 테니까요.
직접 구현하시면서 정규표현식 패턴이 꼬이거나 특정 문법이 잘 안 풀리는 부분이 있다면 언제든 댓글로 남겨주세요.