브라우저 탭 일곱 개를 하나로 줄여봤다

아침마다 브라우저 탭 일곱 개를 순서대로 훑는 게 루틴이 됐습니다. 메일 하나, 캘린더 하나, 슬랙 하나, 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를 정렬시키는 쪽이 훨씬 안정적입니다. 전자는 매번 새로 하는 일이고 후자는 한 번 써두면 재사용됩니다.
이 습관은 여기서 처음 생긴 게 아닙니다. 사이드 프로젝트 네 개를 AI와 만들면서 매번 같은 자리에서 발이 멈춘 기록을 남긴 적이 있는데, 그때 네 번 다 해법이 같은 모양이었습니다. 제 프로젝트의 결정을 모델 안에 두지 말고 바깥에 적어두고 매번 들고 들어가는 것. 사주 앱에서는 35페이지짜리 도메인 문서였고, 로봇에서는 120줄짜리 규칙 파일이었습니다. 이번엔 그게 "세 계층" 블록이 됐을 뿐입니다.
위 프롬프트에서 실질적인 내용은 "세 계층"과 "제약" 블록이고, 나머지는 그냥 지시문입니다. 그 두 블록을 문서로 빼두면 다음 요청에서 그대로 붙여 쓸 수 있습니다. 저는 이걸 파일 하나로 빼뒀고, 이후 기능을 추가할 때마다 같은 블록을 앞에 붙여서 던집니다. 계층이 하나 늘면 그 파일만 고치면 됩니다.
스토리지를 고르는 기준
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로 인증됩니다. 코드를 제 리포로 빼내서 다른 데 올릴 수 있는지가 명확하지 않습니다. 로컬 프로젝트를 연결하는 경로는 있는데, 반대로 완전히 떼어내는 시나리오는 검증해보지 않았습니다. 개인 대시보드니까 감수하는 거지, 팀 자산이었으면 이것부터 확인했을 겁니다. 인증이 플랫폼에 묶여 있다는 건 나갈 때 로그인을 새로 짜야 한다는 뜻이기도 하입니다. 들어올 때 아꼈던 이틀을 나갈 때 낼 수도 있습니다.
이 대목에서 몇 주 전에 정리해둔 그림이 겹쳤습니다. 구글이 서울에서 모델이 아니라 그 위층, 밑에 깔린 모델을 갈아끼울 수 있는 오케스트레이션 층을 팔고 있다는 관찰이었습니다. Sites는 정확히 반대편에 서 있습니다. 인증과 배포를 대신 짊어지는 대가로 그 층을 갈아끼울 여지를 가져갑니다. 어느 쪽이 옳다는 얘기가 아니라, 무엇을 남에게 맡기고 무엇을 제 손에 남길지가 여기서도 같은 질문이었다는 뜻입니다.
데이터 레지던시. 출시 시점 기준 지원하지 않습니다. 배포된 사이트, 코드, D1·R2 데이터, 로그 전부. 개인 메모 정도면 상관없지만 회사 데이터를 넣을 생각이면 여기서 걸립니다.
결제 불가. 금융 거래와 카드 결제 데이터 처리가 약관으로 금지돼 있습니다. 대시보드에는 해당 없지만, 이걸로 판매 페이지를 만들 생각이었다면 접어야 합니다.
모든 배포 URL이 프로덕션. 스테이징이 없습니다. 대신 "버전 저장"과 "배포"가 분리돼 있어서, 저장만 해두고 검토한 뒤 배포하는 흐름은 만들 수 있습니다. 이 두 단계를 구분 안 하고 쓰면 어느 순간 라이브가 깨진 채로 며칠 갑니다. 혼자 쓰는 대시보드라 남한테 피해는 없지만, 아침에 열었는데 화면이 비어 있으면 그날 루틴이 통째로 어그러집니다.
다시 만든다면
익숙한 스택으로 같은 걸 짜면 어떻게 되나 한 번 계산해봤습니다.
Supabase에 Edge Function과 pg_cron을 얹으면 3층 구조가 두 층으로 줄어듭니다. 커넥터라는 개념이 없으니 Gmail도 그냥 OAuth 붙여서 2층으로 처리하면 되고, 크론이 있으니 워밍업용 GitHub Actions도 필요 없습니다. 신선도 계층을 나눌 이유 자체가 줄어듭니다.
대신 로그인을 짜야 합니다. 그 이틀이 돌아옵니다.
그러니까 이건 어느 쪽이 더 좋은 플랫폼이냐의 문제가 아니라, 어느 비용을 낼 거냐의 문제입니다. Sites는 인증과 배포 비용을 가져가고 대신 런타임 자유도를 뺏습니다. Supabase는 런타임 자유도를 주고 대신 초기 세팅 비용을 청구합니다.
이 계산에서 자주 빠뜨리는 항목이 하나 있는데, 인증 비용은 한 번이고 런타임 제약은 계속이라는 점입니다. 크론이 없다는 건 이 대시보드가 살아 있는 내내 우회 구조를 유지해야 한다는 뜻입니다. 반면 로그인은 붙이고 나면 다시 안 만집니다.
그래서 쓸 만한가
한 달쯤 써본 지금 기준으로, 브라우저 탭은 일곱 개에서 네 개로 줄었습니다. Gmail과 Slack은 여전히 따로 엽니다. 실시간이 필요해섭니다.
기대만큼은 아니지만 기대를 잘못한 쪽이 나였습니다. 인증과 호스팅을 안 짜도 되는 대신 데이터 계층은 직접 설계해야 하고, 그건 어느 플랫폼을 쓰든 똑같습니다. Sites가 줄여준 건 배포 왕복이지 아키텍처가 아닙니다.
판단 기준을 하나로 좁히면 이렇게 됩니다. 워크스페이스 인증과 제로컨피그 배포가, 벤더 락인과 크론 부재를 감수할 만큼 가치 있는가.
사내 대시보드라면 대체로 그렇습니다. 로그인 붙이는 이틀이 통째로 사라지고, 사내 툴은 어차피 데이터가 하루 한 번 갱신돼도 되는 게 대부분입니다.
혼자 쓰는 개인 대시보드라면 애매합니다. 어차피 나 혼자니까 인증의 가치가 반감되고, 익숙한 스택에 크론까지 있으면 우회할 일이 없습니다. 다시 만든다면 그쪽으로 갈 것 같기도 하입니다.
다만 반나절 만에 돌아가는 화면을 본 건 Sites 쪽이었습니다. 그 속도가 필요한 순간은 분명히 있습니다.
댓글
댓글 쓰기