- 발행일
접힘 하나 고친 지 2주 뒤, 규약으로 넓히자 서버 혼자서는 끝낼 수 없는 초기 상태가 셋 남았다
접힘 하나 고친 지 2주 뒤, 규약으로 넓히자 서버 혼자서는 끝낼 수 없는 초기 상태가 셋 남았다
9월 4일에 신청서 단계 구획이 로딩 중에 전부 보였다2026-09-04신청서 단계 구획이 로딩 중에 전부 보였다 — 접힘의 첫 상태는 서버가 내야 한다OID 등록 신청 화면을 열면 여섯 구획이 다 펼쳐졌다가 한 박자 뒤에 하나로 접혔습니다. 접는 일을 DOMContentLoaded의 스크립트가 하는데, 그 앞에 d3를 동기로 싣고 있어 첫 페인트와의 틈이 310ms였어요. 서버가 첫 단계 말고는 hidden을 달아 내보내고 d3를 defer로 돌렸습니다. 같은 구조인 표준데이터 신청은 틈이 1ms라 안 보였을 뿐이었습니다.를 고쳤습니다. 접는 스크립트가 DOMContentLoaded에서 돌기 때문에, 서버가 hidden을 달아 내보내지 않으면 첫 페인트에 구획이 다 펼쳐져 보인다는 이야기였어요. 그때는 그 화면 하나만 고쳤습니다.
9월 18일에 같은 모양을 다른 화면에서 다시 봤습니다. 표준데이터 인스턴스 상세에서 접혀 있어야 할 구획이 펼쳐진 채 그려졌다가 접히면서 문서 높이가 501px 줄었고, 위쪽 목차 띠지의 첫 칸은 흰 칸으로 그려졌다가 파랗게 물들었어요. 이번에는 화면을 하나씩 고치지 않고 아코디언과 목차의 초기 상태 전체를 규약으로 정리했습니다(ff387a15, 8개 파일 +101/−13).
정리하고 보니 "서버가 첫 상태를 그린다"는 원칙으로 바로 풀리는 것과, 그렇게는 안 풀리는 것이 나뉘었습니다.
왜 간헐적으로 보였나
운영에서 잰 값은 HTML 도착 242ms → DOMContentLoaded 361ms였습니다. 그 120ms 남짓 사이에 첫 페인트가 끼면 펼쳐진 그림이 보이고, 캐시가 따뜻하면 그 틈이 0에 가까워져 안 보입니다. 그래서 "가끔 껌뻑인다"는 말로만 남기 쉬운 현상이었어요.
목차 칸은 한 가지가 더 있었습니다. 칸에 transition-colors(0.15초)가 걸려 있어서, 흰 칸에서 파란 칸으로 바뀌는 순간이 짧은 페이드로 늘어납니다. 한 프레임짜리 차이가 눈에 띄는 깜빡임이 된 거예요.
간헐적인 현상이라 눈으로 판정하지 않기로 했습니다. 판정은 서버 응답 HTML로 하고, 개발자 도구에서 JavaScript를 끈 채 들어가 최종 모양과 같은지를 봅니다. 이 판정 방법을 UI 관례 문서의 초기 상태 절에 같이 적었습니다.
서버가 그리면 풀리는 것 — 접힌 구획과 아이콘
이건 9월 4일과 같은 방법입니다. 접고 시작하는 구획의 .accordion-content에 hidden 클래스를 서버가 함께 붙입니다. 적용한 곳은 인스턴스 상세 구획 3개, FAQ 답변, 관리 카탈로그 필터, 신청·심사 절차 안내, 메시지 메타데이터 트리(접고 부를 때만)로 모두 7곳입니다(그중 하나는 아래 '같이 남긴 것'의 그려지지 않는 패널).
<div class="accordion-content hidden">
같이 맞춘 게 머리줄 아이콘입니다. 접고 시작하는 구획 두 곳이 위쪽 화살표(icn_expand_up_blue_24px.svg, alt="접어두기")로 그려져 있었어요. 스크립트의 syncAccordionIndicator가 로드 뒤에 아래쪽으로 뒤집으니 최종 모양은 맞았지만, 그 전까지는 접힌 구획에 "접기" 아이콘이 붙어 있는 셈이었습니다. 서버가 처음부터 아래쪽 화살표와 alt="펼쳐보기"를 내보내게 바꿨습니다.
풀리지만 묶여 있지 않은 것 — 목차 첫 칸의 활성 클래스
목차 띠지의 첫 칸은 서버가 활성 상태로 내보내게 했습니다. 활성 클래스 4개와 aria-current="location"을 첫 칸에만 붙여요.
{{ loop.first and 'bg-primary hover:!bg-primary !text-white font-semibold' or 'text-gray-600' }}
문제는 이 클래스 목록이 tocSidebar.js의 activeClass에도 있다는 점입니다. 스크롤하면 스크립트가 활성 칸을 옮기면서 자기가 들고 있는 목록으로 클래스를 붙였다 뗍니다. 템플릿 쪽만 고치면 처음엔 맞다가 스크롤 한 번 뒤 원래 모양으로 돌아가요. 3주 남짓 전 팔레트에 없는 색은 조용히 안 칠해진다2026-08-24팔레트에 없는 색은 조용히 안 칠해진다 — 색 클래스 142곳 정리와 대비 감사토큰에 없는 색 이름은 빌드 오류가 아니라 그냥 안 칠해집니다. 그렇게 죽어 있던 클래스 142곳을 토큰으로 바꾸고, 다시 들어오면 빌드를 세우는 가드를 붙였어요. 이어서 팔레트 안에 있는 색들의 명도 대비를 재봤더니 회색 스케일이 통째로 한 칸 밝아 입력 경계와 회색 글자가 기준 미달이었습니다. 못 고치기로 한 35곳도 이유와 함께 적었습니다.에서 같은 tocSidebar.js가 비활성 클래스를 다시 붙이면서 겪은 일과 같은 구조입니다.
이번에도 두 곳을 코드로 묶지는 않았습니다. 템플릿 주석과 규약 문서에 "활성 클래스 4개는 JS의 activeClass와 글자까지 같아야 한다"고 적은 게 전부예요. 서버가 첫 상태를 그리는 원칙을 따르면, 같은 상태를 표현하는 목록이 서버와 클라이언트 두 곳에 생깁니다. 9월 4일 글의 한계에 적은 "hidden이 두 곳에 있다"와 같은 부채가 하나 더 늘었습니다.
서버가 알 수 없는 것 ① — URL 해시
목차 링크를 눌러 #instance-section-4 같은 해시를 달고 들어오면, 활성 칸은 첫 칸이 아니라 네 번째 칸이어야 합니다. 그런데 해시는 서버로 전달되지 않아서 마크업으로 맞출 방법이 없어요. 서버는 첫 칸을 켜 두고, 스크립트가 로드 뒤에 네 번째 칸으로 옮길 수밖에 없습니다.
그 한 번의 차이는 없앨 수 없으니, 대신 그 순간을 애니메이션하지 않게 했습니다. 첫 동기화 동안만 칸의 transition을 끄고, 다음 프레임에 되돌립니다.
tocLinks.forEach(function (link) {
link.style.transitionProperty = "none";
});
syncActiveFromHash();
syncActiveFromScroll();
window.requestAnimationFrame(function () {
tocLinks.forEach(function (link) {
link.style.transitionProperty = "";
});
});
이 화면의 다른 코드는 상태를 클래스로 표현하는데 여기만 인라인 style을 썼습니다. 한 프레임 동안만 끄고 되돌리는 값이라 클래스로 둘 자리가 없다고 보고, 그 이유를 주석으로 남겼어요. 해시 없이 맨 위에서 여는 대부분의 진입에서는 서버가 그린 첫 칸과 스크립트의 결과가 같아서 아무것도 바뀌지 않습니다.
서버가 알 수 없는 것 ② — "페이지의 첫 항목만 펴기"
accordion.js의 기본값은 첫 항목만 펴고 나머지는 접는 것입니다. 이 "첫 항목"이 어떻게 정해지는지 보면 문제가 보여요.
const accordionItems = document.querySelectorAll(".accordion-item");
accordionItems.forEach((item, index) => {
// ...
const expanded = initiallyOpen || (index === 0 && !initiallyClosed);
index는 페이지 전체에서의 순번입니다. 그런데 아코디언 상당수는 부분 템플릿(공통 구획, 하이퍼링크 표, 데이터 이력 등)이 그립니다. 부분 템플릿은 자기가 페이지에서 몇 번째 항목인지 모르니, hidden을 붙일지 말지 서버에서 정할 수가 없어요. 속성 없는 아코디언이 한 페이지에 둘 이상 놓이는 화면에서는 둘째부터 같은 깜빡임이 남습니다.
없애는 방법은 있습니다. 기본값을 "전부 접기"로 바꾸거나, 아코디언마다 초기 상태 속성을 필수로 만들면 마크업이 상태를 말할 수 있어요. 다만 어느 쪽이든 여러 화면의 첫 인상이 한꺼번에 바뀝니다. 화면이 처음 열렸을 때 무엇을 펴 둘지는 기획이 정할 일이라 판단해서, 이 커밋에서는 바꾸지 않고 규약 문서에 "아직 안 정한 것"으로 남겼습니다.
같이 남긴 것
규약을 적용하면서 관리 카탈로그 목록을 보다가 필터 패널이 한 번도 그려지지 않는다는 걸 찾았습니다. 패널이 {% if all_schemes %} 안에 있는데, 지금은 이 값을 넘기는 뷰가 하나도 없어요. 관리 뷰가 물려받는 카탈로그 목록 뷰가 원래 넘겼는데, 7월 말 카탈로그를 트리 화면으로 바꾸면서 제 커밋(25c20a46)에서 그 줄이 빠졌습니다. 제가 만든 회귀였어요. 테스트 클라이언트로 렌더해 봐도 응답에 패널이 없었습니다. 패널을 자동으로 펼치던 인라인 스크립트도 같이 죽어 있었고요.
살린다면 뷰가 값을 넘기게 하고, 자동 펼치기는 서버가 이미 아는 값이니 data-initially-open으로 옮기는 게 이 규약에 맞습니다. 지울지 살릴지는 기획 판단이라 QA 소견 문서에 남겨 두었습니다. 이번 커밋에서는 그려지지 않는 패널이지만 살릴 때 다시 어긋나지 않게 아이콘과 hidden만 규약대로 맞춰 두었어요.
한계
- 목차 활성 클래스는 서버와 JS 두 곳에 있습니다. 주석과 문서로만 묶여 있어서 한쪽만 고치면 스크롤 뒤에 어긋납니다.
- 해시로 들어오는 경우는 여전히 한 번 바뀝니다. 애니메이션을 껐을 뿐 활성 칸이 첫 칸에서 다른 칸으로 옮겨 가는 건 그대로예요.
- "첫 항목만 펴기" 기본값은 정해지지 않았습니다. (이 글을 정리하며 덧붙임) 10월 말 기준으로도 규약 문서에 미결로 남아 있고, 속성 없는 아코디언이 둘 이상인 화면의 깜빡임도 그대로입니다.
- 이 커밋에는 회귀 테스트가 없습니다. 판정 방법(서버 응답 HTML, JavaScript를 끈 화면)은 문서에 적었지만, "접고 시작하는 구획은 응답 HTML에
hidden이 있다"를 단언하는 테스트는 붙이지 않았습니다.