온프레미스 Next.js 서비스를 Vercel과 Supabase로 옮기기

상시 실행하던 단일 워커를 서버리스 환경에 맞게 분해하고, PostgreSQL·Redis·실시간 통신을 단계적으로 이전하는 설계 기록

온프레미스에서 서비스를 운영하면 애플리케이션, 데이터베이스, Redis, WebSocket 서버와 백그라운드 워커를 한 네트워크 안에 둘 수 있습니다. 프로세스를 계속 띄워두기도 쉽습니다. setInterval로 10초마다 데이터베이스를 확인하는 코드도 일단은 잘 동작합니다.

하지만 같은 구성을 Vercel과 Supabase로 옮기려 하면 문제가 달라집니다. Vercel Function은 요청에 반응해 실행되고 종료되는 환경이고, Supabase는 PostgreSQL을 중심으로 Realtime, Cron, Queue 같은 기능을 제공합니다. 기존 서버를 그대로 복사하는 것이 아니라 각 프로세스가 맡고 있던 책임을 새로운 실행 모델에 맞게 다시 배치해야 합니다.

이 글에서는 다음 구성을 가진 서비스를 예로 살펴보겠습니다.

Next.js가 웹, 관리자 화면, 모바일 REST API를 함께 제공합니다.

Prisma가 PostgreSQL의 약 50개 모델을 관리합니다.

Redis는 캐시, 레이트리밋, 조회수 집계, 작업 큐, Pub/Sub에 사용합니다.

별도 WebSocket 서버가 채팅 이벤트와 접속 상태를 전달합니다.

하나의 Node.js 워커 프로세스 안에서 일곱 개의 반복 작업이 실행됩니다.

사용자 인증은 Firebase Auth를 사용합니다.

이미지 원본과 변환본은 S3 계열 오브젝트 스토리지에 저장합니다.

목표는 애플리케이션을 Vercel로, PostgreSQL과 실시간 기능을 Supabase로 옮기는 것입니다. Redis가 필요한 구간에는 Upstash를 사용하고, Firebase Auth와 기존 스토리지는 위험도가 낮아질 때까지 유지합니다.

아직 이전을 끝낸 뒤의 회고는 아닙니다. 실제 코드와 운영 의존성을 기준으로, 무엇부터 분리해야 안전하게 움직일 수 있는지 정리한 실행 설계에 가깝습니다.

먼저 목표 구조를 그려보았습니다

Web / Mobile Client
│
├──────── Firebase Auth
│
▼
Vercel
├─ Next.js Web / Admin / REST API
├─ 짧고 경계가 명확한 Cron 엔드포인트
└─ Queue Consumer 함수
│
├──────── Supabase Postgres
├──────── Supabase Realtime
├──────── Supabase Queues / Cron
├──────── Upstash Redis
└──────── Object Storage

전환 기간에만 유지
└─ 온프레미스 번역·이미지 워커
└─ 외부로 열린 Queue를 소비하는 outbound-only 구조

여기서 중요한 점은 온프레미스와 클라우드를 한 번에 교체하지 않는 것입니다. 웹과 데이터베이스를 먼저 옮기고, 상시 워커는 잠시 온프레미스에 남겨둘 수 있습니다. 워커가 Supabase Queue나 PostgreSQL에 outbound 연결만 할 수 있다면 외부에서 사내 네트워크로 들어오는 포트를 열 필요도 없습니다.

이 하이브리드 구간을 허용하면 특히 온프레미스 GPU나 Ollama에 의존하는 번역 작업을 서둘러 교체하지 않아도 됩니다.

단일 워커를 그대로 옮길 수 없는 이유

기존 워커는 하나의 프로세스에서 다음 작업을 시작합니다.

작업 | 기존 주기 | 주요 의존성 | 정합성 성격
이미지 변환 | 10초 | PostgreSQL, Sharp, Storage | 재시도와 중복 방지 필요
포인트 만료·프로모션 종료 | 1분 | PostgreSQL | 금전 원장과 트랜잭션 중요
데이터 보존·파기 | 1시간 | PostgreSQL, Storage | 복구 불가능한 삭제 포함
사업자 인증 만료 | 1시간 | PostgreSQL | 사용자 역할 불변식 유지
게시물 마감 | 5분 | PostgreSQL | 반복 실행 가능한 상태 수렴
조회수 반영 | 1분 | Redis, PostgreSQL | 일부 유실을 허용하는 근사 집계
콘텐츠 번역 | 15초 | Redis Queue, LLM, PostgreSQL | 긴 외부 I/O와 재시도 필요

