One Server

문서

함수, 예약 작업, 데이터베이스 웹훅

함수 코드의 모양, 할 수 있는 것과 없는 것(외부 요청은 켜야 가능), 호출하는 법, cron 일정(UTC), 표 변경을 외부로 알리는 웹훅.

함수

서버 없이 요청 하나를 받아 답하는 코드예요. 결제사 웹훅 받기, 비밀 키가 필요한 계산, 예약 작업의 본체로 써요.

list_functions        배포한 함수와 호출 주소
get_function          지금 코드 (고치기 전에 먼저 읽으세요)
deploy_function       배포 (같은 이름이면 통째로 교체, 다음 호출부터 적용)
set_function_public   공개 여부만 바꾸기
set_function_limits   시간 한도 바꾸기 (호출 하나, SQL 한 문장)
remove_function       삭제
list_function_secrets   함수 시크릿 이름 (값은 돌려주지 않음)
set_function_secrets    함수 시크릿 저장 (같은 이름이면 덮어씀)
copy_schema_from_project      다른 프로젝트의 표와 구조(함수·뷰·정책 포함)를 이 프로젝트에 만들기
copy_data_from_project        다른 프로젝트의 표 행을 이 프로젝트로 복사(없는 행만, 또는 변경분까지)
copy_users_from_project       다른 프로젝트의 로그인 사용자를 복사(해시째, 서버 안에서)
compare_projects_data       두 프로젝트의 표를 행 수·해시로 비교(복사가 빠짐없이 됐는지)
get_project_forward         공개 함수 호출을 다른 프로젝트가 대신 받고 있는지 보기
set_project_forward         공개 함수 호출을 다른 프로젝트가 대신 받게 하기(나눈 뒤 웹훅이 끊기지 않게)
set_project_write_lock      프로젝트를 잠시 읽기 전용으로 잠그기(나누기 마지막 복사 직전, 저절로 풀려요)
get_project_write_lock      쓰기 잠금 보기
copy_functions_from_project  다른 프로젝트의 함수를 코드째 복사(비공개로)
copy_function_secrets   다른 프로젝트의 함수 시크릿을 이름으로 복사
delete_function_secret  함수 시크릿 하나 지우기

코드는 파일 하나예요:

