소개 페이지에 AI 사용량과 업무 태스크 수치를 싣고 있습니다. 이 수치는 수집기가 만들어 둔 생성 파일에서 옵니다. 빌드 시점에 코드로 포함되므로 수집기를 실행한 시점에서 멈춥니다. 지난 날짜는 맞지만 진행 중인 오늘 값만 뒤처집니다.
내부용 화면은 API 를 1분마다 읽고 있어, 같은 날짜인데도 두 화면의 값이 여러 배 차이로 벌어졌습니다. 어느 한쪽이 틀린 값이 아니라, 공개 화면이 하루 전 시점의 값을 그대로 보여 주고 있었습니다.
검토한 세 가지 방법
| 방법 | 치르는 값 |
|---|---|
| 서버에서 페이지를 렌더링한다 | 정적 사이트를 포기하고 서버 런타임을 도입해야 함. 수치 몇 개 때문에 전체 구조를 바꾸는 셈 |
| 화면이 API 를 직접 호출한다 | 공개 사이트가 내부 API 에 의존함. API 주소를 공개해야 하고, API 가 멈추면 화면도 영향을 받음 |
| 스냅샷 파일을 읽는다 | 갱신이 파일 생성 주기만큼 늦음. 대신 정적 파일 하나만 추가됨 |
세 번째를 택했습니다. 페이지는 그대로 정적이고, 집계 결과만 담긴 JSON 파일 하나를 옆에 둡니다. 페이지는 그 파일을 읽어 수치만 교체합니다.
스냅샷 생성 과정
수치를 보관하는 설정 API 가 수집 요청을 받을 때마다 스냅샷을 씁니다. 다만 수집은 자주 들어오므로 10분 간격으로만 다시 씁니다. 파일 쓰기에서 지켜야 할 조건은 하나입니다. 읽는 쪽이 작성 중인 파일을 읽으면 안 됩니다.
function writePublicStats(force = false) {
if (!publicStatsPath) return;
const at = Date.now();
if (!force && at - lastPublicWrite < PUBLIC_STATS_MIN_MS) return;
try {
mkdirSync(path.dirname(publicStatsPath), { recursive: true });
// 읽는 쪽이 반쯤 쓰인 파일을 보지 않도록 임시 파일에 쓰고 이름만 바꾼다.
const temp = `${publicStatsPath}.tmp`;
writeFileSync(temp, JSON.stringify(publicStats()));
renameSync(temp, publicStatsPath);
lastPublicWrite = at;
} catch (error) {
// 공개 스냅샷이 없어도 공개 페이지는 빌드 때 구운 값으로 그대로 뜬다.
console.error(`공개 스냅샷을 쓰지 못했습니다: ${error.message}`);
}
}- 임시 파일에 쓰고 이름만 바꿉니다. 같은 파일 시스템 안에서 이름 바꾸기는 원자적 연산이라, 읽는 쪽은 이전 파일이나 새 파일 중 하나만 보게 됩니다. 중간 상태가 없습니다.
- 파일은 릴리스 디렉터리 밖에 둡니다. 배포는 새 디렉터리를 올리고 심볼릭 링크만 바꾸는 방식이라, 릴리스 안에 두면 배포할 때마다 스냅샷이 사라집니다.
- 쓰기에 실패해도 서비스를 멈추지 않습니다. 스냅샷이 없으면 페이지는 빌드 시점 값을 그대로 보여 줍니다. 수치가 다소 오래된 것이 페이지가 열리지 않는 것보다 낫습니다.
서빙은 웹서버 설정 몇 줄이면 됩니다. 스냅샷 디렉터리를 읽기 전용으로 연결하고 짧은 캐시를 지정합니다. 화면이 1분마다 읽고 캐시가 60초이므로, 원본 파일에 도달하는 요청은 그 주기로 제한됩니다.
handle /live/* {
root * /srv/portfolio-live
uri strip_prefix /live
header Cache-Control "public, max-age=60, must-revalidate"
file_server
}화면은 값만 교체한다
정적 사이트에서 가장 주의할 부분입니다. 첫 마크업은 서버에서 렌더링한 결과와 한 글자도 달라선 안 됩니다. 그래서 처음 렌더링할 때는 빌드 시점 값을 쓰고, 마운트가 끝난 뒤에 스냅샷을 읽습니다.
읽은 값을 그대로 대입하면 해당 영역 전체가 다시 그려집니다. 값 하나가 바뀌었을 뿐인데 표와 막대가 통째로 새로 만들어지면 스크롤 위치가 바뀌고 펼쳐 둔 상세 항목도 닫힙니다. 그래서 스토어 하나를 두고 기존 값과 대조해 병합합니다.
/** 짝 맞추기 열쇠. 플랫폼은 이름, 일별은 날짜. */
const keyOf = (item: { key?: string; day?: string }) => item.key ?? item.day;
export async function fetchPublicStats(): Promise<void> {
try {
const response = await fetch("/live/public-stats.json");
if (!response.ok) return;
const parsed = parse(await response.json());
if (parsed) setStats(reconcile(parsed, keyOf));
} catch {
// 스냅샷이 없는 곳(로컬 미리보기)에서는 구운 값으로 그대로 둔다.
}
}플랫폼은 이름, 일별 막대는 날짜를 키로 삼습니다. 같은 키끼리 비교하므로 값이 바뀐 항목만 다시 그려지고, 내용이 같으면 DOM 은 그대로 둡니다. 10분마다 갱신되는 파일을 1분마다 읽으므로 대개는 같은 내용이 옵니다. 이 경우 화면이 전혀 바뀌지 않는 것이 중요합니다.
외부에서 받은 JSON 은 구조부터 검증한다
스냅샷은 다른 프로세스가 쓴 파일입니다. 형식이 바뀌거나 오래된 파일이 남아 있을 수 있습니다. 기대한 구조가 아니면 응답 전체를 버리고 빌드 시점 값을 유지합니다. 일부만 맞는 값으로 화면을 채우는 것보다 낫습니다.
- 배열이어야 할 자리가 배열인지, 합계가 숫자인지부터 확인합니다. 하나라도 어긋나면 그 응답은 버립니다.
- 일부 필드만 빠진 경우에는 그 필드에 한해 빌드 시점 값을 씁니다. 예를 들어 환율은 수집 시점에 0 으로 올 수 있는데, 그대로 쓰면 원화 표기가 사라집니다.
- 하루치 세부 값은 없으면 없는 대로 그립니다. 플랫폼별로 나누지 않고 막대 하나로 그리면 되므로 화면이 비지 않습니다.
조건부 요청은 브라우저에 맡긴다
같은 구조를 내부 화면에도 쓰는데, 이쪽 응답은 태스크 전체를 담아 146KB 입니다. 1분마다 전부 내려받으면 낭비입니다. 응답에 ETag 를 붙이고 cache-control: no-cache(저장하되 매번 확인)를 지정하면 브라우저가 재검증을 처리합니다.
화면 코드가 직접 If-None-Match 를 붙이는 방식은 쓰지 않습니다. 크롬은 캐시에 없는 응답의 304 를 실패로 처리합니다(실측). 브라우저에 맡기면 화면에는 항상 200 과 본문이 오고, 실제 전송량만 줄어듭니다.
폴링 타이머는 하나로
한 페이지의 두 영역이 같은 수치를 사용합니다. 각각 타이머를 만들면 요청이 두 배가 됩니다. 구독 수를 세어 첫 구독에서 타이머를 만들고 마지막 구독이 해제될 때 정리합니다. 여기에 탭이 다시 활성화될 때 한 번 더 읽도록 했습니다. 휴대폰에서 화면을 껐다 켜면 다음 갱신까지 1분을 기다리게 되기 때문입니다.
const onVisible = () => {
// 탭을 다시 열었을 때 1분을 기다리지 않게 한다(휴대폰에서 자주 있는 일이다).
if (document.visibilityState === "visible") void fetchPublicStats();
};공개 파일에 담지 않는 항목
스냅샷은 주소만 알면 누구나 받을 수 있는 파일입니다. 그래서 집계된 수치만 담습니다. 프로젝트 이름, 태스크 본문, 사용한 모델 이름은 넣지 않습니다. 내부 화면이 쓰는 상세 응답과는 생성 함수 자체가 다릅니다.
대신 이 방식에는 비용이 따릅니다. 수집기와 API 가 같은 집계 규칙을 각각 구현하게 되므로, 집계 구간이나 대상 목록을 한쪽만 고치면 두 값이 드러나지 않게 어긋납니다. 두 곳을 함께 고쳐야 한다는 주석을 양쪽에 남겨 두었습니다.
정리
- 정적 산출물은 그대로 두고, 바뀌는 값만 별도 파일로 분리했습니다.
- 첫 마크업은 빌드 시점 값이라 서버 렌더링 결과와 어긋나지 않고, 파일을 읽지 못해도 페이지는 정상적으로 열립니다.
- 갱신은 10분 단위입니다. 실시간이 필요한 화면이 아니라면 이 정도로 충분합니다.
정적 사이트의 장점은 요청마다 서버가 할 일이 없다는 것입니다. 수치 몇 개 때문에 그 장점을 버릴 필요는 없었습니다. 바뀌는 값과 바뀌지 않는 구조를 분리하면, 바뀌는 값만 따로 갱신할 수 있습니다.