"—"로 비어 있던 화면을, 진짜 제품으로
백엔드가 아무리 잘 돌아도, 사용자가 보는 게 빈칸이면 그건 아직 제품이 아닙니다. 이번 TASK-012는 그 빈칸을 채우는 일이었습니다. 대시보드 요약, 모니터 상세(가동률·지연 차트·장애 타임라인), 알림 채널 관리, 알림 이력까지 — 한 호흡에 "실서비스 급" 화면을 만들었습니다.
goodtek은 vibePulse를 빌드인 퍼블릭으로 만들고 있습니다. "내 서비스가 지금 살아 있는가"를 확인하고, 죽으면 즉시 알려주는 단순한 SaaS입니다. 지난 글까지 우리는 감지 → 판단 → 알림이라는 백엔드 파이프라인을 다 만들었습니다. 그런데 정작 사용자가 로그인해서 보는 화면은… 가동률도 지연시간도 전부 —로 비어 있었습니다.백엔드가 아무리 잘 돌아도, 사용자가 보는 게 빈칸이면 그건 아직 제품이 아닙니다. 이번 TASK-012는 그 빈칸을 채우는 일이었습니다. 대시보드 요약, 모니터 상세(가동률·지연 차트·장애 타임라인), 알림 채널 관리, 알림 이력까지 — 한 호흡에 "실서비스 급" 화면을 만들었습니다.
그리고 그 과정에서 MVP 시절의 지름길 하나를 일부러 걷어냈습니다. 그 이야기부터 하겠습니다.
가장 큰 결정: web이 DB를 직접 읽던 길을 막았다
초기에는 빠르게 가려고 Next.js(web)에서 Postgres를 직접 SQL로 읽었습니다. 데모까진 그게 제일 빠릅니다. 그런데 백엔드에 이미 NestJS /v1 API가 있고, 거기에 org 스코프·쿼터·도메인 규칙이 다 들어 있는데, web이 그걸 우회해서 DB를 직접 보면 어떻게 될까요?
규칙이 두 군데로 갈라집니다. API는 "남의 org 리소스는 404"인데 web의 직접 쿼리는 그 가드를 안 거칩니다. 쿼터 검사도 우회됩니다. 시간이 지나면 "어? 여긴 왜 다르게 동작하지?"가 쌓입니다.
그래서 이번에 web의 도메인 데이터 조회를 전부 NestJS /v1 계약을 소비하는 서버사이드 API 클라이언트로 표준화했습니다. 도메인 로직은 API 한 곳에만 두기로요.
이 전환을 그림으로 그리면 이렇습니다.

API 클라이언트는 어떻게 생겼나
서버 전용 fetch 래퍼 하나로 정리했습니다. 핵심은 세 가지입니다: (1) base URL은 서버 env에서만 읽어 브라우저에 노출하지 않는다, (2) 들어온 Better Auth 세션 쿠키를 그대로 NestJS로 forward 해서 AuthGuard가 userId/orgId를 풀 수 있게 한다, (3) 응답은 @vibepulse/shared의 zod 파서로 검증한다.
// apps/web/lib/api/client.ts (발췌)
export async function apiFetch<T>(
path: string,
parse: (data: unknown) => T, // @vibepulse/shared zod 파서
options: ApiFetchOptions = {},
): Promise<T> {
const authHeaders = await buildAuthHeaders(); // 들어온 cookie/authorization forward
// ...
const response = await fetch(buildUrl(path, options.query), init);
if (!response.ok) {
throw ApiError.fromResponse(response.status, await safeJson(response));
}
return parse(response.status === 204 ? null : await safeJson(response));
}덕분에 그동안 비어 있던 대시보드 가동률/지연이 채워졌습니다. 대시보드 데이터 함수가 직접 쿼리 대신 API를 부르도록 바뀌었거든요.
// apps/web/lib/dashboard/data.ts (발췌) — 이제 직접 SQL이 아니라 API
const { items } = await listMonitors(projectId);
// 모니터마다 90일 롤업 uptime을 붙임 (실패해도 페이지는 안 죽고 null로 degrade)
uptime = await getUptime(monitor.id, '90d');작지만 사람 냄새 나는 디테일: 부팅 중 재시도
로컬에서 pnpm dev를 띄우면 NestJS API가 리스닝을 시작하기까지 몇 초 걸립니다. 그 사이 web이 API를 부르면 ECONNREFUSED로 화면이 에러 바운더리로 튕깁니다. 짜증나죠.
그래서 GET 요청 한정으로 일시적 네트워크 오류는 backoff 재시도를 넣었습니다. dev에서는 부팅 창을 버티려고 넉넉하게, prod에서는 진짜 장애는 빨리 실패하도록 짧게.
const NETWORK_RETRY_DELAYS_MS =
process.env.NODE_ENV === 'production'
? ([150, 400] as const) // 운영: 빨리 실패
: ([200, 400, 800, 1600, 2400, 2400] as const); // 개발: 부팅 대기이런 건 스펙 문서엔 안 적혀 있지만, 매일 pnpm dev를 띄우는 사람으로서 안 넣고는 못 배기는 부분입니다.
대시보드: "2초 안에 정상인지 / 어디가 문제인지"
대시보드의 목표는 단 하나입니다. 밤에 폰으로 열었을 때 2초 안에 "다 괜찮네" 혹은 "여기가 문제네"가 보일 것. 그래서 맨 위에 글로벌 상태를 한 줄로 요약하는 StatusHero + PulseIndicator를 뒀습니다. 정상이면 차분한 녹색 펄스, 장애면 빨강 강조, 확인 중이면 짧은 펄스.
여기서 접근성 원칙 하나: 펄스(움직임)에만 의존하지 않습니다. prefers-reduced-motion을 켠 사용자에겐 정적 fallback을 주고, 상태는 항상 색 + 아이콘 + 라벨 세 가지로 동시에 표현합니다. 색을 구분하기 어렵거나 모션을 끈 사람도 똑같이 읽혀야 하니까요.

