1장. 관리형 앱·데이터 플랫폼 (기초: Vercel·Supabase)
학습 목표
- Vercel의 배포 환경(Preview·Production) 분리 원칙과 환경 변수 노출 경계(
NEXT_PUBLIC_대 서버 전용 변수)를 구분해 설정한다. - 배포 보호(Deployment Protection)로 미리보기 환경을 외부로부터 잠그고, 자동화 테스트·웹훅이 통과할 수 있도록 우회 비밀값(
x-vercel-protection-bypass)을 안전하게 설계한다. - 서버리스 함수 리전(예: 서울
icn1)을 데이터베이스 위치에 맞춰 배치하고, 실행 시간 상한(maxDuration)과 Fluid Compute·스트리밍이 AI 응답 지연 및 비용에 미치는 영향을 설명한다. - Supabase Postgres의 연결 풀러인 Supavisor의 트랜잭션 모드(포트 6543)와 세션 모드(포트 5432)의 차이를 이해하고 워크로드에 맞는 연결 구성을 고른다.
- Row Level Security(RLS)의
USING절과WITH CHECK절의 작동 차이를 설명하고,anon키와service_role키의 신뢰 경계를 지키는 정책을 작성한다. - Supabase Auth의 리다이렉트 URL 허용 목록(웹 오리진과 모바일 딥링크) 및 비공개 Storage 버킷의 서명된 URL(Signed URL) 접근 제어를 설계한다.
들어가며
주말 해커톤에서 코딩 에이전트와 함께 멋진 AI 문서 요약 웹앱을 만든 창업팀이 있습니다. 로컬 컴퓨터에서 잘 돌아가던 코드를 GitHub에 올리고 Vercel에 연결하자 1분 만에 전 세계에서 접속 가능한 HTTPS 주소가 생겼고, Supabase 프로젝트를 연결하자 로그인과 데이터베이스, 파일 업로드까지 단숨에 완성되었습니다. 팀은 환호하며 월요일 아침에 소셜 미디어에 출시 소식을 올렸습니다.
그런데 출시 두 시간 만에 세 가지 일이 연달아 터졌습니다. 첫째, 한 사용자가 브라우저 개발자 도구의 네트워크 탭과 자바스크립트 번들을 열어 보고는 팀의 유료 LLM API 키와 Supabase 관리자 키를 그대로 찾아냈습니다. 개발자가 프레임워크에서 변수를 편하게 읽으려고 모든 환경 변수 앞에 NEXT_PUBLIC_을 붙여 둔 탓이었습니다. 둘째, 사용자가 200명쯤 동시에 몰리자 요약 화면이 3초 이상 멈추더니 remaining connection slots are reserved for non-replication superuser connections라는 데이터베이스 연결 고갈 오류를 뿜으며 멈춰 섰습니다. 서버리스 함수는 미국 동부(iad1)에서 뜨고 데이터베이스는 서울(icn1)에 있어 왕복 지연이 길어진 데다, 함수 인스턴스마다 직접 연결(포트 5432)을 물고 놓지 않았기 때문입니다. 셋째, 아직 출시하지 않은 다음 달 유료 기능 브랜치의 미리보기(Preview) URL이 검색 엔진과 외부 커뮤니티에 노출되어 누구나 실험 중인 기능을 열어 볼 수 있었습니다.
Vercel과 Supabase 같은 관리형 플랫폼은 인프라 구축 시간을 며칠에서 몇 분으로 줄여 주지만, "기본값을 그대로 두어도 안전하다"는 뜻은 결코 아닙니다. 오히려 배포가 쉬운 만큼 환경 변수 하나, 포트 번호 하나, RLS 체크박스 하나가 곧바로 프로덕션 보안 경계와 직결됩니다. 이 장에서는 관리형 앱·데이터 플랫폼을 '장난감 프로토타입' 수준에서 '실전 프로덕션' 수준으로 끌어올리는 아홉 가지 핵심 원리와 설계 기준을 차례대로 살펴봅니다.
핵심 개념
Vercel 환경 분리: Preview와 Production의 경계
관리형 앱 플랫폼에서 가장 먼저 세워야 할 기둥은 **환경 분리(Environment Separation)**입니다. Vercel은 Git 저장소와 연결되면 크게 세 가지 환경을 구분합니다. 개발자의 노트북에서 돌아가는 Development, 기능 브랜치나 풀 리퀘스트(PR)마다 자동으로 생성되는 Preview, 그리고 주 브랜치(예: main)에 병합되거나 명시적으로 승격될 때 실제 고객 도메인에 연결되는 Production입니다.
비유하자면 Production은 손님을 맞이하는 식당의 본매장이고, Preview는 신메뉴를 시식하고 조리 동선을 시험하는 테스트 주방입니다. 테스트 주방과 본매장이 같은 금고, 같은 식자재 창고, 같은 주문 전표를 쓰면 어떻게 될까요? 테스트 주방에서 새 결제 취소를 시험하다가 본매장 손님의 실제 결제를 취소하거나, 테스트용 가짜 주문 수천 건이 본매장 장부에 섞여 들어갑니다.
따라서 환경 분리에는 세 가지 철칙이 따릅니다.
| 구분 항목 | Preview 환경 | Production 환경 | 분리하지 않았을 때의 사고 |
|---|---|---|---|
| 도메인 | 브랜치·커밋별 고유 미리보기 URL | 실제 서비스 도메인 (예: app.example.com) |
미검증 코드가 실제 고객에게 노출됨 |
| 데이터베이스·스토리지 | 개발·스테이징 전용 DB 또는 격리된 테스트 프로젝트 | 실제 고객 데이터가 담긴 운영 DB | 마이그레이션 실험이나 E2E 테스트가 운영 데이터를 훼손함 |
| 외부 결제·메시징 키 | 샌드박스(테스트) 키, 발송 차단 모드 | 라이브 결제 키, 실제 발송 키 | 테스트 중 실제 고객에게 알림이 가거나 실결제가 발생함 |
| 인증·개발자 백도어 | 제한된 테스트 계정 허용(배포 보호 뒤에서만) | 테스트 로그인·권한 부여 API 완전 비활성화 | 공격자가 개발용 권한 부여 경로로 관리자 권한 획득 |
특히 Vercel에서 환경 변수를 등록할 때는 변수마다 적용 대상 환경(Production, Preview, Development)을 개별 체크박스로 지정할 수 있습니다. 운영 데이터베이스 연결 문자열(DATABASE_URL)이나 라이브 결제 비밀키를 등록할 때 무심코 세 환경을 모두 체크해 두면, 아무 개발자나 PR을 하나 열어 Preview 빌드를 띄운 뒤 콘솔 로그로 운영 비밀값을 찍어 보거나 운영 DB에 붙을 수 있게 됩니다. 운영 비밀값은 오직 Production 환경에만 주입해야 하며, Preview 환경에는 별도의 스테이징 자격 증명을 주입해야 합니다. 또한 Preview 브랜치마다 격리된 설정이 필요할 때는 브랜치별 환경 변수(Branch-specific Environment Variables)를 지정해 실험 범위를 좁힐 수 있습니다.
환경 변수와 NEXT_PUBLIC_ 노출 경계
웹 애플리케이션 프레임워크(Next.js 등)는 서버에서 실행되는 코드와 사용자의 브라우저로 전송되어 실행되는 클라이언트 코드를 한 저장소 안에서 함께 다룹니다. 여기서 초보자와 숙련자 모두가 가장 자주 겪는 보안 사고가 바로 환경 변수 번들 노출입니다.
빌드 도구는 보안을 위해 기본적으로 모든 환경 변수를 **서버 전용(Server-only)**으로 취급합니다. 즉 process.env.OPENAI_API_KEY나 process.env.SUPABASE_SERVICE_ROLE_KEY는 서버 컴포넌트, API 라우트, 서버 액션처럼 Vercel의 서버 환경에서만 읽을 수 있고, 브라우저로 내려가는 자바스크립트 파일에는 포함되지 않습니다.
반면 변수 이름이 NEXT_PUBLIC_으로 시작하면(예: NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_SUPABASE_URL) 이야기가 완전히 달라집니다. 빌드 도구는 빌드 시점에 이 변수의 실제 문자열 값을 찾아 클라이언트 자바스크립트 번들 파일 안에 글자 그대로 박아 넣습니다(Inlining).
// 서버 전용 코드 (Route Handler / Server Action) — 안전함
// 이 코드는 서버에서만 실행되며 브라우저 번들에 포함되지 않는다.
const apiKey = process.env.LLM_SECRET_API_KEY;
// 클라이언트 컴포넌트 ("use client") — 전 세계에 공개됨!
// 빌드 시점에 아래 코드는 "https://proj.supabase.co" 같은 실제 문자열로 치환되어
// 누구나 브라우저 개발자 도구(Sources/Network)에서 읽을 수 있다.
const publicUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
여기서 기억해야 할 두 가지 중요한 성질이 있습니다.
NEXT_PUBLIC_이 붙은 값은 비밀이 아닙니다. 이름을 아무리NEXT_PUBLIC_SECRET_KEY라고 지어도, 그 값은 웹페이지의 HTML 소스에 적어 둔 글자와 똑같이 전 세계 누구나 볼 수 있습니다. 따라서 LLM API 키, 데이터베이스 접속 문자열,service_role키, 웹훅 서명 검증 시크릿, 암호화 키에는 절대로NEXT_PUBLIC_접두사를 붙이면 안 됩니다.NEXT_PUBLIC_변수는 빌드 시점(Build-time)에 고정됩니다. 서버 전용 환경 변수는 서버가 요청을 처리할 때 읽을 수 있지만, 클라이언트 번들에 인라이닝된NEXT_PUBLIC_값은 이미 정적 파일로 구워져 CDN에 올라가 있으므로 대시보드에서 값을 바꾼 뒤 반드시 재배포(Redeploy)해 새로 빌드해야 브라우저에 반영됩니다.
주의
간혹 클라이언트 컴포넌트에서 서버 전용 변수가 undefined로 나온다는 이유로, 오류를 빨리 없애려고 기존 비밀 변수 앞에 NEXT_PUBLIC_을 붙여 해결하는 경우가 있습니다. 이는 금고 열쇠가 안 열린다고 열쇠를 건물 정문 게시판에 걸어 두는 것과 같습니다. 클라이언트에서 비밀 키가 필요한 기능(예: LLM 호출, 결제 승인)은 반드시 서버 API 라우트나 서버 액션을 거치도록 구조를 고쳐야 합니다.
배포 보호(Deployment Protection)와 자동화 우회 설계
Preview 배포는 PR마다 고유한 URL(예: my-app-git-feat-new-ui-team.vercel.app)을 만듭니다. 이 URL은 주소만 알면 인터넷 어디서든 접속할 수 있는 공개 엔드포인트입니다. 만약 배포 보호를 켜지 않으면, 아직 보안 검토가 끝나지 않은 신기능, 내부 어드민 화면 초안, 혹은 출시 전 마케팅 페이지가 외부에 그대로 노출될 뿐 아니라 검색 엔진 봇이 이를 색인해 버릴 수도 있습니다.
Vercel의 **배포 보호(Deployment Protection)**는 Preview 배포(및 프로덕션 기본 .vercel.app 도메인) 앞에 인증 관문을 세우는 기능입니다.
- Vercel Authentication: 같은 Vercel 팀 소속으로 로그인한 구성원만 Preview URL에 접근할 수 있게 제한합니다.
- Password Protection: 추가 비밀번호를 아는 외부 협력사나 리뷰어에게만 화면을 열어 줍니다.
- Trusted IPs: 사내 고정 IP나 VPN 게이트웨이 대역에서만 접근을 허용합니다.
문제는 사람이 아닌 자동화 도구가 Preview URL에 접근해야 할 때 생깁니다. 예를 들어 GitHub Actions에서 Playwright로 배포된 Preview 환경을 대상 삼아 엔드투엔드(E2E) 테스트를 돌리거나, 외부 결제·메시징 서비스의 테스트 웹훅이 Preview 서버의 /api/webhook을 호출하거나, 백엔드 CI 파이프라인이 헬스 체크를 보내면 Vercel Authentication 로그인 화면(HTTP 401/403 또는 로그인 리다이렉트)에 가로막혀 모두 실패합니다.
이때 배포 보호를 통째로 끄는 대신 사용하는 정석 해법이 **자동화 보호 우회(Protection Bypass for Automation)**입니다. 프로젝트 설정에서 우회 시크릿(VERCEL_AUTOMATION_BYPASS_SECRET)을 발급받으면, 자동화 클라이언트는 요청 시 다음 헤더를 함께 보냅니다.
GET /api/health HTTP/1.1
Host: my-app-git-feat-team.vercel.app
x-vercel-protection-bypass: <발급받은_우회_시크릿_값>
x-vercel-set-bypass-cookie: true
x-vercel-protection-bypass헤더 값이 프로젝트의 우회 시크릿과 일치하면 Vercel 엣지는 인증 화면을 건너뛰고 요청을 함수로 통과시킵니다.- 브라우저 기반 E2E 테스트(Playwright 등)처럼 첫 페이지 진입 후 여러 하위 리소스와 페이지를 연달아 요청해야 하는 경우에는
x-vercel-set-bypass-cookie: true(다중 도메인·교차 사이트 테스트 시samesitenone) 헤더를 함께 보내 우회 쿠키를 구워 두면 이후 요청들도 끊김 없이 통과합니다. - 외부 서드파티 웹훅처럼 커스텀 HTTP 헤더를 넣을 수 없는 서비스의 경우에는 쿼리 파라미터(
?x-vercel-protection-bypass=<시크릿>) 방식을 제한적으로 사용할 수 있으나, URL이 로그에 남을 수 있으므로 헤더 방식을 우선하고 웹훅 본문의 자체 HMAC 서명 검증을 반드시 병행해야 합니다.
함수 리전 배치, 실행 시간 상한과 Fluid Compute·스트리밍
정적 파일(이미지, CSS, 번들 JS)은 전 세계 CDN 엣지 노드에 복사되어 사용자와 가장 가까운 곳에서 즉시 내려갑니다. 그러나 데이터베이스에 질의하고 LLM API를 호출하는 **서버리스 함수(Serverless Function)**는 어디에 배치해야 할까요?
많은 개발자가 "함수도 사용자와 가까워야 빠르다"고 생각하거나, 아무 설정을 하지 않아 Vercel의 기본 리전인 **미국 동부(iad1, 워싱턴 D.C.)**에 함수를 그대로 둡니다. 그런데 데이터베이스(Supabase Postgres)가 **서울(ap-northeast-2, Vercel 리전 코드 icn1)**에 있다면 심각한 현상이 벌어집니다.
서울에 있는 사용자가 요청을 보냈을 때 두 구성을 비교해 봅시다.
| 구성 | 요청 흐름과 왕복 거리 | DB 쿼리 5회 실행 시 네트워크 지연 |
|---|---|---|
함수 iad1(미국) + DB 서울 |
서울 사용자 → 미국 함수(180ms) → 서울 DB 쿼리 5회 왕복(5 × 180ms = 900ms) → 서울 사용자(180ms) | 약 1,260ms 이상 (쿼리가 늘어날수록 폭증) |
함수 icn1(서울) + DB 서울 |
서울 사용자 → 서울 함수(15ms) → 서울 DB 쿼리 5회 왕복(5 × 3ms = 15ms) → 서울 사용자(15ms) | 약 45ms (데이터베이스와 함수가 같은 권역) |
웹 요청 하나를 처리할 때 사용자와 함수 사이의 왕복은 딱 한 번이지만, 함수와 데이터베이스 사이의 왕복은 인증 확인, 프로필 조회, 권한 검사, 내역 저장 등 한 요청 안에서 수차례 직렬(Waterfall)로 일어납니다. 따라서 서버리스 함수의 리전은 반드시 **주 데이터베이스(Primary DB)와 가장 가까운 리전(예: 한국 DB라면 icn1)**으로 고정해야 합니다. vercel.json의 regions: ["icn1"] 설정 하나로 수백 밀리초의 지연을 즉시 덜어낼 수 있습니다.
AI 애플리케이션에서는 리전 배치만큼이나 함수 실행 시간 상한(maxDuration)과 스트리밍, 그리고 Fluid Compute가 중요합니다.
- 실행 시간 상한(
maxDuration): 일반 CRUD API는 1초 안에 끝나지만, 추론 모델(Reasoning Model) 호출이나 다단계 도구 실행 에이전트는 응답이 끝날 때까지 30초~수 분이 걸립니다. 라우트별export const maxDuration = 60;(또는 플랜이 허용하는 범위 내 상한)을 명시하지 않으면, 모델이 열심히 답을 생성하는 도중에 함수가 타임아웃으로 강제 종료되어 사용자는 빈 화면을 보고 토큰 비용만 날아가게 됩니다. - 스트리밍(Streaming, SSE / ReadableStream): LLM이 첫 번째 토큰을 내보내는 즉시 브라우저로 흘려보내면 첫 바이트 도달 시간(TTFB)과 체감 지연을 크게 줄일 수 있고, 연결이 살아 있음을 유지해 중간 프록시의 유휴 타임아웃을 피할 수 있습니다.
- Fluid Compute(유체 컴퓨팅): 전통적인 서버리스 함수는 요청 하나당 인스턴스 하나가 배정되어, 그 함수가 OpenAI나 Anthropic API의 느린 스트리밍 응답을 하염없이 기다리는(I/O 대기) 동안에도 CPU와 메모리를 독점하며 비용을 태웠습니다. Vercel의 Fluid Compute는 Node.js/Python 런타임의 비동기 I/O 특성을 살려 하나의 따뜻한(Warm) 함수 인스턴스가 네트워크 I/O를 기다리는 동안 다른 요청들을 동시에(Concurrency) 처리하게 해 줍니다. 그 결과 콜드 스타트(Cold Start) 빈도와 I/O 대기 중의 유휴 과금이 극적으로 줄어듭니다. 또한 응답을 이미 사용자에게 보낸 뒤 감사 로그 기록이나 사용량 집계를 마무리해야 할 때는
waitUntil()API를 써서 사용자 응답 지연 없이 백그라운드 후처리를 마칠 수 있습니다.
Supabase Postgres와 Supavisor 커넥션 풀링: 6543 대 5432
PostgreSQL은 전통적으로 클라이언트 연결(Connection) 하나마다 서버 운영체제에서 별도의 프로세스(Process)를 띄워 메모리를 할당하는 무거운 연결 모델을 씁니다. 그래서 데이터베이스 한 대가 동시에 유지할 수 있는 직접 연결 수(max_connections)는 인스턴스 크기에 따라 수십 개에서 수백 개 수준으로 엄격히 제한됩니다.
전통적인 상주형 서버(예: EC2 2대) 환경에서는 각 서버가 시작할 때 연결 10개씩만 열어 두고 돌려쓰므로(총 20개) 아무 문제가 없습니다. 하지만 Vercel 같은 서버리스 환경에서는 트래픽이 몰리는 순간 함수 인스턴스가 수십~수백 개로 불어나고, 각 인스턴스가 PostgreSQL에 직접 TCP+TLS 핸드셰이크를 맺으려 들면 연결 한도가 순식간에 바닥납니다.
이 문제를 해결하기 위해 Supabase는 앞단에 Supavisor라는 클라우드 네이티브 커넥션 풀러(Connection Pooler)를 둡니다. Supavisor는 수천 개의 가벼운 클라이언트 연결을 받아서, 뒤쪽 PostgreSQL에는 소수의 실제 연결만 유지하며 쿼리를 중계합니다. 이때 포트 번호에 따라 두 가지 모드를 제공하며, 이 둘의 차이를 구분하는 것이 시험과 실무의 핵심입니다.
| 비교 항목 | 트랜잭션 모드 (Transaction Mode) | 세션 모드 (Session Mode) | 직접 연결 (Direct Connection) |
|---|---|---|---|
| 기본 포트 | 6543 (Supavisor) | 5432 (Supavisor 풀러 호스트) | 5432 (DB 직접 호스트, IPv6 기본) |
| 백엔드 연결 점유 단위 | 단일 트랜잭션(BEGIN ~ COMMIT) 동안만 빌려 쓰고 즉시 풀에 반납 |
클라이언트가 접속을 끊을 때까지 백엔드 연결을 1:1로 독점 | 풀러 없이 Postgres 프로세스에 1:1 직접 연결 |
| 적합한 워크로드 | 서버리스 함수(Vercel, Edge, Lambda), 고동시성 웹 API | 상주형 백엔드 서버, 세션 상태(SET, 임시 테이블)가 꼭 필요한 장기 작업 |
스키마 마이그레이션(CREATE/ALTER), pg_dump, 관리 도구 |
| 세션 수준 기능 제약 | 연결이 매 트랜잭션마다 바뀌므로 세션 수준 PREPARE(Prepared Statement), SET, LISTEN/NOTIFY, 세션 임시 테이블이 유지되지 않음 |
세션 수준 기능 모두 정상 동작 (단, 동시 연결 수 한도는 직접 연결만큼 빠르게 차오름) | 모든 Postgres 기능 사용 가능 |
서버리스 애플리케이션에서 Supavisor를 쓸 때 반드시 지켜야 할 실무 규칙은 세 가지입니다.
- 런타임 앱 트래픽은 포트 6543(트랜잭션 모드)을 씁니다. 함수가 수백 개 떠도 실제 쿼리를 실행하는 몇 밀리초 동안만 데이터베이스 연결을 빌려 쓰고 즉시 반납하므로 연결 고갈을 막을 수 있습니다.
- ORM·드라이버에서 Prepared Statement 캐시를 비활성화하거나 호환 옵션을 켭니다. 트랜잭션 모드에서는 첫 번째 쿼리에서 만든
PREPARE stmt_1이 담긴 백엔드 연결과 두 번째 쿼리가 배정받은 백엔드 연결이 서로 다를 수 있어prepared statement "s1" does not exist오류가 발생할 수 있습니다(예: Prisma에서는 연결 문자열에?pgbouncer=true를 붙이고,postgres.js에서는prepare: false를 지정). - 데이터베이스 마이그레이션(
migrate)은 세션 모드나 직접 연결(5432)로 분리합니다. 많은 마이그레이션 도구는 동시에 두 개의 마이그레이션이 실행되는 것을 막기 위해 PostgreSQL의 세션 수준 권고 잠금(pg_advisory_lock)을 쓰거나 긴 DDL 작업을 수행합니다. 따라서 런타임용DATABASE_URL(6543)과 마이그레이션 전용DIRECT_URL(5432)을 분리해 관리하는 것이 정석입니다. 만약 트랜잭션 모드 안에서 설정값을 주입해야 한다면 세션 전체에 남는SET대신 현재 트랜잭션 안에서만 유효하고 커밋 시 자동으로 초기화되는SET LOCAL(예:SET LOCAL role,SET LOCAL app.user_id = ...)을 사용해야 다른 요청으로 상태가 새어 나가지 않습니다.
Row Level Security(RLS): USING과 WITH CHECK의 정확한 작동 원리
Supabase는 PostgreSQL 위에 자동으로 REST/GraphQL API(PostgREST)를 생성해 브라우저나 모바일 앱에서 바로 데이터를 읽고 쓸 수 있게 해 줍니다. 너무나 편리한 기능이지만, 그만큼 데이터베이스 테이블 자체가 인터넷의 첫 번째 방어선이 됩니다. 만약 테이블을 만들고 **Row Level Security(RLS, 행 수준 보안)**를 켜지 않으면(ALTER TABLE ... ENABLE ROW LEVEL SECURITY 미실행), 공개된 anon 키만 가진 누구나 그 테이블의 모든 행을 읽고, 수정하고, 삭제할 수 있습니다.
RLS를 켜는 순간 해당 테이블은 기본 거부(Default Deny) 상태가 됩니다. 즉 정책(Policy)으로 명시적으로 허용하지 않은 작업은 단 한 행도 보이지 않고 쓰이지도 않습니다(단, 테이블 소유자나 BYPASSRLS 권한을 가진 service_role은 예외).
RLS 정책을 설계할 때 가장 많이 헷갈리는 지점이 바로 USING 절과 WITH CHECK 절의 역할 차이입니다. 비유하자면 USING은 **"창고에서 어떤 물건을 꺼내 볼 수 있는가(기존 행 필터)"**이고, WITH CHECK는 **"창고에 새로 넣거나 고쳐서 넣는 물건이 합격품인가(새 행 검증)"**입니다.
ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
-- 조회(SELECT): 내가 소유한 행만 볼 수 있다.
CREATE POLICY "docs_select_own" ON public.documents
FOR SELECT TO authenticated
USING ( (SELECT auth.uid()) = owner_id );
-- 생성(INSERT): 내 이름으로만 새 행을 넣을 수 있다.
CREATE POLICY "docs_insert_own" ON public.documents
FOR INSERT TO authenticated
WITH CHECK ( (SELECT auth.uid()) = owner_id );
-- 수정(UPDATE): 내 행만 골라서(USING), 수정 후에도 여전히 내 소유여야 한다(WITH CHECK).
CREATE POLICY "docs_update_own" ON public.documents
FOR UPDATE TO authenticated
USING ( (SELECT auth.uid()) = owner_id )
WITH CHECK ( (SELECT auth.uid()) = owner_id );
-- 삭제(DELETE): 내가 소유한 행만 지울 수 있다.
CREATE POLICY "docs_delete_own" ON public.documents
FOR DELETE TO authenticated
USING ( (SELECT auth.uid()) = owner_id );
명령문별로 두 절이 어떻게 작동하는지 표로 정리하면 다음과 같습니다.
| SQL 명령 | USING 절의 역할 (기존 행 검사) |
WITH CHECK 절의 역할 (새 행/변경 후 행 검사) |
자주 나는 설계 실수 |
|---|---|---|---|
SELECT |
조건이 참(true)인 행만 결과에 포함 (거짓인 행은 조용히 숨김) |
사용 불가 (새로 생기는 행이 없음) | RLS를 아예 켜지 않거나 USING (true)로 민감 테이블 전체 공개 |
INSERT |
사용 불가 (비교할 기존 행이 없음) | 새로 삽입하려는 행이 조건을 만족하지 않으면 오류로 거부 | WITH CHECK (true)로 두어 남의 owner_id를 도용해 행을 삽입함 |
UPDATE |
수정 대상이 될 기존 행을 먼저 거름 | 수정이 끝난 뒤의 새 상태가 조건을 만족하는지 다시 검증 | USING만 적고 변경 컬럼을 통제하지 않거나, 소유권을 남에게 넘겨 버림 |
DELETE |
삭제할 수 있는 기존 행을 거름 | 사용 불가 (남는 새 행이 없음) | SELECT 정책만 있으면 삭제도 막힌다고 착각하거나 조건을 빠뜨림 |
참고
UPDATE 정책에서 USING 절만 작성하고 WITH CHECK 절을 생략하면 PostgreSQL은 기본적으로 USING 절의 조건을 WITH CHECK에도 그대로 복사해 적용합니다. 하지만 실무에서는 "초안(status = 'draft')인 내 글만 수정할 수 있되(USING), 수정 후 상태는 초안이거나 제출(status IN ('draft', 'submitted'))이어야지 바로 승인(approved) 상태로 바꿀 수는 없다(WITH CHECK)"처럼 수정 전 조건과 수정 후 조건이 다른 경우가 많으므로 두 절을 명시적으로 구분해 적는 습관이 안전합니다. 또한 (SELECT auth.uid()) = owner_id처럼 auth.uid()를 스칼라 서브쿼리로 감싸고 owner_id에 인덱스를 걸어 두면 PostgreSQL 옵티마이저가 함수 호출 결과를 한 번만 평가(InitPlan)해 대량 행 조회 성능이 크게 좋아집니다.
anon 키와 service_role 키의 신뢰 경계
Supabase 프로젝트에는 역할이 전혀 다른 두 종류의 API 키(또는 새로운 Publishable 키와 Secret 키 체계)가 있습니다.
anon키 (Publishable Key): 브라우저나 모바일 앱에 넣어도 되도록 설계된 공개 키입니다. 사용자가 로그인하기 전에는 PostgreSQL의anon역할로 동작하고, 사용자가 Supabase Auth로 로그인해 JWT 액세스 토큰을 받으면 요청이authenticated역할로 전환됩니다. 어느 경우든 모든 쿼리는 RLS 정책의 통제를 엄격히 받습니다. 즉anon키가 외부에 노출되는 것은 정상이며, 보안은 키의 비밀성이 아니라 RLS 정책의 완결성에 달려 있습니다.service_role키 (Secret Key): 백엔드 관리 작업, 웹훅 수신 처리, 배치 집계를 위해 제공되는 최고 권한 비밀 키입니다. 이 키로 요청을 보내면 PostgreSQL의BYPASSRLS권한이 작동해 테이블의 모든 RLS 정책을 완전히 무시하고 모든 행을 읽고 쓰고 지울 수 있습니다.
따라서 service_role 키는 데이터베이스의 루트 마스터 키와 다름없습니다.
- 절대로 브라우저(
NEXT_PUBLIC_), 모바일 앱 바이너리, 공개 Git 저장소에 포함되어서는 안 됩니다. - 백엔드 서버 안에서 쓰더라도, 일반 사용자의 요청을 대신 처리하는 경로에서 무심코
service_role클라이언트를 공용으로 쓰면 큰일 납니다.service_role을 쓰는 순간 RLS라는 안전망이 꺼지므로, 개발자가 코드의WHERE user_id = ?조건 하나만 실수로 빠뜨려도 다른 고객의 데이터가 그대로 반환됩니다(혼동된 대리인, Confused Deputy 문제). - 사용자 요청을 처리하는 서버 코드는 사용자의 세션을 전달받아 RLS가 적용되는 클라이언트(또는 트랜잭션 로컬 컨텍스트)로 질의하고,
service_role은 결제 웹훅 반영이나 시스템 크론 작업처럼 사용자 세션 없이 서버가 주체가 되어야 하는 최소한의 경로에서만 격리해 사용해야 합니다.
Supabase Auth 콜백 허용 목록과 모바일·웹 인증 흐름
소셜 로그인(Google, Apple, GitHub)이나 이메일 매직 링크(Magic Link)·비밀번호 재설정 링크를 사용할 때, 인증이 끝난 사용자는 서비스가 지정한 주소(redirectTo)로 토큰이나 인증 코드를 들고 돌아옵니다.
만약 공격자가 로그인 요청의 redirectTo=https://evil.example.com/callback 파라미터를 마음대로 바꿀 수 있고 인증 서버가 이를 검사 없이 그대로 허용한다면(Open Redirect 취약점), 피해자가 정상 로그인을 마치는 순간 액세스 토큰이나 인증 코드가 공격자의 서버로 고스란히 넘어갑니다.
이를 막기 위해 Supabase Auth는 두 가지를 설정하도록 강제합니다.
- Site URL:
redirectTo가 지정되지 않았을 때 돌아갈 기본 프로덕션 오리진(예:https://aidan.agentcat.app). - Redirect URLs (허용 목록, Allowlist):
redirectTo로 허용할 정확한 URL 또는 패턴 목록입니다. 여기에 등록되지 않은 주소로 리다이렉트를 요청하면 Supabase Auth는 이를 거부하거나 기본 Site URL로 돌려보냅니다.
웹과 모바일 앱을 함께 운영하는 서비스에서는 이 허용 목록을 정교하게 관리해야 합니다.
| 환경·플랫폼 | 허용 목록 등록 예시 | 주의할 점 |
|---|---|---|
| 프로덕션 웹 | https://app.example.com/** |
http://가 아닌 https://를 명시하고, 서브도메인 와일드카드(https://*.example.com/**)는 공격자가 제어 가능한 서브도메인이 없을 때만 제한적으로 사용 |
| Preview 배포 웹 | https://*-myteam.vercel.app/** |
https://*.vercel.app/**처럼 너무 넓게 열면 타인의 Vercel 앱으로 토큰이 넘어갈 수 있으므로 프로젝트·팀 범위로 한정 |
| 모바일 네이티브 앱 | myapp://auth/callback |
커스텀 URL 스킴(Deep Link) 또는 Universal Links / App Links를 정확한 경로로 등록 |
| 로컬 개발 환경 | http://localhost:3000/** |
로컬 개발용 주소가 운영 환경의 기본 Site URL이 되지 않도록 주의 |
또한 서버 사이드 렌더링(SSR)과 모바일 앱이 공존할 때는 일회용 인증 코드 교환 방식인 PKCE(Proof Key for Code Exchange) 흐름을 기본으로 사용합니다. URL 해시(#access_token=...)에 토큰을 직접 담아 보내는 암묵적(Implicit) 흐름과 달리, PKCE는 인증을 시작한 클라이언트가 생성한 code_verifier가 있어야만 일회용 code를 세션 토큰으로 바꿀 수 있어 중간 가로채기 위험을 크게 줄여 줍니다.
Supabase Storage: 공개 버킷 대 비공개 버킷과 서명된 URL(Signed URL)
AI 서비스는 사용자가 올린 PDF 문서, 음성 녹음, 신분증·자격증 사본, 생성된 결과물 이미지를 자주 다룹니다. Supabase Storage는 파일 자체는 객체 스토리지(S3 호환)에 저장하고, 누가 어떤 버킷의 어떤 경로(bucket_id, name)에 접근할 수 있는지는 PostgreSQL의 storage.objects 테이블에 걸린 RLS 정책으로 통제합니다.
버킷을 만들 때는 먼저 **공개 버킷(Public Bucket)**과 **비공개 버킷(Private Bucket)**을 선택합니다.
- 공개 버킷 (
public = true): 버킷 안의 파일 경로(URL)만 알면 인증 헤더나 RLS 조회 없이도 누구나 CDN을 통해 즉시 다운로드할 수 있습니다. 서비스 로고, 공개 과정 표지, 누구나 볼 수 있는 공유 배지 이미지처럼 기밀성이 전혀 없는 자산에만 써야 합니다. 파일 이름을 UUID로 어렵게 지었으니 공개 버킷에 계약서를 넣어도 안전하다고 생각하는 것은 위험합니다(URL이 브라우저 기록, 레퍼러 헤더, 로그를 통해 한 번이라도 새면 영구히 열람 가능). - 비공개 버킷 (
public = false): 모든 다운로드·업로드 요청에 인증이 필요하며storage.objects의 RLS 정책을 통과해야 합니다. 고객이 업로드한 원본 문서, 개인 맞춤형 성적표 PDF, 시험 중 촬영된 사진 등 권한 있는 당사자만 봐야 하는 모든 파일은 반드시 비공개 버킷에 두어야 합니다.
그렇다면 비공개 버킷에 들어 있는 파일을 로그인한 사용자에게 안전하게 내려주거나, 외부 파서(Parser) 워커가 잠깐 읽어가게 하려면 어떻게 해야 할까요? 크게 두 가지 패턴을 씁니다.
- 사용자 세션 기반 RLS + 경로 규칙: 파일 경로를
{user_id}/{document_id}.pdf처럼 규칙적으로 저장하고,storage.objects테이블에(storage.foldername(name))[1] = (SELECT auth.uid()::text)조건의 RLS 정책을 걸어 본인 폴더의 파일만 읽고 쓰게 합니다. - 시간 제한 서명된 URL(Signed URL): 서버에서 사용자의 권한을 확인한 뒤
createSignedUrl(path, 60)처럼 짧은 만료 시간(예: 60초~5분)이 박힌 암호화 서명 URL을 발급해 클라이언트에 전달합니다. 이 URL은 만료 시각이 지나면 자동으로 무효가 되므로, 설령 링크가 유출되더라도 노출 시간 창을 최소화할 수 있습니다. 대용량 파일을 클라이언트가 서버를 거치지 않고 스토리지로 직접 올릴 때도 **서명된 업로드 URL(createSignedUploadUrl)**을 발급하면 서버리스 함수의 페이로드 크기 제한(예: Vercel 4.5MB 요청 본문 한도)을 우회하면서 권한 통제까지 지킬 수 있습니다.
깊이 읽기
서버리스 콜드 스타트, DB TLS 핸드셰이크와 트랜잭션 로컬 컨텍스트
왜 서버리스 함수와 PostgreSQL을 연결할 때 리전 거리와 풀러 모드가 그토록 치명적인지 네트워크 패킷 수준에서 들여다봅시다.
함수 인스턴스 하나가 새로 깨어나(Cold Start) 원격 PostgreSQL에 새로운 연결을 맺으려면 다음 과정을 순서대로 밟아야 합니다.
- DNS 질의로 데이터베이스 호스트의 IP를 찾습니다.
- TCP 3-way 핸드셰이크(
SYN→SYN-ACK→ACK)로 왕복 1회(1 RTT)를 씁니다. - PostgreSQL
SSLRequest협상 및 TLS 1.2/1.3 핸드셰이크로 인증서를 검증하고 대칭키를 교환하는 데 왕복 1~2회(1~2 RTT)를 더 씁니다. - PostgreSQL 시작 패킷 전송과 SCRAM-SHA-256 인증(Challenge-Response)으로 왕복 2회(2 RTT)를 씁니다.
- 비로소 첫 번째
SELECT쿼리를 보내고 답을 받습니다(1 RTT).
즉 첫 쿼리 하나를 실행하기 위해 최소 **5~6번의 네트워크 왕복(RTT)**이 필요합니다. 함수(iad1)와 DB(icn1) 사이의 1 RTT가 180ms라면, 아무리 쿼리가 1ms 만에 끝나는 인덱스 조회라도 연결을 맺는 데만 1초(1,000ms)가 날아갑니다. 반면 함수를 icn1에 두어 1 RTT를 2~3ms로 줄이고, Supavisor가 뒤쪽 PostgreSQL과의 무거운 연결을 미리 맺어 둔 채 대기하면 이 연결 수립 비용이 수십 밀리초 이내로 압축됩니다.
여기에 보안이 하나 더 얹힙니다. 원격 데이터베이스에 접속할 때는 중간자 공격(MITM)을 막기 위해 반드시 **TLS 암호화와 서버 인증서 검증(rejectUnauthorized: true 및 공급자 루트 CA 인증서 지정)**을 강제해야 합니다. 연결이 잘 안 된다고 코드에 ssl: { rejectUnauthorized: false }를 넣는 것은 공격자가 가짜 데이터베이스 서버로 행세해도 암호화 통신을 맺겠다는 뜻이므로 프로덕션에서 절대 금물입니다.
또한 자체 세션 관리나 서버 사이드 인증을 쓰면서 PostgreSQL의 RLS나 트리거에 현재 요청자의 신원을 넘겨야 할 때가 있습니다. 앞서 본 것처럼 Supavisor 트랜잭션 모드(6543)에서는 일반 SET app.current_user_id = 'u_123'을 실행하면 그 설정이 커넥션 풀의 연결에 그대로 남아 **다음 요청을 보낸 전혀 다른 사용자에게 내 권한이 넘어가는 대형 보안 사고(세션 오염)**가 일어납니다. 따라서 트랜잭션 풀러 뒤에서는 반드시 하나의 BEGIN ... COMMIT 블록 안에서 **SET LOCAL(또는 set_config('...', '...', true))**을 사용해 트랜잭션이 끝나는 순간 설정값이 자동으로 소멸되게 만들어야 합니다.
멀티테넌트 AI 서비스의 RLS 성능 함정과 우회 패턴 점검
RLS는 강력한 보안 장치이지만, 정책 조건식을 잘못 작성하면 테이블 행이 10만 건만 넘어가도 데이터베이스 CPU가 100%로 치솟으며 전체 서비스가 느려집니다. 왜냐하면 PostgreSQL은 RLS 조건식을 **모든 조회 쿼리의 WHERE 절에 강제로 덧붙이는 보안 장벽(Security Barrier)**으로 실행하기 때문입니다.
가장 흔한 두 가지 성능 함정과 해법을 비교해 봅시다.
| 패턴 | 느린 RLS 작성 예 | 빠른 RLS 최적화 해법 | 원리 차이 |
|---|---|---|---|
| 함수 반복 호출 | USING ( auth.uid() = user_id ) |
USING ( (SELECT auth.uid()) = user_id ) |
스칼라 서브쿼리로 감싸면 행마다 함수를 재호출하지 않고 쿼리당 딱 1회 평가(InitPlan) 후 인덱스 스캔 수행 |
| 다대다 소속 조인 | USING ( team_id IN (SELECT team_id FROM members WHERE members.user_id = auth.uid()) )에 인덱스 없음 |
members(user_id, team_id) 복합 인덱스 생성 + SECURITY DEFINER 헬퍼 함수 또는 JWT 클레임 활용 |
조인 대상 컬럼에 인덱스가 없으면 매번 전체 테이블 스캔(Seq Scan)이 발생함 |
여기서 SECURITY DEFINER 함수를 쓸 때는 각별히 조심해야 합니다. PostgreSQL 함수의 기본값은 호출자의 권한과 RLS를 그대로 따르는 SECURITY INVOKER입니다. 반면 SECURITY DEFINER는 함수를 만든 사람(보통 관리자)의 권한으로 실행되어 RLS를 우회합니다. 복잡한 소속 확인을 빠르게 처리하려고 SECURITY DEFINER 함수를 만들었는데, 그 함수가 임의의 인자를 받아 테이블 데이터를 그대로 반환하고 public/anon 역할에 실행 권한(EXECUTE)이 열려 있다면 공격자는 RPC 호출 하나로 RLS를 완전히 우회해 전체 데이터를 뽑아낼 수 있습니다. 따라서 SECURITY DEFINER 함수를 쓸 때는 SET search_path = ''(스키마 하이재킹 방지)를 명시하고, 반환값을 엄격한 불리언(boolean)이나 최소한의 식별자로 제한하며, 불필요한 역할의 EXECUTE 권한을 REVOKE해야 합니다.
실무 장면
한빛에듀: 출시 직전 발견한 환경 변수 유출과 Supavisor 연결 고갈
교육 기술 스타트업 '한빛에듀'는 수강생의 논술 답안을 첨삭해 주는 웹 서비스를 Next.js(Vercel)와 Supabase(서울 리전)로 개발했다. 출시 사흘 전, 모의 논술 대회를 열어 500명의 학생이 동시에 답안을 제출하고 실시간 스트리밍 첨삭을 받는 부하 테스트를 진행했다.
부하 테스트 시작 3분 만에 두 가지 치명적인 문제가 발견되었다. 첫째, 보안 점검을 맡은 엔지니어가 브라우저에서 페이지 소스와 _next/static/chunks/ 아래의 자바스크립트 번들을 검색하다가 NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY라는 문자열과 함께 실제 service_role JWT가 통째로 들어 있는 것을 발견했다. 주니어 개발자가 학생 점수 집계 화면에서 RLS 권한 오류를 겪자, 클라이언트 코드에서 바로 집계를 돌리려고 service_role 키에 NEXT_PUBLIC_을 붙여 넣은 것이었다. 둘째, 학생 150명이 동시에 첨삭 버튼을 누르자 응답이 10초씩 밀리더니 절반 이상의 요청이 500 오류(too many clients already, FUNCTION_INVOCATION_TIMEOUT)로 실패했다.
인프라 리드는 즉시 아키텍처를 뜯어고쳤다.
- 키 회수 및 경계 재설정: 이미 클라이언트 번들에 한 번이라도 노출된
service_role키는 유출된 것으로 간주하고 즉시 Supabase에서 비밀 키를 재발급(회전)했다. 클라이언트에는 오직NEXT_PUBLIC_SUPABASE_URL과NEXT_PUBLIC_SUPABASE_ANON_KEY만 남기고, 점수 집계는 서버 전용 Route Handler로 옮기되service_role대신 적절한 RLS 정책과 검증된 서버 쿼리로 처리하게 고쳤다. - 함수 리전과 실행 시간 조정: 확인해 보니 Vercel 함수 리전이 기본값인 미국 동부(
iad1)로 되어 있어 서울 Supabase DB와 매번 태평양을 건너 통신하고 있었다.vercel.json에"regions": ["icn1"]을 지정해 함수를 서울로 옮기고, 첨삭 스트리밍 라우트에export const maxDuration = 60;과 Fluid Compute를 활성화했다. - Supavisor 트랜잭션 모드 전환:
DATABASE_URL이 포트5432(세션 모드)를 가리키고 있어 동시 실행된 함수들이 연결을 금방 고갈시키고 있었다. 런타임 연결 문자열을 포트6543(Supavisor 트랜잭션 모드)으로 바꾸고 드라이버의 Prepared Statement 옵션을 껐다. 스키마 마이그레이션 스크립트만 별도의DIRECT_URL(포트 5432)을 쓰도록 분리했다.
이튿날 다시 진행한 500명 동시 부하 테스트에서는 데이터베이스 활성 연결 수가 15개 안팎으로 안정적으로 유지되었고, 첫 첨삭 문장이 화면에 찍히는 시간(TTFB)이 평균 4.2초에서 0.8초로 줄어들었다.
청류바이오: 비공개 연구 노트 유출을 막은 RLS WITH CHECK와 서명된 업로드 URL
신약 후보 물질 문헌을 AI로 분석하는 '청류바이오'는 연구소마다 독립된 워크스페이스를 제공하는 B2B SaaS를 운영한다. 각 연구원은 실험 결과 PDF(최대 30MB)를 업로드하고 요약 노트를 작성한다. 초기 버전에서는 두 가지 민원이 접수되었다.
첫째, 10MB가 넘는 PDF를 업로드할 때마다 Vercel API 라우트에서 413 Payload Too Large 오류가 났다. 클라이언트가 파일을 Vercel 서버리스 함수로 먼저 보낸 뒤 함수가 다시 Supabase Storage로 올리는 구조였는데, Vercel 함수의 요청 본문 크기 제한에 걸린 것이다. 둘째, 내부 모의 해킹에서 A 연구소 계정으로 로그인한 뒤 브라우저 콘솔에서 supabase.from('notes').update({ lab_id: 'lab_B' }).eq('id', myNoteId)를 호출하자, 내 노트가 B 연구소 목록으로 그대로 넘어가는 현상이 발견되었다.
플랫폼 팀은 원인을 분석해 다음과 같이 수정했다.
| 문제 영역 | 기존 설계의 허점 | 개선된 설계 |
|---|---|---|
notes 테이블 RLS |
FOR UPDATE USING (lab_id = current_user_lab())만 있고 WITH CHECK에 대한 별도 불변성 통제가 없었거나, INSERT 정책이 WITH CHECK (true)로 열려 있어 타 연구소 lab_id 주입 가능 |
INSERT와 UPDATE 모두 WITH CHECK (lab_id = current_user_lab())를 명시하고, 트리거로 생성 후 lab_id 변경 자체를 차단 |
| 대용량 PDF 업로드 | 브라우저 → Vercel 함수(4.5MB 한도) → Supabase Storage 중계 | 서버에서 권한 검증 후 createSignedUploadUrl 발급 → 브라우저가 Supabase Storage 비공개 버킷으로 직접 업로드 |
| PDF 열람 | 편의를 위해 Storage 버킷을 Public으로 열어 두고 URL만 숨김 |
버킷을 Private으로 전환하고, 열람 시 서버가 소속 연구소를 확인한 뒤 60초 만료 서명된 URL(createSignedUrl) 발급 |
처음에는 누군가 "클라이언트에서 바로 Storage에 올리게 버킷을 Public으로 풀자"고 제안했지만, 연구 데이터 유출 위험 때문에 즉시 기각했다. 비공개 버킷 + 서버가 발급하는 일회용 서명 업로드 URL 조합 덕분에 서버리스 함수의 본문 크기 제한을 우회하면서도 "권한 있는 연구원만 자기 연구소 경로에 파일을 올리고 읽는다"는 보안 경계를 완벽히 지킬 수 있었다.
자주 틀리는 지점
- "
NEXT_PUBLIC_환경 변수는 Vercel 대시보드에서 암호화 저장되므로 브라우저에서도 안전하다." 대시보드에서 암호화되는 것은 빌드 전 보관 상태일 뿐입니다.NEXT_PUBLIC_이 붙은 변수는 빌드되는 순간 자바스크립트 파일 안에 평문 문자열로 박혀 전 세계 사용자의 브라우저로 내려갑니다. 비밀값에는 절대 쓰면 안 됩니다. - "
NEXT_PUBLIC_변수 값을 대시보드에서 바꿨으니 즉시 사이트에 반영된다." 서버 전용 변수와 달리 클라이언트 번들에 인라이닝된 변수는 빌드 시점에 고정됩니다. 값을 바꾼 뒤에는 반드시 새 빌드를 배포(Redeploy)해야 반영됩니다. - "서버리스 함수는 CDN처럼 전 세계 모든 리전에 동시에 배포할수록 항상 빠르다." 함수가 단일 리전 데이터베이스(예: 서울)에 여러 번 질의하는 구조라면, 함수가 DB와 멀리 떨어진 리전에서 실행될 때 함수↔DB 간 다중 왕복(RTT) 지연이 폭증합니다. 함수는 주 데이터베이스와 가까운 리전(
icn1)에 붙이는 것이 원칙입니다. - "포트 5432가 PostgreSQL 표준 포트이니 서버리스 앱에서도 5432를 쓰면 된다." 서버리스 함수처럼 짧은 수명의 인스턴스가 급격히 늘었다 줄어드는 환경에서 세션 모드나 직접 연결(5432)을 쓰면 연결 슬롯이 금방 고갈됩니다. 런타임 트래픽은 Supavisor 트랜잭션 모드(6543)를 쓰고, 마이그레이션만 5432로 분리해야 합니다.
- "테이블에
SELECTRLS 정책만 잘 걸어 두면 다른 사용자가 데이터를 수정하거나 지울 수 없다." RLS를 켠 테이블은 명령별로 정책이 평가됩니다.SELECT정책은 조회만 제어할 뿐이며, 만약INSERT나UPDATE정책을 만들면서WITH CHECK (true)처럼 허술하게 열어 두면 타인의 소유자 ID로 데이터를 밀어 넣거나 덮어쓸 수 있습니다. - "파일 이름을 추측하기 어려운 긴 UUID로 지으면 Public 버킷에 비공개 문서를 넣어도 안전하다." 주소 추측이 어렵다는 것(Security by Obscurity)은 접근 통제가 아닙니다. 공개 버킷의 URL은 인증 없이 열리므로 로그, 브라우저 히스토리, 공유 링크로 한 번만 새어 나가도 회수할 방법이 없습니다. 민감 파일은 반드시 비공개 버킷과 짧은 만료의 서명된 URL로 다루어야 합니다.
한눈에 정리
| 핵심 주제 | 반드시 기억할 설계 원칙 |
|---|---|
| 환경 분리 | Preview와 Production은 DB, 스토리지, 외부 API 키를 철저히 분리하고 운영 비밀은 Production에만 주입한다 |
NEXT_PUBLIC_ 경계 |
빌드 시 클라이언트 JS 번들에 평문으로 인라이닝된다. 비밀키·service_role 키에는 절대 금지하며 변경 시 재배포가 필요하다 |
| 배포 보호와 우회 | Preview 배포는 Vercel Authentication 등으로 잠그고, CI E2E·웹훅은 x-vercel-protection-bypass 헤더로 통과시킨다 |
| 함수 리전과 실행 | 함수는 주 DB와 같은 리전(예: 서울 icn1)에 배치하고, 긴 LLM 호출에는 maxDuration·스트리밍·Fluid Compute를 구성한다 |
| Supavisor 6543 vs 5432 | 서버리스 런타임은 트랜잭션 모드(6543, Prepared Statement 주의), 스키마 마이그레이션·세션 잠금은 세션/직접 연결(5432)을 쓴다 |
RLS USING vs WITH CHECK |
USING은 읽거나 수정·삭제할 기존 행을 거르고, WITH CHECK는 INSERT/UPDATE로 들어갈 새 행 상태를 검증한다 |
anon vs service_role |
anon 키는 공개 전제이며 RLS가 보안 경계다. service_role은 RLS를 우회(BYPASSRLS)하므로 서버 최소 경로에만 격리한다 |
| Auth 리다이렉트 허용 목록 | 오픈 리다이렉트·토큰 탈취를 막기 위해 웹 오리진과 앱 딥링크(myapp://...)를 정확한 허용 목록으로 관리하고 PKCE를 쓴다 |
| Storage 접근 제어 | 민감 파일은 비공개(Private) 버킷에 두고, 열람·대용량 직접 업로드에는 만료 시간이 짧은 서명된 URL(Signed URL)을 발급한다 |
공식 문서로 확인하기
- Vercel — Deployments & Environments: Production, Preview, Development 환경의 차이와 환경별 변수 분리 원칙.
- Vercel — Environment Variables: 서버 전용 변수와 프레임워크 공개 접두사(
NEXT_PUBLIC_)의 빌드 인라이닝 동작. - Vercel — Deployment Protection & Protection Bypass for Automation:
x-vercel-protection-bypass헤더와 쿠키 설정 방법. - Vercel — Configuring Functions & Fluid Compute: 함수 리전(
icn1),maxDuration, 스트리밍과 Fluid Compute 동시성 모델. - Supabase — Connecting to Postgres & Supavisor: 트랜잭션 모드(6543)와 세션 모드(5432), 서버리스 ORM 설정.
- Supabase — Row Level Security (RLS):
USING과WITH CHECK문법,(SELECT auth.uid())성능 최적화. - Supabase — Auth Redirect URLs 및 Storage Access Control: 리다이렉트 와일드카드 규칙과 비공개 버킷 서명 URL.
셀프 체크
1-1. Next.js 애플리케이션을 Vercel에 배포할 때 NEXT_PUBLIC_ 접두사가 붙은 환경 변수와 붙지 않은 환경 변수가 빌드 및 런타임에 어떻게 다르게 처리되는지 설명하고, 대시보드에서 NEXT_PUBLIC_API_BASE 값을 수정했을 때 브라우저에 반영하기 위해 반드시 해야 하는 작업을 쓰라.
1-2. 주 데이터베이스(Supabase Postgres)가 서울 리전에 있고 주요 사용자도 한국에 있는 웹 서비스에서, Vercel 서버리스 함수 리전이 기본값인 미국 동부(iad1)로 설정되어 있을 때 한 번의 HTTP 요청 안에서 지연 시간이 급격히 늘어나는 이유를 네트워크 왕복(RTT) 관점에서 설명하라.
1-3. Supavisor의 트랜잭션 모드(포트 6543)와 세션 모드(포트 5432)가 백엔드 데이터베이스 연결을 빌려 쓰고 반납하는 시점을 비교하고, 서버리스 앱 런타임 쿼리와 데이터베이스 스키마 마이그레이션에 각각 어느 모드를 써야 하는지 이유와 함께 쓰라.
1-4. PostgreSQL Row Level Security(RLS)에서 USING 절과 WITH CHECK 절이 각각 언제 평가되는지 구분하고, UPDATE 정책에 USING만 올바르게 적혀 있고 WITH CHECK 조건이 느슨할 때 발생할 수 있는 보안 문제를 예를 들어 설명하라.
1-5. Preview 배포에 Vercel Deployment Protection이 켜져 있을 때, GitHub Actions에서 실행되는 자동화 E2E 테스트 스크립트가 배포 보호를 끄지 않고도 Preview URL의 여러 페이지를 연속해서 테스트할 수 있게 하는 HTTP 요청 헤더 두 가지를 쓰라.
1-6. 설계 과제: 사용자가 업로드한 개인 계약서 PDF(최대 25MB)를 안전하게 저장하고 본인만 열람할 수 있게 하려 한다. Vercel 함수의 요청 크기 제한을 피하면서 보안을 지키기 위해 (1) Supabase Storage 버킷의 공개 여부 설정, (2) 업로드 흐름, (3) 열람 흐름을 각각 어떻게 설계해야 하는지 서술하라.
실습 과제
실습
환경 변수·연결 문자열 보안 감사표 작성하기 (40분)
- 본인이 운영 중이거나 가상으로 설계한 AI 웹 서비스의 환경 변수 10개를 적는다(예:
NEXT_PUBLIC_SITE_URL,NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,DATABASE_URL,DIRECT_URL,OPENAI_API_KEY,STRIPE_WEBHOOK_SECRET,VERCEL_AUTOMATION_BYPASS_SECRET,SESSION_SECRET). - 각 변수에 대해 표로 다음 다섯 칸을 채운다: (가) 클라이언트 번들 포함 여부, (나) Preview/Production 값이 달라야 하는지 여부, (다) 노출 시 피해 시나리오, (라) 올바른 호출 위치(브라우저 / 서버 Route Handler / 마이그레이션 CLI / CI 러너), (마) 값 변경 시 재빌드 필요 여부.
DATABASE_URL과DIRECT_URL에 들어갈 포트 번호(6543 vs 5432)와 쿼리 파라미터(?pgbouncer=true등)를 명시한다.
완료 기준: 10개 변수 중 클라이언트 번들에 들어가도 안전한 변수 3개와 절대 들어가면 안 되는 서버 전용 변수 7개가 명확히 구분되어 있고, 런타임 풀러 연결과 마이그레이션 연결의 포트·용도가 분리되어 있다.
실습
멀티테넌트 테이블의 RLS 정책과 Storage 서명 URL 흐름 설계하기 (50분)
projects (id, org_id, owner_id, title, status)테이블을 정의하는 SQL을 작성하고 RLS를 활성화(ENABLE ROW LEVEL SECURITY)한다.authenticated역할을 대상으로SELECT,INSERT,UPDATE,DELETE네 가지 정책을 각각 작성한다. 이때auth.uid()는 스칼라 서브쿼리(SELECT auth.uid())형태로 쓰고,UPDATE정책에는USING과WITH CHECK를 모두 명시해 소유자(owner_id)나 소속 조직(org_id)을 임의로 다른 값으로 바꿀 수 없게 막는다.- 비공개 Storage 버킷
project-files에 파일을 올리고 읽을 때, 서버 API가 무엇을 검사한 뒤 어떤 메서드(createSignedUploadUrl,createSignedUrl)로 몇 초짜리 URL을 발급할지 4단계 시퀀스를 적는다.
완료 기준: 네 개의 SQL 정책이 문법에 맞게 작성되어 있으며, INSERT와 UPDATE에서 타인의 owner_id/org_id 도용이 WITH CHECK로 차단되고, 비공개 버킷의 업로드·다운로드가 모두 시간 제한 서명 URL로 통제된다.