Supabase 완전 정복 시리즈 9편 — Cron Jobs & Queues: 백그라운드 작업 자동화 완벽 가이드




시리즈 목차 1편 – Supabase란 무엇인가? Firebase와 제대로 비교해보기 2편 – 데이터베이스 & 자동 API (REST/GraphQL) 3편 – 인증(Auth) — 소셜 로그인, JWT, MFA, SSO 4편 – RLS 보안 — Row Level Security 완벽 가이드 5편 – 실시간 기능(Realtime) — Broadcast, Presence, Postgres Changes 6편 – 스토리지(Storage) — 파일 업로드, 이미지 최적화, CDN 7편 – Edge Functions — Deno 기반 서버리스 함수 완벽 가이드 8편 – AI & 벡터 검색 (pgvector + RAG) 9편 👉 Cron Jobs & Queues — 백그라운드 작업 자동화 완벽 가이드 (현재 글) 10편 – MCP 서버 연동 — AI 에이전트와 Supabase …


들어가며

서비스를 운영하다 보면 “지금 당장 처리하면 안 되는” 작업들이 생깁니다.

  • 매일 자정에 만료된 세션 정리
  • 가입 후 7일이 지났는데 첫 게시글을 안 쓴 사용자에게 리마인더 이메일
  • 사용자가 버튼을 클릭하면 AI 분석이 시작되는데, 결과가 나올 때까지 기다리면 타임아웃
  • 상품이 등록될 때마다 자동으로 임베딩 생성

이런 요구사항을 해결하는 두 가지 핵심 도구가 바로 Cron JobsMessage Queues입니다.

Supabase는 이 둘을 PostgreSQL 안에서 기본 제공합니다.

  • pg_cron: crontab 문법으로 SQL 또는 Edge Function을 정기 실행
  • pgmq: PostgreSQL 기반 메시지 큐 (Redis나 RabbitMQ 없이도 구현 가능)
  • pg_net: DB에서 HTTP 요청 발송 (Edge Function 비동기 호출)

이번 편에서 다룰 내용:

  • pg_cron 기본 사용법과 cron 문법
  • 주요 Cron Job 패턴 (DB 정리, 리마인더, 통계 집계)
  • pg_net으로 Edge Function 주기적 호출
  • pgmq 메시지 큐 기본 개념
  • 큐에 메시지 등록 → Edge Function 워커로 처리
  • 실전 패턴: 이메일 큐, AI 작업 큐, 웹훅 재시도
  • Next.js에서 큐에 작업 등록하기
  • 모니터링 및 실패 처리

pg_cron — 정기 작업 스케줄러

활성화

-- 대시보드: Database → Extensions → pg_cron 검색 후 활성화
-- 또는 SQL:
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions;
CREATE EXTENSION IF NOT EXISTS pg_net  WITH SCHEMA extensions;

또는 대시보드 통합 메뉴: Integrations → Cron 에서 활성화 (권장, 2024년 이후)

cron 문법

┌─── 분 (0-59)
│  ┌─── 시 (0-23)
│  │  ┌─── 일 (1-31)
│  │  │  ┌─── 월 (1-12)
│  │  │  │  ┌─── 요일 (0-7, 0=일요일, 7=일요일)
│  │  │  │  │
*  *  *  *  *

자주 쓰는 패턴:

표현식설명
* * * * *매분 실행
*/5 * * * *5분마다
0 * * * *매시 정각
0 9 * * *매일 오전 9시 (UTC)
0 0 * * 0매주 일요일 자정
0 0 1 * *매월 1일 자정
0 2 * * 1-5평일 새벽 2시

⚠️ pg_cron은 UTC 기준입니다. 한국 시간(KST)은 UTC+9이므로, KST 오전 9시 = UTC 오전 0시 (0 0 * * *)


Cron Job 기본 패턴

1. 만료된 데이터 정리

-- 30일 이상 된 알림 삭제 (매일 새벽 3시 UTC)
SELECT cron.schedule(
  'cleanup-old-notifications',          -- 작업 이름 (고유)
  '0 3 * * *',                          -- 스케줄
  $$
    DELETE FROM notifications
    WHERE created_at < NOW() - INTERVAL '30 days'
      AND is_read = true;
  $$
);

