블로그로 돌아가기

구조화된 사용자 지정 템플릿을 만든 방법: 임의의 CSS를 열어 주지 않고도 스타일을 바꿀 수 있게 하기

MD-TO Team •

사용자분들께 비슷한 요청을 계속 받아 왔습니다. “GitHub 템플릿이 마음에 드는데, 제목 색만 우리 브랜드 색으로 바꿀 수 없을까요? 줄 간격도 조금만 더 넓혀 주세요.”

작은 기능처럼 들리지만, 실제로 구현하려면 피할 수 없는 선택이 하나 있습니다. 임의의 CSS를 열어 줄 것인가, 말 것인가?

이 글에서는 저희가 최종적으로 선택한 방식인 기존 시스템 템플릿 위에 구조화된 오버라이드를 덧씌우는 방법과, 이 방식이 md-to.com처럼 “하나의 콘텐츠를 여러 형식으로 내보내는” 도구에 왜 더 잘 맞는지 정리합니다.


잘 맞지 않는 두 가지 극단

대부분의 마크다운 도구는 “사용자 지정 스타일”을 다음 두 가지 중 하나로 처리합니다.

방식 1: 고정된 템플릿만 제공. 단순하고 안정적이지만 부족합니다. 사용자는 가장 비슷해 보이는 템플릿을 고른 뒤, 곧바로 “제목 색만 바꿨으면” 하고 아쉬워합니다.

방식 2: 임의의 CSS 허용. 자유도가 가장 높고, 미리보기 영역은 얼마든지 손볼 수 있습니다. 문제는 브라우저에서 보기 좋다고 해서 내보낸 결과물도 보기 좋은 것은 아니라는 점입니다.

저희는 Markdown을 PDF / Word / HTML / 이미지로 변환하는 도구를 만들고 있는데, 이 중 여러 내보내기 경로는 CSS를 전혀 사용하지 않습니다.

  • Word(.docx)는 OOXML 스타일을 사용합니다. 여기에는 “임의의 CSS”라는 개념 자체가 없습니다.
  • PDF 인쇄에는 자체적인 페이지 분할과 여백 규칙이 있습니다. 브라우저 CSS만으로 맞춰 둔 레이아웃은 인쇄 단계에서 무너지는 경우가 많습니다.
  • 이미지 내보내기는 Canvas 스크린샷 방식이라, 일부 CSS 기능(filter, backdrop-filter의 일부)은 지원이 제한적입니다.

사용자에게 임의의 CSS를 허용하면 미리보기는 멀쩡해 보이는데 내보내기 결과만 조용히 깨지게 됩니다. 이렇게 “보이는 것과 얻는 것이 다른” 격차는 사용자 경험을 크게 해칩니다.

그래서 저희는 세 번째 길을 택했습니다.


우리의 방식: 기본 템플릿 + 구조화된 오버라이드

핵심 아이디어는 한 문장으로 요약됩니다. 사용자가 먼저 시스템 템플릿을 고르고, 그 위에서 정해진 몇 가지 구조화된 필드만 변경한다는 것입니다.

완성된 템플릿 전체를 저장하지 않고, “기본 템플릿 ID + 오버라이드”만 저장합니다.

{
  version: 1,
  baseTemplateId: 'classic',
  overrides: {
    colors: {
      heading: '#0F766E'
    },
    fonts: {
      body: 'Noto Sans SC'
    },
    fontSizes: {
      body: 13
    },
    spacing: {
      lineHeight: 1.7
    }
  }
}

왜 템플릿 전체의 스냅샷을 저장하지 않을까요? 이유는 세 가지입니다.

  1. 기본 템플릿이 개선되면 사용자도 혜택을 받습니다. 예를 들어 나중에 Classic의 코드 블록 패딩을 다듬으면, 오버라이드에 해당 필드가 없는 사용자 지정 템플릿은 새 값을 자동으로 따라갑니다.
  2. 페이로드가 작아 양쪽에서 검증하기 쉽습니다. 백엔드는 템플릿 객체 전체가 아니라 화이트리스트에 있는 몇 개의 필드만 검증하면 됩니다.
  3. “어떤 템플릿을 기반으로 했는지”라는 의미가 명시적으로 보존됩니다. UI에서 “Classic 위에 3가지를 변경했습니다”와 같이 안내할 수 있습니다.

현재 편집기 UI는 다음과 같으며, 자주 쓰는 네 가지 그룹인 색상, 글꼴, 글꼴 크기, 간격을 다룹니다.

사용자 지정 템플릿 편집기


런타임 동작: 클릭 한 번에서 내보내기 완료까지

전체 시스템은 세 가지 파이프라인으로 구성됩니다.

파이프라인 1: 실시간 미리보기

