발행일

Django 템플릿에서 공용 매크로와 스크롤 스파이를 외부화한 회고

Django 템플릿에서 공용 매크로와 스크롤 스파이를 외부화한 회고

여러 정적 페이지(약관·방침, 소개·가이드 류)가 같은 모양·같은 동작을 갖고 있는데도 마크업과 인라인 스크립트가 페이지마다 복붙되어 있었습니다. 정리하면서 *"어디서부터 추출해야 가장 손해가 적게 끝날까"*를 다시 한 번 배웠어요.


1. 처음 상태 — 중복이 세 층위로 쌓여 있었음

대상 페이지 분류:

그룹페이지 수공통점
약관/방침3개 (이용약관, 개인정보처리방침, 책임의 한계)좌측 목차 + 우측 본문 + 스크롤 스파이
소개/가이드5개 (서비스 소개, 사용 가이드 등)상단 히어로 + 단계별 카드 + 다운로드 버튼

각 그룹 안에서 마크업이 거의 동일한데, 페이지마다 별도 .html.j2 파일로 복붙되어 있었습니다. 가장 안 좋았던 부분은 각 페이지에 인라인 <script> 블록이 박혀 스크롤 스파이를 직접 구현하고 있던 것.

<!-- 약관 페이지 하단에 매번 들어있던 인라인 스크립트 -->
<script>
  document.querySelectorAll('.toc-link').forEach(link => {
    link.addEventListener('click', e => {
      e.preventDefault();
      // ... 스크롤 처리 ...
    });
  });
  window.addEventListener('scroll', () => {
    // ... 활성 메뉴 갱신 ...
  });
</script>

페이지마다 미세하게 다른 버전이 박혀 있어서, "어떤 버전이 진짜인가"를 가리는 것부터 일이었어요.


2. 정리 순서 — base → macro → JS 외부화

세 단계 중 어느 것부터 손대느냐가 어렵지 않은데, 잘못 잡으면 추출이 꼬입니다. 결정한 순서는 이랬어요.

2.1 1단계: base 템플릿 추출

먼저 약관/방침 그룹에 대해 공통 base를 뽑았습니다.

{# templates/_base/legal_base.html.j2 #}
{% extends "base.html.j2" %}

{% block content %}
<div class="legal-layout">
  <aside class="legal-toc">
    {% block toc %}{% endblock %}
  </aside>
  <main class="legal-body">
    {% block legal_body %}{% endblock %}
  </main>
</div>
{% endblock %}

{% block extra_js %}
  <script src="{% static 'js/scroll_spy.js' %}"></script>
{% endblock %}

각 페이지는 이 base를 상속해서 toclegal_body 블록만 채우면 됩니다.

2.2 2단계: 매크로 추출 (소개/가이드 그룹)

소개/가이드 그룹은 base만으로 정리가 안 됩니다. 각 페이지가 카드 + 단계 + 다운로드 버튼을 다른 조합으로 쓰기 때문에, 블록보다 매크로가 적합했어요.

{# templates/_macros/intro_macros.html.j2 #}
{% macro step_card(number, title, body, icon_class='fa-circle-info') %}
<div class="intro-card">
  <div class="step-num">{{ number }}</div>
  <h3>{{ title }}</h3>
  <p>{{ body }}</p>
  <i class="fas {{ icon_class }}"></i>
</div>
{% endmacro %}

{% macro download_button(label, url, file_size=None) %}
<a class="download-btn" href="{{ url }}">
  <i class="fas fa-download"></i>
  <span>{{ label }}</span>
  {% if file_size %}<small>({{ file_size }})</small>{% endif %}
</a>
{% endmacro %}

페이지에서는 {% from "_macros/intro_macros.html.j2" import step_card, download_button %} 후 필요한 만큼 호출합니다.

2.3 3단계: 스크롤 스파이 JS 외부화

인라인 스크립트를 static/js/scroll_spy.js로 옮겼습니다. 옮기면서 두 가지를 정리했어요.

  • 어떤 페이지는 활성 메뉴 갱신을 requestAnimationFrame 으로 쓰고 있고, 어떤 페이지는 디바운스도 없이 매 scroll 이벤트마다 갱신하고 있었음 → 통일
  • 일부 페이지는 .toc-link 클래스를 쓰고 다른 페이지는 .legal-nav a를 쓰고 있었음 → data-scroll-spy 속성 기반으로 통일
// static/js/scroll_spy.js
(() => {
  const container = document.querySelector('[data-scroll-spy]');
  if (!container) return;

  const links = container.querySelectorAll('a[href^="#"]');
  const targets = [...links].map(a => document.querySelector(a.getAttribute('href')));

  links.forEach((a, i) => {
    a.addEventListener('click', e => {
      e.preventDefault();
      targets[i]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
    });
  });

  let ticking = false;
  window.addEventListener('scroll', () => {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      const offset = window.scrollY + 80;
      const activeIndex = targets.findIndex((el, i) => {
        const next = targets[i + 1];
        return el.offsetTop <= offset && (!next || next.offsetTop > offset);
      });
      links.forEach((a, i) => a.classList.toggle('active', i === activeIndex));
      ticking = false;
    });
  });
})();

페이지 마크업에서는 <aside data-scroll-spy> 만 붙이면 자동으로 동작합니다.


3. 추출 순서가 중요한 이유

