시리즈 목차 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 — 백그라운드 작업 자동화 …
들어가며
“AI 검색”이라고 하면 별도의 벡터 데이터베이스(Pinecone, Weaviate, Qdrant 등)를 떠올리는 분이 많습니다. 하지만 Supabase는 PostgreSQL에 pgvector 확장을 내장해, 이미 사용 중인 DB에서 바로 벡터 검색을 구현할 수 있습니다.
이것이 의미하는 바는 큽니다.
- 별도 벡터 DB 비용 없음
- 기존 데이터와 벡터를 JOIN으로 결합 가능
- RLS로 사용자별 벡터 검색 접근 제어
- PostgreSQL의 모든 기능(트랜잭션, 인덱스, FTS)을 함께 활용
이번 편에서 다룰 내용:
- 벡터 임베딩(Vector Embedding) 개념 정리
- pgvector 활성화 & 스키마 설계
- HNSW vs IVFFlat 인덱스 전략
- 거리 연산자 (코사인 / L2 / 내적)
- OpenAI Embeddings로 벡터 생성 & 저장
- RPC 함수로 유사도 검색
- RAG(검색 증강 생성) 파이프라인 구축
- 자동 임베딩 갱신 (트리거 + Edge Function)
- Next.js App Router 완성 예시
- 하이브리드 검색 (벡터 + 키워드)
벡터 임베딩이란?
임베딩(Embedding)은 텍스트(또는 이미지, 음성)를 **숫자 배열(벡터)**로 변환한 것입니다. 의미가 비슷한 문장은 벡터 공간에서 가까운 위치에 놓입니다.
"강아지가 공원에서 뛴다" → [0.23, -0.71, 0.45, ...]
"개가 들판에서 달린다" → [0.25, -0.69, 0.43, ...] ← 가까움
"내일 주식 시장 전망" → [-0.82, 0.11, -0.63, ...] ← 멀리 있음
이 특성을 이용하면 키워드 매칭이 아닌 의미 기반 검색이 가능합니다.
주요 임베딩 모델 비교
| 모델 | 차원 | 특징 |
|---|---|---|
text-embedding-3-small (OpenAI) | 1,536 | 저비용, 고품질, 권장 |
text-embedding-3-large (OpenAI) | 3,072 | 최고 품질, 비용 높음 |
text-embedding-ada-002 (OpenAI) | 1,536 | 구버전, 마이그레이션 권장 |
gte-small (오픈소스) | 384 | 로컬 실행 가능, 무료 |
nomic-embed-text (오픈소스) | 768 | 무료, 고성능 |
💡 2025년 권장:
text-embedding-3-small이 비용 대비 성능이 가장 우수합니다. 차원이 적을수록 저장 비용이 낮고 검색이 빠릅니다.
pgvector 설정
확장 활성화
-- SQL 에디터에서 실행
CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA extensions;
또는 대시보드: Database → Extensions → vector 검색 후 활성화
스키마 설계
-- 문서 임베딩 테이블
CREATE TABLE documents (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL, -- 원본 텍스트
metadata JSONB DEFAULT '{}', -- 추가 필드 (source, category 등)
-- 임베딩 컬럼: text-embedding-3-small = 1536차원
-- halfvec: float16으로 메모리 절반 절약 (정확도 거의 동일)
embedding HALFVEC(1536),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- RLS 활성화
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- 본인 문서만 접근
CREATE POLICY "본인 문서 접근"
ON documents FOR ALL
TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);
-- HNSW 인덱스 생성 (코사인 유사도 기준)
CREATE INDEX ON documents
USING hnsw (embedding halfvec_cosine_ops);
HNSW vs IVFFlat 인덱스
pgvector는 두 종류의 ANN(근사 최근접 이웃) 인덱스를 지원합니다.
| 항목 | HNSW | IVFFlat |
|---|---|---|
| 검색 속도 | ⚡ 빠름 | 보통 |
| 정확도(Recall) | ✅ 높음 | 보통 |
| 메모리 사용 | 높음 | 낮음 |
| 데이터 추가 후 인덱스 | 자동 갱신 | 재구축 권장 |
| 빌드 시간 | 느림 | 빠름 |
| 권장 | 일반적인 경우 | 메모리 제약 시 |
HNSW 인덱스 생성
-- vector 타입 (최대 2,000 차원)
CREATE INDEX idx_documents_embedding
ON documents
USING hnsw (embedding vector_cosine_ops);
-- halfvec 타입 (최대 4,000 차원, 메모리 절반)
CREATE INDEX idx_documents_embedding_half
ON documents
USING hnsw (embedding halfvec_cosine_ops);
-- HNSW 파라미터 튜닝 (기본값: m=16, ef_construction=64)
CREATE INDEX idx_documents_embedding_tuned
ON documents
USING hnsw (embedding halfvec_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- m: 각 노드의 최대 연결 수 (높을수록 정확도↑, 메모리↑)
-- ef_construction: 인덱스 빌드 시 탐색 범위 (높을수록 품질↑, 빌드 시간↑)
거리 연산자
-- 코사인 거리 (0: 동일, 2: 완전 반대) — 텍스트 검색에 주로 사용
embedding <=> query_vec
-- L2(유클리드) 거리
embedding <-> query_vec
-- 내적(Negative inner product) — 정규화된 벡터에서 가장 빠름
embedding <#> query_vec
💡 텍스트 임베딩에는 **코사인 거리(
<=>)**를 사용하는 것이 일반적입니다. 코사인 유사도 =1 - 코사인 거리
OpenAI로 임베딩 생성 & 저장
lib/embeddings.ts
// lib/embeddings.ts
const OPENAI_API_KEY = process.env.OPENAI_API_KEY!
export async function generateEmbedding(text: string): Promise<number[]> {
// 텍스트 전처리: 줄바꿈 제거, 공백 정규화
const input = text.replace(/\n/g, ' ').trim()
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input,
// dimensions 파라미터로 차원 축소 가능 (선택)
// dimensions: 512,
}),
})
if (!response.ok) {
throw new Error(`OpenAI Embeddings API 오류: ${response.statusText}`)
}
const data = await response.json()
return data.data[0].embedding as number[]
}
// 여러 텍스트 배치 처리 (API 요청 최소화)
export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
const inputs = texts.map((t) => t.replace(/\n/g, ' ').trim())
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: inputs,
}),
})
const data = await response.json()
// 순서 보장: index 기준으로 정렬
return data.data
.sort((a: { index: number }, b: { index: number }) => a.index - b.index)
.map((item: { embedding: number[] }) => item.embedding)
}
문서 저장 (Server Action)
// app/documents/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { generateEmbedding } from '@/lib/embeddings'
import { revalidatePath } from 'next/cache'
export async function saveDocument(title: string, content: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('로그인이 필요합니다.')
// 1. 임베딩 생성 (OpenAI API 호출)
const embedding = await generateEmbedding(`${title}\n\n${content}`)
// 2. DB에 저장
const { data, error } = await supabase
.from('documents')
.insert({
user_id: user.id,
title,
content,
// pgvector는 number[] → vector 타입 자동 변환
embedding: JSON.stringify(embedding),
})
.select('id')
.single()
if (error) throw new Error(error.message)
revalidatePath('/documents')
return data
}
RPC 함수로 유사도 검색
PostgREST는 pgvector 거리 연산자를 직접 지원하지 않으므로, **PostgreSQL 함수(RPC)**로 래핑합니다.
-- 유사도 검색 함수
CREATE OR REPLACE FUNCTION match_documents(
query_embedding HALFVEC(1536),
match_threshold FLOAT DEFAULT 0.5, -- 최소 유사도 (0~1)
match_count INT DEFAULT 5, -- 반환할 문서 수
filter_user_id UUID DEFAULT NULL -- 사용자 필터 (선택)
)
RETURNS TABLE (
id BIGINT,
title TEXT,
content TEXT,
metadata JSONB,
similarity FLOAT
)
LANGUAGE SQL STABLE
AS $$
SELECT
d.id,
d.title,
d.content,
d.metadata,
1 - (d.embedding <=> query_embedding) AS similarity -- 코사인 유사도
FROM documents d
WHERE
(filter_user_id IS NULL OR d.user_id = filter_user_id)
AND 1 - (d.embedding <=> query_embedding) > match_threshold
ORDER BY d.embedding <=> query_embedding -- 거리 기준 오름차순
LIMIT match_count;
$$;
TypeScript에서 RPC 호출
// lib/search.ts
import { createClient } from '@/lib/supabase/server'
import { generateEmbedding } from '@/lib/embeddings'
export interface SearchResult {
id: number
title: string
content: string
metadata: Record<string, unknown>
similarity: number
}
export async function semanticSearch(
query: string,
options: {
threshold?: number
limit?: number
userId?: string
} = {}
): Promise<SearchResult[]> {
const { threshold = 0.5, limit = 5, userId } = options
const supabase = await createClient()
// 검색어 임베딩 생성
const queryEmbedding = await generateEmbedding(query)
// RPC 호출
const { data, error } = await supabase.rpc('match_documents', {
query_embedding: JSON.stringify(queryEmbedding),
match_threshold: threshold,
match_count: limit,
filter_user_id: userId ?? null,
})
if (error) throw new Error(error.message)
return (data as SearchResult[]) ?? []
}
RAG 파이프라인 구축
RAG(Retrieval-Augmented Generation)는 다음 3단계로 구성됩니다.
1. 사용자 질문 입력
↓
2. 질문을 임베딩 → 유사 문서 검색 (Retrieval)
↓
3. 검색 결과를 컨텍스트로 LLM에 전달 → 답변 생성 (Generation)
Edge Function으로 RAG API 구현
// supabase/functions/rag-chat/index.ts
import { corsHeaders } from '../_shared/cors.ts'
import { createAdminClient } from '../_shared/supabase.ts'
const OPENAI_API_KEY = Deno.env.get('OPENAI_API_KEY')!
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
// 인증 확인
const authHeader = req.headers.get('Authorization')
if (!authHeader) {
return new Response(JSON.stringify({ error: '인증 필요' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 401,
})
}
const { question } = await req.json()
if (!question) {
return new Response(JSON.stringify({ error: '질문이 필요합니다.' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
}
const supabase = createAdminClient()
// Step 1: 질문 임베딩 생성
const embeddingRes = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: question,
}),
})
const embeddingData = await embeddingRes.json()
const queryEmbedding = embeddingData.data[0].embedding
// Step 2: 유사 문서 검색
const { data: matchedDocs, error: searchError } = await supabase.rpc(
'match_documents',
{
query_embedding: JSON.stringify(queryEmbedding),
match_threshold: 0.5,
match_count: 5,
}
)
if (searchError) {
console.error('검색 오류:', searchError)
return new Response(JSON.stringify({ error: searchError.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
})
}
// Step 3: 컨텍스트 구성
const contextText = matchedDocs
.map((doc: { title: string; content: string; similarity: number }) =>
`[문서: ${doc.title} (유사도: ${(doc.similarity * 100).toFixed(1)}%)]\n${doc.content}`
)
.join('\n\n---\n\n')
const systemPrompt = `당신은 주어진 문서를 기반으로 정확하게 답변하는 AI 어시스턴트입니다.
아래 문서들을 참고하여 사용자의 질문에 답변하세요.
문서에 없는 내용은 모른다고 솔직하게 말하세요.
=== 참고 문서 ===
${contextText || '관련 문서가 없습니다.'}
================`
// Step 4: ChatGPT로 답변 생성 (스트리밍)
const chatRes = 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: systemPrompt },
{ role: 'user', content: question },
],
stream: true,
}),
})
// 스트리밍 응답 그대로 전달
return new Response(chatRes.body, {
headers: {
...corsHeaders,
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
})
})
Next.js에서 RAG 채팅 UI
// app/chat/page.tsx
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export default function RagChat() {
const [question, setQuestion] = useState('')
const [answer, setAnswer] = useState('')
const [loading, setLoading] = useState(false)
const supabase = createClient()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!question.trim() || loading) return
setLoading(true)
setAnswer('')
try {
const { data: { session } } = await supabase.auth.getSession()
// Edge Function 호출 (스트리밍)
const response = await fetch(
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/rag-chat`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session?.access_token}`,
},
body: JSON.stringify({ question }),
}
)
if (!response.body) throw new Error('스트리밍 지원 안 됨')
// SSE 스트림 읽기
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n').filter((l) => l.startsWith('data: '))
for (const line of lines) {
const data = line.slice(6)
if (data === '[DONE]') break
try {
const json = JSON.parse(data)
const delta = json.choices?.[0]?.delta?.content
if (delta) setAnswer((prev) => prev + delta)
} catch {
// JSON 파싱 실패 무시
}
}
}
} catch (error) {
console.error('RAG 오류:', error)
setAnswer('오류가 발생했습니다. 다시 시도해주세요.')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-bold">문서 기반 AI 채팅</h1>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="문서에 대해 질문하세요..."
className="flex-1 border rounded-lg px-4 py-2"
disabled={loading}
/>
<button
type="submit"
disabled={loading || !question.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{loading ? '답변 중...' : '질문'}
</button>
</form>
{answer && (
<div className="bg-gray-50 rounded-xl p-4 whitespace-pre-wrap leading-relaxed">
{answer}
{loading && <span className="animate-pulse">▌</span>}
</div>
)}
</div>
)
}
자동 임베딩 갱신
문서가 수정될 때 임베딩을 자동으로 재생성하는 패턴입니다. Supabase의 pg_net + Edge Function을 조합합니다.
-- pg_net 확장 활성화
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;
-- 임베딩 갱신 트리거 함수
CREATE OR REPLACE FUNCTION trigger_generate_embedding()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- 컨텐츠가 변경된 경우에만 임베딩 재생성
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND OLD.content IS DISTINCT FROM NEW.content) THEN
-- Edge Function 비동기 호출 (pg_net)
PERFORM net.http_post(
url := current_setting('app.settings.edge_function_url') || '/generate-embedding',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || current_setting('app.settings.service_role_key')
),
body := jsonb_build_object('document_id', NEW.id, 'content', NEW.content)
);
END IF;
RETURN NEW;
END;
$$;
-- 트리거 연결
CREATE TRIGGER on_document_change
AFTER INSERT OR UPDATE ON documents
FOR EACH ROW
EXECUTE FUNCTION trigger_generate_embedding();
// supabase/functions/generate-embedding/index.ts
import { createAdminClient } from '../_shared/supabase.ts'
const OPENAI_API_KEY = Deno.env.get('OPENAI_API_KEY')!
Deno.serve(async (req: Request) => {
const { document_id, content } = await req.json()
// 임베딩 생성
const res = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: content.replace(/\n/g, ' '),
}),
})
const data = await res.json()
const embedding = data.data[0].embedding
// DB 업데이트
const supabase = createAdminClient()
await supabase
.from('documents')
.update({ embedding: JSON.stringify(embedding) })
.eq('id', document_id)
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
})
})
하이브리드 검색 (벡터 + 키워드 FTS)
순수 벡터 검색은 의미 기반이지만, 정확한 단어/고유명사 매칭이 필요할 때는 PostgreSQL의 **전문 검색(Full-Text Search)**과 결합한 하이브리드 검색이 효과적입니다.
-- 하이브리드 검색 함수 (RRF: Reciprocal Rank Fusion)
CREATE OR REPLACE FUNCTION hybrid_search(
query_text TEXT,
query_embedding HALFVEC(1536),
match_count INT DEFAULT 5,
full_text_weight FLOAT DEFAULT 1.0, -- 키워드 검색 가중치
semantic_weight FLOAT DEFAULT 1.0 -- 벡터 검색 가중치
)
RETURNS TABLE (
id BIGINT,
title TEXT,
content TEXT,
rrf_score FLOAT
)
LANGUAGE SQL
AS $$
WITH
-- 키워드 FTS 검색
fts AS (
SELECT
id,
ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(
to_tsvector('simple', title || ' ' || content),
websearch_to_tsquery('simple', query_text)
) DESC
) AS rank_ix
FROM documents
WHERE to_tsvector('simple', title || ' ' || content)
@@ websearch_to_tsquery('simple', query_text)
ORDER BY rank_ix
LIMIT LEAST(match_count, 30) * 2
),
-- 벡터 유사도 검색
semantic AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY embedding <=> query_embedding) AS rank_ix
FROM documents
ORDER BY embedding <=> query_embedding
LIMIT LEAST(match_count, 30) * 2
)
-- RRF 스코어 통합
SELECT
d.id,
d.title,
d.content,
COALESCE(1.0 / (60 + fts.rank_ix), 0.0) * full_text_weight
+ COALESCE(1.0 / (60 + semantic.rank_ix), 0.0) * semantic_weight AS rrf_score
FROM documents d
LEFT JOIN fts ON d.id = fts.id
LEFT JOIN semantic ON d.id = semantic.id
WHERE fts.id IS NOT NULL OR semantic.id IS NOT NULL
ORDER BY rrf_score DESC
LIMIT match_count;
$$;
성능 최적화 팁
1. halfvec으로 메모리 절약
-- vector (float32, 4 bytes/dimension)
embedding VECTOR(1536) -- 6KB/문서
-- halfvec (float16, 2 bytes/dimension)
embedding HALFVEC(1536) -- 3KB/문서 ← 메모리 절반, 정확도 거의 동일
2. 검색 시 ef_search 조정
-- 쿼리 시 HNSW 탐색 범위 조정 (기본값: 40)
-- 높을수록 정확도↑, 속도↓
SET hnsw.ef_search = 100;
SELECT ... FROM documents ORDER BY embedding <=> $1 LIMIT 10;
RESET hnsw.ef_search;
3. 차원 축소 (text-embedding-3-small)
// OpenAI text-embedding-3 모델은 dimensions 파라미터로 차원 축소 지원
const response = await fetch('https://api.openai.com/v1/embeddings', {
body: JSON.stringify({
model: 'text-embedding-3-small',
input: text,
dimensions: 512, // 1536 → 512 (저장 비용 1/3, 정확도 소폭 감소)
}),
})
4. 필터링 주의사항
-- ❌ WHERE 절 필터 + 벡터 인덱스 → 인덱스가 충분한 결과를 못 찾을 수 있음
SELECT * FROM documents
WHERE user_id = $1
ORDER BY embedding <=> $2
LIMIT 5;
-- ✅ 필터를 RPC 함수 내에서 처리하거나, 파티셔닝 고려
-- 또는 match_count를 크게 잡고 애플리케이션 레벨에서 필터링
자주 하는 실수
1. 다른 모델의 임베딩 비교
❌ text-embedding-ada-002로 생성한 벡터와
text-embedding-3-small 벡터를 같은 테이블에서 비교
→ 무의미한 결과
✅ 반드시 동일 모델로 생성한 임베딩만 비교
모델 변경 시 전체 재임베딩 필요
2. 인덱스 없이 대용량 검색
-- ❌ 인덱스 없으면 순차 스캔 (수십만 건 이상에서 심각하게 느림)
-- ✅ HNSW 또는 IVFFlat 인덱스 반드시 생성
CREATE INDEX ON documents USING hnsw (embedding halfvec_cosine_ops);
3. 너무 긴 청크 임베딩
❌ 문서 전체(수천 단어)를 하나의 벡터로 임베딩
→ 의미가 희석되어 검색 정확도 급락
✅ 문서를 300~500 토큰 단위로 청크 분할 후 각각 임베딩
오버랩(중복 구간)을 두면 컨텍스트 연속성 유지
4. 임베딩 생성 비용 과다
✅ 동일 텍스트는 임베딩 캐싱 (Redis, DB 캐시)
✅ 배치 API로 한 번에 여러 텍스트 처리
✅ 차원 수 줄이기 (1536 → 512)
✅ 오픈소스 모델 사용 (Edge Function에서 Transformers.js)
마치며
Supabase + pgvector 조합은 별도의 벡터 DB 없이 시맨틱 검색과 RAG를 구현할 수 있는 강력한 솔루션입니다. 특히 RLS와 결합하면 사용자별 격리된 벡터 검색이 가능하고, 하이브리드 검색으로 키워드와 의미 기반 검색을 동시에 활용할 수 있습니다.
다음 편에서는 Cron Jobs & Queues를 다룹니다. pg_cron으로 정기적인 DB 작업을 예약하고, pgmq로 안정적인 메시지 큐 기반 백그라운드 작업을 처리하는 방법을 살펴봅니다.