-- 만료된 세션/토큰 정리 (1시간마다)
SELECT cron.schedule(
  'cleanup-expired-sessions',
  '0 * * * *',
  $$
    DELETE FROM user_sessions
    WHERE expires_at < NOW();
  $$
);

-- 소프트 딜리트된 레코드 영구 삭제 (매주 일요일)
SELECT cron.schedule(
  'purge-deleted-records',
  '0 0 * * 0',
  $$
    DELETE FROM posts WHERE deleted_at < NOW() - INTERVAL '90 days';
    DELETE FROM comments WHERE deleted_at < NOW() - INTERVAL '90 days';
  $$
);

2. 통계 집계 (Materialized View 갱신)

-- 일별 통계 집계 Materialized View
CREATE MATERIALIZED VIEW daily_stats AS
SELECT
  DATE(created_at) AS date,
  COUNT(*)         AS new_users,
  COUNT(DISTINCT user_id) FILTER (WHERE event_type = 'login') AS active_users
FROM events
GROUP BY DATE(created_at);

-- 매시간 갱신
SELECT cron.schedule(
  'refresh-daily-stats',
  '0 * * * *',
  $$
    REFRESH MATERIALIZED VIEW CONCURRENTLY daily_stats;
  $$
);

3. 상태 변경 처리

-- 결제 후 48시간이 지나도록 배송이 없으면 상태 변경
SELECT cron.schedule(
  'auto-flag-delayed-orders',
  '*/30 * * * *',  -- 30분마다
  $$
    UPDATE orders
    SET status = 'delayed'
    WHERE status = 'paid'
      AND created_at < NOW() - INTERVAL '48 hours'
      AND shipped_at IS NULL;
  $$
);

-- 구독 만료 처리
SELECT cron.schedule(
  'expire-subscriptions',
  '*/10 * * * *',  -- 10분마다
  $$
    UPDATE subscriptions
    SET status = 'expired'
    WHERE status = 'active'
      AND expires_at < NOW();
  $$
);

Cron Job 관리

-- 등록된 작업 목록 확인
SELECT * FROM cron.job;

-- 실행 이력 확인 (최근 10건)
SELECT *
FROM cron.job_run_details
ORDER BY start_time DESC
LIMIT 10;

-- 실패한 작업 확인
SELECT *
FROM cron.job_run_details
WHERE status = 'failed'
ORDER BY start_time DESC;

-- 작업 비활성화
SELECT cron.unschedule('cleanup-old-notifications');

-- 작업 수정 (삭제 후 재등록)
SELECT cron.unschedule('cleanup-old-notifications');
SELECT cron.schedule('cleanup-old-notifications', '0 4 * * *', $$ ... $$);

pg_net + pg_cron으로 Edge Function 주기적 호출

SQL만으로 해결하기 어려운 작업(외부 API 호출, 복잡한 로직)은 Edge Function을 스케줄로 실행합니다.

Vault에 URL & 토큰 저장 (권장)

-- Supabase Vault에 시크릿 저장 (대시보드: Database → Vault)
SELECT vault.create_secret(
  'https://[project].supabase.co',
  'project_url'
);

SELECT vault.create_secret(
  'Bearer eyJhbGciOi...', -- service_role key
  'service_role_token'
);

pg_net으로 Edge Function 호출 스케줄

-- 매일 오전 9시(UTC) 리마인더 이메일 발송 Edge Function 호출
SELECT cron.schedule(
  'send-reminder-emails',
  '0 0 * * *',
  $$
    SELECT net.http_post(
      url     := (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'project_url')
                  || '/functions/v1/send-reminders',
      headers := jsonb_build_object(
        'Content-Type',  'application/json',
        'Authorization', (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'service_role_token')
      ),
      body    := '{}'::JSONB
    );
  $$
);

-- 10분마다 큐 처리 워커 실행
SELECT cron.schedule(
  'process-email-queue',
  '*/10 * * * *',
  $$
    SELECT net.http_post(
      url     := (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'project_url')
                  || '/functions/v1/process-email-queue',
      headers := jsonb_build_object(
        'Content-Type',  'application/json',
        'Authorization', (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'service_role_token')
      ),
      body    := '{"batch_size": 20}'::JSONB
    );
  $$
);

pgmq — PostgreSQL 메시지 큐

