블로그 개발기 4 (백엔드 편)
정적 블로그를 DB 기반으로 갈아엎은 기록 - Supabase 설계부터 배포 후 만난 함정까지
1년 넘게 이 블로그에 글을 쓰면서 가장 귀찮았던 건 글쓰기 그 자체가 아니었다. 글을 다 쓰고 난 다음이었다.
Notion에서 글을 쓰고 → 마크다운으로 내보내고 → mdx 파일을 만들어서 프로젝트에 넣고 → 커밋하고 → 푸시해서 재배포. 오타 하나를 고치려고 해도 이 과정을 처음부터 다시 밟아야 했다.
심지어 이미지 경로는 매번 손으로 고쳐야 했고, 글 하나 올리는 데 배포가 한 번씩 돌았다. 글은 쓰고 싶은데 올리기가 싫어지는, 아주 이상한 상태가 됐다.
"이건 블로그가 아니라 그냥 정적 사이트인데?"
맞는 말이다. 애초에 Contentlayer로 빌드 타임에 mdx를 읽는 구조였으니 당연한 결과였다. 그래서 이번에 아예 뒤집기로 했다. 포스트를 DB에 넣고, 브라우저에서 쓰고 발행하면 재배포 없이 반영되게.
이 글은 그 과정을 정리한 것이다. 실제로 손댄 날로만 세면 나흘, 커밋은 76개가 쌓였다.
무엇을 쓸 것인가
일단 필요한 게 뭔지부터 적어봤다.
- 포스트를 담을 DB
- 로그인 (나만 글을 쓸 수 있어야 하고, 댓글은 누구나)
- 이미지를 올릴 저장소
- 그리고 이 모든 게 무료여야 한다
개인 블로그에 돈을 쓰고 싶지는 않았다. 후보는 두 개였다.
| Supabase | Neon | |
|---|---|---|
| DB | Postgres | Postgres |
| 인증 | 있음 | 없음 (직접 구현) |
| 파일 저장소 | 있음 | 없음 |
| 무료 제약 | 7일 무접속 시 정지 | 월 100 CU-h |
Neon도 좋았지만 인증과 저장소를 따로 붙여야 했다. 계정이 세 개로 늘어난다는 뜻이다. 반면 Supabase는 DB·인증·저장소가 한 계정에 다 있었다.
무료 제약도 재밌었다. Supabase는 7일 동안 아무도 안 들어오면 프로젝트가 정지되는데, 마침 방문자 집계 기능을 만들 거라 매일 DB를 건드리게 된다. 제약이 알아서 무력화되는 셈이다. 반대로 Neon은 월 100 CU-h를 방문자 집계가 갉아먹어서 월중에 DB가 멈출 위험이 있었다.
그래서 Supabase로 정했다.
DB 설계
테이블은 넷이다. Post, Comment, Profile, DailyStat.
설계하면서 고민한 지점만 적어보겠다.
태그를 별도 테이블로 빼지 않았다
보통 태그는 Tag 테이블을 만들고 PostTag 조인 테이블로 잇는다. 정석이다. 그런데 안 했다.
이유는 단순하다. 포스트가 25개고 태그가 6종이고 작성자가 나 혼자다. 태그에 딸린 메타데이터도 없다. 조인 테이블을 만들면 모든 목록 쿼리에 조인이 하나씩 붙는데, 얻는 게 없다.
tags String[] @default([])
@@index([tags], type: Gin)
Postgres의 배열 컬럼에 GIN 인덱스를 걸면 태그 검색은 충분히 빠르다. 태그 이름을 바꿔야 하면 25행짜리 UPDATE 한 번이면 된다.
"나중에 태그별 설명 페이지가 필요해지면 그때 옮기자."
방문 통계에 요청당 행을 만들지 않았다
이게 가장 조심한 부분이다. 방문 기록을 요청마다 한 행씩 쌓으면 편하다. 나중에 시간대별 분석도 할 수 있고. 그런데 무료 티어 500MB를 갉아먹는 건 이것 하나뿐이다.
그래서 KST 기준 하루에 딱 한 행만 유지하기로 했다.
INSERT INTO daily_stats (date, visitors)
VALUES ($1::date, 1)
ON CONFLICT (date) DO UPDATE
SET visitors = daily_stats.visitors + 1
연 365행이면 20KB 정도다. 그리고 방문자당 하루 한 번만 쓰도록 쿠키로 막았다. 누적 방문자는 SUM(visitors)로 구한다.
이 문장이 하나라는 점이 중요하다. 방문자 수 올리기를 "읽고 → 더하고 → 쓴다" 로 나누면 읽은 시점과 쓰는 시점 사이에 틈이 생긴다. 그 틈에 다른 요청이 끼어들면 이렇게 된다.
A: 읽는다 → 5
B: 읽는다 → 5 ← A 가 쓰기 전에 끼어듦
A: 쓴다 → 6
B: 쓴다 → 6 ← 방문 하나가 사라짐
ON CONFLICT DO UPDATE 에는 그 틈이 없다. "없으면 넣고 있으면 고친다" 를 Postgres 가
한 번에, 행에 잠금을 걸고 처리한다. 게다가 daily_stats.visitors + 1 은 앱이 아까 읽어둔
값이 아니라 지금 그 행의 값에 1을 더한다. 그래서 몇 개가 동시에 들어와도 전부 반영된다.
처음엔 "Prisma의
upsert는 이걸 보장 못 하니까 raw 로 썼다" 고 적었는데, 찍어보니 Prisma 7 도 똑같은 단일 문장으로 컴파일하고 있었다. 사실이 아니어서 지웠다. raw 를 유지하는 이유는 그 보장이 ORM 의 컴파일 결과가 아니라 우리 코드에서 바로 보이기 때문이다.
관리자 판별은 DB 컬럼으로만
처음엔 환경변수에 내 GitHub ID를 넣고 비교하려고 했다. 그런데 이 방식의 자연스러운 구현은 user_metadata.provider_id를 읽는 것인데...
user_metadata는 사용자가 직접 쓸 수 있다.
// 아무 로그인 사용자나 이걸 실행하면?
supabase.auth.updateUser({ data: { provider_id: "<내 id>" } })
그럼 아무나 관리자가 된다. 위조가 불가능한 GitHub ID는 auth.identities에 따로 있지만 그건 어차피 쿼리를 한 번 더 도는 것이라, 그럴 바에는 내가 통제하는 profiles.role 컬럼을 읽기로 했다.
RLS는 켜되 정책은 하나도 안 만들었다
이 저장소는 public이다. 그 말은 NEXT_PUBLIC_ 접두사가 붙은 키가 번들에 그대로 나간다는 뜻이다. 그런데 Supabase는 public 스키마의 테이블을 PostgREST로 자동 노출한다.
즉 아무나 그 키로 내 테이블을 읽을 수 있다는 얘기다.
ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
-- 정책은 만들지 않는다
RLS를 켜고 정책을 하나도 만들지 않으면 익명 접근이 전부 막힌다. 그런데 우리 앱은 Prisma로 postgres 롤을 쓰고, 이 롤은 RLS를 우회하기 때문에 아무 영향이 없다. 공격 표면 하나를 이렇게 지웠다.
마크다운을 직접 렌더하기
Contentlayer를 걷어내면 마크다운을 HTML로 바꾸는 걸 직접 해야 한다. unified 파이프라인을 짜면서 두 번 크게 데였다.
플러그인 순서가 전부다
unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw) // <- remarkRehype 뒤, rehypePrism 앞
.use(rehypeSlug)
.use(rehypePrismGenerator(refractor), { ignoreMissing: true })
.use(rehypeStringify, { allowDangerousHtml: true })
rehypeRaw가 rehypePrism보다 뒤에 있으면 코드 하이라이팅이 안 된다. 그 전까지 <aside> 같은 raw HTML은 그냥 불투명한 문자열 노드라서 Prism이 볼 수가 없기 때문이다.
조용히 깨지는 게 제일 무섭다
rehype-prism-plus/common을 쓰면 코드블록이 그냥 하이라이팅 없이 나온다. 오류도 안 난다.
이유를 찾아보니 refractor의 common 번들에는 jsx와 tsx가 없었다. 그리고 ignoreMissing: true가 그 오류를 조용히 삼킨다. 내 글에 jsx가 56개, tsx가 16개 있었으니 72개가 소리 없이 깨질 뻔했다.
import { refractor } from "refractor/lib/core.js";
// 필요한 언어만 명시적으로 등록한다
[markup, css, clike, javascript, jsx, typescript, tsx, bash, yaml, sql, /* … */]
.forEach((l) => refractor.register(l));
등록 순서도 중요했다. clike → javascript → jsx, typescript → tsx 순이어야 한다. jsx와 tsx가 등록 시점에 베이스 문법의 정의를 복사해가기 때문이다.
이런 종류의 버그는 눈으로 안 보이기 때문에, 아예 검증 스크립트를 만들어서 "코드블록 개수만큼 토큰이 나왔는지"를 세게 했다.
"렌더는 되는데 내용만 틀린 버그가 제일 악질이다."
에디터
이제 글을 쓸 화면이 필요하다. CodeMirror나 Monaco 같은 걸 붙일까 하다가 그냥 **textarea**로 갔다.
이유는 이 프로젝트에 폼 프리미티브가 하나도 없고 Tailwind도 안 쓰기 때문이다. 드롭인 에디터는 각자의 테마를 들고 오는데, 내 CSS 변수 토큰이랑 Emotion이랑 부딪힐 게 뻔했다. selectionStart를 만지는 툴바를 60줄쯤 쓰면 기존 디자인이랑 정확히 맞는다.