base를 먼저 뽑지 않고 매크로부터 손댔다면 어떻게 됐을까 시뮬레이션해보면 — 매크로 자리에 둬야 할지, base 블록으로 둬야 할지 모호한 조각들이 나옵니다. 그러면 매크로가 비대해지고, 결국 base에서 다시 잘라내야 해요.

순서 원칙:

  1. 각 페이지의 외곽 구조가 같은가? → 같으면 base 먼저
  2. 외곽은 다른데 내부 부품이 반복되는가? → 매크로 추출
  3. 마크업에 인라인 스크립트가 박혀 있는가? → 마크업 정리 후 JS 외부화

이번엔 약관 그룹이 1번에, 소개 그룹이 2번에 해당했고, JS는 3번 단계에서 한 번에 처리했습니다.


4. 부수 효과

  • 수정 시 파급 검증이 단순해짐: 약관 페이지 디자인이 바뀌면 base만 고침. 매크로 변경 시 호출처는 import만 따라가면 됨.
  • 새 페이지 만들기가 빨라짐: "약관과 비슷한 신규 페이지"를 추가할 때 base 상속 한 줄로 시작
  • 스크롤 스파이 동작이 모든 페이지에서 동일: 페이지마다 다르게 작동하던 버그가 자연 소멸

5. 검증

항목결과
약관/방침 3개base 상속으로 렌더, 레이아웃 이전과 동일
소개/가이드 5개매크로 조합으로 렌더, 카드·버튼 표시 동일
인라인 스크립트8개 페이지에서 잔재 0건
스크롤 스파이8개 페이지 모두 동일 동작 (이전엔 페이지마다 달랐음)
셀렉터 통일.toc-link / .legal-nav adata-scroll-spy 단일 규칙

6. 외부화하면서 생긴 새 위험

인라인 스크립트를 하나로 모으면 버그도 하나로 모입니다. 페이지마다 다르게 깨지던 게 사라지는 대신, 한 군데가 깨지면 8개 페이지가 같이 깨져요. 그래서 이 파일은 더 방어적이어야 하는데, 지금 코드에는 그렇지 못한 지점이 있습니다.

① 앵커가 없는 링크 하나가 전체를 멈춥니다.

const targets = [...links].map(a => document.querySelector(a.getAttribute('href')));

querySelector가 못 찾으면 null이 배열에 들어갑니다. 클릭 핸들러는 옵셔널 체이닝으로 막아뒀는데(targets[i]?.scrollIntoView), 스크롤 핸들러는 안 막혀 있어요.

const activeIndex = targets.findIndex((el, i) => {
  const next = targets[i + 1];
  return el.offsetTop <= offset && ...   // el 이 null 이면 여기서 터진다
});

목차에 오타가 있거나 본문 섹션 id가 바뀌면, 스크롤할 때마다 TypeError가 나고 활성 표시가 통째로 멈춥니다. 한쪽만 옵셔널 체이닝을 쓴 게 오히려 단서예요 — null이 들어올 수 있다는 걸 알고 있었는데 한 군데만 막은 겁니다. map 단계에서 .filter(Boolean)로 걸러내되 링크와 짝을 유지하도록 같이 정리했어야 했습니다.

offsetTop은 문서 기준 좌표가 아닙니다.

offsetTop은 가장 가까운 offsetParent 기준입니다. 본문 섹션이 position: relative인 컨테이너 안에 들어가면 값이 문서 최상단 기준이 아니게 되고, window.scrollY + 80과 비교하는 게 어긋나요.

지금 레이아웃에서 맞는 건 마침 조상 중에 offsetParent가 될 요소가 없기 때문입니다. 레이아웃을 조금만 바꿔도 활성 판정이 틀어질 수 있어요. getBoundingClientRect().top으로 뷰포트 기준으로 재는 쪽이 레이아웃에 안 흔들립니다.

③ 첫 진입 시 활성 표시가 없습니다.

활성 갱신이 scroll 이벤트에만 걸려 있어서, 페이지를 열고 스크롤하기 전까지는 목차에 아무 표시가 없습니다. #약관-3 같은 해시로 바로 진입한 경우에도 그 항목이 활성으로 안 잡히고요. 초기화 시점에 한 번 갱신을 불러주면 되는 일이었습니다.

④ 컨테이너를 하나만 찾습니다.

document.querySelector('[data-scroll-spy]') — 단수라 한 페이지에 목차가 둘이면 첫 번째만 동작합니다. 지금은 그럴 일이 없지만, "속성 하나만 붙이면 자동으로 동작한다"고 소개한 것 치고는 조건이 붙어 있는 셈이에요.


7. 교훈

  • 정적 페이지가 5개 이상 같은 모양으로 누적되기 시작하면 추출 시점이에요. 더 늦으면 어느 페이지가 정답 버전인지 가리는 데 시간이 듭니다.
  • 인라인 스크립트는 추출의 신호. 같은 동작을 페이지마다 살짝 다르게 다시 짜고 있다면 외부화가 정답.
  • 추출 순서: base → macro → JS. 거꾸로 하면 매크로가 비대해집니다.
  • 매크로 인자는 적게 두기. 인자 수가 4개를 넘으면 슬슬 컴포넌트 분리를 고민할 신호.

마크업 중복 정리는 화면에 보이는 변화가 없어서 회고하기 좋은 작업은 아닌데, 다음 신규 페이지를 만들 때 "아 이미 다 만들어져 있네" 하는 그 순간이 보상이라고 생각하면 들이는 시간이 아깝지 않습니다.