온프레미스에서는 이 모든 작업을 setInterval로 실행해도 프로세스가 계속 살아 있습니다. 반면 서버리스에서는 함수가 언제 재사용되고 언제 종료될지 제어할 수 없습니다. 따라서 요청 처리 코드에서 타이머를 등록하거나 하나의 Cron 요청 안에서 모든 작업을 순서대로 실행하면 안 됩니다.

또한 Vercel Cron은 HTTP로 함수를 호출하는 스케줄러입니다. 실패한 호출을 자동으로 재시도하지 않으며 동일 작업이 겹치거나 중복 전달될 가능성도 고려해야 합니다. 공식 문서도 락과 멱등성을 함께 적용할 것을 권장합니다. 자세한 동작은 Vercel Cron 문서에서 확인할 수 있습니다.

분리해야 하는 것은 파일이 아니라 실행과 실패의 경계입니다.

워커를 네 종류로 분해했습니다

1. DB 안에서 끝나는 상태 수렴 작업

게시물 마감과 프로모션 종료는 다음 형태입니다.

update jobs
set status = 'ARCHIVED'
where status = 'PUBLISHED'
and expires_at <= now();

같은 작업을 두 번 실행해도 두 번째 실행은 변경할 행이 없습니다. 이런 작업은 멱등성이 자연스럽고 외부 API도 호출하지 않습니다. Supabase의 pg_cron과 PostgreSQL 함수로 옮기기 가장 좋은 대상입니다.

게시물 만료 상태 수렴

기간이 끝난 프로모션 종료

단순한 만료 플래그 변경

오래된 임시 행 정리

사업자 인증 만료도 DB 함수로 옮길 수 있지만 인증 상태와 사용자 역할을 함께 갱신해야 합니다. 두 변경을 하나의 트랜잭션에 넣고, 다른 유효 인증이 남아 있는지 다시 확인하는 불변식을 함수 안에 보존해야 합니다.

Supabase는 호스팅 환경에서 pg_cron을 지원합니다. Edge Function을 주기적으로 호출하거나 SQL 함수를 직접 실행할 수 있습니다. Supabase의 함수 스케줄링 가이드

2. 애플리케이션 트랜잭션이 필요한 작업

포인트 만료는 단순한 update가 아닙니다. 지갑 행을 잠그고, 만료된 무상 포인트 lot을 계산하고, 원장 거래를 추가하고, 잔액을 같은 트랜잭션에서 변경해야 합니다. 이미 Prisma 서비스 레이어에 이 규칙이 구현되어 있다면 SQL로 다시 작성하면서 두 개의 정본을 만들 필요가 없습니다.

이 작업은 다음 형태로 옮기는 편이 낫습니다.

Vercel Cron
→ 보호된 내부 Route Handler
→ 기존 포인트 서비스 함수
→ Supabase Postgres transaction

한 번의 호출은 처리할 지갑 수를 제한합니다. 다음 호출이 남은 대상을 이어서 처리하도록 만들면 함수 실행 시간이 데이터 양에 비례해 무한히 늘어나지 않습니다.

금전 작업에는 세 가지 방어가 필요합니다.

지갑 단위 데이터베이스 잠금

거래별 idempotency key와 unique constraint

작업 중복 실행을 막는 job lease

DB 안에서 짧게 끝나는 작업은 transaction-scoped advisory lock을 사용할 수 있습니다. 반대로 외부 API 호출이 포함된 긴 작업에서 데이터베이스 트랜잭션을 계속 잡고 있어서는 안 됩니다. 이 경우 job_leases 같은 테이블에서 짧은 트랜잭션으로 lease를 얻고, 만료 시 다른 실행자가 인계할 수 있게 합니다.

3. CPU 또는 외부 I/O가 큰 이벤트 작업

이미지 변환과 번역은 Cron보다 Queue가 어울립니다.

