ChatGPT Work로 브라우저 탭 일곱 개를 줄여봤다

아침마다 브라우저 탭 일곱 개를 순서대로 훑는 게 루틴이 됐다. 메일 하나, 캘린더 하나, 슬랙 하나, RSS 리더 하나. 몇 년째 그러고 있었다.

그래서 ChatGPT Work에 Sites가 붙었다는 얘기를 들었을 때 제일 먼저 떠오른 게 이거였다. 저 일곱 개를 한 화면에 밀어넣을 수 있겠다. 그날 저녁에 바로 붙잡고 앉았고, 화면은 정말 나왔다. 반나절 만에 도는 대시보드를 봤다.

절반만 나왔다는 걸 안 건 그 다음이었다.

내가 원했던 화면

설계라고 부르기도 뭐한 수준의 스케치부터 그렸다.

왼쪽 컬럼에 오늘 할 일. 체크박스 누르면 바로 반영되는 흔한 todo. 가운데에 오늘 일정과 액션이 필요한 메일 몇 줄. 오른쪽에 구독 중인 개발 블로그 RSS와 GitHub 알림. 하단에 이번 주 커밋 히트맵.

특별할 게 없다. Notion이나 Raycast로도 되고, 시간 들이면 Supabase로 짜도 된다. 굳이 Sites를 본 이유는 하나였다. 인증과 배포를 안 짜도 된다는 것.

오랫동안 이 일을 하면서 제일 지겨웠던 게 사내 툴 만들 때 로그인 붙이는 작업이다. 기능은 두 시간이면 되는데 OAuth 붙이고 세션 관리하고 권한 테이블 짜다 보면 이틀이 간다. 그 이틀에서 나오는 산출물이 제품 가치랑 아무 상관이 없다는 게 특히 그렇다. 로그인이 잘 된다고 대시보드가 좋아지진 않으니까. Sites는 그걸 없앴다고 했다. 그 지점 하나 때문에 들여다봤다.

그리고 여기서 내가 무의식적으로 한 가정이 하나 있었는데, 그게 나중에 반나절을 잡아먹는다.

"연동된다"는 말이 숨기는 것

Sites를 다룬 글들을 몇 개 읽었는데 대부분 이런 톤이었다. 프롬프트로 앱을 만들고, 호스팅되고, Gmail이나 Slack 같은 서비스와 연동된다.

세 번째 항목이 문제다. 저 문장은 참인데, 참인 위치가 사람들이 생각하는 곳이 아니다.

ChatGPT의 커넥터는 OpenAI가 관리하는 MCP 래퍼다. Gmail도 캘린더도 Slack도 전부 그 층 위에 얹혀 있다. 여기까지는 맞다. 그런데 그 MCP 클라이언트가 어디에서 도는가를 따져보면 얘기가 갈린다.

채팅창에서 "어제 온 메일 요약해줘"라고 하면, 모델이 MCP 툴을 고르고 호출한다. 이때 클라이언트는 OpenAI 쪽에 있고, 내 OAuth 토큰은 그 세션에 묶여 있다. 정상 동작한다.

배포된 사이트는 다르다. 그건 그냥 웹 서버다. HTTP 요청이 들어오면 응답을 뱉는 프로세스. 여기엔 모델이 없다. 모델이 없으면 MCP 클라이언트도 없고, 툴을 고를 주체도 없다.

한 문장으로 줄이면 이렇다. MCP는 모델이 있는 곳에서만 산다. 커넥터는 "이 계정에 붙어 있는 기능"이 아니라 "모델이 도는 세션에 붙어 있는 기능"이다.

이 구분이 실무에서 왜 중요하냐면, 대부분의 사람이 "ChatGPT가 Gmail을 읽을 수 있으니, ChatGPT가 만든 사이트도 Gmail을 읽겠지"라고 자연스럽게 넘어가기 때문이다. 나도 그랬다. 만드는 주체와 실행되는 주체를 무의식적으로 같은 것으로 취급한 거다.