활성화

-- 대시보드: Integrations → Queue 활성화 (권장)
-- 또는:
CREATE EXTENSION IF NOT EXISTS pgmq WITH SCHEMA pgmq;

pgmq는 pgmq_public 스키마를 통해 supabase-js에서도 접근할 수 있습니다.

큐 생성 및 기본 조작

-- 큐 생성
SELECT pgmq.create('email_queue');
SELECT pgmq.create('ai_jobs');
SELECT pgmq.create('webhooks');

-- 메시지 전송
SELECT pgmq.send(
  'email_queue',
  '{"to": "user@example.com", "subject": "안녕하세요", "template": "welcome"}'::JSONB
);

-- 메시지 읽기 (visibility timeout: 30초)
-- 읽은 메시지는 30초간 다른 워커에게 보이지 않음
SELECT * FROM pgmq.read(
  queue_name   := 'email_queue',
  vt           := 30,  -- visibility timeout (초)
  qty          := 5    -- 한 번에 읽을 메시지 수
);

-- 처리 완료 후 삭제
SELECT pgmq.delete('email_queue', 42);  -- 42: message id

-- 처리 실패 시 즉시 재노출 (visibility 초기화)
SELECT pgmq.set_vt('email_queue', 42, 0);

-- 큐에 남은 메시지 수 확인
SELECT * FROM pgmq.metrics('email_queue');

pgmq 핵심 개념: Visibility Timeout

메시지를 읽으면 일정 시간 동안 다른 워커에게 보이지 않습니다. 처리 완료 시 삭제하고, 실패하면 시간이 지나면 자동으로 다시 보이게 됩니다. 이것이 Redis/Bull의 “at-least-once” 보장과 같은 원리입니다.

메시지 전송 → [큐에 대기]
    ↓
워커가 읽기 (visibility timeout 시작)
    ↓
    ├── 처리 성공 → DELETE → 큐에서 영구 제거
    │
    └── 처리 실패 / 타임아웃 → visibility 만료 → 다시 대기 상태 → 재시도

실전 패턴 1: 이메일 큐 워커

1단계: 큐 생성 및 이메일 등록 함수

-- 이메일 큐 생성
SELECT pgmq.create('email_queue');

-- 이메일 큐에 추가하는 헬퍼 함수
CREATE OR REPLACE FUNCTION queue_email(
  to_email  TEXT,
  subject   TEXT,
  template  TEXT,
  data      JSONB DEFAULT '{}'
)
RETURNS BIGINT
LANGUAGE SQL
AS $$
  SELECT pgmq.send(
    'email_queue',
    jsonb_build_object(
      'to',       to_email,
      'subject',  subject,
      'template', template,
      'data',     data
    )
  );
$$;

-- 사용 예시: 새 사용자 가입 시 환영 이메일 큐 등록
SELECT queue_email(
  'user@example.com',
  '가입을 환영합니다!',
  'welcome',
  '{"name": "홍길동"}'
);

2단계: Next.js Server Action에서 큐 등록

// app/actions/email.ts
'use server'

import { createClient } from '@/lib/supabase/server'

export async function queueWelcomeEmail(userId: string) {
  const supabase = await createClient()

  const { data: user } = await supabase
    .from('profiles')
    .select('email, name')
    .eq('id', userId)
    .single()

  if (!user) return

  // pgmq_public 스키마로 메시지 전송
  await supabase.schema('pgmq_public').rpc('send', {
    queue_name: 'email_queue',
    message: {
      to: user.email,
      subject: `${user.name}님, 환영합니다!`,
      template: 'welcome',
      data: { name: user.name },
    },
  })
}

// 일반 알림 이메일
export async function queueNotificationEmail(
  to: string,
  subject: string,
  content: string
) {
  const supabase = await createClient()

  await supabase.schema('pgmq_public').rpc('send', {
    queue_name: 'email_queue',
    message: { to, subject, template: 'notification', data: { content } },
  })
}

3단계: 이메일 워커 Edge Function

// supabase/functions/process-email-queue/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY')!

interface EmailMessage {
  to: string
  subject: string
  template: string
  data: Record<string, string>
}