"에디터. 왼쪽에 쓰면 오른쪽에 바로 보인다"
오른쪽 미리보기가 마음에 드는 부분이다. 저 미리보기는 실제 포스트 페이지가 쓰는 Article 컴포넌트를 그대로 import 해서 쓴다. 그리고 renderMarkdown도 같은 모듈을 브라우저에서 한 번 더 돌린다.
그래서 미리보기와 발행 결과가 구조적으로 어긋날 수가 없다. 서버에 물어볼 필요도 없다.
이미지는 끌어다 놓거나 붙여넣으면 자동으로 올라간다. 올라가는 동안에는 ![업로드 중 a1b2c3d4]() 같은 자리표시자를 먼저 꽂아두고, 끝나면 실제 주소로 바꾼다. 큰 파일일 때 몇 초 동안 아무 일도 안 일어나는 것처럼 보이는 게 싫어서다.
그리고 글 주소(slug)는 타이핑이 멈추면 중복을 확인해준다. 한국어 제목에서 slug를 자동 생성하는 건 불가능하니 손으로 쓸 수밖에 없는데, 그럼 최소한 중복은 알려줘야 하지 않겠나.
관리 화면

"포스트 관리 화면"
여기서 은근히 오래 걸린 게 화면이 좁아질 때였다. 표는 좁아지면 답이 없다. 열이 6개인데 폭이 400px이 되면 글자가 세로로 서기 시작한다.
그래서 컨테이너 쿼리로 단계적으로 접었다.
| 폭 | 동작 |
|---|---|
| 900px 이하 | 태그 열을 숨긴다 |
| 720px 이하 | 댓글 열을 숨긴다 |
| 560px 이하 | 표를 버리고 카드로 바꾼다 |
뷰포트가 아니라 컨테이너 쿼리를 쓴 이유는, 표가 차지하는 폭이 화면 폭과 항상 같지는 않기 때문이다.
댓글
댓글은 GitHub 로그인으로 받는다. 어차피 관리자 인증을 만들 거였으니 인증 비용이 0이었다.
여기서 포스트 본문과 의도적으로 다르게 처리한 게 하나 있다.
- 포스트 본문은 sanitize하지 않는다. 작성자가 나 하나뿐이고 서버에서 강제되며,
rehype-sanitize의 기본 스키마는 내가 지키려는<aside>콜아웃을 지워버린다. - 댓글 본문은 아예 평문으로 저장하고 렌더한다. URL만 정규식으로 잘라서
<a>엘리먼트로 만든다. HTML 문자열은 만들지 않는다.
저장형 XSS를 sanitize로 막는 게 아니라 표면 자체를 없앤 것이다. 나중에 댓글에 마크다운을 허용하고 싶어지면 별도 프로세서를 새로 만들 생각이다. renderMarkdown을 재사용하면 안 된다.
도배 방지도 넣었다. Upstash 같은 걸 붙이기는 개인 블로그에 과했고, 인메모리 카운터는 서버리스에서 인스턴스마다 따로 놀아서 의미가 없다. 그냥 insert 직전에 세 가지를 함께 확인한다.
const [lastMinute, lastHour, previous] = await Promise.all([
prisma.comment.count({
where: { authorId, createdAt: { gt: new Date(now - 60_000) } },
}),
prisma.comment.count({
where: { authorId, createdAt: { gt: new Date(now - 3_600_000) } },
}),
prisma.comment.findFirst({
where: { authorId, deletedAt: null },
orderBy: { createdAt: "desc" },
select: { body: true },
}),
]);
if (lastMinute >= 3) throw new HttpError(429, "잠시 후 다시 시도해 주세요.");
if (lastHour >= 20) throw new HttpError(429, "한 시간에 20개까지 작성할 수 있습니다.");
if (previous?.body === body) throw new HttpError(429, "직전 댓글과 내용이 같습니다.");
이미 @@index([authorId, createdAt])가 있어서 밀리초 수준이다. 직전 댓글과 내용이 같은 것도 함께 막는다.
그리고 나서 마주친 것들
기능이 다 되고 나서가 진짜였다.
로컬은 10ms인데 프로덕션은 2초
배포하고 나니 헤더의 로그인 영역이 2초쯤 늦게 떴다. 방문자 집계도 1초 가까이 걸렸다.
그런데 로컬에서는 전부 10~14ms였다. 재현이 안 되니 원인을 찾을 수가 없었다.
한참 헤매다가 응답 헤더를 봤다.
X-Vercel-Id: icn1::iad1::...
앞의 icn1은 서울, 뒤의 iad1은 미국 동부다. 요청은 서울 엣지로 들어오는데 함수는 미국에서 돈다는 뜻이다. 그런데 내 Supabase는 서울(ap-northeast-2)에 있다.
DB 쿼리 하나마다 태평양을 왕복하고 있었다. Prisma가 연결을 맺을 때는 TCP·TLS·인증으로 왕복이 여러 번이라, 콜드 연결이면 그것만으로 1~2초가 나온다.
{ "regions": ["icn1"] }
vercel.json에 이 한 줄을 넣으니 끝났다.
| 전 (iad1) | 후 (icn1) | |
|---|---|---|
/api/me | 0.38 ~ 1.80s | 0.15 ~ 0.20s |
로컬에서 절대 재현되지 않는 종류의 문제였다. 태평양을 건너지 않으니까. "재현이 안 되니 문제 없나 보다" 하고 넘어갔으면 그대로 남았을 것이다.
<p> 안에 <div>를 넣으면 안 된다
콘솔에 React 오류 #418이 찍혔다. 하이드레이션 실패다.
원인은 방문자 집계 컴포넌트였다. 값이 오기 전에 스켈레톤을 깔아두려고 넣었는데, 그 스켈레톤이 styled.div였고 감싸는 게 styled.p였다.
StatsWrapper = styled.p
└ Skeleton = styled.div ← 여기
브라우저 파서는 <p> 안에서 <div>를 만나면 <p>를 강제로 닫는다. 그래서 서버가 만든 트리와 브라우저의 실제 DOM이 갈라지고, React는 하이드레이션을 포기하고 그 트리를 통째로 다시 그린다.
느려지는 건 덤이었다. 요청은 0.18초에 끝나는데 화면이 늦게 자리를 잡던 이유가 이거였다.
styled 컴포넌트는 태그가 눈에 안 보여서 밟기 쉽다. 빌드도 lint도 이걸 못 잡는다. 결국 서버 HTML을 파서에 넣어서 찾았다.
브라우저 confirm()을 걷어냈다
삭제 확인에 confirm()을 쓰고 있었는데, 저 창은 테마도 폰트도 내 것이 아니라 화면에서 혼자 튄다. 문구도 못 꾸며서 "댓글도 함께 삭제됩니다"를 줄바꿈으로 우겨넣고 있었다.
무엇보다 떠 있는 동안 페이지의 자바스크립트가 통째로 멈춘다.