생각해보면 이건 새로운 함정도 아니다. 로컬에서 잘 돌던 스크립트가 CI에서 죽을 때랑 구조가 같다. 내 셸에는 있던 환경변수가 러너에는 없는 것. 다만 이번엔 "없는 것"이 환경변수가 아니라 모델이라서, 없다는 사실 자체가 눈에 잘 안 띈다.

헤더 두 개가 말해주는 것

추측만 하고 있을 수는 없어서 실제로 뭐가 넘어오는지 봤다. 결정적인 증거는 로그인 쪽에 있었다.

Sites의 Sign in with ChatGPT는 방문자가 로그인하면 서버로 헤더를 두 개 넘긴다.

oai-authenticated-user-email
oai-authenticated-user-full-name

이게 전부다. 신원만 넘어오고 액세스 토큰은 안 넘어온다. 방문자가 ChatGPT에 Gmail을 연결해뒀더라도, 그 권한을 사이트가 물려받을 경로가 없다는 뜻이다.

이 두 줄을 보고 나서야 앞의 추론이 확정됐다. 토큰이 안 넘어오는 건 구현이 덜 된 게 아니라 설계다. 사이트는 방문자의 권한을 위임받는 자리가 아니라 방문자가 누구인지만 아는 자리다.

받아들이고 나니 오히려 정리는 쉬웠다. 이 헤더가 주는 건 딱 하나, 사용자 식별자다. 그러면 사이트가 할 수 있는 일도 명확해진다. 사용자별로 데이터를 격리하는 것. 그 이상은 안 된다.

그래서 이후 설계에서 oai-authenticated-user-email을 사실상의 유저 키로 쓰기로 했다. 다른 선택지가 없기도 했고.

문서 한 줄이 정리해준 것

한참 여기저기 뒤지다가 use-cases 문서에서 답을 찾았다. build-and-deploy-internal-apps 페이지에 이렇게 적혀 있다.

라이브 데이터 페칭이 필요하면 API 키로 서드파티 도구에 연결할 수 있다. 다만 플러그인 연결을 쓰고 싶다면, 현재 채팅에서 태스크를 스케줄해 플러그인으로 데이터를 가져오고, 앱을 업데이트한 뒤 재배포하면 된다.

두 문장이 전부인데 이게 설계 전체를 결정한다.

경로 사이트 런타임에서 신선도
API 키로 외부 REST 직접 호출 가능 요청 시점
플러그인/커넥터(MCP) 불가 스케줄 → 재배포 주기

아웃바운드 HTTP는 열려 있다. 이건 좋은 소식이다. 사이트에서 GitHub API를 부르든 RSS를 긁든 날씨 API를 때리든 다 된다. API 키만 있으면 된다.

닫혀 있는 건 커넥터 경로 하나다. 그런데 내가 제일 넣고 싶었던 Gmail·캘린더·Slack이 하필 전부 거기 걸려 있다.

그래서 처음 그렸던 스케치는 그대로는 못 만든다. 다시 그려야 했다.

대시보드를 셋으로 쪼갰다

여기서 축을 바꿨다. 원래 스케치는 기능별로 잘려 있었다. todo 위젯, 메일 위젯, RSS 위젯, 커밋 히트맵. 화면에 보이는 덩어리가 곧 설계 단위였다.

그 축으로는 답이 안 나온다. 메일 위젯과 RSS 위젯은 화면에서 나란히 붙어 있지만 데이터가 오는 경로가 완전히 다르고, 그 차이가 구현의 90%를 결정하기 때문이다.

그래서 데이터가 얼마나 신선해야 하는가, 그리고 그 신선도를 어디서 보장받는가로 다시 잘랐다. 화면 배치는 그 다음 문제다.

1층 — 사이트가 직접 소유하는 데이터

todo, 메모, 북마크, 필터 설정. 외부 어디에도 원본이 없고 사이트에서 만들어지고 사이트에서 끝나는 것들.

D1에 직접 쓰면 된다. 체크박스 누르면 즉시 반영되고, 다른 기기에서 열어도 남아 있다. 여기엔 아무 제약이 없다. 그냥 CRUD다.

