2026년 11월 13일 금요일

나만의 아침 대시보드 만들기 (1) 의존성 없는 뼈대와 수집 스케줄러

대시보드-따라하기 시리즈 1편. 카드를 스무 장 넘게 붙여도 버티는 뼈대를 만든다. 핵심은 "수집기 규약"과 "실패에 강한 스케줄러" 두 가지다.

폴더 구조

server.js            HTTP 서버, /api/state, 정적 파일
lib/scheduler.js     수집기를 주기적으로 실행
lib/http.js          타임아웃·오류 처리를 넣은 fetch 래퍼
collectors/*.js      카드 하나당 파일 하나
config/*.json        종목·검색어 같은 설정
public/index.html    화면 전부

수집기 규약

수집기는 이름, 주기, 그리고 데이터를 돌려주는 함수만 내보낸다.

// collectors/weather.js
module.exports = {
  name: 'weather',
  title: '날씨',
  intervalMs: 10 * 60e3,
  timeoutMs: 20e3,
  enabled: (env) => !!env.WEATHER_LAT,      // 설정이 없으면 카드를 끈다
  async fetch(ctx) {
    const r = await ctx.http.json('https://api.example.com/forecast?...');
    return { now: r.current, hourly: r.hourly.slice(0, 12) };
  },
};

server.js 는 수집기를 폴더 스캔이 아니라 명시 목록으로 불러온다. 순서가 곧 화면 순서이고, 지운 파일이 실수로 다시 실행되는 일도 없다.

스케줄러

setInterval 대신 수집기마다 setTimeout 을 이어 붙인다. 한 번 끝난 뒤에 다음 실행을 잡으므로 느린 API 때문에 실행이 겹치지 않는다.

const MAX_BACKOFF = 30 * 60e3;

async function run(c) {
  const s = state[c.name];
  if (running[c.name]) return;
  running[c.name] = true;
  let timer;
  try {
    const data = await Promise.race([
      c.fetch(ctx),
      new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('timeout')), c.timeoutMs || 60e3); }),
    ]);
    Object.assign(s, { data, updatedAt: Date.now(), error: null, failures: 0 });
  } catch (e) {
    s.error = e.message; s.failures++;      // data 는 건드리지 않는다 → 화면엔 직전 값
  } finally {
    clearTimeout(timer);
    running[c.name] = false;
    const delay = s.failures ? Math.min(c.intervalMs * 2 ** s.failures, MAX_BACKOFF) : c.intervalMs;
    setTimeout(() => run(c), delay);
  }
}
  • 실패해도 data 를 지우지 않는다. 화면에는 직전 값과 "몇 분 전 갱신"이 보인다. 카드가 텅 비는 것보다 조금 오래된 값이 낫다.
  • 지수 백오프. 실패가 이어지면 간격을 두 배씩 늘리되 30분을 넘기지 않는다. 죽은 API 를 두들겨서 차단당하는 일을 막는다.
  • 첫 실행은 0~5초 사이 무작위 지연. 기동 직후 스무 개가 한꺼번에 나가면 같은 호스트의 호출 제한에 걸린다.

겪은 함정: "지금 실행"이 건너뛰어진다

화면에서 값을 저장한 직후 해당 수집기를 즉시 다시 돌리는 runNow() 를 만들었는데, 가끔 저장 전 값이 그대로 보였다. 마침 그 수집기가 실행 중이면 running 검사에 걸려 그냥 돌아와 버렸기 때문이다. 진행 중인 실행은 저장 전 데이터를 읽고 있었다.

async runNow(name) {
  if (inflight[name]) await inflight[name];   // 진행 중인 실행이 끝나길 기다리고
  await start(byName[name]);                  // 한 번 더 돌린다
  return state[name];
}

"중복 실행 방지"와 "최신 값 보장"은 다른 요구다. 건너뛰기만 하면 앞의 것만 만족한다.

화면: 태블릿에서 배운 것

  • 카드 안쪽 스크롤을 없애고 페이지 스크롤 하나만 남겼다. 터치로 넘기다 카드 안에 갇히는 일이 사라진다. 긴 목록은 "더보기"로 펼친다.
  • 안쪽 스크롤을 없애면 줄바꿈 안 되는 긴 문구가 카드 밖으로 삐져나와 페이지가 가로로 넓어진다. .card { overflow: hidden } 과 html, body { overflow-x: clip } 로 막았다.
  • 60초마다 다시 그릴 때 펼침 상태, 선택한 탭이 초기화되지 않게 상태를 따로 기억한다.
  • 지도 카드는 터치 기기에서 전체화면일 때만 드래그를 받는다. 그러지 않으면 페이지를 내리려다 지도만 움직인다.
  • 새로고침하면 브라우저가 이전 스크롤 위치로 돌아가려 한다. 대시보드는 항상 맨 위에서 시작하는 편이 낫다: history.scrollRestoration = 'manual'.

로컬에서 돌리기

node --env-file=.env server.js

수집기 하나만 따로 실행해 결과를 찍어 보는 스크립트를 만들어 두면 새 카드를 붙일 때 서버를 띄우지 않아도 된다.

다음 편은 카드에 넣을 데이터를 어디서 구하는지다. 시리즈 전체 목록은 여기.

댓글 없음:

댓글 쓰기

반도체 주식을 보기 전에 그려 두는 지도 — 설계, 파운드리, 메모리, 장비, 소재, 후공정

"반도체주"라고 한 묶음으로 부르지만, 그 안의 회사들은 하는 일도 돈 버는 방식도 전혀 다르다. 같은 뉴스에 어떤 회사는 오르고 어떤 회사는 내리는 이유가 여기에 있다. 종목을 들여다보기 전에 머릿속에 그려 두면 좋은 지도를 정리한...