모니터가 0개일 때의 빈 상태도 신경 썼습니다. 빈 표를 보여주는 대신 "첫 모니터 추가" 버튼 하나로 온보딩 흐름에 바로 연결합니다. 빈 화면은 사용자가 길을 잃는 지점이라, CTA 하나로 다음 행동을 못 박아주는 게 중요합니다.
모니터 상세: 가동률, 지연, 그리고 장애의 역사
모니터 하나를 클릭하면 들어가는 상세 화면이 이번 작업의 하이라이트입니다.
- 90일 가동률 상태바: 하루 한 칸, 장애가 있던 날은 hover하면 툴팁으로 무슨 일이 있었는지.
- 지연 차트:
24h / 7d / 30d / 90d토글. 여기서 중요한 규칙 하나 — 원본 시계열을 풀스캔하지 않습니다. 가동률·지연은 전부 롤업 API(/v1/monitors/:id/uptime?period=)에서 읽습니다.check_results는 hypertable이라 며칠만 지나도 수백만 행이거든요. 차트 하나 그리자고 그걸 긁으면 그날로 느려집니다. - 장애 타임라인: incident가 열리고(open) 닫힌(resolved) 구간, 지속 시간, 마지막 에러. ack(확인) 액션도 여기서.
- heartbeat 모니터: ping URL 카드(복사 버튼) + cron/shell 예시 + 마지막 ping + 최근 ping 스파크라인.