CREATE TABLE todos (
  id          INTEGER PRIMARY KEY AUTOINCREMENT,
  user_email  TEXT    NOT NULL,
  body        TEXT    NOT NULL,
  done        INTEGER NOT NULL DEFAULT 0,
  due_at      INTEGER,
  created_at  INTEGER NOT NULL
);

CREATE INDEX idx_todos_user_done ON todos (user_email, done, due_at);

user_email을 모든 테이블에 박아두는 게 포인트다. 어차피 헤더로 그것밖에 안 넘어오니까 그게 사실상의 사용자 키가 된다. 나중에 여러 명이 쓰게 되더라도 이 컬럼 하나로 격리된다.

인덱스를 (user_email, done, due_at) 순서로 잡은 것도 같은 이유다. 이 대시보드가 던지는 쿼리는 거의 전부 "이 사람의 / 안 끝난 / 마감 가까운 순" 형태다. 선두 컬럼이 user_email이면 다른 사용자 행은 아예 스캔에 안 들어온다. 격리가 애플리케이션 로직이 아니라 인덱스 모양으로 한 번 더 박히는 셈이다.

이 층은 재미없지만 대시보드에서 실제로 제일 많이 만지는 부분이었다. 체크박스 누르는 동작 하나가 즉시 반응하느냐가 "이 화면 쓸 만하다"는 감각을 대부분 결정한다.

2층 — API 키로 직접 긁는 데이터

RSS, GitHub 알림, 날씨, 환율. 공개 API거나 개인 토큰으로 접근 가능한 것들.

요청이 들어올 때 가져오고, D1에 캐시하고, TTL 지나면 다시 가져온다. 서버 사이드에서 처리하고 키는 Sites 설정의 시크릿에 넣는다. 프롬프트나 코드에 박으면 안 된다.

interface Env {
  DB: D1Database;
  GITHUB_TOKEN: string;
}

const TTL_MS = 10 * 60 * 1000;

export async function onRequest(req: Request, env: Env): Promise<Response> {
  const email = req.headers.get('oai-authenticated-user-email');
  if (!email) return new Response('Unauthorized', { status: 401 });

  const cached = await env.DB
    .prepare('SELECT body, fetched_at FROM cache WHERE user_email = ?1 AND key = ?2')
    .bind(email, 'gh:notifications')
    .first<{ body: string; fetched_at: number }>();

  const fresh = cached && Date.now() - cached.fetched_at < TTL_MS;
  if (fresh) return Response.json(JSON.parse(cached.body));

  try {
    const res = await fetch('https://api.github.com/notifications', {
      headers: {
        Authorization: `Bearer ${env.GITHUB_TOKEN}`,
        'User-Agent': 'personal-dashboard',
        Accept: 'application/vnd.github+json',
      },
    });

    if (!res.ok) throw new Error(`github ${res.status}`);

    const body = JSON.stringify(await res.json());

    await env.DB
      .prepare(
        `INSERT INTO cache (user_email, key, body, fetched_at) VALUES (?1, ?2, ?3, ?4)
         ON CONFLICT(user_email, key) DO UPDATE SET body = ?3, fetched_at = ?4`,
      )
      .bind(email, 'gh:notifications', body, Date.now())
      .run();

    return Response.json(JSON.parse(body));
  } catch {
    // 만료됐어도 있으면 내보낸다. 빈 화면보다 낡은 화면이 낫다
    if (cached) return Response.json(JSON.parse(cached.body));
    return Response.json({ error: 'unavailable' }, { status: 503 });
  }
}

catch 블록의 stale fallback이 실제로는 제일 중요했다. GitHub API가 레이트 리밋에 걸리는 순간이 생각보다 자주 온다. 그때 위젯 하나가 빈칸이 되면 대시보드 전체가 못 미더워진다. 위젯 하나가 비었을 뿐인데 나머지 다섯 개도 같이 의심하게 되는, 좀 비합리적이지만 확실한 반응이 있다.

10분 전 데이터라도 띄우고 "10분 전"이라고 라벨을 붙이는 쪽이 훨씬 낫다. 그래서 캐시 응답에 fetched_at을 항상 실어 보내고 UI에서 상대 시각으로 찍는다. 이 라벨 하나가 붙고 나서는 낡은 데이터가 떠 있어도 화면을 의심하지 않게 됐다. 낡았다는 걸 화면이 먼저 말해주니까.

