Supabase 연결 객체NEXT_PUBLIC_ 환경 변수테크 딥다이브

쇼핑몰 팀 프로젝트 ④ 프론트와 백엔드 연결 — 연결 객체, 환경 변수, 쿠키, 권한 정책 실측

· 2026-10-27 발행· 31분 읽기

목차

10월 20일 오후 네 시 오십오 분, .env.local 의 키 이름을 고쳤어요. 상세 주소에서 데이터가 안 나오는 원인을 네 겹째 걷어 내던 중이었는데, 이름 하나를 바꾸고 서버를 다시 켜니 오류가 또 바뀌었어요. 다섯 겹을 다 걷어 낸 뒤에는 오류가 없었고요. 대신 네 표 모두 0줄이었습니다. 시드가 없는 건지 읽기 정책이 없는 건지, 그때는 구분할 수 없었어요.

이 글은 그 0줄의 정체를 포함해, 프론트와 백엔드를 잇는 부품 네 개를 다루는 글이에요. 1편 에서 화면과 폴더 구조를 세웠고, ②-1 과 ②-2 에서 ERD(Entity-Relationship Diagram, 표와 표 사이 관계를 그린 그림) 여덟 표를 설계했고, ③-1 에서 표가 타입과 컴포넌트와 주소가 되는 읽기 경로를, ③-2 에서 문의 폼이 표에 닿는 쓰기 경로를 봤어요. 두 글이 이 글로 미룬 연결 객체, 환경 변수, 권한 정책의 팀 DB 실측을 다루고, 시리즈는 여기서 끝나요. ③-2 가 미룬 팀 DB 등록은 10월 23일 시험 문의 두 줄로 끝났고(③-2 의 표 캡처), 그 줄이 어떤 토큰과 정책을 지났는지는 여기서 봅니다. 개념은 수업에서, 증거는 제 상세 페이지와 팀 DB 에서. 실행해 보지 않은 코드는 싣지 않고, 틀렸던 순서를 지우지 않는다. 다 읽고 나면 Next.js 에 Supabase 를 붙일 때 “이 값은 어느 파일에, 이 코드는 어느 객체로, 이 요청은 어떤 쿠키를 들고, 이 표는 어떤 정책으로” 를 한 장에 적을 수 있을 거예요. 배포는 프로젝트 기간 안에 하지 않았고, 이 글에서도 다루지 않습니다.

이 글의 지도. 위에 입력, 가운데에 환경 변수·연결 객체·세션과 proxy·읽기 정책 네 부품과 조립·적용·교정·함정 띠, 아래에 10-16 부터 10-23 까지의 내 작업 타임라인과 이 세션의 실측 목록이 있다 이 글의 지도. 개념은 가운데 네 부품에서, 증거는 아래 타임라인과 10-26 실측에서 가져온다

프론트와 백엔드를 “연결한다” 는 건 무엇을 잇는 걸까?

연결은 세 질문에 답하는 일이다. 어디에 (주소), 누가 (키와 로그인 토큰), 무엇을 (정책). 셋 중 하나가 비면 연결은 성립하지 않고, 비는 자리마다 실패하는 모양이 다르다.

수업에서 데이터베이스 접근을 연결 정보, 인증, 권한 셋으로 나눠 배웠어요. 건물로 치면 주소, 출입증, 출입증에 찍힌 층이에요. 상세 페이지를 팀 DB 에 붙이는 사흘 동안 만난 실패를 늘어놓고 보니 전부 이 셋 중 하나가 빈 상태였습니다.

주소와 키는 .env.local 에 적어요. 그 값으로 연결 객체를 만드는데, Next.js 에서는 그 객체가 하나가 아니에요. 브라우저용, 서버용, 정책을 건너뛰는 관리자용. “누가” 의 나머지 반은 로그인이에요. 토큰이 쿠키에 들어가고, 요청마다 따라가고, 서버 쪽 연결 객체가 DB 에 싣고, DB 는 거기서 사용자 id 를 꺼내요. “무엇을” 은 정책이 정해요. 공개 키가 노출돼도 괜찮은 이유가 이 정책인데, 정책이 없는 표는 공개 키에게 “없음” 으로 답합니다. 오류가 아니라요.

연결의 세 질문과 다섯 단계. 위에 어디에·누가·무엇을 세 알약, 가운데에 키 이름·객체·쿠키·정책·빌드 다섯 상자가 화살표로 이어지고, 아래에 단계마다 틀렸을 때 보이는 것과 내가 본 날짜가 점선 상자로 있다 연결의 절차. 단계마다 실패하는 모양이 달라서, 보이는 모양으로 어느 단계인지 역추적할 수 있다

이 글의 순서는 요청이 지나가는 순서예요. 키 이름, 객체, 쿠키, 정책, 빌드. 10월 16일 회고에는 읽기 권한을 연결보다 먼저 하라고 써 뒀는데, 실제로는 연결을 먼저 하고 정책을 나중에 열었고 그 사이에 0줄을 만났어요. 그 순서대로 씁니다.

키는 어디에 적고 어디까지 나갈까 — .env.local 과 NEXT_PUBLIC_?

환경 변수가 브라우저까지 나갈지는 파일이 아니라 이름이 정한다. NEXT_PUBLIC_ 이 붙은 값은 빌드된 브라우저 코드에 들어가고, 안 붙은 값은 서버에만 남는다. 접두어를 붙이는 것은 공개 선언이다.