사용자가 편집기에서 색상을 바꾸면 미리보기가 즉시 갱신되어야 합니다.

  1. 편집기가 새 오버라이드 값을 localStorage.customDocumentTemplate에 기록합니다.
  2. 편집기가 template-change 이벤트를 발생시킵니다.
  3. MarkdownPreview가 이 이벤트를 수신하고 getActiveTemplate()을 호출합니다.
  4. getActiveTemplate()은 documentTemplate(현재 기본 템플릿)과 customDocumentTemplate(오버라이드)을 읽습니다. 두 값이 서로 대응하면 createCustomTemplate(baseTemplate, overrides)를 호출해 완전한 템플릿으로 병합합니다.
  5. generatePreviewCSS(template)가 미리보기 스타일을 다시 생성합니다.

병합 로직은 단순합니다. 필드 그룹별로 얕은 병합(shallow merge)을 수행합니다.

return {
  ...baseTemplate,
  id: `custom:${baseTemplate.id}`,
  colors: {
    ...baseTemplate.colors,
    ...(overrides.colors ?? {}),
  },
  fonts: {
    ...baseTemplate.fonts,
    ...(overrides.fonts ?? {}),
  },
  fontSizes: {
    ...baseTemplate.fontSizes,
    ...(overrides.fontSizes ?? {}),
  },
  spacing: {
    ...baseTemplate.spacing,
    ...(overrides.spacing ?? {}),
  },
};

파이프라인 2: 비공개 템플릿으로 저장

사용자가 “템플릿 저장”을 누르면, 도구 어댑터가 현재 상태를 하나로 묶습니다.

{
  version: 1,
  toolSlug,
  documentTemplateId,
  customDocumentTemplate
}

이 페이로드는 saved_templates.settings_json에 저장됩니다. documentTemplateId와 customDocumentTemplate을 따로 저장한다는 점에 주목하세요. 비공개 템플릿은 출처와 분리된 스타일 스냅샷이 아니라, “템플릿 X 위에 Y만큼 수정한 것”을 의미하기 때문입니다.

파이프라인 3: 비공개 템플릿 적용

사용자가 PrivateTemplateSelector에서 저장해 둔 템플릿을 고르거나, ?tpl=<id> URL 파라미터로 불러올 때의 흐름입니다.

  1. 어댑터가 applySettings(settingsJson)을 호출합니다.
  2. 페이로드에 customDocumentTemplate이 있으면 localStorage에 다시 기록하고, 없으면 해당 값을 지웁니다.
  3. documentTemplateId를 적용합니다.
  4. template-change를 발생시키면, 미리보기와 내보내기 경로가 모두 병합된 완전한 템플릿을 사용하게 됩니다.

놓치기 쉽지만 중요한 부분: 시스템 템플릿을 선택하면 오버라이드를 지운다

setActiveTemplate(templateId) 안에 다음 한 줄을 추가했습니다.

localStorage.removeItem('customDocumentTemplate');

왜 필요할까요? 이 줄이 없으면 이런 혼란스러운 상황이 생깁니다.

  • 사용자가 Classic 템플릿에서 제목 색을 초록색으로 바꾸고, 이 값이 localStorage에 저장됩니다.
  • 사용자가 Dracula 템플릿으로 전환합니다.
  • 다시 Classic으로 돌아오면 제목이 또 초록색인데, 사용자는 자신이 바꿨다는 사실을 기억하지 못합니다.

저희는 “시스템 템플릿을 선택하는 행위”를 명시적인 초기화로 정의했습니다. 깨끗한 템플릿을 고르면 깨끗한 템플릿이 적용됩니다. 다시 사용자 지정하고 싶다면 “템플릿 편집”을 다시 누르면 됩니다. “가능한 한 사용자의 마지막 편집 내용을 유지한다”는 방식보다 훨씬 이해하기 쉬운 동작입니다.

TemplatePickerModal에서도 “적용”을 누를 때 같은 정리 작업을 수행합니다.


백엔드 검증: 화이트리스트가 안전망

백엔드의 validateTemplateInput()은 settingsJson.customDocumentTemplate을 엄격하게 검증합니다.

  • customDocumentTemplate은 선택 항목이며, 있다면 객체여야 합니다.
  • version은 1이어야 합니다(이후 마이그레이션을 위한 여지입니다).
  • baseTemplateId는 비어 있지 않은 문자열이어야 합니다.
  • overrides는 객체여야 합니다.
  • 화이트리스트에 없는 필드는 거부합니다.

화이트리스트에는 현재 UI에서 다루는 모든 필드와, 앞으로 추가할 필드를 위한 약간의 여유분이 포함되어 있습니다.

그룹허용되는 필드
colorsheading, text, link, codeBackground, codeBorder, codeText, quoteText, quoteBorder, quoteBackground, tableHeaderBg, tableHeaderText, tableBorder, tableRowOdd, tableRowEven
fontsheading, body, code
fontSizesh1, h2, h3, h4, h5, h6, body, code
spacingheadingBefore, headingAfter, paragraphBefore, paragraphAfter, lineHeight