신선도를 보장할 수 없으면 신선도를 표시하면 된다. 둘 다 안 하는 위젯은 차라리 없는 게 낫다.

3층 — 커넥터를 거쳐야만 오는 데이터

Gmail, 캘린더, Slack. 여기가 우회 구간이다.

채팅에서 스케줄 태스크를 만들어두면, 그 태스크가 커넥터로 데이터를 긁고 사이트를 갱신한 뒤 재배포한다. 실행 컨텍스트가 채팅 쪽이니까 MCP가 살아 있다. 앞에서 정리한 "모델이 있는 곳에서만 산다"를 그대로 이용하는 구조다.

문제는 재배포라는 단어다. 이건 데이터 갱신이 아니라 빌드다. 아침 8시에 한 번 돌게 해두면 하루 종일 8시 스냅샷을 보게 된다.

처음엔 이게 못 쓸 수준이라고 생각했다. 15분마다 재배포할 수는 없으니까.

그런데 며칠 써보니 생각이 바뀌었다. 애초에 메일을 실시간으로 볼 거면 Gmail 탭을 열지 대시보드를 왜 보나. 대시보드에서 원했던 건 "오늘 놓치면 안 되는 게 뭔지" 한 번 확인하는 거였다. 그건 하루 한두 번이면 충분하다.

이 구간에서 코드 쪽으로 해둘 일은 많지 않다. 3층 데이터를 받을 테이블을 미리 만들어두고, 렌더링은 그 테이블만 보게 해두면 된다. 그러면 채팅 태스크가 무엇을 어떻게 긁어오든 사이트 코드는 안 바뀐다.

CREATE TABLE injected (
  user_email  TEXT NOT NULL,
  source      TEXT NOT NULL,   -- 'gmail' | 'calendar' | 'slack'
  payload     TEXT NOT NULL,   -- JSON
  captured_at INTEGER NOT NULL,
  PRIMARY KEY (user_email, source)
);

captured_at을 넣어둔 게 2층의 fetched_at과 같은 역할을 한다. 3층은 신선도가 구조적으로 낮은 층이라 오히려 이 라벨이 더 필요하다. 화면에 "오늘 08:00 기준"이라고 박아두면 그 아래 내용이 몇 시간 지난 거라도 오해가 안 생긴다.

Gmail을 실시간으로 넣고 싶으면 방법이 없는 건 아니다. Google Cloud에서 OAuth 클라이언트를 직접 만들고, refresh token을 시크릿에 넣고, 2층 방식으로 REST를 직접 치면 된다. 커넥터를 안 쓰고 내가 Google API 클라이언트가 되는 거다. 그러면 3층에 있던 항목이 2층으로 내려온다.

해볼까 하다가 접었다. 토큰 갱신 처리하고 스코프 관리하는 데 드는 시간이, 그렇게 해서 얻는 신선도보다 비싸 보였다. 실시간이 진짜 필요한 순간이 오면 그때 붙이면 된다. 층 구조를 이렇게 잡아두면 나중에 항목 하나를 3층에서 2층으로 옮기는 게 화면을 안 건드리고 되는 작업이라, 지금 결정을 미뤄도 비용이 안 쌓인다.

크론이 없다는 게 제일 아팠다

Sites는 백그라운드 서비스를 지원하지 않는다. 상주 프로세스도, 스케줄 워커도, WebSocket 서버도 없다.

무슨 뜻이냐면 사이트 안에서 "5분마다 RSS를 긁어둔다" 같은 걸 못 한다는 거다. 누군가 페이지를 열어야만 코드가 돈다.

그래서 2층 위젯들은 전부 첫 방문자가 지연을 떠안는 구조가 됐다. 아침에 처음 열면 로딩이 2~3초 걸리고, 그 다음부터는 캐시라 즉시 뜬다. 혼자 쓰는 대시보드니까 매일 아침 내가 그 2초를 낸다는 뜻이다.