집으로 치면 .env.local 은 현관 옆 서랍이에요. 집 안(서버)에서는 모든 열쇠를 쓰지만 손님(브라우저)에게 들려 보내는 건 공개 표시가 붙은 것뿐이고, 비밀 키에 그 표시를 붙이는 순간 손님 주머니에 들어갑니다.

제 프로젝트의 .env.local 에는 키가 다섯 개 있어요. 값은 적지 않고 이름만 봅니다.

$ sed 's/=.*//' .env.local
NEXT_PUBLIC_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY

NEXT_PUBLIC_RECAPTCHA_SITE_KEY

# 서버 전용 (NEXT_PUBLIC_ 붙이지 말 것)
RECAPTCHA_SECRET_KEY

# 로그인 세션 서명용 (팀원도 각자 설정 필요)
SESSION_SECRET
키 이름 접두어 어디까지 가나 정체
NEXT_PUBLIC_SUPABASE_URL 있음 서버 + 브라우저 프로젝트 주소. 비밀 아님
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY 있음 서버 + 브라우저 공개 키(예전 이름 anon key). 정책이 지키는 전제로 노출 허용
NEXT_PUBLIC_RECAPTCHA_SITE_KEY 있음 서버 + 브라우저 봇 검사 위젯의 공개 키 (팀원 영역)
RECAPTCHA_SECRET_KEY 없음 서버만 봇 검사 서버 비밀 키
SESSION_SECRET 없음 서버만 팀의 옛 로그인 쿠키 서명용. 10-22 뒤로는 로그인 경로에서 빠짐

첫 두 줄이 Supabase 연결의 “어디에” 와 “누가” 의 절반이에요. .env.local 은 수업에서 배운 우선순위로 .env 보다 높은 로컬 실사용 파일이고, .gitignore 의 .env* 에 걸려 저장소에 올라가지 않아요. 팀원마다 자기 파일을 갖고, 저장소에는 이름만 적은 템플릿을, 배포 환경에는 플랫폼 화면에 같은 이름을 넣는 게 수업의 처방이었어요.

환경 변수의 길. 왼쪽 .env.local 의 키 이름 다섯 개에서 next dev / build 가 한 번 읽고, 서버로는 전부, 브라우저 번들로는 NEXT_PUBLIC_ 만 가는 두 화살표, 아래에 이름이 다를 때의 결과와 절대 접두어를 붙이면 안 되는 이름 접두어가 갈림길이다. 읽는 자리는 하나(시작할 때)인데 나가는 길이 둘이라, 이름을 틀리면 오류도 엉뚱한 자리에서 난다

Supabase API 키 화면. Publishable key 와 Secret keys 두 구역, 각각 default 한 줄이 있고 키 값은 가려져 있다. Publishable key 설명에 RLS 와 정책이 있으면 브라우저에서 써도 안전하다는 문구 팀 프로젝트의 API 키 화면 (2026-10-26, 값은 가림). 위 칸이 NEXT_PUBLIC_ 으로 나가는 공개 키, 아래 칸이 서버 밖으로 나가면 안 되는 비밀 키다

접두어가 붙은 값이 빌드 때 브라우저 코드에 문자열로 박히는지는 수업도 위키도 “확인 전” 이고, 배포를 안 한 저도 확인하지 못했어요. 제가 확인한 건 이름이 틀렸을 때예요. 10월 20일에 SUPABASE_URL 로 적은 파일을 팀 공용 연결 파일은 NEXT_PUBLIC_SUPABASE_URL 로 읽었고, 값은 빈 문자열이었고, 오류는 그 빈 문자열로 연결 객체를 만드는 자리에서 났어요. 환경 변수의 실패는 변수를 읽는 줄이 아니라 그 값을 처음 쓰는 줄에서 터집니다. 그래서 오류 메시지에는 변수 이름이 안 나와요. 재현 캡처는 함정 절에 뒀습니다.

연결 객체는 왜 하나가 아니라 셋일까 — 브라우저, 서버, 관리자?

연결 객체는 같은 주소로 만들되, 어디서 실행되느냐에 따라 열쇠와 쿠키 통로가 다르다. 브라우저용은 브라우저가 쿠키를 알아서 보내고, 서버용은 요청의 쿠키 통을 넘겨받아야 하며, 관리자용은 정책을 건너뛰는 열쇠를 쓰므로 서버 밖으로 나가면 안 된다.

매장으로 치면 손님용 출입문, 직원용 뒷문, 금고 열쇠예요. 같은 매장인데 문이 다르고, 금고 열쇠는 매장 밖으로 나가지 않아요.

10월 19일 설계 때는 읽기도 쓰기도 서버에서 일어나니 서버용 하나만 만들었어요. 그런데 10월 20일에 팀이 올린 공용 파일에는 브라우저용과 서버용이 함께 있었고, 며칠 뒤 관리자용 파일도 생겼어요. 지금 저장소의 셋은 이렇게 다릅니다.

브라우저용 서버용 관리자용
만드는 함수 getBrowserClient() createServerClient() createAdminClient()
실행 위치 'use client' 컴포넌트 서버 컴포넌트 · 서버 액션 서버만 ('server-only')
열쇠 공개 키 (NEXT_PUBLIC_) 같은 공개 키 SUPABASE_SERVICE_ROLE_KEY (접두어 없음)
쿠키 브라우저가 알아서 cookies() 로 읽고 쓰는 통로를 넘김 없음 (세션 유지 끔)
생성 시점 처음 부를 때 한 번, 재사용 요청마다 새로 부를 때마다, 키 없으면 null
정책 받는다 받는다 건너뛴다
내 경로에서 안 씀 상세 조회 · 문의 등록 · 로그인 · 담기 액션 안 씀