"직접 만든 확인 모달"
만들고 보니 네이티브 창이 공짜로 주던 걸 전부 직접 챙겨야 했다.
role="alertdialog"와aria-modal- 포커스는 취소 버튼에. Enter를 무심코 눌렀을 때 일어나는 일이 "아무 일도 없음"이어야 한다
- Tab을 대화상자 안에 가두기. 안 그러면 가려진 페이지로 빠져나가 보이지 않는 곳에 포커스가 놓인다
- Escape와 바깥 클릭으로 취소, 닫을 때 열었던 버튼으로 포커스 복귀
결과

"홈 화면. 카드에 조회수와 댓글 수가 들어갔다"
이제 이 글도 브라우저에서 쓰고 있다. 발행 버튼을 누르면 재배포 없이 올라간다.
Notion → 마크다운 변환 → mdx 생성 → 커밋 → 재배포
이 다섯 단계가 사라졌다.
느낀점
- 무료 티어의 제약을 설계에 녹이면 오히려 결정이 쉬워진다. 500MB 안에 들어와야 하니 방문 통계를 하루 1행으로 정할 수 있었고, 7일 정지 제약이 오히려 Supabase를 고르는 근거가 됐다.
- 조용히 깨지는 버그가 제일 위험하다. 코드 하이라이팅도,
<p>안의<div>도, 빌드는 멀쩡히 통과했다. 그래서 검증 스크립트를 따로 만들게 됐다. - 로컬에서 재현이 안 된다고 문제가 없는 게 아니다. 리전 문제는 태평양을 건너야만 나타난다. 배포본을 직접 재보지 않았으면 못 찾았을 것이다.
- 보안은 기능을 더하는 게 아니라 표면을 지우는 것에 가깝다. RLS 정책을 0개로 두는 것, 댓글에서 HTML을 아예 안 만드는 것, 업로드 경로를 하나로 모으는 것 전부 그랬다.
이번에 밟은 함정들은 따로 정리해서 프로젝트에 남겨뒀다. 같은 걸 두 번 밟지 않으려고.
솔직히 백엔드는 오랜만이라 헤맨 시간이 훨씬 길었는데, 그만큼 배운 것도 많았다. 여기까지 읽어주신 분들께 감사 인사 올리고 여기서 마치겠다(넙죽).
댓글