이미지 업로드가 완료되면 UPLOADED 상태를 10초마다 검색하는 대신 그 시점에 작업 메시지를 발행합니다.

업로드 완료 API
→ Image 상태를 UPLOADED로 변경
→ Queue에 imageId 기록
→ Consumer가 claim
→ 원본 다운로드
→ Sharp 변환
→ 변환본 업로드
→ READY 전환 후 ack

현재처럼 단일 워커만 실행한다는 전제에서는 attempts를 먼저 올리는 정도로 중복을 피할 수 있습니다. Consumer가 여러 개로 늘어나는 순간에는 이것으로 충분하지 않습니다. PROCESSING, lease_until, claimed_by 또는 Queue의 visibility timeout을 사용해 한 작업의 소유권을 명확히 해야 합니다.

Supabase Queues는 PostgreSQL의 pgmq를 기반으로 visibility window와 메시지 보존을 제공합니다. 단순 Redis SPOP은 메시지를 꺼내는 순간 삭제되므로 프로세스가 직후 종료되면 작업이 유실될 수 있습니다. 내구성이 필요한 이미지와 번역은 Supabase Queues로 옮기는 편이 안전합니다.

Queue가 작업을 직접 실행해 주는 것은 아닙니다. 전환 기간에는 온프레미스 Consumer가 Queue를 폴링하고, 이후에는 Supabase Cron이나 Vercel Cron이 짧은 drain 함수를 호출하게 할 수 있습니다. 업로드 직후처럼 더 빠른 실행과 HTTP 재시도가 필요하면 Database Webhook이나 QStash 같은 전달 계층을 별도로 붙입니다. Queue는 작업의 보관·소유권·재시도 상태를 책임지고, Consumer 실행은 별도 책임으로 둡니다.

이미지는 두 가지 경로 중 하나를 선택할 수 있습니다.

기존 변환 규격과 DB 상태 모델을 유지한다면 Queue Consumer에서 Sharp를 실행합니다.

작은 이미지와 WebP 제공만 필요하다면 Supabase Storage의 이미지 변환 기능으로 사전 생성 워커 자체를 없앱니다.

스토리지까지 한 번에 옮길 필요는 없습니다. 기존 업로드가 S3 checksum이나 특정 presigned header에 의존하면 먼저 스토리지를 유지하고 호환성을 별도로 검증합니다. Supabase Storage는 S3 프로토콜을 지원하지만 모든 S3 기능을 구현한 것은 아닙니다. Supabase Storage S3 호환 범위

번역은 더 신중해야 합니다. 온프레미스 Ollama 주소는 Vercel에서 접근할 수 없습니다. 선택지는 세 가지입니다.

외부에서 접근 가능한 관리형 LLM으로 교체합니다.

온프레미스 번역 Consumer가 Supabase Queue를 계속 소비합니다.

별도의 장기 실행 컨테이너 환경으로 번역 워커만 옮깁니다.

전환 초기에는 두 번째가 현실적입니다. 웹과 DB 이전을 번역 인프라 교체와 분리할 수 있고, Queue가 양쪽 환경의 계약이 됩니다.

4. 빠르지만 유실을 허용하는 집계·캐시 작업

조회수는 이미지나 포인트와 성격이 다릅니다. 한 틱 분량이 드물게 유실되어도 서비스 정합성이 무너지지 않습니다. 상세 조회마다 PostgreSQL 행을 갱신하면 잠금과 WAL 부하가 커지므로 Redis에서 INCR한 뒤 1분마다 합산 반영하는 현재 설계를 유지할 수 있습니다.

이 역할에는 Upstash Redis가 잘 맞습니다.

피드 첫 페이지 캐시

읽지 않은 알림·대화 수 캐시

메시지·번역 API 레이트리밋

조회수 pending counter

짧은 circuit breaker와 모델 목록 캐시

Vercel에서는 연결 기반 Redis 클라이언트를 계속 사용할 수도 있지만 서버리스 특성에는 HTTP 기반 @upstash/redis 클라이언트가 더 단순합니다. 연결 풀을 관리하지 않아도 되고 Vercel 연동으로 환경변수를 주입할 수 있습니다. Vercel과 Upstash Redis 연동