// HTML 템플릿 렌더러 (간단 버전)
function renderTemplate(template: string, data: Record<string, string>): string {
  const templates: Record<string, string> = {
    welcome: `
      <h1>환영합니다, ${data.name}님! 🎉</h1>
      <p>저희 서비스에 가입해 주셔서 감사합니다.</p>
    `,
    notification: `
      <p>${data.content}</p>
    `,
  }
  return templates[template] ?? `<p>${data.content ?? ''}</p>`
}

Deno.serve(async (req: Request) => {
  const { batch_size = 10 } = await req.json().catch(() => ({}))

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
    { db: { schema: 'pgmq_public' } }
  )

  // 큐에서 메시지 읽기 (30초 visibility timeout)
  const { data: messages, error } = await supabase.rpc('read', {
    queue_name:    'email_queue',
    sleep_seconds: 30,
    n:             batch_size,
  })

  if (error || !messages?.length) {
    return new Response(
      JSON.stringify({ processed: 0, message: '처리할 메시지 없음' }),
      { headers: { 'Content-Type': 'application/json' } }
    )
  }

  let processed = 0
  let failed = 0

  for (const msg of messages) {
    const { to, subject, template, data } = msg.message as EmailMessage

    try {
      const res = await fetch('https://api.resend.com/emails', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${RESEND_API_KEY}`,
        },
        body: JSON.stringify({
          from: 'noreply@yourapp.com',
          to,
          subject,
          html: renderTemplate(template, data),
        }),
      })

      if (!res.ok) throw new Error(`Resend 오류: ${await res.text()}`)

      // 성공 → 큐에서 삭제
      await supabase.rpc('delete', {
        queue_name: 'email_queue',
        message_id: msg.msg_id,
      })

      processed++
    } catch (err) {
      console.error(`이메일 발송 실패 (msg_id: ${msg.msg_id}):`, err.message)
      // 실패 시 아무것도 안 해도 됨 — visibility timeout 후 자동 재시도
      failed++
    }
  }

  return new Response(
    JSON.stringify({ processed, failed }),
    { headers: { 'Content-Type': 'application/json' } }
  )
})

4단계: pg_cron으로 워커 주기 실행

-- 5분마다 이메일 큐 처리
SELECT cron.schedule(
  'process-email-queue',
  '*/5 * * * *',
  $$
    SELECT net.http_post(
      url     := (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'project_url')
                  || '/functions/v1/process-email-queue',
      headers := jsonb_build_object(
        'Content-Type',  'application/json',
        'Authorization', (SELECT decrypted_secret FROM vault.decrypted_secrets WHERE name = 'service_role_token')
      ),
      body    := '{"batch_size": 20}'::JSONB
    );
  $$
);

실전 패턴 2: AI 장기 작업 큐

AI 처리(이미지 분석, 문서 요약, 임베딩 생성)는 수 초에서 수십 초가 걸리는 장기 작업입니다. 사용자가 버튼을 클릭하면 즉시 응답하고, 결과가 준비되면 알려주는 패턴이 이상적입니다.

-- AI 작업 추적 테이블
CREATE TABLE ai_jobs (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID REFERENCES auth.users(id),
  type        TEXT NOT NULL,     -- 'summarize', 'embed', 'analyze' 등
  input       JSONB NOT NULL,
  status      TEXT DEFAULT 'pending', -- pending / processing / done / failed
  result      JSONB,
  error_msg   TEXT,
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  updated_at  TIMESTAMPTZ DEFAULT NOW()
);

ALTER TABLE ai_jobs ENABLE ROW LEVEL SECURITY;

CREATE POLICY "본인 작업만 접근"
  ON ai_jobs FOR ALL
  TO authenticated
  USING ((SELECT auth.uid()) = user_id);

-- AI 작업 큐 생성
SELECT pgmq.create('ai_jobs');
// app/actions/ai-jobs.ts
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'

export async function submitAiJob(
  type: 'summarize' | 'analyze',
  input: Record<string, unknown>
) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) throw new Error('로그인 필요')

  // 1. DB에 작업 레코드 생성
  const { data: job, error } = await supabase
    .from('ai_jobs')
    .insert({ user_id: user.id, type, input, status: 'pending' })
    .select('id')
    .single()

  if (error) throw error

  // 2. 큐에 작업 ID 등록
  await supabase.schema('pgmq_public').rpc('send', {
    queue_name: 'ai_jobs',
    message: { job_id: job.id, type, input },
  })

  revalidatePath('/dashboard')
  return job.id
}
// supabase/functions/process-ai-jobs/index.ts
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const OPENAI_API_KEY = Deno.env.get('OPENAI_API_KEY')!

Deno.serve(async () => {
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  )

  const supabasePgmq = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
    { db: { schema: 'pgmq_public' } }
  )

  // 큐에서 최대 5개 작업 읽기 (60초 visibility timeout)
  const { data: messages } = await supabasePgmq.rpc('read', {
    queue_name:    'ai_jobs',
    sleep_seconds: 60,
    n:             5,
  })

  if (!messages?.length) {
    return new Response('처리할 작업 없음', { status: 200 })
  }

  for (const msg of messages) {
    const { job_id, type, input } = msg.message

    // 작업 상태를 'processing'으로 변경
    await supabase
      .from('ai_jobs')
      .update({ status: 'processing', updated_at: new Date().toISOString() })
      .eq('id', job_id)

    try {
      let result: unknown

      if (type === 'summarize') {
        const res = await fetch('https://api.openai.com/v1/chat/completions', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${OPENAI_API_KEY}`,
          },
          body: JSON.stringify({
            model: 'gpt-4o-mini',
            messages: [
              { role: 'system', content: '다음 텍스트를 3문장으로 요약하세요.' },
              { role: 'user', content: input.text },
            ],
          }),
        })
        const data = await res.json()
        result = { summary: data.choices[0].message.content }
      }

      // 성공: 결과 저장 + 큐에서 삭제
      await supabase
        .from('ai_jobs')
        .update({ status: 'done', result, updated_at: new Date().toISOString() })
        .eq('id', job_id)

      await supabasePgmq.rpc('delete', {
        queue_name: 'ai_jobs',
        message_id: msg.msg_id,
      })
    } catch (err) {
      // 실패: 에러 저장 + 큐에서 삭제 (재시도 방지)
      await supabase
        .from('ai_jobs')
        .update({
          status:    'failed',
          error_msg: err.message,
          updated_at: new Date().toISOString(),
        })
        .eq('id', job_id)

      await supabasePgmq.rpc('delete', {
        queue_name: 'ai_jobs',
        message_id: msg.msg_id,
      })
    }
  }

  return new Response(JSON.stringify({ processed: messages.length }), {
    headers: { 'Content-Type': 'application/json' },
  })
})

