AI

Supabase 사용해보기 (4) | Edge Function으로 데이터 적재하기

pepega 2026. 3. 8. 19:50

개요

1편에서 Supabase 테이블과 검색 함수를 구성했습니다. 이번 편에서는 Supabase Edge Functions를 활용해 실제 약품 데이터를 벡터 DB에 적재하는 과정을 다룹니다.

이번 편에서 다루는 내용

  • Supabase Edge Function 작성 (ingest)
  • Voyage AI로 임베딩 생성
  • 로컬 JSON 파일을 Edge Function으로 전송하는 Python 스크립트

1. Supabase 새 API 키 체계 이해

Edge Function을 작성하기 전에 Supabase의 새 API 키 체계를 이해해야 합니다.

Supabase는 기존의 JWT 기반 키(anon, service_role)에서 새로운 키 형식으로 전환 중입니다.

구분 기존 신규

공개 키 anon (JWT) sb_publishable_...
비밀 키 service_role (JWT) sb_secret_...

여기서 중요한 점이 있습니다. 새 키 형식(sb_publishable_, sb_secret_)은 JWT가 아니기 때문에 Edge Function의 JWT 검증을 통과할 수 없습니다.

Supabase 공식 문서에 따르면:

"Edge Functions only support JWT verification via the anon and service_role JWT-based API keys. You will need to use the --no-verify-jwt option when using publishable and secret keys."

따라서 새 키를 사용할 때는 두 가지 조치가 필요합니다.

  1. Edge Function 배포 시 --no-verify-jwt 옵션 사용
  2. Edge Function 코드 내부에서 직접 API 키 검증 구현

또한 Gateway를 통과하려면 Authorization: Bearer {key}와 apikey: {key} 헤더를 동일한 값으로 함께 전송해야 합니다.


2. Edge Function 작성 - ingest

프로젝트 구조

supabase/
└── functions/
    ├── ingest/
    │   └── index.ts
    └── search/
        └── index.ts

ingest/index.ts

import { createClient } from "jsr:@supabase/supabase-js@2";

const VOYAGE_API_KEY = Deno.env.get("VOYAGE_API_KEY")!;
const SUPABASE_URL   = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_KEY   = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const API_SECRET_KEY = Deno.env.get("API_SECRET_KEY")!;

const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);

// JSON 필드 → 청크 타입/라벨 매핑
const CHUNK_TYPES: Record<string, [string,="" string]=""> = {
  efcyQesitm:          ["efcy",      "효능"],
  useMethodQesitm:     ["useMethod", "복용법"],
  atpnWarnQesitm:      ["atpnWarn",  "경고"],
  atpnQesitm:          ["atpn",      "주의사항"],
  intrcQesitm:         ["intrc",     "상호작용"],
  seQesitm:            ["se",        "부작용"],
  depositMethodQesitm: ["deposit",   "보관법"],
};

