- 발행일
스피너 하나 넣으려다 jQuery를 두 번 실을 뻔했다 — 로딩 표시는 UI 장식이 아니라 로드 순서 문제였다
스피너 하나 넣으려다 jQuery를 두 번 실을 뻔했다
관계도 화면들과 데이터 맵에는 로딩 표시가 없었습니다. 응답이 올 때까지 캔버스가 빈 채로 있어요. 데이터 맵은 더 나빴는데, 앞 탭의 그림이 그대로 남아 있어서 "탭이 안 바뀌었다"로 읽혔습니다. 응답이 느린 환경에서는 그 상태가 길어졌고요.
새 스피너를 만들 이유는 없었습니다. 트리 화면(jstree)이 이미 waitMe를 쓰고 있었거든요. 화면마다 다른 로딩 표시가 뜨면 같은 시스템으로 안 읽힙니다. 그래서 있는 걸 그대로 가져다 쓰기로 했는데, 이게 jQuery 플러그인이라는 사실 하나 때문에 세 가지가 연달아 걸렸어요(61be7cb8).
함정 ① — jQuery를 두 번 실으면 플러그인이 사라진다
관계도와 데이터 맵은 D3만 쓰는 화면이라 jQuery가 없습니다. waitMe를 쓰려면 jQuery를 같이 실어야 해요. 그런데 트리 목록 화면은 jstree 때문에 이미 jQuery를 싣고 있고, 그 화면 안에 데이터 맵 본문이 통째로 들어갑니다(목록/지도 보기 전환).
나중에 실린 jQuery가 window.$를 갈아치우면, 먼저 그 객체에 등록된 플러그인은 같이 사라집니다. waitMe는 $.fn.waitMe로 붙어 있으니 두 번째 jQuery가 실리는 순간 없는 함수가 돼요.
그래서 자산 조각을 둘로 갈랐습니다.
| 조각 | 싣는 것 | 쓰는 화면 |
|---|---|---|
utils/_waitme.html.j2 | waitMe CSS·JS + 헬퍼 | jQuery가 이미 있는 화면(트리 목록·상세) |
utils/_jquery_waitme.html.j2 | jQuery + 위 조각 | jQuery가 없는 화면(관계도·데이터 맵) |
그리고 본문 쪽에는 가드를 뒀어요.
{% if not jquery_loaded %}{% include "utils/_jquery_waitme.html.j2" %}{% endif %}
목록 화면은 데이터 맵 본문을 넣기 직전에 jquery_loaded를 참으로 둡니다.
여기서 한 가지가 걸립니다. 이 set은 템플릿 블록 안의 if 안에 있고, 그 값을 읽는 쪽은 바로 다음 줄에서 include된 다른 파일이에요. 블록 안에서 정한 값이 include로 넘어가지 않으면 가드는 조용히 기본값(거짓)이 되고, 증상은 "가끔 스피너가 안 뜬다"로만 보입니다. 이 전제는 아래 검증에서 따로 확인했습니다.
함정 ② — defer로 실으면 그림 자체가 안 그려진다
헬퍼 두 개(run_waitMe·hide_waitMe)를 어디에 둘지가 다음 문제였습니다. 공용 유틸이니 utils.js가 자연스러운 자리인데, utils.js는 defer라서 body 안 인라인 script보다 늦게 돕니다.
관계도 템플릿은 그 인라인 스크립트에서 스피너를 켜요. defer면 그 시점에 run_waitMe가 아직 정의 전이라 ReferenceError가 나고, 인라인 블록이 거기서 죽으니 그 아래에 있는 그래프 생성 호출까지 같이 안 돕니다. 로딩 표시를 넣으려다 그림을 없애는 셈이에요.
그래서 waitMeOverlay.js라는 파일로 따로 빼서 waitMe.js와 같은 자리에서 defer 없이 싣습니다.
function run_waitMe(el) {
if (!el || !window.jQuery || !window.jQuery.fn.waitMe) return;
window.jQuery(el).waitMe({ effect: "ios", text: "Please wait...", /* … */ });
}
첫 줄 가드가 함정 ①의 보험입니다. jQuery나 플러그인이 없는 화면에서 불려도 조용히 아무것도 안 해요. 로딩 표시 하나 때문에 화면이 죽는 것보다는 스피너가 안 뜨는 쪽이 낫다고 봤습니다.
트리 쪽 호출부는 commonClassTreeUtils.js에서 run_waitMe를 import해 쓰고 있었는데, 그 모듈을 관계도가 안 싣습니다. 그래서 정의는 옮기고 그 자리에는 한 줄짜리 재수출만 남겼어요. 호출부 열 몇 군데(run_waitMe($("table#classInfo")) 같은)를 안 건드리려고요. 헬퍼가 DOM 요소와 jQuery 객체를 둘 다 받는 것도 같은 이유입니다.
함정 ③ — 끌 때의 캔버스는 켤 때의 캔버스가 아니다
관계도에는 전체를 한 번에 그리는 화면과, 중심 노드 주변만 그리고 펼쳐 나가는 탐색 화면이 있습니다. 탐색 화면은 첫 응답을 탐색 스크립트가 직접 받고, 공통 렌더러(createD3Graph)는 그 뒤에 이미 받아 둔 데이터로 불려요. 즉 렌더러 안에는 기다릴 자리가 없습니다.
그래서 켜는 쪽과 끄는 쪽을 갈랐습니다. 탐색 화면은 템플릿 인라인에서 켜고, 끄는 것은 어느 경로로 들어오든 렌더러가 합니다. 실패해도 catch로 떨어지니 거기서도 끄고요.
문제는 끌 때 무엇을 끄느냐였습니다. 진입 시점에 캔버스를 한 번 잡아 두면 안 됩니다.
// ⚠️ 켤 때·끌 때 **그때그때 다시 찾는다.** 탐색 화면은 그리기 직전에 캔버스를 비워
// <svg> 가 잠깐 사라지는데, 진입 시점에 한 번 잡아 두면 그 순간 null 이라 스피너가
// 영영 안 걷힌다(2026-09-01 개념체계 관계도에서 재현).
const loadingHost = () => { /* … .graph-canvas 를 다시 조회 … */ };
if (!injectedData) window.run_waitMe(loadingHost());
const stopLoading = () => window.hide_waitMe(loadingHost());
탐색 화면은 그리기 직전에 캔버스를 비웁니다. 진입할 때 잡아 둔 svg 참조는 그 순간 화면에서 떨어져 나가요. 그 낡은 참조로 hide를 부르면 스피너가 영영 안 걷힙니다. 개념체계 관계도에서 실제로 그렇게 됐습니다.
덮는 자리를 .graph-canvas로 고른 것도 이유가 있어요. 여기에 position: relative와 overflow: hidden이 걸려 있어서 오버레이가 그림 자리에 정확히 맞습니다.
데이터 맵 쪽 — 스피너를 켜기 전에 화면을 비운다
데이터 맵은 스피너를 덮는 것만으로는 부족했습니다. 앞 탭의 그림이 아래 깔려 있으면 반투명 오버레이 너머로 그대로 보이거든요. 그래서 소스를 바꿀 때 화면을 먼저 비웁니다 — 브레드크럼을 감추고, 건수 배지를 "총 0건"으로 되돌리고, SVG를 비운 뒤에 스피너를 켜요.
이 화면에는 같은 날 넣은 순번 가드(loadSeq)가 먼저 있었습니다. 탭을 빠르게 오갈 때 밀린 응답이 새 탭 위에 그려지는 걸 막는 장치예요. 스피너를 끄는 코드는 그 가드 뒤에 붙였습니다.
.then((raw) => {
if (seq !== loadSeq) return;
window.hide_waitMe(treemapContainer);
// …
})
밀린 응답은 스피너를 끄지 않습니다. 끄는 건 언제나 마지막 요청이에요. 밀린 응답이 끄게 두면, 뒤늦게 도착한 첫 요청이 아직 기다리는 중인 두 번째 요청의 스피너를 걷어 버립니다.
검증
자동화된 잠금이 없는 작업이라, 확인은 두 가지로 했습니다.
- 가드가 실제로 작동하는 조건을 직접 렌더해 봤습니다. 이 글을 정리하면서 프로젝트가 쓰는 Jinja 3.1.4로 최소 템플릿을 돌렸어요. 같은
block안이라면if안에서set한 값도 바로 뒤include로 넘어갑니다. 안 넘어가는 건set한 블록 안에 또 중첩된 블록에서 include할 때와,with안에서 set할 때였습니다. 목록 템플릿은set과include가 같은 블록의 연속한 두 줄이라 앞쪽에 해당합니다. 관계도 11개 화면 글에는 "블록 안에서 set한 값은 include로 안 넘어간다"고 적었는데, 실제로 안 넘어가는 조건은 그보다 좁았습니다. - 플래그를 세우는 자리를 저장소 전체에서 셌습니다.
jquery_loaded를 참으로 두는 곳은 목록 템플릿 한 군데뿐이고, 나머지 관계도 화면은 레이아웃이 jQuery를 안 실어 본문이 직접 싣습니다. 가드가 걸리는 경우가 하나뿐이라는 뜻이라, 아래 한계의 첫 항목이 그만큼 현실적인 위험입니다.
남은 것 · 한계
- 회귀 잠금이 없습니다. 스피너를 확인하는 단위 테스트도 e2e 스펙도 안 만들었어요. 함정 ①은 특히 조용한 종류입니다 — 새 화면이 jQuery를 직접 실으면서
jquery_loaded를 안 세우면 그 화면의 waitMe만 죽는데, 아무도 안 알려 줍니다. 자산 조각에 경고 주석은 남겼지만 주석은 테스트가 아니에요. - 정의를 옮기면서 남긴 주석이 틀렸습니다. 트리 유틸에 "정의는 utils.js로 옮겼다"고 적어 뒀는데 실제 파일은
waitMeOverlay.js입니다.utils.js의 defer가 애초에 이 파일을 만든 이유였으니 정확히 반대로 적힌 셈이에요. 다음에 이 줄을 읽는 사람은 없는 자리를 찾게 됩니다. - 문구가 "Please wait..." 영문 그대로입니다. 트리 화면에서 쓰던 설정을 그대로 옮겼고, 한글 문구로 바꾸는 건 이번 범위 밖으로 뒀어요. 전면이 국문인 화면에 영문 한 줄이 떠 있는 상태입니다.
- 로딩 표시의 기준선이 여전히 화면마다 다릅니다. 이번에 맞춘 건 관계도와 데이터 맵이고, 신청서 모달이나 목록 필터처럼 자기 방식으로 기다리는 자리는 그대로예요.
관련 글: 관계도 11개 화면을 렌더러 한 곳에서 바꿨다 · 운영 2732건이 92948px 캔버스를 만들었다 · 데이터 맵에서 세 단계 들어간 자리를 주소에 남겼다 · 서버에서만 안 되는 로딩