Next.js에서 작업 상태 폴링

// hooks/useJobStatus.ts
'use client'

import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

export function useJobStatus(jobId: string | null) {
  const [status, setStatus] = useState<string | null>(null)
  const [result, setResult] = useState<unknown>(null)
  const supabase = createClient()

  useEffect(() => {
    if (!jobId) return

    // Realtime으로 작업 상태 구독
    const channel = supabase
      .channel(`job-${jobId}`)
      .on(
        'postgres_changes',
        {
          event: 'UPDATE',
          schema: 'public',
          table: 'ai_jobs',
          filter: `id=eq.${jobId}`,
        },
        (payload) => {
          setStatus(payload.new.status)
          setResult(payload.new.result)
        }
      )
      .subscribe()

    // 초기 상태 조회
    supabase
      .from('ai_jobs')
      .select('status, result')
      .eq('id', jobId)
      .single()
      .then(({ data }) => {
        if (data) {
          setStatus(data.status)
          setResult(data.result)
        }
      })

    return () => { supabase.removeChannel(channel) }
  }, [jobId])

  return { status, result }
}

실전 패턴 3: 웹훅 재시도 큐

외부 서비스로 보내는 웹훅이 실패할 경우 자동으로 재시도하는 패턴입니다.

-- 웹훅 큐 생성
SELECT pgmq.create('webhooks');

-- 웹훅 전송 함수 (트리거에서 호출)
CREATE OR REPLACE FUNCTION queue_webhook(
  url     TEXT,
  payload JSONB,
  headers JSONB DEFAULT '{"Content-Type": "application/json"}'
)
RETURNS BIGINT
LANGUAGE SQL
AS $$
  SELECT pgmq.send(
    'webhooks',
    jsonb_build_object(
      'url',        url,
      'payload',    payload,
      'headers',    headers,
      'attempt',    1,
      'max_retries', 3
    )
  );