// Voyage AI 임베딩 일괄 생성
async function getEmbeddings(texts: string[]): Promise<number[][]> {
  const res = await fetch("https://api.voyageai.com/v1/embeddings", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${VOYAGE_API_KEY}`,
    },
    body: JSON.stringify({
      model: "voyage-3",
      input: texts,
      input_type: "document",  // 저장 시 document
    }),
  });
  const data = await res.json();
  return data.data.map((d: { embedding: number[] }) => d.embedding);
}

// 검색 품질 향상을 위해 약품명/제조사 컨텍스트 포함
function buildChunkText(item: Record<string, string="">, label: string, content: string) {
  return `약품명: ${item.itemName}\n제조사: ${item.entpName}\n${label}: ${content}`;
}

Deno.serve(async (req) => {
  // 새 키 형식은 JWT 검증 불가 → 코드에서 직접 apikey 헤더 검증
  const apiKey = req.headers.get("apikey");
  if (apiKey !== API_SECRET_KEY) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
      headers: { "Content-Type": "application/json" },
    });
  }

  try {
    const { items } = await req.json();

    for (const item of items) {
      // 1. medicines 테이블에 원본 upsert
      const { data: medData } = await supabase
        .from("medicines")
        .upsert({
          item_seq:   item.itemSeq,
          item_name:  item.itemName,
          entp_name:  item.entpName,
          open_de:    item.openDe,
          update_de:  item.updateDe,
          item_image: item.itemImage,
          bizrno:     item.bizrno,
          raw_json:   item,
        }, { onConflict: "item_seq" })
        .select("id")
        .single();

      // 2. 항목별 청킹
      const chunkMetas: { type: string; label: string; text: string }[] = [];
      for (const [field, [chunkType, label]] of Object.entries(CHUNK_TYPES)) {
        const content = item[field]?.trim();
        if (!content) continue;
        chunkMetas.push({
          type:  chunkType,
          label: label,
          text:  buildChunkText(item, label, content),
        });
      }

      if (chunkMetas.length === 0) continue;

      // 3. 임베딩 일괄 생성 (API 호출 최소화)
      const embeddings = await getEmbeddings(chunkMetas.map(c => c.text));

      // 4. medicine_chunks insert
      await supabase.from("medicine_chunks").insert(
        chunkMetas.map((c, i) => ({
          medicine_id: medData!.id,
          item_seq:    item.itemSeq,
          item_name:   item.itemName,
          chunk_type:  c.type,
          chunk_label: c.label,
          content:     c.text,
          embedding:   embeddings[i],
        }))
      );

      console.log(`✅ ${item.itemName} → ${chunkMetas.length}개 청크 삽입`);
    }

    return new Response(JSON.stringify({ ok: true }), {
      headers: { "Content-Type": "application/json" },
    });

  } catch (err) {
    return new Response(JSON.stringify({ error: String(err) }), {
      status: 500,
      headers: { "Content-Type": "application/json" },
    });
  }
});
</string,></number[][]></string,>

핵심 포인트

  • 임베딩을 청크마다 개별 호출하지 않고 배열로 한 번에 요청해서 API 호출 수를 최소화합니다.
  • 청크 텍스트에 약품명: ...\n제조사: ...\n{라벨}: ... 형태로 컨텍스트를 포함시켜 청크 하나만 봐도 어떤 약인지 알 수 있게 합니다.
  • 저장 시 Voyage AI input_type을 "document"로 설정합니다. (검색 시에는 "query"로 다릅니다.)

3. Secrets 설정

Edge Function에서 사용하는 환경변수를 Supabase Dashboard에 등록합니다.

Supabase Dashboard → Edge Functions → Secrets → Add

Name Value

API_SECRET_KEY sb_secret_... (Secret key)
VOYAGE_API_KEY Voyage AI API 키
ANTHROPIC_API_KEY Anthropic API 키

SUPABASE_URL과 SUPABASE_SERVICE_ROLE_KEY는 Edge Function 환경에 자동으로 주입되므로 별도 등록 불필요합니다.

Voyage AI API 키는 dash.voyageai.com에서, Anthropic API 키는 console.anthropic.com에서 발급받을 수 있습니다.


4. Edge Function 배포

새 키 형식(sb_secret_)은 JWT가 아니므로 --no-verify-jwt 옵션을 반드시 추가해야 합니다.

supabase functions deploy ingest --no-verify-jwt

또는 Supabase Dashboard → Edge Functions → ingest → Details → Verify JWT with legacy secret → OFF 로 설정해도 동일합니다.

 

위 방식으로 해도 되고

 

supabase의 Deploy a new funtion으로 함수를 배포해도 됩니다.

 

--no-verify-jwt 옵션은 supabase의 UI 에서

 

버튼을 클릭하면 됩니다.

 


5. 데이터 적재 Python 스크립트

로컬의 medicines.json 파일을 Edge Function으로 전송하는 스크립트입니다.

medicines.json 은 https://www.data.go.kr/data/15075057/openapi.do?recommendDataYn=Y 에서 가져왔습니다.

pip install requests
import json
import time
import requests

INGEST_URL  = "https://{your-project-ref}.supabase.co/functions/v1/ingest"
SECRET_KEY  = "sb_secret_..."  # Secret key
BATCH_SIZE  = 10  # 한 번에 보낼 약품 수

def send_batch(items: list, batch_num: int) -> bool:
    try:
        res = requests.post(
            INGEST_URL,
            headers={
                "apikey":        SECRET_KEY,
                "Authorization": f"Bearer {SECRET_KEY}",  # 두 헤더 값이 동일해야 Gateway 통과
                "Content-Type":  "application/json",
            },
            json={"items": items},
            timeout=60,
        )
        if res.status_code == 200:
            print(f"✅ 배치 {batch_num} 완료 ({len(items)}개)")
            return True
        else:
            print(f"❌ 배치 {batch_num} 실패: {res.status_code} - {res.text}")
            return False
    except Exception as e:
        print(f"❌ 배치 {batch_num} 오류: {e}")
        return False

def main():
    with open("medicines.json", "r", encoding="utf-8") as f:
        data = json.load(f)

    items = data["body"]["items"]
    total = len(items)
    print(f"📦 총 {total}개 약품 데이터 적재 시작\n")

    failed_batches = []
    for i in range(0, total, BATCH_SIZE):
        batch = items[i:i + BATCH_SIZE]
        batch_num = i // BATCH_SIZE + 1
        success = send_batch(batch, batch_num)
        if not success:
            failed_batches.append(batch_num)
        time.sleep(1)  # API 레이트 리밋 방지

    print(f"\n{'='*40}")
    if not failed_batches:
        print("🎉 전체 성공!")
    else:
        print(f"❌ 실패 배치: {failed_batches} → 재시도 필요")

if __name__ == "__main__":
    main()

헤더 설정 주의사항

새 키 형식을 사용할 때는 apikey와 Authorization: Bearer 헤더를 동일한 값으로 함께 전송해야 합니다. 하나만 보내면 Gateway에서 401이 반환됩니다.


6. 적재 결과 확인

Supabase Dashboard → SQL Editor에서 확인합니다.

-- 약품별 청크 개수 확인
select item_name, count(*) as chunk_count
from medicine_chunks
group by item_name
order by item_name;

-- 특정 키워드 포함 청크 조회
select item_name, chunk_label, content
from medicine_chunks
where content like '%임신%';

마무리

이번 편에서는 Supabase Edge Function을 활용한 데이터 적재 과정을 다뤘습니다.

핵심 요약

  • 새 Supabase 키(sb_secret_)는 JWT가 아니므로 --no-verify-jwt 옵션 필수
  • apikey와 Authorization: Bearer 헤더를 동일한 값으로 함께 전송
  • 코드 내부에서 apikey 헤더로 직접 인증 검증
  • 임베딩 생성 시 input_type: "document" (저장) / "query" (검색) 구분 필수

다음 편에서는 적재된 데이터를 Edge Function으로 자연어 검색하고 Claude가 답변을 생성하는 과정을 다루겠습니다.