화이트리스트는 이 설계 전체의 안전망입니다. 임의의 키를 받지 않기 때문에, 나중에 어떤 필드에 렌더링 로직을 추가하거나 이름을 바꾸더라도 사용자가 이전에 저장해 둔 오래된 키가 런타임 템플릿에 섞여 들어가 출력을 오염시키지 않습니다.


얻은 효과: 내보내기 경로는 “사용자 지정 템플릿”의 존재를 몰라도 된다

병합이 끝나면 완전한 DocumentTemplate이 만들어지므로, 이후의 내보내기 함수는 하나도 수정할 필요가 없습니다.

  • 미리보기: generatePreviewCSS(template)
  • PDF / 인쇄: generatePdfStyles() / generatePrintStyles()
  • HTML: generateExportHTML()
  • 서식 있는 텍스트 복사: generateRichTextHTML()
  • Word: templateToDocxStyles(template)

임의의 CSS를 허용하지 않기로 한 결정이 가져다준 바로 그 효과입니다. 모든 출력이 DocumentTemplate이라는 하나의 중간 표현을 계속 사용하는 한, 형식 간 일관성은 사실상 공짜로 얻어집니다. 사용자가 고른 제목 색은 브라우저 미리보기, PDF, Word, 이미지 내보내기에서 모두 똑같이 나타납니다. “비슷하게”가 아니라 문자 그대로 같은 hex 값입니다. 모든 출력이 병합된 하나의 템플릿에서 값을 읽기 때문입니다.


현재의 한계

첫 번째 버전은 의도적으로 범위를 좁혔습니다.

  • 자주 쓰는 필드만 노출하며, 모든 색상, 글꼴 크기, 간격 값을 편집할 수 있는 것은 아닙니다.
  • 사용자 지정 템플릿의 이름을 정하는 전용 진입점은 없으며, 이름 지정은 여전히 “템플릿 저장” 대화상자에서 합니다.
  • 템플릿을 JSON으로 가져오거나 내보내는 기능이 없습니다.
  • 임의의 CSS는 지원하지 않습니다.
  • 편집기 UI는 가볍게 만든 초기 버전이라, 그룹별 패널, 색상 프리셋, 모바일 레이아웃 모두 개선할 여지가 있습니다.

앞으로의 방향

이 구조에서 이어갈 방향은 분명합니다.

  • 편집 가능한 필드 확대. 표 테두리 색, 번갈아 나타나는 행 배경, 인용문 배경, 코드 텍스트 색 등은 화이트리스트에 이미 자리가 있고 UI만 없는 상태입니다.
  • 통합된 필드 스키마. 프런트엔드 폼과 백엔드 검증이 하나의 정의를 공유해 서로 어긋나지 않게 합니다.
  • 워크스페이스 템플릿 목록에 “편집” 진입점 추가. 현재는 새 사본으로만 저장할 수 있고, 이미 저장한 비공개 템플릿을 계속 편집할 수는 없습니다.
  • “시스템 템플릿을 비공개 템플릿으로 복제”하는 명시적인 흐름. “Classic을 기반으로 내 템플릿을 만들고 싶다”를 더 직관적인 동작으로 만듭니다.
  • 사용자 지정 템플릿 JSON 가져오기 / 내보내기. 팀 내에서 스타일을 공유할 때 유용합니다.

정리하며

돌이켜 보면 이 설계의 핵심은 무엇을 만들었느냐가 아니라 무엇을 만들지 않았느냐에 있습니다.

  • 임의의 CSS를 허용하지 않았습니다. 덕분에 형식 간 깨짐을 피했습니다.
  • 템플릿 전체의 스냅샷을 저장하지 않았습니다. 덕분에 기본 템플릿 업그레이드의 이점을 유지했습니다.
  • 시스템 템플릿을 전환할 때 오버라이드를 몰래 유지하지 않았습니다. 덕분에 “아무것도 안 바꿨는데 스타일이 다르다”는 혼란을 피했습니다.
  • 백엔드는 화이트리스트에 없는 필드를 받지 않습니다. 덕분에 설계를 발전시킬 여지가 남았습니다.

제약은 오히려 더 예측 가능한 경험을 만들어 줍니다. 여러 내보내기 형식을 아우르는 도구에서는 이런 예측 가능성이 자유로움보다 더 큰 가치를 갖습니다.

여러분도 “하나의 콘텐츠, 여러 가지 내보내기”를 지원하는 제품을 만들고 있다면, md-to.com에서 이 사용자 지정 템플릿을 직접 사용해 보세요. 찾고 계시던 “딱 적당한” 수준의 자유도인지 확인해 보시기 바랍니다.