연결 객체 셋. 왼쪽 브라우저 영역에 브라우저용, 점선 경계 오른쪽 서버 영역에 서버용(라임)과 관리자용, 아래에 proxy.ts 상자. 경계를 건너는 것은 쿠키뿐이라는 설명 경계는 브라우저와 서버 사이에 있고, 그 경계를 건너는 것은 쿠키뿐이다. 서버용과 관리자용의 차이는 쿠키가 아니라 열쇠다

서버용이 “요청마다 새로” 인 이유가 핵심이에요. 만들 때 그 요청의 쿠키 통을 넘겨받고, 쿠키 안의 로그인 토큰이 DB 요청의 인증 헤더에 실려요. A 의 요청으로 만든 객체를 B 의 요청에 재사용하면 안 되니 함수로 두고 부를 때마다 만드는 거예요. 브라우저용은 자기 쿠키를 알아서 보내니 한 번 만들어 재사용해요. 이 생성 규칙에는 팀 저장소에 vitest(테스트 실행기) 단위 테스트가 있어서 이 세션에서 돌렸습니다.

vitest 출력. 연결 파일 테스트 3건(불러오기만으로는 안 만든다, 처음 부를 때 한 번, 옛 이름도 같은 객체), proxy 테스트 3건(갱신 쿠키 전파, 설정 없음 통과, 인증 장애 통과), 이미지 주소 테스트 6건. 3 파일 12 통과 610ms. 중간에 환경 변수가 없다는 경고 두 줄 연결 층의 테스트 12건. 표의 “생성 시점” 열이 위 세 건이고, 경고 두 줄은 테스트 환경에 환경 변수가 없어서 찍힌 것이다 — 실패가 아니다

캡처 중간의 “설정되지 않았습니다” 경고는 팀 파일이 환경 변수가 비어 있을 때 멈추지 않고 경고만 남기기 때문이에요. 10월 19일 제 설계본은 바로 예외를 던졌는데, 팀 방식이 수업의 방식이에요.

서버용 객체가 서버에서만 돈다는 건 브라우저에서 확인할 수 있어요. 상세 페이지를 새로고침하고 개발자 도구의 Network 탭을 보면 요청 42개가 전부 문서·스크립트·폰트·이미지고, Supabase 로 가는 rest/v1 요청은 한 건도 없어요. 조회는 서버 컴포넌트 안에서 끝났고 브라우저는 완성된 HTML 만 받은 거예요.

개발자 도구 Network 탭. 상세 페이지 새로고침 직후 요청 42개 목록 — document 하나, 폰트, 스크립트, webp 이미지뿐이고 Supabase 주소로 가는 요청이 없다. 아래에 42 requests, 3.0 MB transferred 상세 페이지 로드 직후의 브라우저 요청 (2026-10-26). 서버용 객체의 조회는 여기 안 보인다 — 브라우저가 아니라 서버가 DB 에 물었기 때문이다

로그인은 어떻게 DB 까지 따라올까 — 쿠키, proxy, auth.uid()?

로그인하면 토큰이 쿠키에 들어가고, 쿠키는 요청마다 자동으로 따라가며, 서버용 연결 객체가 그 토큰을 DB 요청에 싣는다. DB 안의 auth.uid() 는 저장된 값이 아니라 이번 요청의 토큰에서 사용자 id 를 꺼내는 함수다.

찜질방으로 치면 사물함은 카운터 쪽에 있고 손님은 열쇠만 들고 다녀요. 다만 Supabase 가 쿠키에 넣는 것은 번호표가 아니라 서명된 토큰(JWT, 헤더·내용·서명 세 토막 문자열)이고, 짧은 접근 토큰과 긴 갱신 토큰 한 쌍이에요. 쿠키 값이 암호화가 아니라 인코딩이라는 위키의 보강은 “미검증” 이고, 이것과 HttpOnly(자바스크립트가 못 읽게 하는 쿠키 속성) 여부는 개발자 도구의 쿠키 탭에서 판정돼요. 그 캡처는 올리기 전에 직접 찍어 넣을 자리로 남겨 둡니다.

브라우저 개발자 도구 Application 탭의 Cookies. localhost:3000 에 sb- 로 시작하는 auth-token 쿠키 한 줄이 있고 값은 가려져 있다. SameSite 열은 Lax 로그인 뒤 개발자 도구에서 본 쿠키 (2026-10-26, 이름의 프로젝트 식별자와 값은 가림). Supabase 가 심은 sb-…-auth-token 하나가 요청마다 따라간다

이해하는 데 오래 걸린 자리가 셋이에요.

첫째, 서버 컴포넌트는 쿠키를 읽을 수는 있는데 쓸 수는 없어요. 그래서 만료된 토큰을 갱신해 새 쿠키를 내려보내는 일은 요청이 페이지에 닿기 전에 도는 proxy.ts 가 해요. 수업에서는 middleware.ts 로 배웠는데 Next.js 16 은 빌드 때 그 이름이 구식이라 경고하고 proxy 를 쓰라고 해요. 요청의 쿠키로 서버용 객체를 만들고 사용자를 한 번 물은 뒤, 갱신된 쿠키를 요청 쪽(뒤따르는 서버 컴포넌트가 읽게)과 응답 쪽(브라우저가 저장하게) 양쪽에 써요. 수업 자료의 미들웨어 코드는 위키에 “실행 검증 없음, 신뢰도 낮음” 으로 남아 있고, 팀 proxy 는 테스트 3건이 위 캡처에서 통과했어요. 1시간 뒤 실제 갱신은 시리즈 내내 실측하지 못했습니다.