heartbeat 모니터 상세에는 복붙용 cron 예시를 그대로 노출합니다. 사용자가 고민 없이 붙여넣을 수 있게요.
curl -fsS -m 10 --retry 3 "https://hb.vibepulse.app/ping/<your-token>"피드백 UX를 "한 번에" 정한 이유
이번 작업 전까지 web에는 토스트 라이브러리조차 없었습니다. 로그인만 인라인 배너를 쓰고, 나머지 화면은 성공/실패 피드백 패턴이 아예 없었어요. 화면을 잔뜩 추가하기 전에, 피드백을 어떻게 줄지 규칙을 한 번에 못 박는 게 먼저였습니다.
먼저 토스트 라이브러리를 골랐습니다. 자체 useToast를 또 만들거나 react-hot-toast를 쓰는 대신 sonner(이 스택의 사실상 표준)를 채택하고, 루트에 <Toaster />를 딱 한 번 마운트했습니다.
그리고 "언제 무엇을 쓸지"를 3가지로 규정했습니다.
상황 | 무엇을 쓰나 | 예 |
|---|---|---|
폼/인증 차단성 에러 | 인라인 알림 | 로그인 실패, 모니터·채널 폼 검증 |
비동기 액션 비차단 피드백 | 토스트 | 채널 테스트 발송, 일시정지/재개, 복사, 낙관적 업데이트 롤백 |
화면 단위 로드 실패 | 에러 바운더리( | 페이지가 빈 채로 깨지지 않게 |
가장 중요한 한 가지: 원시 에러 문자열을 사용자에게 그대로 노출하지 않습니다. API 에러 코드를 사용자 친화 i18n 카피로 매핑합니다.
// apps/web/lib/ui/toast.ts (발췌)
export function apiErrorMessageKey(error): string {
switch (code) {
case 'unauthorized': return 'unauthorized';
case 'quota_exceeded': return 'quota';
case 'network_error': return 'network';
// ...
default: return 'generic';
}
}
// 호출부: notifyError(t(apiErrorMessageKey(err))) ← 절대 raw 메시지 직접 안 씀곁다리지만 제일 만족스러운 작업: 로그인 에러 다시 그리기
원래 로그인 화면의 에러 배너는 카드 바깥, 브랜드 로고보다 위, 컬럼 최상단에 떠 있었습니다. 정작 실패한 액션(로그인 버튼)과 한참 떨어져서, "생뚱맞고 눈에 안 들어온다"는 느낌이었죠. 게다가 2px 테두리 + 강한 그림자 + 44px 아이콘 + 큰 destructive 배경이라, 차분한 로그인 카드와 톤이 완전히 따로 놀았습니다.
세 가지를 고쳤습니다.
- 위치: 에러를 카드 안, provider 버튼 그룹 바로 위로 옮겨 "이 액션이 실패했다"는 맥락에 붙였습니다.
- 톤: 무거운 알람 톤을 낮춰 카드와 어울리는 절제된 표현으로. 단, 무시되지 않게 색+아이콘+카피로는 명확하게.
- 공용화: 이 정리된 인라인 에러를
InlineAlert/FieldError공용 컴포넌트로 일반화해서 모니터 폼·채널 폼에서도 재사용. 앱 전역 톤이 통일됐습니다.

알림 이력: 없던 백엔드 API부터 만들었다
"알림이 잘 갔나?"를 보여주는 알림 이력 페이지를 만들려는데, 문제가 있었습니다. notification_log 테이블과 그걸 쓰는 워커는 있는데, 읽는 REST 엔드포인트가 없었습니다. 프론트만 만들 수 있는 일이 아니라 백엔드부터 신설해야 했죠.
그래서 @vibepulse/shared에 DTO·필터·응답 스키마를 정의하고, NestJS에 notification-logs 모듈을 새로 만들었습니다.
GET /v1/projects/:projectId/notification-logs # 필터(채널/상태/기간/모니터) + 페이지네이션
GET /v1/incidents/:incidentId/notification-logs # 장애 상세 인라인용페이지네이션은 offset이 아니라 keyset 커서({ items, nextCursor })로 했습니다. 로그는 계속 쌓이는 데이터라, offset 페이지네이션은 뒤로 갈수록 느려지고 중간에 행이 끼면 어긋납니다. "더 보기" 버튼이 커서를 들고 다음 묶음을 가져옵니다.
화면에서 또 하나 신경 쓴 건 색입니다. 실패를 빨갛게 칠하고 싶은 유혹이 있지만, 우리 디자인 원칙은 "평소 빨강 남발 금지"입니다. 그래서 알림 상태 4종을 기존 상태 5색 토큰에 그대로 매핑했습니다.
발송 상태 | 재사용한 상태 토큰 | 색 |
|---|---|---|
| operational | 녹색 |
| down | 빨강 |
| paused | 회색 |
| pending | 옅은 톤 |
임의의 새 빨강을 만들지 않고, 이미 앱 전체가 쓰는 의미 색을 재사용한 겁니다. 일관성은 이렇게 작은 데서 쌓입니다.

그리고 모니터 상세의 장애 타임라인에서 incident를 펼치면 "이 장애로 보낸 알림"이 인라인으로 뜹니다. 장애와 알림을 한 화면에서 잇는 거죠.
빈 화면 문제: 시드로 "보이게" 만들기
새 화면을 만들 때 제일 곤란한 건 로컬에서 다 비어 보이는 겁니다. 차트도 빈칸, 타임라인도 빈칸, 이력도 빈칸이면 내가 만든 게 잘 돌아가는지 확인할 수가 없습니다.
그래서 시드 데이터를 보강했습니다. 롤업이 채워진 모니터, resolved/open incident 각 1건, notification_log 10건. pnpm db:seed 한 방이면 차트·타임라인·이력이 실제 데이터로 채워진 상태가 됩니다.
pnpm dev # web + api + worker + postgres + redis 풀스택
pnpm db:seed # 데모 모니터 · 장애 이력 · 알림 로그 주입솔직한 고백: 이번에 안 한(못 한) 것들
빌드인 퍼블릭이니 타협한 부분도 그대로 적습니다. "정공법으로 전부 API 경유"가 원칙이었지만, 현실적으로 남겨둔 직접 SQL 경로가 있습니다.
- 프로젝트 목록 조회는 아직 직접 SQL입니다.
/v1/projects엔드포인트가 아직 없거든요(다른 TASK 소관). 프로젝트 전환 셀렉터엔 당장 필요해서, 예외로 두고 주석에 사유를 박았습니다.
// apps/web/lib/dashboard/data.ts (발췌)
/**
* Project listing is the one remaining direct-SQL domain read in web: there is
* no `/v1/projects` endpoint yet ... All monitor, uptime, incident and channel
* reads go through the NestJS `/v1` API client.
*/- 온보딩의 모니터 생성도 아직 직접 DB write입니다. 인증/프로비저닝 경로를 다시 건드리는 리스크를 피하려고 이번엔 손대지 않았습니다(API CRUD는 병행 진입점으로 살아 있음).
- 카카오톡 채널은 OAuth connect 흐름이 필요해서 이번 채널 생성 폼에선 telegram/discord/slack까지만. (API 쪽
kakao-oauth.service는 이미 있지만 풀 연동은 다음에.) - next-themes는 도입하지 않고, 기존 쿠키 기반
ThemeProvider에 sonner 토스터를 물렸습니다.
이런 걸 "나중에 한다"가 아니라 TASK 문서와 글에 명시해두면, 다음에 여는 사람(=미래의 나)이 "왜 여기만 다르지?"로 시간 안 버립니다.
숫자와 검증
이번 패스는 정적 검증(빌드/타입체크/테스트/린트) 기준 전부 green이었습니다.
pnpm --filter @vibepulse/shared test # ✅ 8 files / 67 tests (log-api 9건 포함)
pnpm --filter @vibepulse/api test # ✅ 9 files / 40 tests (notification-logs 13건 포함)
pnpm --filter @vibepulse/web build # ✅ /dashboard/notifications 라우트 등록 확인
pnpm lint # ✅ 0 errors대시보드를 풀스택으로 띄워 클릭하며 도는 런타임 QA, 반응형·키보드·reduced-motion 수동 점검은 ship 전 사람이 직접 하는 몫으로 남겼습니다. 정적 검증으로 잡히지 않는 건 결국 눈으로 봐야 하니까요.
정리하며
이번 TASK의 진짜 주제는 "예쁜 화면 만들기"가 아니라 "도메인 규칙을 한 곳에 모으면서 사용자에게 보이게 만들기" 였습니다. 돌아봤을 때 잘했다 싶은 것들:
- 도메인 데이터는 한 입구로. web의 직접 SQL을 걷어내고
/v1API + zod 검증으로 표준화. 규칙이 갈라지지 않습니다. - 피드백 UX는 화면 추가 전에 규칙부터. 토스트/인라인/에러바운더리 경계를 한 번에 정해두니 새 화면마다 고민이 없습니다.
- 상태 색은 의미 토큰만. 알림 실패도 임의 빨강이 아니라 기존
down토큰 재사용. - 차트는 롤업만. 원본 hypertable 풀스캔은 금지.
- 빈 화면은 시드로 정복. 만들면서 바로 눈으로 확인 가능하게.
이제 로그인하면 정말로 "다 괜찮네 / 여기가 문제네"가 2초 안에 보입니다. 다음은 로그인 없이도 보여줄 수 있는 화면 — 공개 상태 페이지입니다.
goodtek은 막힌 부분, 되돌린 선택, 타협한 지점까지 그대로 기록하며 vibePulse를 빌드인 퍼블릭으로 만들고 있습니다. 다음 글에서 또 만나요.