두 가지 우회를 시도해봤다.

외부 크론으로 워밍업. GitHub Actions에 스케줄 워크플로를 하나 만들어서 매일 7시 50분에 사이트 엔드포인트를 때리게 했다. 내가 8시에 열면 이미 캐시가 채워져 있다.

name: warm-dashboard
on:
  schedule:
    - cron: '50 22 * * *'   # UTC 22:50 = KST 07:50
jobs:
  warm:
    runs-on: ubuntu-latest
    steps:
      - run: |
          curl -sf -X POST "${{ secrets.DASHBOARD_URL }}/api/refresh" \
               -H "x-warm-key: ${{ secrets.WARM_KEY }}" \
            || echo "warm failed, non-fatal"

-f를 붙여서 4xx/5xx면 실패로 잡되, || echo로 워크플로 전체는 안 죽게 했다. 워밍업이 실패해도 대시보드는 정상 동작해야 하니까. 캐시를 미리 채우는 작업이 실패했다고 알림이 오면 그 알림 자체가 노이즈가 된다.

그리고 이 엔드포인트는 로그인 헤더가 없다. 크론이 때리는 요청에는 oai-authenticated-user-email이 안 붙으니까 앞의 401 가드에 그대로 걸린다. 그래서 별도 키로 막고 워밍업 전용 경로를 하나 뒀다. 이 경로가 하는 일은 캐시를 채우는 것뿐이고 사용자 데이터를 읽거나 쓰지 않는다. 인증이 약한 경로를 하나 여는 대신 그 경로가 할 수 있는 일을 최소로 줄이는 쪽을 택했다.

좀 웃긴 구조다. 배포 파이프라인 안 짜려고 Sites를 골랐는데 GitHub Actions를 붙였다. 그래도 워크플로 파일 하나가 전부라 감당할 만하다.

전날 밤 스케줄 태스크에서 미리 밀어넣기. 3층 갱신을 어차피 돌리니까, 그때 2층 데이터도 같이 D1에 써두는 방식. 이쪽이 더 깔끔한데 채팅 태스크가 D1을 직접 만지게 하는 거라 검증이 번거로웠다. 모델이 SQL을 쓰는 경로를 하나 여는 셈이라, 스키마가 바뀌면 조용히 어긋날 여지가 생긴다. 지금은 앞의 방식을 쓰고 있다.

프롬프트를 어떻게 넣었는가

이 부분이 의외로 제일 시간을 아껴준 지점이다.

처음엔 "개인 대시보드 만들어줘, todo랑 RSS랑 GitHub 알림 넣어줘" 식으로 던졌다. 나온 결과물은 그럴듯했는데 전부 프론트엔드 상태였다. 새로고침하면 날아갔다. localStorage에 넣은 것도 있었다.

이게 모델이 잘못한 거냐면 아니다. 내가 준 요구사항 안에 "지속돼야 한다"는 말이 없었다. 화면만 요구하면 화면만 나온다.

그래서 방식을 바꿨다. 만들어달라고 하지 말고, 경계를 먼저 못박고 그 안에서 만들게 했다.

개인 대시보드를 만들어서 배포해줘.

데이터 계층을 세 가지로 나눠서 구현해:

1. 사이트 소유 데이터 — todo, 메모, 북마크.
   D1에 저장. 모든 테이블에 user_email 컬럼을 두고
   oai-authenticated-user-email 헤더 값으로 격리해.

2. 외부 API 데이터 — GitHub 알림, RSS 피드.
   서버사이드에서 fetch하고 D1에 TTL 10분으로 캐시해.
   API 키는 환경변수에서 읽어. 코드에 박지 마.
   fetch 실패 시 만료된 캐시라도 반환하고,
   응답에 fetched_at을 포함해서 UI에 "N분 전"을 표시해.

3. 외부 주입 데이터 — 나중에 스케줄 태스크가 채울 자리.
   지금은 빈 테이블과 렌더링 로직만 만들어둬.

