Perplz Logo
Productivity

블로그를 직접 만들며 배운 것

알버트알버트

회사 콘텐츠를 외부 플랫폼(네이버·브런치)에 발행하다가 자체 사이트(Next.js 15 + Supabase) 안에 블로그를 만들어 옮긴 사례입니다. 한 작업 사이클에서 부딪힌 결정들을 정리해 보았습니다.


1. WordPress vs 자체 구축 — "별도 서버 띄울 가치가 있나?"

처음 질문은 "WordPress를 서브도메인으로 띄울까, 코드 안에 만들까?"였습니다.

결정: 코드 안에. 이유는

  • 인증·이미지 저장소·다국어·디자인 시스템이 이미 있어서 재활용 비용이 작음

  • 같은 origin이면 메인 도메인 SEO가 합산됨

  • WordPress는 별도 보안 패치·인프라·디자인 이중화의 영구 부담

일반 원칙: 별도 시스템 도입 비용은 "한 번 설치"가 아니라 "영구 유지보수". 기존 인프라를 80% 재활용할 수 있으면 직접 만드는 쪽이 거의 항상 답입니다.


2. 테이블 재사용 vs 신규 — 의미가 다르면 분리

퍼플즈에는 이미 커뮤니티용 posts 테이블이 있었습니다. 블로그를 여기 끼워 넣을 수도 있었지만 신규 blog_posts를 만들었습니다.

판단 기준:

  • posts에는 topic enum, 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. 발견성 — 사용자가 어떻게 찾는가

좋은 콘텐츠도 안 보면 의미 없습니다. 진입 동선을 단계적으로 늘렸습니다:

  1. 사이드바 메뉴 — 직접 이동 (비용 0)

  2. 다른 콘텐츠 페이지의 탭 — 다른 글 보러 온 유저가 발견

  3. 랜딩 GNB + 푸터 — 비로그인 방문자 진입

  4. (미래) 홈 중앙 별도 섹션 — 발행 빈도가 안정화되면 승격

한 위치에 강하게 vs 여러 자연스러운 위치에 약하게 — 후자가 트래픽 분산 안정성에 유리. 단, 너무 흩뿌리면 시각 무게가 약해지므로 1차/2차 동선 구분 필요.


정리: 작은 결정의 누적

이 작업에서 가장 중요한 학습:

  • 재사용은 비용을 줄이지만, 의미가 다른 데이터를 같은 테이블에 넣는 건 미래의 부채

  • 권한은 단순하게 시작하되, 확장 가능한 모양으로

  • 다국어·URL·캐싱은 작은 디테일 같지만 사용자 경험을 결정

  • 에디터처럼 복잡한 도메인은 검증된 라이브러리에 맡기되, 출력 포맷은 직접 제어