반대로 내구성 있는 작업 큐까지 Redis Set 하나로 해결하려고 하지는 않습니다. 빠른 임시 상태는 Upstash, 재시도와 ack가 필요한 작업은 Supabase Queue로 역할을 나눕니다.

워커 코드는 스케줄러에서 분리합니다

서버리스 이전을 시작하기 전에 기존 worker/*.ts의 실제 작업 함수를 실행 방식과 분리합니다.

src/jobs/post-expiration.ts
└─ runPostExpiration({ now, batchSize })

src/app/api/internal/jobs/post-expiration/route.ts
└─ 인증 → lease/lock → runPostExpiration → 결과 기록

supabase/migrations/..._schedule_jobs.sql
└─ pg_cron 등록 또는 Vercel Cron은 vercel.json에 등록

작업 함수는 다음 조건을 만족하게 만듭니다.

setInterval을 포함하지 않습니다.

한 번 호출하면 유한한 양만 처리하고 종료합니다.

현재 시각과 batch size를 인자로 받아 테스트할 수 있습니다.

처리 건수와 실패 건수를 결과로 반환합니다.

같은 입력으로 다시 실행해도 안전합니다.

스케줄러, HTTP, CLI 어디에서 호출해도 같은 동작을 합니다.

이 구조가 되면 로컬에서는 CLI로 실행하고, 운영에서는 Cron 또는 Queue Consumer로 호출할 수 있습니다. 이전 기간에는 기존 온프레미스 워커가 같은 함수나 같은 Queue 계약을 사용할 수도 있습니다.

작업별 최종 배치안

기존 작업 | 1차 이전 | 장기 목표 | 핵심 방어
이미지 변환 | 온프레미스 Consumer 유지 | Supabase Queue + Vercel Consumer 또는 Storage 변환 | visibility timeout, 상태 claim, 최대 재시도
포인트 만료 | Vercel Cron Route | 동일 | 지갑 잠금, idempotency key, job lease
프로모션 종료 | Supabase Cron | 동일 | 조건부 update
데이터 보존 | Vercel Cron Route | Queue 기반 파기 또는 Storage lifecycle 병행 | 작은 batch, 삭제 순서, 감사 로그
사업자 인증 만료 | Supabase Cron DB 함수 | 동일 | 인증·역할 단일 트랜잭션
게시물 마감 | Supabase Cron DB 함수 | 동일 | 멱등 update
조회수 반영 | Upstash + Vercel Cron | 트래픽에 따라 유지 | 중복 drain 방지, 유실 허용 범위 명시
콘텐츠 번역 | 온프레미스 Consumer 유지 | Supabase Queue + 관리형 LLM/전용 Consumer | ack-after-write, retry, circuit breaker

데이터 보존 작업은 DB 행뿐 아니라 스토리지 객체까지 삭제하므로 특히 조심해야 합니다. 스토리지 삭제에 성공한 뒤 DB 행을 지우는 기존 순서를 유지하고, 한 호출에서 처리하는 양을 제한합니다. Vercel Cron 자체에는 실패 재시도가 없으므로 복구 불가능한 삭제 작업은 상태 테이블과 재시도 가능한 단계로 쪼개는 편이 좋습니다. HTTP 전달 재시도와 DLQ가 필요해지면 QStash 같은 전달 계층을 추가할 수 있지만 1차 이전의 필수 구성요소는 아닙니다.

WebSocket 서버는 Realtime로 교체합니다

기존에는 별도 Node.js 서버가 /ws 연결을 유지하고 Redis Pub/Sub 메시지를 사용자별 소켓으로 전달했습니다. Redis에는 presence TTL도 저장했습니다.

Vercel은 현재 WebSocket 연결을 지원하지만 기존의 server.listen() 프로세스를 그대로 올리는 방식은 아닙니다. 연결은 함수의 최대 실행 시간에 묶이고 인스턴스 간 상태를 외부에 둬야 합니다. 이미 Supabase를 도입한다면 채팅 이벤트는 Supabase Realtime의 Broadcast, 접속 상태는 Presence로 옮기는 편이 단순합니다. Vercel Functions의 WebSocket 안내, Supabase Realtime

메시지 본문은 계속 PostgreSQL이 정본입니다. Realtime은 즉시 전달 신호일 뿐이며, 클라이언트는 재연결하면 REST API로 누락을 다시 읽습니다. 이 원칙을 유지하면 실시간 계층 장애가 메시지 유실로 이어지지 않습니다.

Firebase Auth도 당장 교체할 필요가 없습니다. Supabase는 Firebase를 third-party auth로 연결해 Data API, Storage, Realtime에서 Firebase JWT를 사용할 수 있습니다. 단, Firebase 토큰에 role: authenticated claim을 부여하고 채널 RLS 정책을 설계해야 합니다. 기존 애플리케이션이 발급하는 별도 대칭키 JWT를 그대로 Supabase 클라이언트에 전달하는 것과는 다른 방식입니다. Supabase의 Firebase Auth 연동

PostgreSQL 연결은 런타임과 마이그레이션을 나눕니다

Vercel Function은 짧은 시간에 여러 인스턴스가 만들어질 수 있습니다. 각 인스턴스가 PostgreSQL 연결을 오래 잡으면 데이터베이스 연결 한도를 빠르게 소진합니다.

Supabase에서는 애플리케이션 런타임과 마이그레이션의 연결 주소를 분리합니다.

DATABASE_URL = Supavisor transaction pooler :6543 + pgbouncer=true
DIRECT_URL = direct 또는 session pooler :5432

Vercel의 Prisma 런타임은 DATABASE_URL을 사용합니다.

prisma migrate deploy, pg_dump, restore 같은 운영 명령은 DIRECT_URL을 사용합니다.

서버리스 런타임과 DB는 가능한 한 가까운 리전에 둡니다.

Prisma만 서버에서 사용한다면 Supabase Data API를 끄거나 모든 public 테이블에 RLS를 적용해 불필요한 노출면을 없앱니다.

연결 방식은 Supabase PostgreSQL 연결 가이드와 Prisma 연동 가이드를 기준으로 삼았습니다.

실제 이전은 여섯 단계로 나누어 진행합니다

1단계: 애플리케이션을 무상태로 만든다

빌드 스크립트에서 온프레미스용 환경 파일 강제 로딩을 제거합니다.

Vercel 환경변수를 Preview, Staging, Production으로 분리합니다.

Nginx가 넣던 보안 헤더를 Next.js 또는 Vercel 설정으로 옮깁니다.

로컬 메모리 캐시는 정합성에 사용하지 않습니다.

/api/health가 DB 연결 여부와 배포 버전을 확인할 수 있게 합니다.

2단계: Supabase에 스키마를 복제한다

빈 프로젝트에서 전체 Prisma migration을 처음부터 검증합니다.

partial unique index, check constraint, enum, raw SQL migration을 확인합니다.

개발 데이터의 dump/restore를 반복해 소요 시간을 측정합니다.

테이블 건수, FK, sequence, 주요 집계 결과를 비교하는 검증 스크립트를 만듭니다.

3단계: Vercel Preview를 Supabase 개발 DB에 연결한다

이 단계에서는 Firebase Auth, 기존 Storage, 기존 워커를 유지합니다. 웹과 API가 Vercel에서 정상 동작하고 pooled connection이 안정적인지 먼저 확인합니다.

4단계: 실시간 기능과 워커를 하나씩 분리한다

Redis Pub/Sub 기반 채팅을 Supabase Realtime로 교체합니다.

DB-only job을 Supabase Cron으로 이동합니다.

Redis 캐시·레이트리밋·카운터를 Upstash로 이동합니다.

이미지와 번역은 Queue 계약을 만들고 온프레미스 Consumer를 붙입니다.

각 작업에 실행 이력과 실패 지표를 추가합니다.

5단계: 운영 DB를 전환한다

작은 서비스라면 짧은 쓰기 중단 방식이 가장 단순합니다.

온프레미스 서비스를 maintenance/read-only로 전환합니다.

마지막 pg_dump를 생성합니다.

Supabase에 restore하고 sequence를 동기화합니다.

데이터 검증 스크립트와 핵심 API smoke test를 실행합니다.

Vercel Production의 도메인을 전환합니다.

이전 환경은 일정 기간 read-only로 보관합니다.

한 가지 원칙은 두 데이터베이스를 동시에 쓰는 dual-primary 상태를 만들지 않는 것입니다. 새 DB에서 쓰기가 시작된 뒤 단순히 DNS만 원복하면 데이터가 갈라집니다. 롤백이 필요하면 쓰기 전환 시점과 역방향 동기화 방법까지 사전에 정해야 합니다.

6단계: 남은 온프레미스 의존성을 제거한다

번역 Consumer를 관리형 LLM 또는 전용 실행 환경으로 이동합니다.

이미지 변환을 클라우드 Consumer나 Storage transformation으로 교체합니다.

불필요해진 Redis Queue·Pub/Sub 코드를 제거합니다.

필요하면 마지막에 Firebase Auth와 Storage를 Supabase로 이전합니다.

Cron보다 중요한 것은 작업 실행 이력입니다

온프레미스에서는 로그 파일을 열어 워커가 돌고 있는지 확인할 수 있었습니다. 서버리스에서는 호출 한 번이 짧게 나타났다 사라집니다. 따라서 job_runs 같은 실행 이력 테이블을 두는 편이 좋습니다.

job_name
run_id
triggered_at
started_at
finished_at
status
processed_count
failed_count
cursor
error_summary
deployment_id

운영 화면에서는 다음을 확인할 수 있어야 합니다.

마지막 성공 시각이 기준보다 오래되었는가

같은 작업이 동시에 실행 중인가

처리 대기 건수가 계속 증가하는가

재시도 횟수를 소진한 항목이 있는가

한 호출의 처리 시간이 함수 제한에 가까워지고 있는가

스케줄 등록 여부만으로는 작업이 정상이라고 판단할 수 없습니다. 스케줄러는 시작 신호를 보낼 뿐이고, 완료와 복구는 애플리케이션이 책임집니다.

듀오랩스가 보는 관점

관리형 서비스로 이전한다고 해서 기존 비즈니스 로직을 모두 다시 작성할 필요는 없습니다. 오히려 포인트 원장, 보존정책, 인증 상태처럼 이미 검증된 규칙은 유지하고, 그 규칙을 호출하는 실행 방식만 Cron·Queue·함수로 교체하는 편이 위험이 작습니다.

특히 중소규모 서비스의 인프라 이전에서는 최종 구성의 화려함보다 전환 과정의 복구 가능성이 중요합니다. 웹과 DB를 먼저 옮기고, 인증·스토리지·고비용 워커를 뒤로 미루면 한 단계에서 문제가 생겨도 영향 범위를 좁힐 수 있습니다. 각 작업에 멱등성, 실행 이력, 작은 배치, 명확한 소유권을 추가하는 일은 특정 클라우드 제품을 고르는 것보다 오래 남는 투자입니다.

듀오랩스는 이런 이전을 단순한 재배포가 아니라 운영 모델을 정리하는 과정으로 봅니다. 어떤 기능이 정본을 소유하는지, 어디까지 유실을 허용하는지, 실패 시 누가 다시 실행하는지를 먼저 문서화해야 Vercel과 Supabase의 장점을 안전하게 활용할 수 있습니다.

마치며

온프레미스에서 Vercel과 Supabase로의 이전은 서버 위치만 바꾸는 작업이 아닙니다. 계속 살아 있던 프로세스에 암묵적으로 기대던 실행 모델을 명시적인 요청, Queue, lease, transaction으로 바꾸는 작업에 가깝습니다.

설계하면서 가장 중요하게 본 판단은 세 가지였습니다.

웹, DB, 인증, 스토리지, 워커를 동시에 바꾸지 않습니다.

하나의 워커 프로세스를 실패와 재시도 성격에 따라 분리합니다.

빠른 임시 상태는 Upstash, 정본은 PostgreSQL, 내구성 있는 작업은 Queue에 둡니다.

이렇게 경계를 잡으면 Vercel은 웹과 짧은 작업 실행에 집중하고, Supabase는 데이터와 실시간 이벤트, Queue와 Cron을 맡습니다. 온프레미스 워커는 전환 기간 동안 안전한 다리 역할을 한 뒤 마지막에 제거할 수 있습니다. 한 번에 완성된 클라우드 구조를 만들기보다, 각 단계에서 롤백 가능한 구조를 유지하는 것이 실제 이전에서는 더 중요합니다.