제약:
- 인증 판단은 전부 서버사이드에서. 클라이언트 신뢰 금지.
- localStorage 사용 금지. 지속 데이터는 전부 D1.
- 배포 전에 로그인/미로그인 두 경우를 다 테스트해.

먼저 스키마와 라우트 구조를 제안하고, 내 확인을 받은 다음에 구현 시작해.

마지막 줄이 핵심이다. 바로 만들게 두면 되돌리는 비용이 크다. 스키마부터 보여달라고 하면 3분 안에 잘못된 전제를 잡아낼 수 있다. 실제로 첫 제안에서 캐시 테이블에 user_email이 빠져 있는 걸 봤는데, 그 상태로 구현이 끝난 다음에 발견했으면 라우트를 전부 다시 봐야 했을 거다.

이건 AI 협업 개발 전반에 적용되는 얘기이기도 한데, 원하는 결과물을 뽑아내려고 프롬프트를 다듬는 것보다 미리 정해둔 기준에 AI를 정렬시키는 쪽이 훨씬 안정적이다. 전자는 매번 새로 하는 일이고 후자는 한 번 써두면 재사용된다.

위 프롬프트에서 실질적인 내용은 "세 계층"과 "제약" 블록이고, 나머지는 그냥 지시문이다. 그 두 블록을 문서로 빼두면 다음 요청에서 그대로 붙여 쓸 수 있다. 나는 이걸 파일 하나로 빼뒀고, 이후 기능을 추가할 때마다 같은 블록을 앞에 붙여서 던진다. 계층이 하나 늘면 그 파일만 고치면 된다.

스토리지를 고르는 기준

Sites는 두 가지를 준다. D1은 SQLite 호환 관계형 DB, R2는 오브젝트 스토리지.

경계가 애매해 보이지만 실제로는 단순하다. 구조화된 건 D1, 바이트 덩어리는 R2. 파일을 올린다면 파일 자체는 R2에, 파일명·크기·업로드 시각·소유자는 D1에 넣고 R2 키만 참조로 저장한다.

판단이 갈리는 경우도 결국 같은 질문 하나로 정리된다. 이걸 조건으로 걸어서 찾을 일이 있는가. 있으면 D1이고, 통째로 꺼내 쓰기만 하면 R2다. 2층 캐시 응답은 JSON 덩어리라 R2에 넣고 싶어질 수 있는데, fetched_at으로 만료를 판정해야 하니까 D1이 맞다. 반대로 히트맵을 PNG로 미리 렌더해서 저장한다면 그건 R2다.

주의할 건 반대 방향이다. 지속 스토리지를 남용하지 말라는 것.

테마 설정이나 배너 닫힘 여부 같은 걸 D1에 넣고 싶어지는데, 문서도 이걸 명시적으로 말린다. 저런 건 화면 상태지 제품 데이터가 아니다. D1에 넣는 순간 읽기 쿼리가 하나 늘고, 그게 페이지 로드마다 붙는다. 혼자 쓰는 대시보드에서 체감은 안 되겠지만 습관으로 굳으면 나중에 아프다.

기준을 하나 세워두면 편하다. 다른 기기에서 열었을 때 따라와야 하면 D1, 아니면 클라이언트에 둔다. todo는 따라와야 하고 사이드바 접힘 상태는 안 따라와도 된다.

그리고 SQLite다. 조인 몇 개 걸린 리포팅 쿼리를 돌릴 생각이면 처음부터 다른 걸 쓰는 게 맞다. 대시보드처럼 단일 사용자 기준으로 몇 십 행 읽는 패턴이면 충분하고도 남지만, 여기에 분석 쿼리를 얹기 시작하면 금방 경계에 닿는다.

아직 못 넘은 것들

포터빌리티. 사이트는 OpenAI가 호스팅하고 ChatGPT로 인증된다. 코드를 내 리포로 빼내서 다른 데 올릴 수 있는지가 명확하지 않다. 로컬 프로젝트를 연결하는 경로는 있는데, 반대로 완전히 떼어내는 시나리오는 검증해보지 않았다. 개인 대시보드니까 감수하는 거지, 팀 자산이었으면 이것부터 확인했을 거다. 인증이 플랫폼에 묶여 있다는 건 나갈 때 로그인을 새로 짜야 한다는 뜻이기도 하다. 들어올 때 아꼈던 이틀을 나갈 때 낼 수도 있다.