둘째, auth.uid() 가 값을 갖는 조건이에요. ③-2 에서 문의 등록이 user_id 를 보내지 않는데 작성자가 채워지는 이유를 봤죠. 서버용 객체가 쿠키의 토큰을 인증 헤더에 실어 보내면, Supabase 의 API 층이 서명을 검사하고 DB 역할을 authenticated 로 바꾸고 토큰 내용을 요청에 실어요. auth.uid() 는 거기서 id 를 꺼내요. 브라우저용 객체를 서버에서 썼다면 토큰이 없어 NULL 이고, 정책이 거절합니다.

로그인이 DB 까지 따라오는 길. 왼쪽 위에서 로그인 → 브라우저 쿠키 → 다음 요청, 가운데 줄에 proxy.ts(라임) → createServerClient → DB 의 auth.uid(), 오른쪽 위에 같은 토큰을 보는 세 자리(문의 등록·장바구니·현재 사용자), 아래에 10-22 전에 두 갈래였던 로그인 설명 토큰 하나를 세 자리가 본다. 문의 등록의 DEFAULT 와 WITH CHECK, 장바구니의 네 정책, 현재 사용자 조회. 10-22 전에는 이 길이 두 갈래였다

셋째, 이 길이 두 갈래였어요. 10월 19일 설계 때 팀의 /login 은 아이디와 파일에 적힌 회원 목록으로 로그인하고 직접 서명한 쿠키(SESSION_SECRET 으로)를 심었어요. DB 는 그 쿠키를 몰라요. 팀 로그인으로 담기나 문의를 하면 auth.uid() 가 비어 “로그인 필요” 가 나올 거라고 적어 두고 팀 결정 항목으로 남겼는데, 10월 22일 저녁 팀원이 PR #51 로 합쳤습니다.

