블로그를 직접 만들며 배운 것
회사 콘텐츠를 외부 플랫폼(네이버·브런치)에 발행하다가 자체 사이트(Next.js 15 + Supabase) 안에 블로그를 만들어 옮긴 사례입니다. 한 작업 사이클에서 부딪힌 결정들을 정리해 보았습니다.
1. WordPress vs 자체 구축 — "별도 서버 띄울 가치가 있나?"
처음 질문은 "WordPress를 서브도메인으로 띄울까, 코드 안에 만들까?"였습니다.
결정: 코드 안에. 이유는
인증·이미지 저장소·다국어·디자인 시스템이 이미 있어서 재활용 비용이 작음
같은 origin이면 메인 도메인 SEO가 합산됨
WordPress는 별도 보안 패치·인프라·디자인 이중화의 영구 부담
일반 원칙: 별도 시스템 도입 비용은 "한 번 설치"가 아니라 "영구 유지보수". 기존 인프라를 80% 재활용할 수 있으면 직접 만드는 쪽이 거의 항상 답입니다.
2. 테이블 재사용 vs 신규 — 의미가 다르면 분리
퍼플즈에는 이미 커뮤니티용 posts 테이블이 있었습니다. 블로그를 여기 끼워 넣을 수도 있었지만 신규 blog_posts를 만들었습니다.
판단 기준:
posts에는topicenum,is_question등 의미가 다른 컬럼블로그는
slug,excerpt,meta_description,published_at같은 SEO 필드 필요한 테이블에 두 개념을 섞으면 쿼리·인덱스·트리거가 복잡해짐
"공통 컬럼이 많다"는 재사용의 충분조건이 아닙니다. 레코드의 의미가 같은가가 진짜 기준.
3. 에디터 — Tiptap + HTML 저장
항목결정이유라이브러리Tiptap (@tiptap/* MIT)@tiptap-pro/*만 유료. core는 모두 무료저장 포맷HTML변환 단계 없이 바로 렌더정화sanitize-html 화이트리스트 방식XSS 방어
// 위험한 것을 차단하는 게 아니라, 안전한 것만 통과시킨다
allowedTags: ["h1","h2","p","strong","em","a","img","ul","li", ...],
allowedAttributes: {
img: ["src","alt","style"], // style은 width만 추가 화이트리스트
a: ["href","rel","target","title"],
},
allowedStyles: {
img: { width: [/^\d{1,3}%$/, /^\d{1,4}px$/] },
p: { "text-align": [/^left$/, /^center$/, /^right$/] },
},
마크다운 vs HTML은 정답이 없지만, "유저가 직접 HTML 입력 못함"이 전제면 HTML 저장이 더 단순합니다 (변환 ↔ 렌더 라운드트립이 없음).
4. 한글 슬러그의 함정
처음엔 "퍼플즈" 같은 한글 슬러그를 허용했더니 /blog/퍼플즈가 404로 떨어졌습니다.
원인 후보:
NFC vs NFD 정규화 — "한글" 한 글자는 합성된 NFC 또는 자모로 분해된 NFD로 저장 가능. 입력 경로별로 달라지면 매칭 실패
URL 인코딩 —
%ED%8D%BC...라운드트립에서 비-ASCII가 어긋남
해결: ASCII 전용 강제. 한글 제목이면 post-2026-05-07 같은 날짜 fallback. 사용자가 폼에서 직접 영문 슬러그 입력도 가능.
function slugify(input: string): string {
return input
.normalize("NFKD") // 분해
.replace(/[\u0300-\u036f]/g, "") // 결합 발음기호 제거
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
비-ASCII URL을 노출하려면 백엔드·프론트·외부 공유 모든 단계에서 정규화를 통일해야 합니다. 비용 대비 ASCII가 거의 항상 합리적.
5. 권한 모델의 진화
처음엔 단순하게 시작했습니다.
ADMIN_EMAILS=perplz@example.com,bob@example.com
운영하다 보니 한계가 드러남:
사람 추가/제거할 때마다 배포 필요
역할 차등(owner·editor·viewer) 불가
비개발자가 직접 관리 못함
결국: admin_users 테이블 + role 컬럼으로 이전.
create table admin_users (
user_id uuid primary key references users(uuid),
role text check (role in ('owner','editor','viewer')),
...
);
-- 이메일로 한 줄에 추가/제거 가능한 RPC도 같이
create function add_admin_by_email(p_email text, p_role text) ...
운영자가 SQL 한 줄로 관리하다가, 익숙해지면 /admin/admins 같은 UI를 얹는 단계적 접근.
권한은 처음에 단순하게 시작해도 곧 복잡해집니다. 첫 설계가 미래 role 확장을 막지 않도록만 두세요.
6. RLS는 공개 읽기에만
Supabase의 Row Level Security를 어디까지 쓸지가 중요한 결정.
채택한 패턴:
공개 읽기 (발행된 글): RLS 정책으로 허용
어드민 쓰기: RLS 모두 차단, server action에서
requireAdmin()+ service role 클라이언트로 우회
// RLS — 정책 하나만
create policy blog_select on blog_posts
for select using (status = 'published');
// INSERT/UPDATE/DELETE 정책 없음 → service role만 통과
// Server action — 앱 레이어에서 권한 체크
async function updateBlogPost(id, patch) {
await requireAdmin();
const supabase = createAdminClient(); // service role
await supabase.from("blog_posts").update(patch).eq("id", id);
}
왜? RLS는 강력하지만 모든 권한 로직을 SQL로 표현하면 복잡해집니다. "공개 데이터 = RLS, 어드민 = 앱 레이어"가 읽기 쉽고 유지보수 쉬움.
7. 다국어 — SSR과 클라이언트 토글의 균형
블로그 페이지는 두 가지를 동시에 만족해야 했습니다:
SEO: 크롤러는 SSR HTML을 보므로 첫 렌더에 올바른 언어가 박혀야 함
UX: 언어 토글 시 페이지 새로고침 없이 즉시 전환
해결: 두 채널 분리.
// 서버 — cookie 기반 SSR (크롤러용 메타태그)
export async function generateMetadata() {
const lang = await readCookie("preferred_language");
return { title: META[lang].title, ... };
}
// 클라이언트 — useLanguage()로 즉시 전환 + 데이터 재조회
const { lang } = useLanguage();
useEffect(() => {
if (lang !== initialLang) refetch(lang);
}, [lang]);
SEO와 UX는 목적이 다릅니다. SSR은 robots용, hydrate는 사용자용. 분리해서 다루면 둘 다 챙김.
8. 탭 전환 깜빡임 — Stale-While-Revalidate
A 탭 → B 탭 → 다시 A 탭 왕복할 때마다 매번 "빈 화면 + 스켈레톤 + 재호출"이 반복되는 문제.
원인: 컴포넌트가 unmount → remount 되면서 useState(initialItems)가 매번 빈 배열로 초기화.
해결: 모듈 레벨 캐시 + 백그라운드 새로고침.
const cache = new Map<string, Item[]>();
function Tab() {
const [items, setItems] = useState(() => cache.get(key) ?? []);
useEffect(() => {
const cached = cache.get(key);
if (cached) setItems(cached); // 즉시 그리고
fetchFresh().then((fresh) => {
cache.set(key, fresh);
setItems(fresh); // 백그라운드 갱신
});
}, [key]);
}
표준 솔루션은 React Query·SWR. 단일 컴포넌트면 모듈 Map 하나로도 충분합니다.
사용자 입장에서 데이터가 항상 있고, 새 데이터가 오면 부드럽게 교체되는 게 핵심.
9. Tiptap NodeView로 이미지 드래그 리사이즈
"이미지를 드래그로 늘리고 줄이고 싶다"는 요구는 기본 Image 확장에 없습니다.
해결: NodeView 패턴. ProseMirror 노드를 React 컴포넌트로 렌더링해서 DOM 이벤트와 노드 attrs를 직접 다룹니다.
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
renderHTML: (a) => a.width ? { style: `width: ${a.width}` } : {},
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ImageWithDragHandle);
},
});
핵심: NodeView는 에디터에서만 보이고, editor.getHTML()로 저장되는 출력은 깔끔한 <img style="width: 50%"> 그대로 (renderHTML이 결정). 에디터 인터랙션과 출력 포맷이 분리되는 게 NodeView의 강점.
10. 발견성 — 사용자가 어떻게 찾는가
좋은 콘텐츠도 안 보면 의미 없습니다. 진입 동선을 단계적으로 늘렸습니다:
사이드바 메뉴 — 직접 이동 (비용 0)
다른 콘텐츠 페이지의 탭 — 다른 글 보러 온 유저가 발견
랜딩 GNB + 푸터 — 비로그인 방문자 진입
(미래) 홈 중앙 별도 섹션 — 발행 빈도가 안정화되면 승격
한 위치에 강하게 vs 여러 자연스러운 위치에 약하게 — 후자가 트래픽 분산 안정성에 유리. 단, 너무 흩뿌리면 시각 무게가 약해지므로 1차/2차 동선 구분 필요.
정리: 작은 결정의 누적
이 작업에서 가장 중요한 학습:
재사용은 비용을 줄이지만, 의미가 다른 데이터를 같은 테이블에 넣는 건 미래의 부채
권한은 단순하게 시작하되, 확장 가능한 모양으로
다국어·URL·캐싱은 작은 디테일 같지만 사용자 경험을 결정
에디터처럼 복잡한 도메인은 검증된 라이브러리에 맡기되, 출력 포맷은 직접 제어