데이터 레지던시. 출시 시점 기준 지원하지 않는다. 배포된 사이트, 코드, D1·R2 데이터, 로그 전부. 개인 메모 정도면 상관없지만 회사 데이터를 넣을 생각이면 여기서 걸린다.

결제 불가. 금융 거래와 카드 결제 데이터 처리가 약관으로 금지돼 있다. 대시보드에는 해당 없지만, 이걸로 판매 페이지를 만들 생각이었다면 접어야 한다.

모든 배포 URL이 프로덕션. 스테이징이 없다. 대신 "버전 저장"과 "배포"가 분리돼 있어서, 저장만 해두고 검토한 뒤 배포하는 흐름은 만들 수 있다. 이 두 단계를 구분 안 하고 쓰면 어느 순간 라이브가 깨진 채로 며칠 간다. 혼자 쓰는 대시보드라 남한테 피해는 없지만, 아침에 열었는데 화면이 비어 있으면 그날 루틴이 통째로 어그러진다.

다시 만든다면

익숙한 스택으로 같은 걸 짜면 어떻게 되나 한 번 계산해봤다.

Supabase에 Edge Function과 pg_cron을 얹으면 3층 구조가 두 층으로 줄어든다. 커넥터라는 개념이 없으니 Gmail도 그냥 OAuth 붙여서 2층으로 처리하면 되고, 크론이 있으니 워밍업용 GitHub Actions도 필요 없다. 신선도 계층을 나눌 이유 자체가 줄어든다.

대신 로그인을 짜야 한다. 그 이틀이 돌아온다.

그러니까 이건 어느 쪽이 더 좋은 플랫폼이냐의 문제가 아니라, 어느 비용을 낼 거냐의 문제다. Sites는 인증과 배포 비용을 가져가고 대신 런타임 자유도를 뺏는다. Supabase는 런타임 자유도를 주고 대신 초기 세팅 비용을 청구한다.

이 계산에서 자주 빠뜨리는 항목이 하나 있는데, 인증 비용은 한 번이고 런타임 제약은 계속이라는 점이다. 크론이 없다는 건 이 대시보드가 살아 있는 내내 우회 구조를 유지해야 한다는 뜻이다. 반면 로그인은 붙이고 나면 다시 안 만진다.

그래서 쓸 만한가

한 달쯤 써본 지금 기준으로, 브라우저 탭은 일곱 개에서 네 개로 줄었다. Gmail과 Slack은 여전히 따로 연다. 실시간이 필요해서다.

기대만큼은 아니지만 기대를 잘못한 쪽이 나였다. 인증과 호스팅을 안 짜도 되는 대신 데이터 계층은 직접 설계해야 하고, 그건 어느 플랫폼을 쓰든 똑같다. Sites가 줄여준 건 배포 왕복이지 아키텍처가 아니다.

판단 기준을 하나로 좁히면 이렇게 된다. 워크스페이스 인증과 제로컨피그 배포가, 벤더 락인과 크론 부재를 감수할 만큼 가치 있는가.

사내 대시보드라면 대체로 그렇다. 로그인 붙이는 이틀이 통째로 사라지고, 사내 툴은 어차피 데이터가 하루 한 번 갱신돼도 되는 게 대부분이다.

혼자 쓰는 개인 대시보드라면 애매하다. 어차피 나 혼자니까 인증의 가치가 반감되고, 익숙한 스택에 크론까지 있으면 우회할 일이 없다. 다시 만든다면 그쪽으로 갈 것 같기도 하다.

다만 반나절 만에 돌아가는 화면을 본 건 Sites 쪽이었다. 그 속도가 필요한 순간은 분명히 있다.

댓글

이 블로그의 인기 게시물

Google I/O 끝나고, 1편을 다시 펼쳤다

Google I/O 닷새 전, AI 전선이 세 갈래로 갈라졌다

M4 맥북에서 로컬 LLM으로 5일을 살아본 후