export default async (request, { sql }) => {
  const body = request.json() as { id?: string } | null
  const rows = await sql('select * from orders where id = $1', [body?.id])
  return { order: rows[0] ?? null }
}
  • request: method, headers, claims(호출한 키의 클레임. role이 anon·authenticated·service_role, 키 없이 부른 공개 함수는 null), text(), json()
  • sql(text, values): 이 프로젝트의 service_role 권한으로 한 문장 실행. RLS를 거치지 않으니 claims로 직접 확인하세요.
  • 응답: 객체(JSON), 문자열, 또는 { status, headers, body }
  • 네트워크가 기본으로 꺼져 있어요. set_function_egress(또는 콘솔의 함수 화면에서 외부 요청)로 켜면 fetch()로 공개 HTTPS 주소를 부를 수 있어요. 서버가 대신 보내고 내부 주소는 막아요. 요금제의 분당 한도(Free 300 · Pro 3,000 · Enterprise 10,000회, list_functions에 나와요)까지이고, 모두 함수 로그에 남아요.
  • 연결(소켓): Deno.connect, Deno.connectTls, Deno.startTls도 되어서 메일 라이브러리(denomailer)와 데이터베이스 드라이버가 그대로 돌아요. 서버가 대신 열고 외부 요청과 같은 규칙을 따라요: 네트워크를 켜야 하고, 연결 하나가 분당 한도에 한 번으로 세어지고, 공개 주소만 돼요. 포트는 1024번 이상과 웹(80·443)·메일(110·143·465·587·993·995)만 열려 있어요(25번 같은 나머지 낮은 포트는 막아요). TLS 인증서는 서버가 이름으로 확인해요. 한 호출에 4개까지, 60초 동안 쓰이지 않으면 닫고, 호출이 끝나면 함께 닫혀요.
  • 이 프로젝트의 API(https://<ref>.<zone>)는 네트워크를 켜지 않아도 부를 수 있고, 자동으로 들어가는 변수(SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY, blackwall://env)가 들어 있어 Supabase 함수의 createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)가 그대로 동작해요. 이 호출은 분당 한도에 세지 않고, 로그에는 남아요. Supabase의 새 이름 SUPABASE_SECRET_KEYS·SUPABASE_PUBLISHABLE_KEYS({"default": "<키>"} 모양의 JSON)도 들어가요. SUPABASE_DB_URL은 없어요: 함수는 네트워크가 없는 곳에서 돌아 데이터베이스에 직접 연결할 수 없으니 createClient나 두 번째 인자의 sql을 쓰세요.
  • npm 패키지: deploy_function에 dependencies({"zod": "^3.23.8"}) 또는 package_json(+ package_lock)을 함께 보내면, 배포할 때 설치하고 코드와 함께 ESM 파일 하나로 묶어요(최대 5MB). 설치 스크립트는 실행하지 않고, 네이티브 모듈(.node)은 쓸 수 없어요. 여러 파일은 files({"lib/util.ts": "..."})로 보내고 상대 경로로 import해요. 번들된 함수를 고칠 때는 get_function이 돌려준 package_json·files를 함께 다시 보내세요.
  • 설치 시간: 같은 패키지 묶음(package.json·잠금 파일)은 한 번 설치한 것을 다시 써서 두 번째 배포부터는 몇 초면 끝나요. MCP deploy_function은 40초 안에 끝나지 않으면 status: "building"으로 바로 답하고 서버에서 이어서 배포해요. get_function의 build.status가 deployed가 되면 새 버전이 호출을 받고, failed면 build.error에 이유가 있어요.
  • 폴더째 배포: index.ts와 다른 모듈(선택으로 deno.json)이 든 폴더를 oneend functions deploy <이름> <폴더>로, 또는 deploy_function의 files에 index.ts까지 모두 넣어 보내요. Supabase처럼 deno.json의 imports와 npm:zod@3 같은 import를 쓸 수 있고, 배포할 때 npm 패키지로 바꿔 설치해요.
  • jsr:와 URL import: jsr:@supabase/supabase-js@2는 npm의 같은 패키지로, 다른 jsr: 패키지는 JSR의 npm 호환 저장소에서 설치해요. https://deno.land/[email protected]/http/server.ts·https://esm.sh/... 같은 URL import는 배포할 때 한 번 받아 함께 묶어요(받은 주소·최종 주소·SHA-256이 get_function의 remote_imports에 남고, deno.lock이 있으면 해시가 같아야 배포돼요). 함수가 실행 중에 받는 일은 없어요. 주소에 닿지 못하면 그 주소와 이유를 적고 배포가 실패해요.
  • _shared 폴더: Supabase처럼 supabase/functions/<이름>/index.ts가 ../_shared/cors.ts를 import하면, oneend functions deploy <이름>이 그 폴더를 함께 보내요. MCP에서는 files에 ../_shared/cors.ts 경로 그대로 넣어요.
  • 시간 한도: 호출 하나에 기본 150초(Supabase 엣지 함수와 같음), 함수 안의 SQL 한 문장에 기본 8초예요. set_function_limits(또는 oneend functions limits, 콘솔의 함수 화면)로 함수마다 바꿀 수 있고 최대는 get_function의 timeout_cap_seconds이에요. 넘기면 504로 끊겨요.
  • 프로덕션 환경 변수(blackwall://env)가 process.env로 들어와요. 값을 바꾸면 다음 호출부터 새 값이에요.
  • 함수 시크릿: 웹훅 서명 키나 메일 서비스 API 키처럼 함수만 쓰는 값은 set_function_secrets { ref, secrets: { "RESEND_API_KEY": "re_…" } }로 저장해요(콘솔의 함수 시크릿, oneend secrets set, REST POST /v1/projects/<ref>/function-secrets). 사이트 빌드에는 들어가지 않고, 같은 이름의 환경 변수보다 먼저 process.env에 들어가며, 다음 호출부터 쓰여요. 값은 저장만 되고 어떤 도구·API로도 다시 읽을 수 없어요. list_function_secrets는 이름과 바꾼 때만, 지우기는 delete_function_secret(DELETE /v1/projects/<ref>/function-secrets/<name>)이에요. 다른 프로젝트에 이미 넣어 둔 키는 다시 붙여 넣지 말고 copy_function_secrets { ref, from: "<다른 프로젝트 ref>", names: ["ANTHROPIC_API_KEY"] }로 복사해요(oneend secrets copy --from <ref> 이름..., REST POST /v1/projects/<ref>/function-secrets/copy). 값은 서버 안에서만 옮겨지고, 이미 있는 이름은 overwrite: true일 때만 바꾸며, 답은 copied·skipped·missing 이름만 줘요. 두 프로젝트 모두에서 소유자·관리자여야 해요. 소유자·관리자만 보고 바꿀 수 있어요.
  • 함수는 프로덕션 비밀을 읽을 수 있으니 소유자·관리자만 배포할 수 있어요.

호출:

POST https://<ref>.<zone>/functions/v1/<name>
Authorization: Bearer <공개 키, 사용자 토큰 또는 비밀 키>

누가 부를 수 있나:

함수키 없이공개 키사용자 토큰 · 비밀 키
비공개(기본)401됨 (claims.role = anon)됨 (authenticated · service_role)
공개(public: true)됨 (claims = null)됨됨

비공개는 Supabase의 verify_jwt와 같아서, 공개 키를 붙인 fetch와 supabase.functions.invoke가 그대로 돼요. 공개 키는 앱에 들어가 누구나 볼 수 있으니, 로그인한 사용자만 받으려면 함수 안에서 request.claims.role === 'authenticated'를 확인하세요. 부른 쪽의 Authorization·apikey 헤더는 함수에 그대로 가요(Supabase와 같음). 그래서 함수 안에서 createClient(url, anonKey, { global: { headers: { Authorization: req.headers.get('Authorization') } } })로 그 사용자 권한으로 조회할 수 있어요. 이 프로젝트의 키·토큰으로 확인된 값만 가고, 확인되지 않은 apikey·쿠키·플랫폼의 자격 증명은 가지 않아요. 키 없이 들어오는 결제사 웹훅만 public: true로 두고, 그 안에서 서명을 직접 검증하세요. 출력(console.log)은 get_logs의 source "functions"로 봐요.

예약 작업

save_job          만들기·바꾸기 (이름이 같으면 교체)
list_jobs         목록, 다음 실행, 마지막 결과
run_job           지금 한 번 실행 (1분 안에)
list_job_runs     실행 기록과 오류
set_job_enabled   끄기·켜기
remove_job        삭제
set_job_retention 실행 기록 보관 일수 (1 | 3 | 7 | 14 | 30 | 90, null은 기본값)
  • 일정은 다섯 칸 cron이고 UTC예요. 한국 시간은 UTC + 9시간: 한국 자정은 0 15 * * *, 한국 오전 9시는 0 0 * * *.
  • 하는 일은 둘 중 하나: kind: "function"(배포한 함수를 본문 {}의 POST로 부름) 또는 kind: "sql"(서비스 역할로 한 문장).
  • 외부 URL은 save_job으로 부를 수 없어요(SQL에서는 net.http_post). 같은 시각의 실행은 서버가 여러 대여도 한 번만 일어나요. SQL 작업은 timeout_seconds(기본 120초) 안에 끝나야 해요. 작업의 SQL이 set statement_timeout to '15min'처럼 스스로 늘리면 그만큼 기다리고, 어느 쪽이든 요금제의 최대(Free 300초, Pro·Enterprise 3600초)를 넘기면 멈춰요.
  • 프로젝트당 작업 수는 요금제마다 달라요(Free 50, Pro 200, Enterprise 500, SQL로 만든 작업 포함). list_jobs의 limit·used로 봐요. 한 프로젝트의 작업이 한꺼번에 몰려도 다른 프로젝트의 작업은 밀리지 않아요(프로젝트마다 동시에 최대 4개).

Supabase SQL 그대로: 스키마, pg_cron, pg_net, Vault

Supabase에서 쓰던 SQL을 고치지 않고 실행해요. SQL 편집기, run_sql, apply_migrations, oneend db push, SQL 예약 작업이 모두 같은 이름 변환을 거쳐요.

create schema if not exists blackquant;          -- app_<ref>_blackquant로 만들어짐
create table blackquant.prices (...);            -- SQL에서는 쓴 이름 그대로
select cron.schedule('collect', '* * * * *', $$ select net.http_post(url := 'https://<ref>.<zone>/functions/v1/collect') $$);
select decrypted_secret from vault.decrypted_secrets where name = 'service_role_key';
  • 스키마: create schema blackquant는 이 프로젝트만의 app_<ref>_blackquant가 돼요. 새 표는 public처럼 접근 규칙(RLS)이 켜진 채로 만들어지고 anon·authenticated·service_role 권한도 public과 같아요. 다른 프로젝트의 스키마는 이름도 만들 수 없고 읽을 수도 없어요.
  • 노출 스키마: 데이터 API는 public만 답해요. set_exposed_schemas(콘솔의 데이터베이스 → 스키마, oneend db schemas expose)로 노출하면 Accept-Profile·Content-Profile 헤더, supabase-js의 db: { schema: 'blackquant' }로 써요. 목록은 list_schemas. 노출하지 않은 스키마는 PGRST106(406)이에요.
  • 검색 경로·최대 행 수: get_data_api_settings로 보고 set_data_api_settings로 바꿔요(콘솔의 설정 → 데이터 API). search_path에 넣은 스키마(create schema로 만든 것, extensions)는 요청한 스키마 뒤에 붙어, 함수와 뷰가 스키마 이름 없이 그 안을 찾아요.
  • 연결 한도: get_database_settings로 보고 set_database_settings로 바꿔요(콘솔의 설정 → 데이터베이스). 예약 작업이나 함수가 too many connections for role로 실패하면 API 로그인(<ref>_authenticator)이 한도에 막힌 거예요. enforced.api_connections가 limits.api_connections보다 작으면 api_connections를 그 값까지 올리세요. 바로 적용되고 열린 연결은 끊지 않아요. 서버를 혼자 쓰는 프로젝트는 서버의 최대 연결에서 직접 로그인(기본 30)과 예약 10을 뺀 만큼까지 올릴 수 있고, 다른 프로젝트와 같이 쓰는 서버는 서버 최대 연결의 10분의 1(20~80)이에요. 예약 작업은 한도에 닿으면 1분까지 기다렸다가 다시 연결해요.
  • pg_cron: cron.schedule(name, schedule, command), cron.unschedule, cron.alter_job, cron.job, cron.job_run_details(7일 보관)를 써요. One Server의 스케줄러가 서비스 역할로 실행하고 list_jobs에 source: "sql"로 나와요. 최소 1분 간격(30 seconds 같은 초 단위는 거부), @hourly·@daily 같은 약어는 돼요. create extension pg_cron은 오류 없이 지나가요.
  • pg_net: net.http_post(url, body, params, headers, timeout_milliseconds), net.http_get, net.http_delete는 요청을 줄에 넣고 id를 돌려줘요. 트랜잭션이 끝난 뒤 몇 초 안에 보내고 응답은 net._http_response에 6시간 남아요. https만, 내부 주소는 거부, 시간 제한은 요청한 timeout_milliseconds(기본 5초)를 함수 시간 한도의 최대(기본 150초, 운영자가 900초까지)까지 지키고, 응답을 기다리다 끊겨도 부른 함수는 끝까지 돌아요. 함수의 외부 요청과 같은 분당 한도를 함께 써요. 자기 프로젝트 주소(https://<ref>.<zone>)는 함수의 외부 요청이 꺼져 있어도 돼요. 다른 주소는 set_function_egress로 켜야 해요.
  • http 확장(동기 http_get·http_post)은 없어요. 응답을 같은 트랜잭션에서 기다리는 호출은 net.http_get으로 보내고 나중에 net._http_response에서 읽거나, 함수에서 fetch()로 바꾸세요. 응답을 쓰지 않는 호출은 net.http_post로 바꾸면 그대로 돼요. 데이터베이스가 직접 외부로 나가면 내부 주소 차단과 분당 한도를 피해 가서 넣지 않았어요. 옮기기 도구가 이 확장을 쓰는 함수와 예약 작업을 이름으로 알려 줘요.
  • Vault: vault.create_secret, vault.decrypted_secrets는 서비스 역할(SQL 편집기, run_sql, 비밀 키, SQL 예약 작업)에서만 풀려요. 전용 데이터베이스 프로젝트에서 처음 쓸 때 켜져요.

데이터베이스 웹훅

표의 줄이 바뀌면 알려요. 함수의 외부 요청을 켜지 않았다면 외부 서비스(슬랙, 메일 발송 API 등)에 알릴 때 이것을 써요.

create_db_webhook            만들기: 표, 이벤트(insert/update/delete), 보낼 곳(url 또는 function)
list_db_webhooks             목록과 최근 결과(서명 키는 끝 네 글자만)
list_db_webhook_deliveries   전달 기록과 실패 이유
set_db_webhook_enabled       끄기·켜기
remove_db_webhook            삭제
  • 본문: { type: "INSERT"|"UPDATE"|"DELETE", table, schema: "public", record, old_record, hook, delivery, occurred_at }
  • 커밋된 변경만 보내요. 서버가 재시작 중이었어도 나중에 보내요. 순서는 보장하지 않으니 delivery로 중복을 거르세요.
  • 2xx가 아니거나 10초 안에 응답하지 않으면 실패예요. 10초·1분·5분·30분 뒤 다시 보내고 다섯 번째에 버려요.
  • url은 공개된 https 주소만 돼요. 사설·내부 주소와 리다이렉트는 거부해요.
  • 받는 쪽 확인: x-blackwall-signature: t=<초>,v1=<서명>, 서명 = HMAC-SHA256(signing_secret, "<t>.<본문>")의 16진수.
  • signing_secret은 웹훅을 만들 때 한 번만 보여줘요. 목록(list_db_webhooks)에는 signing_secret_set과 끝 네 글자(signing_secret_last4)만 나와요. 잃어버렸으면 웹훅을 지우고 다시 만들면 새 키가 나와요.

웹 표준 Response, Deno.serve

Deno.serve(async (req) => {
  const { name } = await req.json()
  return new Response(JSON.stringify({ hello: name }), { headers: { "content-type": "application/json" } })
})
export default {
  async fetch(req, ctx) {
    const hits = await ctx.kv.incr("hits")
    const on = await ctx.flags.isEnabled("new-checkout", req.claims?.sub ?? null)
    return Response.json({ hits, on })
  },
}

Supabase처럼 쓴 함수도 그대로 돌아가요. new Response(...)(스트림 포함), Deno.serve(handler), export default { fetch }, Deno.env.get()을 써요. 함수 이름 뒤의 경로(/functions/v1/<name>/a/b)는 req.url로 받아요. EdgeRuntime.waitUntil(promise)에 넘긴 일은 응답을 먼저 보낸 뒤에도 함수의 시간 한도 안에서 끝까지 돌고, 그동안 질의와 외부 요청도 돼요. 스트림으로 만든 Response는 쓰는 대로 보내요(서버 전송 이벤트, 모델 답변). ctx.kv와 ctx.config, ctx.flags로 KV와 엣지 설정을 써요(blackwall://edge).