사내 폐쇄망에 셀프서비스 포털을 서버 렌더 방식으로 만들었다. 번들러 없이 템플릿과 바닐라 JS 모듈만 쓴다. 그런데 컨테이너 상태, 요청 처리 상태, 응답시간처럼 사용자가 아무것도 하지 않아도 바뀌는 값이 많은데 새로고침해야 보였다. 사용자는 화면이 멈춘 줄 알거나 반복해서 새로고침한다.

그래서 부분 갱신을 넣었다. data-refresh 속성이 붙은 영역을 서버가 다시 렌더해 JSON으로 돌려주고 클라이언트가 innerHTML로 교체한다. 이 방식을 프래그먼트(서버가 다시 렌더해 돌려주는 화면 일부) 갱신이라고 부른다.

이 글은 그 뒤에 나온 문제의 기록이다. 이어지는 세 편은 조용한 실패, 한도 값을 모르는 API의 호출량 제한, 타임아웃 뒤에도 계속 지우는 작업이다.

증상: 응답시간 그래프가 전부 빈 표시

관리자 대시보드의 응답시간 그래프가 슬롯 11개 모두 "데이터가 모이는 중입니다"로 고정됐다. 첫 화면에는 정상으로 그려지고 갱신 주기가 한 번 지난 뒤부터 그렇게 됐다.

화면과 프래그먼트를 각각 받아 SVG 개수를 셌다.

화면       /admin                       spark svg: 11   빈 표시: 0
프래그먼트  /fragments/admin-dashboard   spark svg:  0   빈 표시: 11

헬스 데이터 자체는 정상이었다. 상세 화면에 24시간 55회 측정, 가용성 100%, 평균 139ms가 표시됐다.

원인: 파생 계산이 두 곳에 복사되어 있었다

렌더 함수는 이미 공유하고 있었다. 화면과 프래그먼트가 같은 함수로 마크업을 만들게 두었으니 값이 어긋날 일이 없다고 봤다. 그런데 인자를 만드는 계산은 공유하지 않았다.

// 화면 라우트
latencies[s.name] = q.recentLatencies(s.name, 20)
  .map((r) => r.latency_ms)   // 숫자 배열
  .filter((v) => v != null);

// 프래그먼트 라우트
latencies[s.name] = q.recentLatencies(s.name, 24);   // 행 객체 배열

그래프 함수는 숫자가 두 개 미만이면 빈 표시를 그린다. 행 객체가 들어오니 하나도 찾지 못한다.

graph LR
    A["화면 라우트"] -->|"숫자 배열"| D["sparkline()"]
    B["프래그먼트 라우트"] -->|"행 객체 배열"| D
    D --> E["SVG path"]
    D --> F["데이터가 모이는 중"]
    style B fill:#fdd,stroke:#c66
    style F fill:#fdd,stroke:#c66

두 라우트가 같은 함수를 호출하는데 넘기는 모양이 다르다. 렌더 함수를 공유하는 것으로는 이 지점을 막지 못한다. 파생을 만드는 계산도 한 곳에 있어야 한다. 쿼리 계층에 latencyMap()을 두고 두 라우트가 그것을 호출하게 바꿨다.

타입 방어를 잘못된 경계에 붙였다

이 종류를 컴파일 단계에서 잡으려고 타입을 지정했다. props 가 Record<string, any> 라서 그때까지 tsc 가 통과시키고 있었다.

interface DashboardProps {
  [key: string]: any;
  latencies: Record<string, number[]>;   // 행 객체를 넘기면 컴파일 실패
}

이걸로 됐다고 봤는데 검수에서 위치가 틀렸다는 지적을 받았다. 처음에 타입을 안쪽 프래그먼트 함수에만 붙였다.

export function slotSections(slots: SlotView[], names) { ... }   // 프래그먼트가 직접 호출
export function slotList({ user, slots, ... }: Record<string, any>) {
  return layout({ body: slotSections(slots, names) });            // 화면이 호출
}

화면 함수의 props가 Record<string, any>면 그 안에서 slotsany가 된다. anySlotView[]에 대입되므로 그대로 통과한다. 프래그먼트 라우트만 지켜지고 초기 화면을 그리는 라우트는 열려 있었다. 타입은 화면 props에 붙여야 한다.

타입만으로 막히지 않는 경로도 남는다. Record<string, any[]>로 느슨하게 넘기면 any[]number[]에 대입되어 통과한다. 그래서 파생을 한 곳으로 모은 것이 본 수정이고 타입은 보조 수단이다.

같은 형태가 다른 필드에서 재발했다

고쳤다고 적은 뒤에 검수에서 같은 패턴을 하나 더 찾았다. 프래그먼트 라우트가 serverInfonull로 넘기고 있었다.

// 프래그먼트 라우트
serverInfo: null,

화면은 실제 값을 넘긴다. 그래서 관리자가 대시보드를 열면 서버 상태 카드가 보인다. 갱신 주기가 지나면 프래그먼트가 null을 넘겨 그 블록이 빈 문자열로 렌더되고 카드가 화면에서 사라진다. 서버가 연결 안 됨으로 바뀌는 순간이 그 카드가 필요한 순간인데 그때 카드는 이미 없다.