$$;

-- 주문 완료 시 자동으로 웹훅 큐에 등록하는 트리거
CREATE OR REPLACE FUNCTION trigger_order_webhook()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
  IF NEW.status = 'completed' AND OLD.status != 'completed' THEN
    PERFORM queue_webhook(
      'https://partner.example.com/webhooks/order',
      jsonb_build_object('order_id', NEW.id, 'status', NEW.status)
    );
  END IF;
  RETURN NEW;
END;
$$;

CREATE TRIGGER on_order_complete
  AFTER UPDATE ON orders
  FOR EACH ROW
  EXECUTE FUNCTION trigger_order_webhook();

모니터링

Cron Job 실행 이력

-- 최근 실패한 Cron Job 확인
SELECT
  j.jobname,
  r.start_time,
  r.end_time,
  r.status,
  r.return_message
FROM cron.job_run_details r
JOIN cron.job j ON j.jobid = r.jobid
WHERE r.status = 'failed'
ORDER BY r.start_time DESC
LIMIT 20;

-- 각 작업별 평균 실행 시간
SELECT
  j.jobname,
  AVG(EXTRACT(EPOCH FROM (r.end_time - r.start_time))) AS avg_seconds,
  COUNT(*) AS total_runs,
  COUNT(*) FILTER (WHERE r.status = 'failed') AS failed_runs
FROM cron.job_run_details r
JOIN cron.job j ON j.jobid = r.jobid
GROUP BY j.jobname
ORDER BY avg_seconds DESC;

Queue 메트릭

-- 모든 큐 상태 확인
SELECT * FROM pgmq.metrics_all();

-- 특정 큐 상세 확인
SELECT * FROM pgmq.metrics('email_queue');
-- 반환: queue_name, queue_length, newest_msg_age, oldest_msg_age, ...

-- 처리되지 않고 오래된 메시지 (데드 레터 대기 중인 것들)
SELECT *
FROM pgmq.q_email_queue
WHERE enqueued_at < NOW() - INTERVAL '1 hour'
ORDER BY enqueued_at;

자주 하는 실수

1. 무거운 작업을 트리거에서 직접 처리

❌ 트리거 → 즉시 외부 API 호출 → DB 트랜잭션 블로킹
✅ 트리거 → 큐에 메시지 등록 → 워커가 비동기 처리

2. Visibility Timeout 너무 짧게 설정

❌ vt=5 (5초) → 처리가 5초 이상 걸리면 중복 처리 발생
✅ 작업의 예상 처리 시간 × 2 이상으로 설정
   이메일: vt=30, AI 작업: vt=120

3. UTC/KST 혼동

-- ❌ 오전 9시(KST)에 실행하고 싶은데
SELECT cron.schedule('morning-job', '0 9 * * *', $$...$$);
-- → UTC 오전 9시 = KST 오후 6시에 실행됨!

-- ✅ KST 오전 9시 = UTC 0시
SELECT cron.schedule('morning-job', '0 0 * * *', $$...$$);

4. 오래된 cron.job_run_details 방치

-- pg_cron 실행 기록은 자동 삭제되지 않으므로 주기적으로 정리 필요
SELECT cron.schedule(
  'cleanup-cron-history',
  '0 3 * * 0',  -- 매주 일요일 새벽 3시
  $$
    DELETE FROM cron.job_run_details
    WHERE end_time < NOW() - INTERVAL '30 days';
  $$
);

마치며

Supabase의 pg_cron + pgmq 조합은 Redis, Bull, RabbitMQ 등 별도의 인프라 없이 PostgreSQL 안에서 완전한 백그라운드 작업 시스템을 구축할 수 있게 해줍니다. 특히 Realtime 구독과 결합하면 “작업 제출 → 비동기 처리 → 완료 알림”의 훌륭한 사용자 경험을 만들 수 있습니다.

다음 편에서는 MCP(Model Context Protocol) 서버 연동을 다룹니다. AI 에이전트가 Supabase를 직접 도구로 사용하도록 연결하는 방법과, Claude 같은 LLM이 데이터베이스를 쿼리하고 수정할 수 있는 MCP 서버 구성을 살펴봅니다.




댓글 남기기