$ git log --format='%h %ad %s' --date=format:'%m-%d %H:%M' e9c74cb^2 -4
7ba0bf3 10-22 20:21 feat: 메인 회원가입 토스트를 실제 로그인 상태에 따라 표시
35e0a11 10-22 20:21 feat: 로그인 화면을 이메일 로그인으로 바꾸고 로그인 후 외부 주소로 이동하지 않게 제한
bdf3134 10-22 20:21 fix: 로그인 확인·로그인·로그아웃을 Supabase 인증 기준으로 정리하고 실패 시 로그인 쿠키 정리
8e78c9a 10-22 20:21 feat: 요청마다 Supabase 로그인 세션을 갱신하는 proxy 추가
10-22 전 10-22 뒤 (PR #51)
/login 아이디 + 파일 회원 목록 + 자체 서명 쿠키 /login 이메일 + Supabase Auth, 세션은 Supabase 쿠키
로그인 확인은 자체 쿠키를 푼다 토큰으로 신분증(auth)을 묻고 users 표로 회원 카드를 한 번 더 묻는다 — 둘 다 있어야 로그인
토큰 갱신 없음 proxy.ts 가 요청마다 갱신
실패 시 쿠키 그대로 실패·탈퇴 시 sb- 로 시작하는 쿠키와 옛 session 쿠키를 지운다

PR 본문과 네 커밋의 변경 파일을 읽고 10월 23일에 설계 노트의 열린 질문 1번을 “해결” 로 바꿨어요. 수업에서 “인증은 직접 만들지 않는다” 의 이유를 넷 배웠는데, 저한테는 이쪽이 더 와닿았어요. 직접 만든 로그인은 그 자체로는 돌아가도, DB 가 모르는 신분증이라 DB 의 정책과 이어지지 않아요.

연결이 다 됐는데 왜 0줄일까 — RLS 와 읽기 정책?

RLS(Row Level Security, 행 단위 접근 제어) 를 켠 표는 기본이 거부다. 정책을 하나도 만들지 않으면 아무 행도 보이지 않고, 그것은 오류가 아니라 정상 응답이다. 공개 키의 안전은 키가 아니라 이 정책에서 온다.

우편함으로 치면 열쇠(공개 키)는 건물 현관을 열고, 정책은 어느 칸이 열리는지를 정해요. 칸에 열쇠 구멍이 없으면 현관에 들어가도 빈손이에요. 오류 없이요.

10월 20일에 다섯 겹을 걷어 내고 공개 키로 네 표를 읽었더니 전부 0줄이었어요. 대시보드의 Table Editor(표 보기 화면)에는 행이 보였고요. 이 차이가 답이었어요. 대시보드는 관리자 권한이라 정책을 건너뛰고, 앱은 공개 키라 정책이 없으면 0줄이에요. 10월 21일에 상품 네 표에 읽기 정책 catalog read 를 열었습니다. 비로그인 anon 과 로그인 authenticated 둘 다, 조건은 true. 공개 키로 브랜드 2줄, 상품 3줄, 이미지 6줄, 옵션 5줄이 보였어요.

Supabase 정책 화면. brands, product_images, product_options, products 네 표에 각각 catalog read 정책이 SELECT · anon, authenticated 로 걸려 있고, 그 사이 product_inquiries 는 API DISABLED 배지에 own inquiry insert 정책만 있다 팀 DB 의 정책 화면 (2026-10-26). 상품 네 표는 누구나 읽기, 문의 표는 직접 읽기 없음·본인 쓰기만 — 두 종류로 끝난다

팀 DB 에서는 “정책 전” 을 다시 만들 수 없어서, 이 세션에서 PGlite(WebAssembly 로 빌드돼 Node 에서 도는 진짜 PostgreSQL) 로 처음부터 다시 돌렸어요. 표 네 개를 만들고, RLS 를 켜고, 표 권한(GRANT)은 주되 정책은 안 만든 상태에서 공개 역할로 읽고, 정책을 연 뒤 다시 읽었습니다. 핵심 문장은 셋이에요.

ALTER TABLE products ENABLE ROW LEVEL SECURITY;              -- 켜면 기본은 거부
GRANT SELECT ON products TO anon, authenticated;              -- 표 권한은 있다 (Supabase 기본과 같게)
CREATE POLICY "catalog read" ON products
    FOR SELECT TO anon, authenticated USING (true);           -- 10-21 에 연 것과 같은 정책

PGlite 출력. 관리자로 2·3·6·5 줄, anon 으로 바꾸면 네 표 모두 0줄이고 id 로 찾아도 0줄 오류 없음, 정책 4개를 연 뒤 anon 으로 2·3·6·5 줄과 트렌치코트·브랜드명 조인 결과, 마지막에 GRANT 만 거두면 permission denied 오류 정책 전/후. 정책이 없을 때 0줄은 오류 없이 돌아오고, 표 권한(GRANT)이 없을 때는 오류로 돌아온다 — 둘은 다른 실패다. PGlite 0.5.11, PostgreSQL 18.6

상태 anon 으로 읽으면 10-20 · 10-21 의 팀 DB
RLS 켬 + 표 권한 있음 + 정책 없음 0줄, 오류 없음 10-20 저녁의 상태 (네 표 0줄)
RLS 켬 + 표 권한 있음 + SELECT 정책 true 2 · 3 · 6 · 5 줄 10-21 정책 연 뒤 (같은 숫자)
RLS 켬 + 표 권한 없음 permission denied 오류 만난 적 없음 — Supabase 는 기본으로 권한을 준다

10월 20일에 “시드가 없는 건지 정책이 없는 건지 구분할 수 없다” 고 적었는데, 표의 마지막 줄이 신호였어요. 권한이 없으면 오류, 정책이 없으면 빈 응답. 빈 응답이 왔으니 권한은 있는 거고, 시드 여부는 대시보드에서 보여요. 빈 응답은 “없다” 가 아니라 “당신에게는 없다” 일 수 있어요. 대시보드와 앱이 다른 걸 보여 줄 때 의심할 자리는 코드가 아니라 정책입니다.

강사는 관리자가 남의 글을 지워야 하는 서비스에서는 “본인 것만” 정책으로 덮을 수 없어 실무에서는 선택적으로 쓴다는 유보를 달았어요. 이 프로젝트는 상품은 누구나, 장바구니와 문의는 본인만, 두 종류로 충분했고, 직접 쓰기를 막고 소유자 권한의 뷰로만 드나드는 문의 표의 설계는 ③-2 에 있습니다.

네 부품은 어떤 순서로 맞물릴까?

요청 하나가 네 부품을 차례로 지난다. 환경 변수가 주소와 열쇠를 주고, 연결 객체가 그 열쇠에 쿠키를 더해 요청을 만들고, 쿠키의 토큰이 “누구” 를 정하고, 정책이 “무엇을” 을 연다. 앞 부품이 틀리면 뒤 부품은 돌지도 않는다.

상품 한 건을 읽는 요청을 따라가 볼게요. 서버 컴포넌트가 서버용 연결 객체를 만들어요. 그 순간 환경 변수 두 개가 주소와 열쇠로 들어가고, 요청의 쿠키 통이 넘어가요. 비로그인이면 토큰이 없고 역할은 anon, 상품 표의 읽기 정책이 anon 에게 열려 있으니 행이 돌아와요. 로그인한 사람이 문의를 등록하면 같은 종류의 객체인데 쿠키에 토큰이 있어요. 역할은 authenticated, auth.uid() 는 그 사람의 id, 문의 표의 쓰기 정책이 그 id 를 대조해요.

부품 요청에서 하는 일 틀렸을 때 어디서 고치나
① 환경 변수 주소와 열쇠를 준다 빈 문자열 → 객체를 만들 때 라이브러리 오류 .env.local 의 이름, 서버 재시작
② 연결 객체 열쇠에 쿠키 통로를 더한다 브라우저용을 서버에서 쓰면 토큰이 안 실림 → 정책 거절 어느 파일의 어느 함수를 쓰는가
③ 쿠키 · proxy 토큰을 요청에 싣고, 만료되면 갱신한다 갱신이 없으면 1시간 뒤 로그인이 풀림 (미실측) proxy.ts, 로그인 액션
④ 정책 역할과 id 로 행을 거른다 정책 없음 → 0줄, 오류 없음 대시보드 SQL, 저장소 db/

연결 객체 셋과 proxy 를 같은 열로 비교하면 이렇습니다.

환경 변수 쿠키 읽기 쿠키 쓰기 정책
브라우저용 공개 두 개 브라우저가 브라우저가 받는다
서버용 공개 두 개 cookies() 시도하되 서버 컴포넌트에선 무시 받는다
proxy 공개 두 개 request.cookies 요청 · 응답 양쪽 받는다
관리자용 주소 + 비밀 키 안 읽음 안 씀 건너뛴다

“정책” 열이 “받는다” 인 세 줄이 제 경로예요. 정책을 건너뛰는 키로 개발하면 정책이 틀려 있어도 다 되는 것처럼 보이거든요. 10월 19일과 21일의 실측은 모두 공개 키로 했습니다.

상품 상세의 연결을 체크리스트로 풀면?

제 상세 페이지에는 백엔드로 가는 경로가 넷 있어요. 상품 읽기, 문의 등록, 담기, 모든 요청 앞에 서는 세션 갱신. 네 경로에 같은 질문 여섯 개를 던졌어요. ⓐ 어느 연결 객체인가. ⓑ 환경 변수는 어느 이름을 읽는가. ⓒ 쿠키의 토큰이 실리는가. ⓓ DB 역할은 무엇이고 어느 정책이 여는가. ⓔ 실패하면 어떤 모양인가. ⓕ 실측은 어디까지 했는가.

경로 ⓐ 객체 ⓑ 환경 변수 ⓒ 토큰 ⓓ 역할 · 정책 ⓔ 실패 모양 ⓕ 실측
상품 읽기 (서버 컴포넌트) 서버용 NEXT_PUBLIC_ 둘 있으면 싣고 없어도 됨 anon/authenticated · 4표 catalog read 정책 없으면 0줄 → 404 화면 10-21 팀 DB: 2·3·6·5 줄, HTML 에 브랜드명 · 배송 정보
문의 등록 (서버 액션) 서버용 같음 필수 — 없으면 액션이 “로그인 필요” authenticated · DEFAULT auth.uid() + WITH CHECK 남의 id 면 42501 10-23 팀 DB 에 시험 문의 2줄(공개 1 · 비공개 1), user_id 채워짐 — ③-2 표 캡처
담기 (팀 컴포넌트 → 팀 서버 액션) 서버용 (팀 액션 안) 같음 회원은 필수, 비회원은 브라우저 저장으로 분기 authenticated · cart_items 네 정책 user_id = auth.uid() 비회원이면 액션이 메시지 반환 → 브라우저 저장 10-23 타입 검사 · 린트만, 브라우저 미실측
세션 갱신 (proxy) 서버용 (요청 쿠키로) 같음 — 없으면 그대로 통과 있으면 갱신 정책 무관 (사용자 조회만) 인증 장애 시에도 공개 페이지는 200 10-26 테스트 3건 통과, 1시간 뒤 미실측

표를 채우면서 담기 줄에서 멈췄어요. 10월 19일에 저는 담기를 폼과 서버 액션과 SQL 함수로 설계했는데, 10월 23일 저장소의 담기는 그 코드가 아니에요. 팀원이 PR #53 으로 장바구니를 DB 에 붙이면서 담기 버튼을 컴포넌트 하나로 만들었고, 저에게는 “버튼만 바꿔 달라” 고 했거든요. 제 변경은 한 커밋, 한 파일, 열여섯 줄 추가에 스물일곱 줄 삭제. 이동만 하던 버튼 두 개(PC · 모바일)를 팀 컴포넌트로 바꾸고 고른 옵션의 id 와 수량만 넘겼어요.

담기의 경계. 왼쪽 상세 패널(내 파일)이 optionId·quantity 를 props 로 넘기고, 가운데 팀 AddToCartForm(라임)이 회원이면 서버 액션 → cart_items, 비회원이면 브라우저 저장으로 보내는 두 갈래, 아래에 경계가 주는 것 세 줄과 미실측 표시 상세는 “무엇을 담을지” 만 알고, 장바구니는 “어떻게 담을지” 만 안다. 가격과 재고는 넘기지 않는다 — 장바구니가 그 id 로 다시 묻는다

연결의 관점에서 보면, 담기의 ⓐ~ⓓ 는 전부 팀 컴포넌트와 팀 액션 안에 있고 제 파일은 그걸 몰라요. 옵션 id 가 UUID(긴 난수 식별자)인 것은 10월 14일 레이아웃 단계에서 사이즈 상태를 글자가 아니라 id 로 두기로 한 결정이 살아남은 자리고, 장바구니는 그 id 로 가격과 재고를 다시 조회하니 상세는 가격을 넘기지 않아요.

마지막 질문은 빌드예요. 배포 플랫폼은 내 컴퓨터가 할 일(빌드하고, 시작한다)을 한 번 더 할 뿐이라, 로컬 빌드가 깨지면 원격에서도 깨져요. 10월 16일에 mock(가짜 데이터)으로 만든 상세 페이지가 개발 서버에서는 뜨는데 빌드에서 멈춘 적이 있어요. 주소 파라미터를 Suspense(기다리는 동안 대신 보여 줄 것을 정하는 React 경계) 바깥에서 기다렸다는 prerender(빌드 때 미리 그리기) 오류였고, 10월 19일 설계에서 바깥 Suspense 와 안쪽 비동기 컴포넌트 구조로 바꿔 통과했어요. 이 세션에서 최신 커밋으로 다시 돌렸습니다.

npm run build 출력. Next.js 16.4.2, Environments: .env.local, TypeScript 통과, 정적 페이지 19개 생성, 라우트 표에 /product/[id] 와 /product/[id]/inquiry 가 Partial Prerender, 맨 아래 ƒ Proxy (Middleware) 빌드 통과. 위에서 셋째 줄이 어느 환경 변수 파일을 읽었는지 알려 주고, 맨 아래 줄이 proxy.ts 를 인식했다는 표시다

볼 자리가 둘이에요. - Environments: .env.local 은 빌드가 어느 파일을 읽었는지, ƒ Proxy (Middleware) 는 proxy 파일이 등록됐다는 뜻이에요. 상세 두 라우트의 Partial Prerender 는 껍데기는 미리, 내용은 요청 때라는 표시고요. 여기까지가 이 프로젝트의 끝이에요. 배포는 프로젝트 기간(10월 12일부터 23일까지) 안에 하지 않았고, 저장소에 배포 설정 파일도 배포 커밋도 없어요. “배포 환경에 환경 변수를 넣는다” 는 수업의 마지막 단계는 실측 없이 남습니다.

처음 설계한 것은 무엇이 달랐고 어떻게 고쳤나?

10월 19일에 저는 저장소를 건드리지 않고 복사본에서 로컬 Supabase 를 띄워 연결을 끝까지 돌렸어요. 빌드 통과, DB 실측 23건, 화면 실측 24건. 그 판과 10월 23일 저장소의 판은 다섯 자리가 달라요.

전/후. 왼쪽 10-19 설계본 다섯 상자(서버용 하나, 없으면 throw, 로그인 두 벌, form + 서버 액션 + SQL 함수, 설계 SQL 의 정책)와 오른쪽 10-23 저장소 다섯 상자(셋, 경고만, Supabase Auth 한 벌, 팀 컴포넌트에 optionId·quantity, 대시보드에서 4표 열림)가 화살표로 짝지어져 있다 전은 혼자 끝까지 돌린 판, 후는 팀 코드에 붙은 판. 바뀐 것은 다섯, 바뀌지 않은 것은 셋

자리 전 (10-19 설계본) 후 (10-23 저장소) 왜 바뀌었나
연결 객체 서버용 하나. 제 파일에 'server-only' 와 connection() 을 넣음 팀 공용 파일의 셋. connection() 은 페이지 쪽으로 10-20 팀이 공용 파일을 올림. 연결 파일이 둘이면 import 경로가 헷갈린다
환경 변수 누락 바로 예외 — 원인이 보이게 멈춤 경고만 찍고 계속 — 만드는 순간 라이브러리가 던짐 팀 방식이자 수업 방식. 어느 쪽이든 오류 위치는 같다 (함정 1)
로그인 제 /signin (Supabase Auth) + 팀 /login (자체 쿠키) 두 벌 팀 /login 이 Supabase Auth 로, proxy 추가 10-22 PR #51. 장바구니 · 문의가 auth.uid() 를 쓰므로 DB 가 아는 로그인이어야 했다
담기 폼 + 서버 액션 + SQL 함수 (DB 안에서 수량 합산) 팀 컴포넌트에 옵션 id 와 수량만 10-23 PR #53. 팀이 담는 방법을 한 곳에 모았다
정책 설계 SQL 에 포함, 로컬에서 실행 10-21 대시보드에서 4표를 열었고, 저장소 db/ 에는 아직 없다 팀 DB 는 설계 SQL 을 그대로 돌리지 않았다

전부를 한 번에 고친 게 아니에요. 10월 20일은 팀 공용 파일에 맞춰 제 연결 파일을 버린 날이고 그날 키 이름 사고가 났어요. 21일은 정책을 연 날, 22일은 팀이 로그인을 합친 날, 23일은 제가 담기 버튼을 바꾼 날이에요. 바뀌지 않은 것도 셋 있어요. 공개 키와 정책은 한 세트, 쓰기의 작성자는 코드가 아니라 토큰이 정한다, 빌드가 통과해야 다음이 있다. 이 셋은 두 판이 같은 말을 합니다.

직접 겪은 함정은 무엇이었나?

1. 환경 변수 이름이 틀리면 오류에 변수 이름이 안 나온다. 10월 20일 기록에는 “SUPABASE_URL 로 적어서 빈 문자열이 됐고, 팀 파일을 불러오는 순간 URL and API key are required 가 났다” 고 돼 있어요. 이 세션에서 같은 실수를 재현했습니다. 작업본의 .env.local 을 잠깐 치우고, 접두어 없는 이름에 자리표시 값만 적은 파일을 두고, 개발 서버로 상세 주소를 요청했어요.

틀린 키 이름 재현. 접두어 없는 .env.local 두 줄, next dev 로그에 Environments: .env.local 과 경고 두 줄, 그리고 Your project’s URL and Key are required to create a Supabase client! 오류가 createServerClient 와 ProductDetail 에서 난 스택, GET 은 200, curl 도 status=200 재현 결과. 오류는 페이지가 서버용 객체를 만드는 줄에서 났고, HTTP 상태는 200 이다 — Suspense 안에서 난 오류라서

10월 20일과 다른 점이 둘이에요. 첫째, 문구. 그날은 URL and API key are required, 오늘은 Your project's URL and Key are required to create a Supabase client!. 제 추정으로는 그날 팀 파일이 브라우저용 객체를 파일 맨 위에서 바로 만들었고, 10월 22일에 팀이 처음 쓸 때 만들도록 고치면서(커밋 메시지 “환경 변수 없는 빌드 실패 해결”) 오류 자리가 페이지 렌더 안으로 옮겨 온 거예요. 둘째, HTTP 200. 오류가 Suspense 안에서 나서 껍데기는 정상으로 나가요. 상태 코드가 아니라 서버 로그를 봐야 해요. 공통점은 하나예요. 어느 문구에도 NEXT_PUBLIC_SUPABASE_URL 이라는 이름은 없고, 로그 첫머리의 - Environments: .env.local 은 파일을 읽긴 읽었다고 말해요. 고친 뒤에는 서버를 다시 켜야 해요. 환경 변수는 시작할 때만 읽어요.

2. 0줄은 오류가 아니다. 부품 ④ 절의 PGlite 재현이 근거예요. 대시보드에는 행이 있고 앱에는 없으면 정책, permission denied 면 표 권한. 10월 20일에는 이 둘을 가르는 눈이 없었어요.

3. 원인이 겹겹이면 뒤의 겹은 앞의 겹을 걷어 내야 보인다. ③-1 이 다섯 겹을 표로 봤고, 이 글은 그중 넷째 환경 변수 이름과, 다섯 겹 뒤에 숨어 있던 여섯째 정책만 깊이 봤습니다.

10-20 의 다섯 겹과 여섯 번째. 주소·id 종류·지운 함수·환경 변수 이름(라임)·열 누락 다섯 상자가 세로로 쌓이고, 아래에 여섯 번째 0줄 = 읽기 정책(라임), 오른쪽에 읽는 법 이 글이 깊이 본 두 겹. 넷째는 오류로 나타나고, 여섯째는 오류 없이 나타난다

4. 개발 서버 통과는 빌드 통과가 아니다. 10월 16일 mock 판이 빌드에서 멈췄고, 10월 21일 저장소 판에서는 connection() 호출이 빠져 같은 종류의 오류가 서버 로그에 찍혔어요. 이 세션의 빌드 캡처가 최종 상태예요.

5. 로그인이 두 벌이면 한쪽은 DB 가 모른다. 근거는 PR #51 의 커밋 네 건과 변경 파일 목록, 제가 실행한 건 그 proxy 의 테스트 3건이에요. 두 벌이 공존하던 기간에 팀 로그인으로 담기를 눌러 본 기록은 없어서, “로그인 필요 가 나왔을 것” 은 예상이지 실측이 아닙니다.

요약

연결은 세 질문이다. 어디에, 누가, 무엇을. 환경 변수가 주소와 열쇠를, 쿠키의 토큰이 사람을, 정책이 범위를 정한다.

접두어는 공개 선언이다. NEXT_PUBLIC_ 이 붙은 이름만 브라우저로 나가고, 이름이 하나라도 다르면 오류는 변수를 읽는 줄이 아니라 처음 쓰는 줄에서 난다.

연결 객체는 실행 위치마다 다르다. 서버용은 요청마다 쿠키 통을 넘겨받아 새로 만들고, 관리자용은 정책을 건너뛰므로 서버 밖으로 내보내지 않는다.

정책이 없는 표는 공개 키에게 빈 응답을 준다. 빈 응답과 권한 오류는 다른 실패이고, 대시보드와 앱이 다르게 보이면 의심할 자리는 정책이다.

빌드가 배포의 첫 관문이다. 개발 서버 통과는 빌드 통과가 아니고, 이 프로젝트는 빌드까지 갔다.

저는 이제 백엔드에 뭔가를 붙일 때 “이 요청은 어느 객체로, 어떤 쿠키를 들고, 어느 역할로, 어느 정책을 지나나” 를 먼저 적어요. 10월 20일 저녁의 0줄은 그 네 칸 중 마지막 칸이 비어 있다는 신호였는데, 그때는 그 칸이 있는 줄도 몰랐습니다.

시리즈 여섯 글에서 남은 건 습관 하나예요. 1편의 화면과 일 목록, 2편의 고객 여정과 ERD, 3편의 표가 타입과 폼이 되는 양방향, 이 글의 연결. 매 단계에서 저를 멈추게 한 건 코드가 아니라 “이 값은 어디서 와서 어디서 거절되나” 를 적지 않은 칸이었고, 열흘짜리 팀 프로젝트에서 배운 건 그 칸을 먼저 적는 순서였습니다.

출처 — 개념은 수업의 React·Next.js·데이터베이스 강의와 수업 가이드에서 배운 것을 제 말로 다시 썼다. 연결 층 단위 테스트(vitest 4.1), 틀린 키 이름 재현(Next.js 16.4 개발 서버, 자리표시 값), 읽기 정책 전/후(PGlite 0.5.11, PostgreSQL 18.6), npm run build 는 2026-10-26 에 팀 브랜치의 임시 작업본에서 직접 얻었다. 로컬 Supabase 는 띄우지 않았다. 10-21 정책과 행 수, 10-22 PR #51, 10-23 담기 커밋과 시험 문의 두 줄은 작업일지와 저장소 기록이며, 날짜만 적은 기록은 10-16 회고와 10-19 ~ 10-23 작업일지다. 환경 변수 값, 쿠키 값, 비밀 키는 싣지 않았다.

관련 키워드

Supabase 연결 객체NEXT_PUBLIC_ 환경 변수Next.js proxy 미들웨어세션 쿠키Row Level Security 읽기 정책auth.uid()npm run build쇼핑몰 팀 프로젝트

앤디의 마케팅노트

마케팅을 시스템으로 만드는 과정을 매주 기록해요. 잘 된 것도, 안 된 것도 같이 봐요.

maily에서 구독하기