최신 프론트 기술로 블로그 만들기
블로그를 설계하며 정리한 개발기
들어가며
개발자라면 한 번쯤 블로그에 글을 쓰다가 문득 생각이 든다. 직접 블로그를 만들어볼까? 라는 생각. 나도 역시 그랬다. 처음에는 velog를 사용했지만 디자인 커스텀이 자유롭지 않고 글을 올릴 때마다 잔디가 심어지길 원하는 마음도 있었다. 물론 충분히 github actions로 가능하지만 그렇게 사용하고 싶지 않았기에 결국 직접 만들기로 했다.
기왕 만드는 김에 요즘 프론트엔드 기술을 제대로 한번 써보자는 욕심도 있었다. 물론 정적 페이지인 블로그이기 때문에 기술이랄 게 제대로 없겠지만.. 그렇게 만든 결과물이 지금 이 블로그다. 이 글에서는 이 블로그를 어떤 기술로, 그리고 왜 그 기술을 골랐는지를 정리해 본다.
어떤 블로그를 만들고 싶었나
기술을 고르기 전에 요구사항부터 정리했다. 세 가지였다.
- 콘텐츠는 MDX로
- 글은 텍스트 파일로 작성하고, 인터랙티브한 요소도 넣을 수 있어야 한다. 그렇기에 마크다운과 JSX를 결합한 문서 형식인 MDX를 선택했다.
- 빠른 로딩
- 블로그는 읽는 곳이다. 정적 사이트로 빌드해 첫 화면이 바로 떠야 한다.
- 자유로운 커스텀
- 다크모드, 좋아요, 댓글, 코드 복사 같은 기능을 원하는 대로 붙일 수 있어야 한다.
이 세 가지를 기준으로 스택을 골랐다.
사용한 기술들
이 블로그의 기술 스택은 이렇다.
- 프레임워크 : Next.js
- 콘텐츠 레이어 : Velite
- 스타일링 : Tailwind CSS
- 린터·포매터 : Biome
- 좋아요 스토어 : Upstash Redis
- 댓글 : Giscus
- 호스팅 : Vercel
하나씩 살펴보자.
Next.js
블로그는 대부분 SSG(정적 사이트 생성)로 만든다. 빌드 타임에 모든 페이지를 HTML로 미리 만들어두는 방식이다. 요청이 오면 완성된 HTML을 그대로 내려주니 빠르고 서버 부담도 적다.
SSG를 지원하는 도구는 많지만 나는 Next.js를 골랐다. 이유는 단순하다. 가장 많이 다뤘기 때문에 익숙하고, 레퍼런스가 가장 많기 때문이다. 막히는 부분이 생겨도 검색하면 답이 나온다는 건 생각보다 큰 장점이다.
App Router에서는 동적 경로의 페이지를 미리 생성하기 위해 generateStaticParams()를 쓴다. 이 블로그를 만들면서 SSG 자체가 처음이라 이 함수도 이번에 처음 써봤다. 글 목록을 넘기면 각 slug에 해당하는 페이지를 빌드 타임에 정적으로 만들어준다.
// 각 slug에 대한 페이지를 빌드 타임에 정적 생성한다
export async function generateStaticParams() {
return posts.map((post) => ({ slug: post.slug }));
}추가로 이 블로그는 Next.js의 App Router와 React 19 + React Compiler를 쓴다. React Compiler는 메모이제이션의 패러다임을 완전히 바꾸는 도구다.
기존 리액트에서는 부모 컴포넌트가 리렌더링되면 의도치 않게 자식 컴포넌트까지 무조건 다시 그려지곤 했다. 이를 막기 위해 우리는 불필요한 리렌더링이 의심되는 곳마다 useMemo와 useCallback을 덕지덕지 붙여 보일러 플레이트 코드가 많아졌었다.
React Compiler는 리액트 코드의 구조를 스스로 분석해, 값이 변하지 않았다면 컴포넌트와 훅의 반환 값을 알아서 캐싱해준다. 덕분에 최적화를 위한 코드를 단 한 줄도 적지 않아도 브라우저에서는 최소한의 컴포넌트만 가장 효율적으로 리렌더링된다. 아주 아주 좋은 기능이다!!
하지만 컴파일러가 코드를 안전하게 최적화하려면 개발자가 리액트의 규칙을 철저하게 준수해야만 한다.
컴포넌트의 순수성 유지 : 렌더링 도중에 외부 변수를 변경하거나 예측 불가능한 사이드 이펙트를 일으키면 안 된다. 불변성 보장 : 리액트의 props와 state는 읽기 전용이다. 기존 객체나 배열을 직접 수정하지 않고, 항상 새로운 객체를 생성해 교체해야 한다.
만약 규칙을 어긴 컴포넌트가 있다면 컴파일러는 에러를 내며 빌드를 멈추는 대신, 해당 컴포넌트만 조용히 최적화 대상에서 스킵해 버린다. 따라서 React Compiler를 쓴다는 것은 올바르게 코드를 짜는 습관을 강제하게 된다.
Velite
가장 핵심적인 고민은 MDX를 어떻게 렌더링 가능한 콘텐츠로 가공할 것인가였다.
이 블로그는 마크다운이 아니라 MDX로 글을 쓴다. MDX는 마크다운 안에서 리액트 컴포넌트를 쓸 수 있게 해주는 포맷이다. 덕분에 글 안에 버튼이나 인터랙티브한 요소를 직접 넣을 수 있다.
예시로 글 안에서 실제로 동작하는 리액트 컴포넌트다. 한번 눌러보자.
MDX에서는 위 버튼을 이렇게 작성한다. 마크다운 문법 사이에 JSX가 그대로 들어간다.
마크다운 문법은 그대로 쓰면서,
<Counter />
이렇게 글 중간에 리액트 컴포넌트를 넣을 수 있다.
버튼, 차트, 라이브 코드 에디터까지 컴포넌트라면 무엇이든 삽입할 수 있다.문제는 MDX를 번들로 컴파일하는 빌드 설정이 까다롭다는 것이다. 그래서 이 과정을 추상화해주는 콘텐츠 레이어 도구인 Velite를 골랐다.
Velite의 특징
Velite는 content/posts/ 아래의 MDX를 빌드 타임에 타입이 보장된 데이터로 변환해준다. 변환 결과는 코드에서 import해 바로 쓸 수 있다.
가장 큰 장점은 스키마로 콘텐츠의 타입을 정의할 수 있다는 점이다. Velite의 스키마 s는 내부적으로 Zod 위에서 동작해, Zod와 같은 문법으로 각 필드의 타입과 필수 여부를 선언한다.
import { defineConfig, s } from "velite";
// s는 Velite가 제공하는 스키마 빌더다
schema: s.object({
title: s.string().max(99), // 필수, 99자 이내
date: s.isodate(), // 필수, ISO 날짜
description: s.string().optional(), // 선택
tags: s.array(s.string()).default([]),
code: s.mdx(), // MDX → 컴파일된 함수 문자열
toc: s.toc(), // 목차 자동 추출
metadata: s.metadata(), // 읽는 시간 등 자동 계산
})스키마를 벗어난 글(예: date 누락)이 있으면 빌드 단계에서 에러로 잡힌다. 메타데이터를 빠뜨린 채 배포하는 사고를 막아준다.
또 toc, metadata 같은 computed 필드를 지원한다. 본문을 파싱해 목차와 예상 읽기 시간을 자동으로 만들어준다. 지금 이 글 오른쪽 목차도 이렇게 생성된 것이다. 참고로 예상 읽기 시간은 한글은 기본적으로 지원을 하지 않는다. 그래서 AI의 도움을 받아 한글과 영문을 따로 집계하는 계산식을 추가했다. 영문은 단어 수를 세고 한글을 포함한 CJK 문자는 글자 수에 가중치를 적용한 뒤 분당 265단어를 기준으로 예상 읽기 시간을 계산했다.
참고로 같은 역할을 하던 Contentlayer가 한동안 표준처럼 쓰였지만 지금은 해당 개발자가 업데이트가 중단 되었다. 마지막 커밋이 2023년이다..
Velite는 그 대안으로 나온 더 가볍고 최신 도구다.
코드 하이라이팅 및 코드 블럭
개발 블로그의 생명은 코드 블록의 가독성이다. 신택스 하이라이팅은 rehype-pretty-code와 Shiki가 담당한다. Shiki는 VS Code와 동일한 TextMate 문법을 사용하여 코드를 분석하고 토큰마다 정확하게 색을 입혀준다고 한다. 여기서 핵심은 이 무거운 파싱 작업을 사용자의 브라우저가 아니라 프로젝트가 빌드되는 시점에 미리 수행한다는 점이다.
기존의 하이라이터(Prism.js, Highlight.js 등)는 사용자가 페이지에 진입하면 브라우저가 자바스크립트를 열심히 돌려 실시간으로 코드를 칠한다고 하는데, 이는 클라이언트의 번들 크기를 키우고 화면이 뒤늦게 깜빡이며 컬러가 입혀지는 Layout Shift 문제를 일으킨다. 반면 Shiki를 사용하는 덕분에 사용자가 추가적인 JS 다운로드 없이 이미 완벽하게 색이 입혀진 가벼운 정적 HTML 마크업만 깔끔하게 받는다. 성능과 안정성을 모두 챙겼다고 할 수 있다!
복사 버튼 구현
MDXContent가 렌더링된 뒤 클라이언트에서 본문 안의 pre 요소를 찾아 일반 DOM <button> 요소를 동적으로 추가하도록 구현했다. 또한 컴포넌트가 다시 렌더링되더라도 버튼이 중복으로 생기지 않게 했다.
rehype-pretty-code가 만든 코드 블록은 pre 바깥의 figure 요소가 전체 컨테이너 역할을 한다. 이때 복사 버튼을 pre 안에 넣으면 가로로 스크롤할 때 버튼까지 함께 움직이는 문제가 생기기 때문에, 이를 피하기 위해 figure가 있으면 버튼을 그곳에 추가하고, 일반 pre 요소만 존재하는 경우에는 어쩔 수 없이 해당 요소를 기준으로 배치하도록 나눴다.
버튼을 누르면 내부 code 요소의 textContent를 읽어 Clipboard API의 navigator.clipboard.writeText()로 복사한다. Clipboard API를 사용할 수 없는 구형 브라우저의 환경도 고려해 폴백 구현도 하였다.
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
// Fallback
const textarea = document.createElement("textarea");
textarea.value = text;
document.execCommand("copy");
textarea.remove();
}
} catch {
}복사 결과도 바로 알 수 있게 했다. 성공하면 버튼을 체크 아이콘과 복사됨 문구로 바꾸고, 예외가 발생하면 오류 아이콘과 실패 문구를 표시한다. 두 상태는 1.5초 뒤 다시 기본 복사 버튼으로 돌아간다.
Tailwind CSS
스타일링도 Next.js와 마찬가지로 익숙한 맛인 Tailwind CSS로 했다. 별도 CSS 파일이나 클래스 네이밍 고민 없이 빠르게 스타일링할 수 있다. 익숙하기 때문에 러닝 커브 없이 바로 작성할 수 있다는 점도 한 몫 했다.
이 블로그는 Tailwind v4를 사용하는데 v4는 설정을 JS 파일이 아니라 CSS 안에서 한다. @theme 블록에 색상·폰트 같은 디자인 토큰을 선언한다.
다크모드
다크모드는 CSS 변수 기반으로 구현했다. 색을 직접 적지 않고 변수로 참조한 뒤, 테마에 따라 변수 값만 교체하는 방식이다.
[data-theme="light"] {
--bg-primary: #fdfcfb;
--text-primary: #1c1c1c;
}
[data-theme="dark"] {
--bg-primary: #121211;
--text-primary: #edece9;
}이러면 테마를 바꿀 때 변수 값만 갈아끼우면 전체가 한 번에 전환된다. 유틸리티마다 dark: 프리픽스를 붙일 필요가 없다.
여기서 흔히 만나는 문제가 FOUC(다크모드에서 첫 프레임이 흰색으로 번쩍이는 눈뽕 현상)다. 하이드레이션 전에 화면이 먼저 그려져서 생기는 문제인데 <head> 최상단에 인라인 스크립트를 둬서 렌더 전에 localStorage의 테마를 먼저 적용한다.
// 렌더 전에 저장된 테마를 적용해 FOUC 방지
const themeScript = `
const theme = localStorage.getItem("theme");
if (theme === "light" || theme === "dark") {
document.documentElement.setAttribute("data-theme", theme);
}
`;Biome
코드 품질 도구로는 보통 린터(ESLint)와 포매터(Prettier)를 같이 쓴다. 거의 대표 격으로 가장 많이 쓰이지만 설정이 번거롭고 속도도 느린 편이다.
이 블로그는 둘을 통합한 Biome를 쓴다. 린트와 포맷을 하나의 도구로 처리하고, Rust로 작성돼 매우 빠르다. 설정 파일도 하나(biome.json)면 끝이다.
pnpm lint # biome check
pnpm format # biome format --write이전에는 Biome의 확장이 어려워서 ESLint + Prettier를 사용하는 편이었지만 Biome 2로 업데이트 되면서 GritQL이라는 플러그인으로 코드 패턴 검사 및 커스텀 진단이라는 걸 받을 수 있다고 한다. 그렇지만 아직도 ESLint만큼은 아니기에 확장성을 생각한다면 ESLint + Prettier 방식이 더 나을 수 있을 것이다.
이 프로젝트는 단순한 블로그 프로젝트이고, Biome만으로 충분히 커버할 수 있는 범위이기에 Biome을 선택했다. 무엇보다 속도가 가장 큰 몫을 했다 할 수 있다.
좋아요
처음에는 좋아요를 구현할 생각이 없었다. 구현하고는 싶었지만, 정적 블로그에 좋아요를 추가하려면 데이터를 저장할 서버와 데이터베이스까지 필요하다고 생각했기 때문이다. 작은 기능 하나를 위해 별도의 백엔드를 구축하는 것은 지나친 오버 엔지니어링처럼 느껴졌다.
그러다 Next.js의 Server Action을 사용하면 별도의 API 라우트를 만들지 않고도 서버 코드를 호출할 수 있다는 것을 알게 됐다. 여기에 Upstash Redis를 붙이면 좋아요 하나를 위해 백엔드 프로젝트 전체를 따로 만들 필요는 없었다.
Redis는 이번에 처음 사용했다. 시작할 때 알고 있던 것은 데이터를 key-value 형태로 저장하고, 읽고 쓰는 속도가 빠르다는 정도였다. 일단 좋아요 숫자를 저장해 본 뒤 생기는 문제를 하나씩 고쳐갔다.
사용자가 좋아요 버튼 클릭
→ 클라이언트 화면에 즉시 반영
→ Server Action 호출
→ Redis에 사용자 ID 추가 또는 제거
→ 서버가 확정한 좋아요 수를 클라이언트에 반환인증 인가 없이 사용자 구분
처음 생각한 방식은 단순했다. 글마다 좋아요 숫자 하나를 저장하고, 버튼을 누르면 증가인 INCR, 취소하면 감소인 DECR을 호출하면 된다고 생각했다.
await redis.incr(key); // 좋아요
await redis.decr(key); // 좋아요 취소하지만 조금 더 생각해 보니 문제가 많았다. 같은 사람이 여러 번 좋아요를 누르는 것을 구분할 수 없었고, 동일한 요청이 중복으로 전달되면 숫자도 그대로 여러 번 증가했다. 취소 요청이 잘못 반복되면 좋아요 수가 음수가 될 가능성도 있었다.
결국 숫자만 저장해서는 누가 이 글에 좋아요를 눌렀는가를 알 수 없었다. 먼저 로그인하지 않은 방문자를 구분할 방법부터 필요했다.
이 블로그에는 로그인 기능이 없다. 좋아요 때문에 회원가입과 인증까지 구현하고 싶지는 않았기 때문에, 방문자에게 임의의 UUID를 발급하고 httpOnly 쿠키에 저장하는 방식을 사용했다.
const cookieStore = await cookies();
let uid = cookieStore.get("blog_uid")?.value;
if (!uid) {
uid = crypto.randomUUID();
cookieStore.set("blog_uid", uid, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365,
path: "/",
});
}좋아요 요청이 들어오면 Server Action이 쿠키에서 UID를 읽어 사용자를 구분한다. httpOnly 쿠키이기 때문에 클라이언트 JavaScript에서는 이 값을 직접 읽을 수 없다.
물론 제대로 된 인증 방식은 아니다. 쿠키를 삭제하거나 다른 브라우저를 사용하면 새로운 사용자로 인식한다. 처음에는 이 부분까지 완벽하게 막아야 하나 고민했지만, 개인 블로그의 좋아요 기능을 위해 로그인 시스템까지 만드는 것은 범위를 벗어난다고 판단했다. 완벽한 중복 방지보다는 가벼운 식별 장치에 가깝다.
Redis Set
사용자 UID를 만들고 나니, 글마다 좋아요를 누른 UID 목록을 저장하면 되겠다고 생각했다. 이때 Redis에도 JavaScript의 Set처럼 중복 값을 허용하지 않는 Set 자료구조가 있다는 것을 처음 알았다.
const key = `likes:set:${slug}`;
if (liked) {
await redis.sadd(key, uid);
} else {
await redis.srem(key, uid);
}
const likes = await redis.scard(key);좋아요를 누르면 SADD로 UID를 추가하고, 취소하면 SREM으로 제거한다. 전체 좋아요 수는 별도 숫자로 저장하지 않고 SCARD로 Set 안의 UID 개수를 센다.
Set은 같은 UID를 여러 번 추가해도 하나만 저장한다. 중복을 허용하지 않기 때문이다. 이미 없는 UID를 다시 제거해도 결과는 그대로다. 따라서 같은 요청이 실수로 여러 번 실행돼도 최종 좋아요 상태가 달라지지 않는다.
이 구조를 공부하면서 이러한 성질을 멱등성이라고 한다. “좋아요 요청을 두 번 보내도 한 번 보낸 것과 결과가 같다”를 예시로 들면 좋을 것 같다.
연속 클릭은 어떻게 처리할까
Set으로 중복 집계는 막았지만, 사용자가 버튼을 계속 누르면 좋아요와 취소 요청이 Redis에 반복해서 전달되는 문제는 남아 있었다. 이를 완전히 막는 방법부터 고민하기보다는 먼저 @upstash/ratelimit에서 제공하는 슬라이딩 윈도우 (서버 과부하를 막고 API 남용을 방지하는 기술로 정해진 시간동안 요청 수를 제한하는 기술) 를 적용해 한 UID가 1분에 최대 20번까지만 요청하도록 제한했다.
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(20, "1m"),
prefix: "ratelimit:like",
});
const { success } = await ratelimit.limit(uid);
if (!success) {
return { ok: false, reason: "rate-limited" };
}20회라는 숫자에도 특별한 근거가 있는 것은 아니다. 일반적인 사용자는 충분히 누를 수 있지만, 짧은 시간에 반복되는 요청은 어느 정도 막을 수 있는 값으로 잡았다. 실제 운영 중 문제가 생긴다면 다시 조정해야 한다.
그리고 낙관적 업데이트로 버튼을 클릭하는 순간 화면의 좋아요 상태를 먼저 바꾸고 이후 서버 상태와 맞추는 방식을 사용했다.
const toggle = () => {
const next = !desiredRef.current;
desiredRef.current = next;
setLiked(next);
void flush();
};물론 요청은 실패할 수 있기 때문에 서버가 마지막으로 확정한 좋아요 상태로 화면을 다시 맞추는 코드도 당연히 추가했다.
if (!result.ok) {
desiredRef.current = serverLikedRef.current;
setLiked(serverLikedRef.current);
return;
}방어 코드
기능이 동작한 뒤에는 생각나는 예외를 조금씩 추가했다. Server Action으로 전달받은 slug가 실제 글인지 먼저 검사하여 존재하지 않는 글의 좋아요 데이터가 생성되지 않도록 했다.
좋아요 수를 읽을 때마다 Redis를 호출하는 것도 아깝다고 생각해, Next.js의 unstable_cache로 결과를 60초 동안 캐시했다. 좋아요를 누른 사람의 화면은 Server Action이 반환한 숫자로 바로 갱신하고 다른 방문자에게는 캐시가 갱신된 뒤 새로운 숫자가 보인다.
Redis를 처음 다뤄서 이 구조가 가장 좋은 방법인지는 아직 확신할 수 없다. 지금 단계에서는 별도의 인증 시스템 없이 좋아요를 구현하기 위한 타협 정도로 보고 있다. 실제로 운영하면서 문제가 생기면 그때 다시 구조를 고쳐볼 생각이다.
Giscus
댓글을 직접 구현하면 DB와 스팸 관리가 부담일 수 밖에 없다. 그래서 Giscus를 사용하였다. GitHub Discussions를 댓글 백엔드로 사용하는 위젯이라 댓글이 내 저장소의 Discussion에 쌓이고 별도 서버가 필요 없다. 인증도 GitHub 계정을 사용하기 때문에 간단하게 댓글 기능을 사용할 수 있다!!
그 밖에,
유저 경험을 높이기 위한 기능도 넣었다.
- 읽기 진행률 바 : 스크롤에 따라 차오르는 상단 인디케이터를 추가하였다.
- 목차(TOC) : 이전에 말했던 velite가 제공하는 computed 필드로 본문의 목차를 자동으로 사이드바에 표시하도록 하였다.
- 검색 : 제목·태그 기준으로 글을 검색하는 기능. 아직 본문 전문 검색은 구현하지 않았다.
- 시리즈 : 연재 글을 묶어서 보여주는 시리즈 네비게이션.
호스팅 — Vercel과 개발 환경
배포는 Vercel을 썼다. Next.js 개발사라 통합이 매끄럽다. GitHub에 푸시하면 자동으로 빌드·배포되고, 커밋마다 프리뷰 배포가 생긴다. CI/CD를 따로 구성하지 않아도 된다.
로컬 개발에선 콘텐츠를 변환하는 Velite와 Next.js 개발 서버를 동시에 띄워야 한다. 매번 터미널을 두 개 켜기 번거로워서 pnpm dev 한 번으로 둘을 함께 실행하는 스크립트를 만들었다. 새 글의 프론트매터 템플릿을 생성하는 pnpm new:post "제목" 스크립트도 추가했다. 이런 자잘한 자동화가 모이면 글쓰기에만 집중할 수 있다.
마치며,
블로그 하나를 만들면서 요즘 프론트엔드 스택을 폭넓게 다뤄봤다. 정리하면 이렇다.
- Next.js로 SSG 블로그의 기본 뼈대 세우기
- Velite로 콘텐츠를 타입 안전한 데이터로 가공
- Tailwind v4로 CSS 변수 기반 스타일링 및 다크모드까지 구현
- Biome로 린트·포맷을 통합
- Upstash Redis 좋아요와 Giscus 댓글로 유저 간 인터렉션
- Vercel로 배포·프리뷰를 자동화
사실 이 블로그를 만드는 과정이 상당히 오래 걸렸다. 레포지토리를 보면 알겠지만 최초 커밋이 2025년이다.. 여러가지 핑계를 대자면 AI 발전으로 인해서 하고 싶은 건 많아졌지만, 그만큼 AI로 인해서 고민도 그만큼 많아지는 시기였다. 더 자세하게 다룰지는 모르겠지만 나중에 작성할 개인 프로젝트 글에서 다룰 예정이다
각설하고, 계속해서 완성해보고 싶었던 프로젝트를 어느정도 마무리했다는 것에 상당한 홀가분한 기분을 느끼고 있다. 하지만 아직 채울 게 많다. 앞으로 다듬고 싶은 것들을 적으며 글을 마친다.
- 본문 전문 검색
- 조회수 / 인기 글
- RSS 피드와 SEO 보강
- 글 안에서 코드를 실행
댓글
Giscus is not configured yet.
Add the NEXT_PUBLIC_GISCUS_REPO, NEXT_PUBLIC_GISCUS_REPO_ID, NEXT_PUBLIC_GISCUS_CATEGORY, and NEXT_PUBLIC_GISCUS_CATEGORY_ID environment variables.