Supabase 도입기 — 정적 사이트에 방문자·방명록·리더보드 붙이기
Next.js 정적 사이트(static export)에 Supabase 를 붙여 방문자 집계·방명록·게임 리더보드를 구현한 경험. 도입 이유부터 RLS·트리거·스팸 방지까지 실제 적용 사례와 보안 대응 정리.
이번에는 정적 사이트로 운영되던 블로그에 Supabase 를 붙여 양방향 기능을 추가한 경험을 정리했습니다. 단순히 "DB 를 붙였다" 보다는, 정적 배포 환경에서 어떻게 백엔드 없이 실시간 데이터가 흐르게 만들었는지, 그리고 그 과정에서 마주친 보안 이슈들을 어떻게 처리했는지에 초점을 맞추었습니다.
왜 도입했나
이 블로그는 Next.js 의 output: 'export' 모드로 순수 정적 파일 만 생성해 S3 + CloudFront 로 배포되고 있습니다. 장점은 명확합니다 — 서버 비용이 거의 없고, 전 세계 어디서든 빠르게 로드됩니다. 하지만 대가도 있습니다. 런타임 서버가 없다는 것 은 사용자와의 상호작용이 필요한 모든 기능에 걸림돌이 됩니다.
- 방문자가 몇 명인지 세고 싶다 → 어디에 기록하나?
- 글마다 방명록을 달고 싶다 → 어느 서버가 받나?
- 게임 최고 점수 랭킹을 붙이고 싶다 → 어디에 저장하나?
전통적으로는 별도 백엔드 API 를 세워야 했지만, 그건 오버엔지니어링이었습니다. 트래픽이 적고 혼자 운영하는 블로그에 DB 서버까지 세우는 건 부담이 컸습니다.
Supabase 는 무엇인가
Supabase 는 PostgreSQL 을 코어로 하는 오픈소스 BaaS(Backend as a Service) 입니다. Firebase 대안으로 자주 비교되지만, 관계형 DB 를 전면에 내세운다는 점이 결정적으로 다릅니다.
기본 구성은 이렇습니다:
- Database — PostgreSQL 그대로. SQL 을 알면 바로 쓸 수 있음
- Auth — 이메일·OAuth·매직링크 등 내장 인증
- Storage — S3 호환 파일 저장소
- Realtime — 테이블 변경을 WebSocket 으로 구독
- Edge Functions — Deno 기반 서버리스 함수
- Auto API — 테이블을 만들면 자동으로 REST/GraphQL 엔드포인트 생성
가장 독특한 지점은 RLS (Row Level Security) 를 전면에 내세웠다는 것입니다. "서버 코드 없이" 클라이언트가 직접 DB 를 호출하게 허용하면서, 보안은 Postgres 의 행 단위 접근 정책으로 방어합니다. 이 설계 덕분에 정적 사이트에서도 백엔드 서버 없이 DB 접근이 가능해집니다.
어떤 경우 쓰기 좋을까
모든 경우에 Supabase 가 최선은 아닙니다. 제 경험상 적합한 상황은 이런 쪽입니다.
- 정적 사이트에 CMS 급 동적 기능을 얹고 싶을 때 — 이번 경우처럼
- PostgreSQL 의 표현력이 필요할 때 — 트리거·함수·조인 등
- 빠르게 MVP 를 만들어야 할 때 — Auto API 덕에 초기 속도가 빠릅니다
- 혼자 운영하는 사이드 프로젝트 — 관리 부담 최소
- Realtime 구독이 가치있는 경우 — 채팅·공동 편집·라이브 대시보드
반대로 이런 경우는 신중해야 합니다:
- 초고트래픽 프로덕션 — 한계치·과금 구조를 미리 파악해야 함
- 비정형 문서 데이터가 압도적으로 많은 경우 — DocumentDB 계열이 나을 수 있음
- 팀 규모가 커서 전담 백엔드 개발자가 있는 경우 — 직접 구성이 더 유연
이 블로그에 붙인 3가지 기능
구체적인 구현 내용은 생략하고, 무엇을 어떻게 구성했는지 개괄만 남깁니다.
1. 방문자 집계 (Unique Visitor)
세션당 하루 1회만 기록되는 UV 방식입니다. 페이지 전환마다 쌓이면 숫자만 커지고 의미가 없어서, 세션 식별자 + 방문 날짜 조합으로 유일성을 보장합니다.
[클라이언트] sessionStorage UUID 생성
↓
[RPC 호출] track_visit(session_id, date)
↓
[서버] visit_log 에 INSERT (중복이면 조용히 무시)
2. 방명록 (Guestbook)
블로그 포스트마다 댓글을 달 수 있는 기능입니다. 페이지네이션, 본인 글 삭제, 서버측 욕설 필터, 속도 제한까지 포함되어 있습니다.
3. 게임 리더보드
블로그에 올려둔 캔버스 게임 5종의 최고 점수를 기록합니다. 유저명은 익명 + 랜덤 할당 방식이고, 같은 유저명을 다른 세션에서 가로채지 못하도록 소유권 체크가 들어 있습니다.
연동하면서 마주친 보안 이슈와 대응
정적 사이트에서 클라이언트가 직접 DB 를 부르는 구조는 편한 만큼 공격면이 넓어집니다. 아래는 실제로 부딪힌 이슈들과 각 대응입니다.
1. 익명 키는 공개 자산이다
Supabase 가 제공하는 anon_key 는 클라이언트 번들에 그대로 노출 됩니다. 누구나 DevTools 로 꺼내 쓸 수 있다는 뜻입니다. 이 키가 프라이빗이라고 착각하면 재앙입니다.
대응: 모든 접근 제어를 RLS 로 내려놓고, anon_key 는 공개된다는 전제로 설계합니다. service_role 같은 어드민 키는 절대 클라이언트 번들에 들어가지 않도록 분리합니다.
2. 같은 세션이 계속 방문 로그를 쌓아 DB 오염
순진하게 INSERT 만 걸면, 사용자 한 명이 F5 를 100번 누르면 100건이 쌓입니다.
대응:
visit_log테이블에(session_id, visit_date)UNIQUE 제약SECURITY DEFINERRPC 함수로 wrapping →ON CONFLICT DO NOTHING- 클라이언트는 원시 INSERT 불가, 오직 이 RPC 만 호출 가능
3. 방명록 스팸 — 봇이 수백 건 쏟아낼 수 있음
RLS 정책만으로는 "동일 세션이 하루 종일 올리는 것" 을 완전히 막기 어려웠습니다.
대응:
- honeypot 필드 (폼에 숨겨진 input — 봇만 채움)
- 30초 쿨다운 (같은 세션의 연속 insert 제한)
- 일일 세션 한도 10개 (
BEFORE INSERT트리거로 카운트 체크) - 포스트별 세션 한도 3개 (한 글에 같은 세션이 도배 방지)
4개 층위를 쌓고 나니 정상 사용자는 거의 체감 못하면서 자동화 공격은 대부분 차단됩니다.
4. 욕설·부적절한 표현 필터
클라이언트에서만 필터링하면 DevTools 에서 우회할 수 있습니다.
대응:
- 클라이언트는 UX 향상용 1차 필터만 (즉시 피드백)
- 서버에
profanity_words테이블 + PL/pgSQL 함수 BEFORE INSERT트리거에서 서버측 재검증- 부적절한 표현 감지 시 고유 errcode 로 reject → 클라이언트는 해당 코드를 받아 안내 메시지 표시
5. 리더보드 유저명 도용
익명 닉네임이라도 "내가 먼저 쓰던 이름을 누가 가져가는" 문제가 생깁니다.
대응:
username_owner테이블에(username, session_id)한 쌍을 등록submit_scoreRPC 가 현재 세션이 그 이름의 소유자인지 확인- 다른 세션이 같은 이름을 시도하면
P0001로 reject
6. user_id 기반 본인 글 삭제
"내가 쓴 글만 내가 삭제할 수 있어야 한다" — 자명하지만 구현은 조심스럽습니다.
대응:
localStorage에 영구user_idUUID 생성 (세션 독립)- INSERT 시
user_id같이 저장 - DELETE RLS 정책에
user_id일치 여부 검증 - 보안 등급: "localStorage 조작으로 다른 user_id 위조 가능" — 허용. UUID 추측이 불가능해 실질 안전하다고 판단
구현 중 생긴 함정들
Next.js 정적 export 환경에서 useMemo(() => localStorage..., [])
'use client' 컴포넌트라 해도 Next.js 는 빌드 시점에 SSR 렌더 를 합니다. 그 순간 window 가 없어 localStorage 접근 함수가 null 을 반환하고, useMemo 는 deps [] 라 hydration 이후에도 재실행되지 않아 영구 null 로 캐시 됩니다.
→ useState + useEffect 로 전환해야 마운트 이후 실제 값이 주입됩니다. 방명록 본인 글 삭제 버튼이 안 보이는 버그로 드러났던 케이스입니다.
crypto.randomUUID 가 Secure Context 에서만 작동
로컬 개발 서버 (HTTP) 에서 crypto.randomUUID is not a function 에러. 브라우저 정책상 비보안 컨텍스트에서는 이 API 가 제공되지 않습니다.
→ Math.random 기반 UUID v4 폴백을 함께 두었습니다.
회고
정적 사이트에 DB 를 붙인다는 선택은 결과적으로 성공이었습니다. 도입 비용은 작았고 (Supabase Free Tier 내 운영), 유지 부담도 크지 않습니다. 가장 크게 배운 것은 이 두 가지입니다.
-
"프론트엔드에서 바로 DB 를 호출" 이라는 구조는 RLS 를 단단히 짤 때만 성립합니다. RLS 정책과 트리거를 미리 꼼꼼히 설계하면 서버 코드 없이도 견고한 시스템이 가능하지만, 허술하면 열려있는 admin console 과 다름없습니다.
-
서버측 재검증을 습관화해야 합니다. 클라이언트 검증은 UX 용이지 보안 수단이 아닙니다. 욕설 필터·스팸 제한·소유권 체크는 전부 서버 트리거·함수로 한 번 더 받아야 합니다.
비슷한 도입을 고려하시는 분이 계시다면, "도입은 쉽지만 보안 설계는 처음부터 포함해야 한다" 는 점을 강조드리고 싶습니다. 나중에 덧붙이려면 기존 데이터 구조를 크게 흔들게 됩니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...