발행일

신청서 단계 구획이 로딩 중에 전부 보였다 — 접힘의 첫 상태는 서버가 내야 한다

신청서 단계 구획이 로딩 중에 전부 보였다

OID 등록 신청 화면을 열면 잠깐 이상한 장면이 보였습니다. 여섯 구획이 위아래로 전부 펼쳐져 있다가, 한 박자 뒤에 첫 구획만 남고 나머지가 접혀요. 매번 그랬습니다. 빠른 회선에서도 눈에 띄는 정도였고, 느린 곳에서는 페이지가 두 번 그려지는 것처럼 보였습니다.

표준데이터 등록 신청은 같은 단계 매크로를 쓰는데 그런 게 없었어요. 그래서 처음엔 OID 화면만의 문제라고 봤습니다.

접는 일을 누가 언제 하나

단계 구획은 step_section 매크로가 그리고, 접고 펴는 일은 requestFormSteps.js가 합니다.

document.addEventListener("DOMContentLoaded", () => {
  ...
  function render() {
    ...
    steps.forEach((step, index) => {
      step.classList.toggle("hidden", index !== current);
    });

DOMContentLoaded에서 처음 render()가 돌면서 현재 단계 말고는 hidden을 붙입니다. 그러니까 서버가 내보낸 HTML에는 hidden이 없고, 브라우저가 그걸 그린 다음에 스크립트가 접는 구조였어요. 첫 페인트가 DOMContentLoaded보다 먼저 일어나면 그 사이에 여섯 구획이 다 보입니다.

그 틈이 얼마나 되는지 봤습니다. OID 신청은 310ms, 표준데이터 신청은 1ms. 표준데이터 화면은 문제가 없던 게 아니라 틈이 한 프레임보다 짧아서 눈에 안 보였을 뿐 같은 구조였습니다.

310ms의 출처

OID 신청 화면은 상위 OID를 고르는 조직도 트리가 있어서 d3를 싣습니다.

-    <script src="{{ 'js/d3.v7.min.js' | static }}"></script>
+    {# defer — 트리는 AJAX 응답이 온 뒤에 그리므로 파서를 세울 이유가 없다.
+       세워 두면 첫 페인트와 DOMContentLoaded 사이가 300ms 벌어져 그 사이에 단계 구획이
+       다 보인다. 뒤따르는 module 스크립트도 지연 실행이라 실행 순서(d3 → module)는 그대로다 #}
+    <script defer src="{{ 'js/d3.v7.min.js' | static }}"></script>

defer 없는 <script src>는 파서를 세웁니다. 그 위까지 그린 화면이 먼저 페인트되고, d3를 다 받아 실행한 뒤에야 나머지를 파싱하고 DOMContentLoaded가 옵니다. 그런데 트리는 AJAX 응답이 온 뒤에야 그리기 때문에 d3가 그 자리에서 파서를 세울 이유가 없었어요. defer로 돌리니 틈이 30~90ms로 줄었습니다.

뒤에 오는 <script type="module">이 d3를 전역으로 쓰는데, module 스크립트는 원래 지연 실행이라 defer끼리는 문서 순서를 지킵니다. d3가 먼저, module이 나중. 순서는 안 바뀝니다.

줄이는 것과 없애는 것은 다르다

틈을 줄여도 30~90ms는 남습니다. 한 프레임보다 길어요. 그래서 스크립트가 접기 전의 첫 상태를 서버가 내도록 바꿨습니다.

 {% macro step_section(no, title, note=none, extra=none, body_class="", optional=false) %}
   <section data-step="{{ no }}" {{ 'data-step-optional="1"' | safe if optional }}
-           class="rounded-xl border-2 border-primary">
+           class="rounded-xl border-2 border-primary{{ ' hidden' if no != 1 }}">

첫 단계가 아니면 hidden 클래스를 달고 나갑니다. 이제 페인트가 언제 일어나든 보이는 구획은 하나예요. 스크립트는 그 상태를 이어받아 단계를 옮길 때만 토글합니다.

여기서 한 가지를 고를 일이 있었습니다. HTML에는 hidden 속성이 있어요. <section hidden>이라고 쓰면 브라우저가 알아서 숨깁니다. 클래스보다 표준적이고 짧습니다. 그런데 안 썼어요.

{# class 여야 한다(속성 `hidden` 을 쓰면 스크립트의 `classList.toggle("hidden")` 이 못 걷어낸다). #}

접고 펴는 스크립트가 classList.toggle("hidden")으로 동작합니다. 속성으로 숨겨 두면 스크립트가 클래스를 아무리 토글해도 속성이 남아 있어 영영 안 펴져요. 서버가 내는 첫 상태와 스크립트가 바꾸는 상태는 같은 수단이어야 합니다. 둘이 다르면 한쪽이 다른 쪽을 못 걷어냅니다.

매크로는 두 신청 화면이 공유하니 표준데이터 쪽도 같이 해결됐습니다. 보이지 않던 1ms의 틈도 없어졌어요.

검증

  • OID 신청 화면을 새로고침해 첫 페인트에서 구획이 하나만 보이는 것을 확인. 고치기 전 여섯 구획이 다 펼쳐졌다 접히는 장면을 먼저 봤습니다.
  • 첫 페인트와 DOMContentLoaded 사이를 재서 310ms에서 30~90ms로 줄어든 것을 확인. 그 틈에도 보이는 구획은 1개입니다.
  • d3를 defer로 돌린 뒤에도 상위 OID 트리가 그려지는 것 확인. module 스크립트와의 순서가 바뀌지 않았습니다.
  • 단계를 옮기고 되돌아오는 동작이 그대로인 것 확인. 서버가 단 hidden을 스크립트가 걷어내는지가 이 변경의 핵심이었습니다.
  • 표준데이터 신청 화면도 같은 매크로라 함께 확인했습니다.

남은 것 · 한계

  • ?step=으로 들어오는 화면은 1단계가 한 프레임 비쳤다가 2단계로 갑니다. 기본정보를 저장한 뒤 edit/{pk}?step=2로 돌아오는 경로예요. 서버가 ?step=을 읽어 첫 구획을 정하면 없앨 수 있는데, 여섯 구획 대신 한 구획이 비치는 정도라 그대로 뒀습니다.
  • hidden이 두 곳에 있습니다. 서버 매크로가 첫 상태를 내고, 스크립트가 이후를 바꿉니다. 한쪽이 클래스 이름을 바꾸면 다른 쪽이 조용히 어긋나요. 매크로 주석에 적어 뒀지만 코드가 묶여 있지는 않습니다.
  • 폼 검증에 실패하면 스크립트가 모든 구획을 펼쳐서(steps.forEach((step) => step.classList.remove("hidden"))) 브라우저에 맡깁니다. 서버가 낸 hidden도 그때 같이 걷히니 문제는 없는데, 여섯 구획이 다 보이는 화면이 이번엔 의도된 상태로 남아 있습니다. 로딩 때는 결함이고 검증 실패 때는 기능인 같은 모양이에요.
  • 틈을 잰 건 한 환경에서 한 번입니다. 310ms는 그날 그 회선의 값이고, d3 파일 크기와 캐시 상태에 따라 달라집니다.

관련 글: 승인될 때까지 아무것도 만들지 않기 · 7단계를 박아둔 템플릿이 5단계 환경에서 거짓말을 했다 · 신청서 항목 모달에서 후보 조회 20회를 2회로 줄였다 · 운영 PDF에서 한글이 통째로 빠졌다