발행일

검색 조건을 바꿀 때 새로고침 대신 조각 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;

느린 회선이나 운영 서버에서만 재현되는 종류라 로컬에서는 못 봤던 버그입니다. loadSeqlistSeq는 파일이 다르고 공유하는 코드도 없어요. 같은 패턴을 두 번 손으로 짰습니다.

순번 하나로는 부족한 게 하나 더 있었습니다. 주소창은 응답이 와야 갱신되는데, 그 사이에 두 번째 조건이 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_idsearch_keyword가 둘 다 있는 것을 e2e로 잠금. waitForURL을 넣으면 통과해 버려서 그 순서를 스펙 주석에 박았습니다.
  • 로그인이 풀린 채 필터를 걸면 흐려진 채로 멈추던 것을 재현하고, 통째 이동으로 로그인 화면이 뜨게 고쳤습니다. 이건 손으로만 봤고 스펙은 없습니다.
  • 카탈로그 드롭다운을 연 채 개념체계 버튼 클릭 → 카탈로그가 닫히고 조회가 나가는 것 확인.
  • 자동검색으로 차량·차량위치를 치고 인기 검색어 기록 0건, Enter 제출 신호만 1건인 것을 dev에서 확인. 헤더 제외는 단위 테스트로 잠금.

남은 것 · 한계

  • 네트워크 비용은 하나도 안 줄었습니다. 응답은 전체 HTML 그대로예요. 목록이 커지면 서버가 조각만 주는 쪽으로 가야 하는데, 그 경계가 언제인지 숫자로 정해 두지 않았습니다.
  • SWAP은 하드코딩 선택자 아홉 개입니다. 계약이 깨지면 통째 이동으로 떨어지게 해 뒀지만, 그건 "조용히 망가지지 않는다"이지 "안 깨진다"가 아니에요. 템플릿과 JS가 id 문자열로만 묶여 있습니다.
  • 순번 폐기 패턴을 두 파일에 따로 짰습니다. loadSeqlistSeq. 세 번째가 생기면 뽑겠다고 미뤄 둔 상태예요.
  • 검색 종류 칸이 있는 화면은 가로채지 않습니다. 검색 폼이 공용이라 확인 못 한 화면까지 끌고 갈 이유가 없다고 봤는데, 그 화면들은 여전히 새로고침입니다.
  • 자동검색 제외는 헤더 하나에 기대고 있습니다. 프록시나 캐시가 그 헤더를 떨어뜨리면 중간 조각이 통계에 다시 섞이는데, 그걸 감지할 방법은 없습니다.

관련 글: 데이터 맵에서 세 단계 들어간 자리를 주소에 남겼다 · 아무도 안 묻는 질문에 답하던 화면 — 워드클라우드를 검색어 빈도로 · 링크를 따라간 뒤 돌아오는 길이 어긋났다 · 필터 선택지 옆 건수를 어느 모집단으로 셀 것인가