latencies에는 타입을 지정했지만 props에 인덱스 시그니처가 있어서 serverInfo는 잡히지 않았다. 필드를 하나씩 지정하는 방식으로는 이 계열이 닫히지 않는다. 라우트마다 props를 조립하는 구조 자체를 바꿨다.

// src/admin/dashboard-data.ts
// 화면 라우트와 프래그먼트 라우트가 이 함수를 함께 쓴다.
export async function dashboardData(viewer) {
  const slots = await service.listSlots(viewer);
  return {
    slots,
    serverInfo: await fetchServerInfo(),
    latencies: q.latencyMap(slots.map((s) => s.name)),
    // ...
  };
}

innerHTML 비교가 동작하지 않았다

교체 전에 내용이 같은지 비교해서 불필요한 교체를 막으려 했다.

if (scope.innerHTML.trim() === data.html.trim()) return;

이 비교는 성립하지 않는다. 브라우저가 직렬화한 innerHTML은 서버가 만든 문자열과 같아지지 않는다.

서버 문자열브라우저 직렬화 결과
<input type="hidden" name="state" value="done" /><input type="hidden" name="state" value="done">
<button disabled><button disabled="">
<path d="..." /><path d="..."></path>

<input> 이나 값 없는 불리언 속성, 자기닫기 태그가 하나라도 있는 영역은 내용이 바뀌지 않아도 매번 교체된다. 관리자 요청 화면, 대시보드, 메트릭 프래그먼트가 모두 해당했다. 이 비교로 스크롤 튐과 선택 해제를 막을 수 있다고 봤는데 그대로 일어났다.

서버가 내용 해시를 함께 내려주고 클라이언트는 해시만 비교하게 바꿨다.

const send = (res, markup) => {
  const html = String(markup);
  res.setHeader('Cache-Control', 'no-store');
  res.json({ html, etag: hash(html) });
};

교체가 사용자 조작을 파괴하는 경우

입력에 포커스가 있거나 펼친 상세가 있으면 교체를 건너뛰게 해 두었는데 그것으로 부족했다.

관리자 요청 큐에 완료와 반려 버튼이 있고 목록은 최신순이다. 버튼으로 마우스를 옮기는 사이에 새 요청이 들어와 갱신되면 새 항목이 맨 위에 삽입되고 모든 행이 한 칸 내려간다. 다른 요청의 반려 버튼이 눌린다. 반려는 신청자에게 알림이 가고 되돌리는 화면이 없다. 커서가 영역 안에 있으면 교체하지 않게 했다.

배포가 실패한 뒤 사용자가 가장 먼저 하는 조작은 로그를 드래그해 복사하는 것이다. 선택 중에는 포커스가 <pre>로 가지 않으므로 기존 검사에 걸리지 않았고 8초 안에 교체되어 선택이 풀렸다. 선택 범위가 영역 안이면 교체하지 않는다.

function busyInside(scope) {
  const active = document.activeElement;
  if (active && active !== document.body && scope.contains(active)) return true;
  if (scope.querySelector('details[open]')) return true;
  if (scope.matches(':hover')) return true;
  const sel = window.getSelection && window.getSelection();
  if (sel && !sel.isCollapsed && sel.rangeCount) {
    if (scope.contains(sel.getRangeAt(0).commonAncestorContainer)) return true;
  }
  return false;
}

검증에서 빠져 있던 항목

프래그먼트 검증에서 HTTP 200만 확인하고 있었다. 프래그먼트가 빈 값을 그려도 200이다. 화면과 프래그먼트를 대조하는 검사를 넣었다. 프래그먼트는 화면의 일부이므로 빈 상태 표시가 화면보다 많아질 수 없다.

배포 전에 이 검사로 버그를 재현했다.

✗ 프래그먼트가 화면보다 적게 보여준다 /admin [데이터가 모이는 중입니다 화면 0 → 프래그먼트 11]
✗ 스파크라인 화면 11 ≠ 프래그먼트 0

수정 후에는 8쌍(홈, 목록, 내 요청, 신고, 대시보드, 요청 처리, 상세 메트릭, 상세 배포)에서 통과했고 스파크라인이 화면 11 = 프래그먼트 11이 됐다.

남은 문제

첫 갱신 때는 클라이언트가 해시를 모르므로 한 번은 교체된다. 화면을 렌더할 때 서버가 해시를 함께 심으면 없앨 수 있는데 화면 템플릿에서 프래그먼트의 해시를 알아야 해서 결합이 생긴다. 지금은 한 번의 교체를 받아들였다.

메트릭과 배포 이력, 커밋 메시지가 등록된 모든 사용자에게 열려 있다. 화면과 프래그먼트의 권한 검사가 같아서 프래그먼트가 더 느슨한 것은 아니다. 다만 커밋 메시지까지 공개하는 것이 의도인지는 확인이 필요하다.