← 블로그 목록

PWA + 푸시 알림, 그리고 혼자 만들며 배운 것들

2026-09-06
PWAWeb PushVercel CronTypeScript회고

시공기사가 당일 아침에 "오늘 어디 가야 해요?" 전화를 한다는 얘기를 들었다. 전날 밤에 일정을 확인하지 못하면 아침에 사무실로 전화가 온다. 하루에 몇 번씩.

앱 알림 기능을 넣기로 했다. 네이티브 앱은 없다. PWA로 했다.

공개 진행조회 토큰 설계

알림 얘기 전에 먼저 짚을 게 있다. 고객사가 진행 현황을 알기 위해 매번 전화하는 문제도 있었다.

해결책은 간단했다. UUID 토큰을 발급해서 링크를 보내면 된다.

// 고객사별 토큰 발급
clients.public_token = crypto.randomUUID()

// /orders/{token} → 로그인 없이 현황 조회

URL 형태를 /orders?token=xxx(쿼리 파라미터) 대신 /orders/xxx(경로 파라미터)로 한 건 카카오톡 링크 미리보기 때문이다. 경로 파라미터 방식이 클린 URL이라 미리보기가 더 자연스럽다.

UUID v4는 128비트 랜덤이라 무작위 추측이 사실상 불가능하다. 노출되는 정보도 진행 현황만이고 단가 상세나 내부 메모는 제외했다.

알림톡 메시지 안에 이 링크를 버튼으로 넣었다. "현황 확인하기" 버튼 하나가 CS 전화 대부분을 대체했다.

PWA manifest

시공기사 앱(/tech/*)은 현장에서 스마트폰으로 쓴다. 브라우저 탭보다 홈 화면 아이콘으로 실행하는 게 네이티브 앱 느낌이 난다.

{
  "name": "아토즈 포털",
  "short_name": "아토즈",
  "start_url": "/tech",
  "display": "standalone",
  "background_color": "#1B3590",
  "theme_color": "#1B3590"
}

display: "standalone"이 핵심이다. 브라우저 주소창과 탭 UI가 숨겨지고 네이티브 앱처럼 보인다.

Web Push + Vercel Cron

[매일 오전 8시] Vercel Cron → /api/cron/push-reminder
  │
  ├── 오늘·내일 일정이 있는 기사 조회 (Supabase JOIN)
  ├── 각 기사별 web-push.sendNotification()
  └── Service Worker → 스마트폰에 알림 표시
// sw.js
self.addEventListener('push', (e) => {
  const data = e.data.json();
  e.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      data: { url: data.url }
    })
  );
});

self.addEventListener('notificationclick', (e) => {
  e.notification.close();
  clients.openWindow(e.notification.data.url);
});

알림을 클릭하면 해당 일정 상세 페이지로 바로 이동한다.

Push Subscription 저장소를 처음에 Vercel Blob에 저장했다가 Supabase로 옮겼다. Blob은 파일 스토리지라 "오늘 일정이 있는 기사의 구독 정보"를 한 번의 SQL JOIN으로 가져올 수 없었다. 별도 쿼리 두 번이 필요하고 확장성도 없었다. Supabase로 이전하니 JOIN 한 번으로 해결됐다.

알림이 너무 많다는 피드백도 있었다. 벨 아이콘으로 구독/해제 토글을 추가했다. is_active=false인 구독은 Cron에서 대상에서 제외된다.

배포 후에 발견한 것들

hover 버그. 첨부파일 삭제 버튼을 opacity-0 group-hover:opacity-100으로 만들었다. 고객사 담당자가 잘못 올린 사진을 삭제하려 했는데 버튼이 안 보인다고 피드백이 왔다.

스마트폰·태블릿은 hover 상태가 존재하지 않는다. 버튼이 영구적으로 투명이었다.

// 이전: hover에서만 표시
className="opacity-0 group-hover:opacity-100"

// 수정: 기본 70% 투명도로 항상 표시
className="opacity-70 hover:opacity-100"

터치 기기를 실제로 써보기 전까지는 알 수 없는 종류의 버그였다.

인증 없는 레거시 API. 초기 개발 때 빠르게 만들어놓은 /api/orders, /api/materials가 인증 없이 남아있었다. 외부 공개 후 보안 점검에서 발견해서 삭제했다. 임시 코드에도 // TODO: add auth 주석이 필요하다. 아니면 배포 전에 인증 없는 엔드포인트를 전수 검사하는 습관이라도.

비밀번호 찾기 OTP. 포털 계정이 생기니 비밀번호 찾기도 필요했다. SMS OTP를 구현했다. 보안 설계는 OTP를 DB에 평문으로 저장하지 않고 SHA256 해시로 저장, 5분 만료.

const otp = Math.floor(100000 + Math.random() * 900000).toString();
const hash = crypto.createHash('sha256').update(otp).digest('hex');
await supabase.from('otp_requests').insert({ phone, hash, expires_at });

3개월 돌아보며

기술 선택에서 후회하는 게 없다. Next.js + Supabase + Vercel 조합은 혼자 만들고 혼자 운영하는 데 맞는 스택이었다. 인프라를 신경 쓸 시간을 비즈니스 로직에 쓸 수 있었다.

가장 오래 걸린 건 예상 밖이었다. PDF가 아니라 도메인 이해였다. 주방상판 업계의 견적 방식, 할인 관행, 시공 프로세스를 이해하는 데 코드보다 시간이 더 걸렸다. 도메인을 모르면 아무리 잘 짜도 처음부터 다시 설계하게 된다. 실제로 견적 구조는 한 번 전면 개편했다.

그리고 모노레포는 처음부터 할 필요가 없었다.


이 시리즈는 여기서 마무리한다. QA 엔지니어가 B2B SaaS를 혼자 만들면서 부딪힌 것들 — 도메인 이해, 외부 API 연동, 기하학 계산, PDF 렌더링, PWA 알림까지. 코드보다 의사결정이 더 어려웠고, 의사결정보다 도메인 파악이 더 어려웠다.