- 발행일
검색 조건을 바꿀 때 새로고침 대신 조각 9개를 갈아끼웠다 — 늦게 온 응답을 버리는 순번을 한 주에 두 번 짰다
검색 조건을 바꿀 때 새로고침 대신 조각 9개를 갈아끼웠다
표준데이터 검색 화면에는 조건을 거는 장치가 여럿입니다. 카탈로그·개념체계 드롭다운, 유형 칩, 페이지네이션, 그리고 검색창. 이 중 하나를 만질 때마다 화면이 통째로 다시 떴습니다.
location.replace로 주소를 바꾸는 구조였으니 당연한 결과예요. 필터를 하나 좁히면 화면이 하얘지고, 스크롤이 맨 위로 튀고, 열어 뒀던 드롭다운이 닫힙니다. 조건 세 개를 연달아 걸면 그 장면을 세 번 봅니다.
사흘 전에 유형 칩과 범위 필터를 location.href에서 location.replace로 바꿔서 이력이 쌓이는 문제는 잡아 둔 상태였습니다. 그런데 새로고침 자체는 그대로였어요. 이번엔 그걸 없앴습니다.
어디까지 바꿀 것인가
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
① 서버가 조각만 주는 엔드포인트(?partial=1) 신설 | 응답이 작아짐 | 뷰·템플릿이 둘로 갈리고, 주소 계약이 바뀌어 북마크·e2e 스펙이 흔들림 | 보류 |
② 같은 주소를 fetch로 받아 달라지는 조각만 교체 | 서버·주소 계약 무변경. 북마크·스펙 그대로 | 응답은 전체 HTML 그대로라 네트워크 비용은 새로고침과 같음 | 채택 |
| ③ 목록을 JS가 JSON으로 그리기 | 가장 가벼움 | 카드 템플릿을 통째로 다시 짬 | 기각 |
②를 골랐습니다. 아끼는 건 렌더링뿐이라는 걸 문서에 그대로 적어 뒀어요.
응답은 그대로 전체 HTML 이다(124KB / 130ms). 서버·네트워크 비용은 새로고침과 같고, 줄어드는
것은 브라우저 재렌더와 에셋 재실행이다. 목록 응답이 더 커지거나 `SWAP` 목록이 늘면 그때
서버가 조각만 주는 쪽(`?partial=1`)으로 올린다. 지금은 그 중간 지대다.
갈아끼우는 조각은 아홉 개입니다.
var SWAP = [
"#scope-selected-data",
// 검색 제출에 실릴 조건 — 갈아끼우지 않으면 검색 누르는 순간 방금 건 필터가 사라진다
"#utils-search-hidden",
"#scope-dropdown-wrapper",
"#typeChips",
"#std-data-card-head",
"#scope-filter-hint",
"#list-total-count",
"#std-data-cards",
".pagination",
];
필터 칸 자체는 목록에 없습니다. JS가 들고 있는 선택 상태로 다시 그리거든요. 열려 있는 패널을 갈아끼우면 펼쳐 둔 가지가 접히니까요.
세 장치가 같은 길을 타야 한다
범위 필터만 조각 교체로 바꾸고 유형 칩과 페이지네이션을 그대로 두면 어떻게 될까요. 같은 화면에서 어떤 조건은 부드럽고 어떤 조건은 깜빡입니다. 그게 전부 새로고침인 것보다 더 어색해요.
// 유형 칩·페이지네이션이 같은 경로를 타게 한다(각자 location 을 만지면 화면 절반만 부드럽다).
window.applyListUrl = applyListUrl;
applyListUrl 하나를 전역에 걸고 세 장치가 전부 그리로 들어옵니다. 사흘 전에 location.replace로 통일했던 그 자리들이 이번엔 applyListUrl로 다시 통일됐어요. 이력은 여전히 replaceState입니다. 조건 변경은 새 목적지가 아니니까요.
조각을 못 찾으면 통째로 이동한다
SWAP은 하드코딩된 선택자 목록입니다. 템플릿에서 id 하나가 바뀌면 그 조각은 조용히 건너뛰어집니다. 오류도 안 나고, 화면도 그럴듯하고, 그 조각만 옛 조건을 들고 남아요.
// 한쪽에만 있는 조각은 계약이 깨진 것이다 — 조용히 건너뛰면 갈아끼워지지 않은 조각이
// 옛 조건을 들고 남는다. `#utils-search-hidden` 이 그러면 검색을 누르는 순간 방금 건
// 필터가 사라지는데 화면에는 아무 신호가 없다. 양쪽 다 없는 것은 정상이다(이 파일을
// 쓰지만 그 조각이 없는 화면들).
var broken = SWAP.some(function (selector) {
var next = doc.querySelector(selector);
var current = document.querySelector(selector);
if (next && current) {
current.replaceWith(next);
return false;
}
return !!(next || current);
});
if (broken) {
window.location.replace(href);
return;
}
응답에만 있거나 현재 화면에만 있는 조각은 계약 위반으로 봅니다. 그때는 옛 방식대로 통째로 이동해요. 조용히 아무 일도 안 일어나는 것이 제일 나쁘다고 봤습니다. 양쪽 다 없는 건 정상입니다. 이 파일을 쓰지만 카드 목록이 없는 화면도 있거든요.
로그인이 풀린 경우도 같은 자리에서 걸립니다. 로그인 페이지로 밀려나면 응답은 200인데 #std-data-cards가 없어요. catch로는 안 잡히니 그것도 통째 이동으로 떨어뜨렸습니다.
검색 버튼이 방금 건 필터를 풀고 있었다
이 구조를 넣고 나서 발견한 게 있습니다. 검색 폼은 걸린 조건을 hidden 필드로 실어 보내는데, 그 hidden도 SWAP 대상이라 응답이 와야 갈아끼워집니다. 필터를 체크하고 드롭다운을 닫자마자 검색어를 치고 Enter를 누르면, 아직 빈 hidden을 들고 나가서 방금 건 필터가 풀립니다.
로컬에서 그 창은 130ms입니다. 사람이 충분히 밟는 간격이고, 운영은 더 넓어요. 결과는 그럴듯하게 나오고 조건만 빠져 있어서 눈으로는 안 잡힙니다.
검색 제출도 같은 길로 태웠습니다. 폼 대신 pendingHref 기준 주소에 검색어만 얹어서 applyListUrl로 보내요. 스왑 타이밍과 무관해집니다.
// 조회 응답을 **기다리지 않고** 검색을 누르는 경우. 필터를 걸면 폼의 hidden 조건은 응답이
// 와야 갈아끼워지므로(SWAP), 그 전에 제출하면 빈 hidden 을 들고 나가 방금 건 필터가 조용히
// 풀렸다. 오류도 없고 결과도 그럴듯해 눈으로는 안 잡힌다 — 비동기 조회가 만든 이음매다.
test("응답을 기다리지 않고 검색해도 방금 건 필터가 안 풀린다", async ({ page }) => {
const catalogId = await checkCatalog(page, 1);
// waitForURL 없이 곧바로 제출한다 — 이 대기가 있으면 회귀가 재현되지 않는다.
이 스펙은 응답을 기다리면 재현이 안 됩니다. 체크 직후 곧바로 제출하는 순서가 스펙의 핵심이라 주석으로 박아 뒀어요.
순번으로 옛 응답을 버린다 — 사흘 만에 두 번째
두 조건을 빠르게 연달아 걸면 요청이 겹칩니다. 응답 도착 순서는 요청 순서가 아니에요. 먼저 보낸 느린 응답이 나중에 도착해 새 목록을 덮어쓰고, 주소만 마지막 것이 남아 화면과 주소가 어긋납니다.
var mine = ++listSeq;
pendingHref = href;
...
.then(function (html) {
if (mine !== listSeq) return;
요청마다 번호를 매기고, 응답이 왔을 때 아직 최신인지만 봅니다. 밀린 응답은 버려요.
그런데 이게 처음이 아닙니다. 사흘 전 데이터 맵에서 같은 걸 짰습니다. 통합 탭과 개념체계 탭을 빠르게 오가면 통합 탭에 개념체계 항목이 그려지는 문제였어요.
// 탭을 오가면 요청이 겹치고 응답 순서는 보장되지 않는다 — 늦게 온 앞 소스의 응답이
// 새 탭 위에 그려져 개념체계 목록 자리에 통합 그래프가 뜨던 자리다. 번호가 밀린
// 응답은 버린다.
const seq = ++loadSeq;
느린 회선이나 운영 서버에서만 재현되는 종류라 로컬에서는 못 봤던 버그입니다. loadSeq와 listSeq는 파일이 다르고 공유하는 코드도 없어요. 같은 패턴을 두 번 손으로 짰습니다.
순번 하나로는 부족한 게 하나 더 있었습니다. 주소창은 응답이 와야 갱신되는데, 그 사이에 두 번째 조건이 location을 읽으면 첫 조건이 빠진 옛 주소 위에 쓰게 됩니다. 두 축의 드롭다운을 연달아 닫는 흔한 흐름에서 먼저 건 축이 통째로 사라져요. 아직 안 온 요청의 주소를 pendingHref에 두고, 다음 조건은 그 위에 쌓도록 했습니다.
stopPropagation 한 줄이 다른 패널을 못 닫게 했다
같은 날 잡은 것 하나 더. 카탈로그 드롭다운을 연 채 개념체계 버튼을 누르면 둘 다 열렸습니다. 버튼 클릭에서 stopPropagation을 하고 있었는데, 패널 닫기가 document 위임이라 그 전파를 끊으면 다른 패널이 안 닫혔던 거예요. 자기 패널은 위임 쪽이 event.target !== button으로 이미 거르고 있어서 끊을 이유가 없었습니다.
사흘 뒤, 검색창도 치는 대로 좁히게 됐다
이 길이 생기고 나니 검색창에 input 리스너 하나만 얹으면 자동검색이 됩니다. 9월 7일에 230ms 디바운스로 붙였어요. 한 글자는 안 보내고, 빈 칸이 되면 전체 목록으로 돌립니다.
그런데 이 화면의 ?search_keyword= GET은 미들웨어가 인기 검색어로 집계합니다. 자동검색이 켜지면 "차량위치"를 치는 동안 "차량"·"차량위"도 요청이 돼서 통계에 쌓여요.
# 검색창에 치는 도중 나가는 자동검색(instanceScopeFilter.js)은 "차량위치" 를
# 치는 동안 "차량"·"차량위" 도 요청이 된다 — 사람이 찾겠다고 한 말이 아니라
# 중간 상태라 통계에서 뺀다. Enter·돋보기 제출은 헤더 없이 와서 그대로 남는다.
if request.headers.get("X-Auto-Search"):
return 0
자동검색 요청은 X-Auto-Search: 1 헤더를 달고 나갑니다. 주소 파라미터가 아니라 헤더인 이유가 있어요. 조회 주소는 replaceState로 주소창에 남아서 새로고침·링크 복사에 따라갑니다. 파라미터로 표시하면 복사한 링크까지 집계에서 빠져요.
검증
- 카탈로그 체크 → 드롭다운 닫기 → 개념체계 체크 → 닫기를 연달아 해도 두 조건이 다 살아 있는 것 확인.
pendingHref전에는 먼저 건 축이 사라졌습니다. - 필터 체크 직후 응답을 기다리지 않고 검색어 제출 → 주소에
catalog_id와search_keyword가 둘 다 있는 것을 e2e로 잠금.waitForURL을 넣으면 통과해 버려서 그 순서를 스펙 주석에 박았습니다. - 로그인이 풀린 채 필터를 걸면 흐려진 채로 멈추던 것을 재현하고, 통째 이동으로 로그인 화면이 뜨게 고쳤습니다. 이건 손으로만 봤고 스펙은 없습니다.
- 카탈로그 드롭다운을 연 채 개념체계 버튼 클릭 → 카탈로그가 닫히고 조회가 나가는 것 확인.
- 자동검색으로
차량·차량위치를 치고 인기 검색어 기록 0건, Enter 제출신호만 1건인 것을 dev에서 확인. 헤더 제외는 단위 테스트로 잠금.
남은 것 · 한계
- 네트워크 비용은 하나도 안 줄었습니다. 응답은 전체 HTML 그대로예요. 목록이 커지면 서버가 조각만 주는 쪽으로 가야 하는데, 그 경계가 언제인지 숫자로 정해 두지 않았습니다.
SWAP은 하드코딩 선택자 아홉 개입니다. 계약이 깨지면 통째 이동으로 떨어지게 해 뒀지만, 그건 "조용히 망가지지 않는다"이지 "안 깨진다"가 아니에요. 템플릿과 JS가 id 문자열로만 묶여 있습니다.- 순번 폐기 패턴을 두 파일에 따로 짰습니다.
loadSeq와listSeq. 세 번째가 생기면 뽑겠다고 미뤄 둔 상태예요. - 검색 종류 칸이 있는 화면은 가로채지 않습니다. 검색 폼이 공용이라 확인 못 한 화면까지 끌고 갈 이유가 없다고 봤는데, 그 화면들은 여전히 새로고침입니다.
- 자동검색 제외는 헤더 하나에 기대고 있습니다. 프록시나 캐시가 그 헤더를 떨어뜨리면 중간 조각이 통계에 다시 섞이는데, 그걸 감지할 방법은 없습니다.
관련 글: 데이터 맵에서 세 단계 들어간 자리를 주소에 남겼다2026-08-07데이터 맵에서 세 단계 들어간 자리를 주소에 남겼다 — 이름을 두 번 인코딩한 이유드릴다운으로 세 단계 파고들어간 뒤 상세로 갔다가 돌아오면 늘 맨 처음이었습니다. 탐색 단계를 pushState로 주소에 남겼는데, 그 주소가 상세의 복귀 파라미터로 실려 가면서 서버 필터에 통째로 버려졌어요. 값을 한 번 더 인코딩해 통과시켰고, 복원이 중간에 끊기면 거기까지만 그리고 주소를 화면에 맞췄습니다. · 아무도 안 묻는 질문에 답하던 화면 — 워드클라우드를 검색어 빈도로2026-07-21아무도 안 묻는 질문에 답하던 화면 — 워드클라우드를 검색어 빈도로ASN.1 노드 단어를 세어 서버에서 PNG로 그려주던 워드클라우드를, 통합검색 검색어를 세어 클라이언트에서 그리는 화면으로 바꿨습니다. 화면의 성격을 "무엇이 등록됐나"에서 "무엇을 찾았나"로 옮긴 결정과, 같은 집계를 두 군데 두는 바람에 같은 검색어가 화면마다 다른 비율로 보이게 된 이야기입니다. · 링크를 따라간 뒤 돌아오는 길이 어긋났다2026-09-01링크를 따라간 뒤 돌아오는 길이 어긋났다 — 이력에 쌓을 것과 덮어쓸 것을 가르는 기준상세에서 상세로 두 번 건너가면 목록 버튼이 검색 조건을 통째로 잃었고, 필터를 만질 때마다 뒤로가기 이력이 하나씩 쌓였습니다. 복귀 주소는 받은 것을 그대로 이어달리게 하고, 조건 변경은 덮어쓰고, 화면 안에서 다른 데이터로 건너뛰는 것만 이력에 쌓도록 나눴어요. 기준은 하나입니다. 다른 곳으로 갔나, 같은 화면의 상태가 바뀌었나. · 필터 선택지 옆 건수를 어느 모집단으로 셀 것인가2026-09-04필터 선택지 옆 건수를 어느 모집단으로 셀 것인가 — 걸린 조건에서 세면 나머지가 전부 0건통합 검색의 카탈로그·개념체계 칸을 셀렉트에서 접이식 트리 드롭다운으로 바꾸고 선택지마다 건수를 붙였습니다. 건수는 "고르면 몇 건이 나오나"인데, 지금 걸린 조건 안에서 세면 고른 항목만 남고 나머지는 전부 0건이 돼요. 세는 모집단에서 범위 조건을 뺐고, 체크 즉시 조회하던 걸 같은 날 패널을 닫을 때 한 번으로 바꿨습니다.