GENERAL · General

Astro로 개인 웹사이트 만들기

· 6min read

왜 Astro인가?

개인 웹사이트를 만들기 위해 여러 프레임워크를 고민했다. Next.js, Gatsby, Hugo 등 선택지가 많았지만, 결국 Astro를 선택했다.

Next.js

물론 좋은 프레임워크지만, 개인 웹사이트만큼은 다른 걸로 만들어보고 싶었다.

그리고 개인 웹사이트에 Next.js는 좀 과하다고 생각했다. SSR과 API Routes는 굳이였다. 정적 페이지면 충분한데, 굳이 SSR 프레임워크를 쓸 이유가 없다.

Gatsby vs Hugo vs Astro

Gatsby는 GraphQL을 강제하는 게 마음에 안 들었다. 블로그 만드는데 GraphQL까지 써야 하나? 그리고 빌드가 느리다는 악명이 자자하다.

Hugo는 Go 기반이라 빠르긴 한데, 템플릿 문법이 너무 낯설었다. React/Vue에 익숙한 프론트엔드 개발자에게는 러닝커브가 있다.

Astro는 둘의 장점을 합친 느낌이었다. 정적 사이트 생성에 최적화되어 있으면서도, 컴포넌트 문법이 React/Vue와 비슷해서 친숙했다.

Astro의 장점

  1. 정적 사이트 생성 - 블로그는 대부분 정적 콘텐츠라 SSG가 적합하다
  2. 마크다운 지원 - Content Collection으로 마크다운 파일을 쉽게 관리할 수 있다
  3. Zero JavaScript by Default - 기본적으로 JavaScript를 안 보내서 빠르다. 필요한 곳에만 추가하면 된다
  4. 유연한 컴포넌트 - React, Vue, Svelte 등 원하는 프레임워크를 섞어 쓸 수 있다 (아일랜드 아키텍처)
  5. 개발 경험 - 핫 리로드가 빠르고, 타입스크립트 지원이 잘 된다

무엇보다 새로운 걸 배워보고 싶었다.

프로젝트 시작하기

npm create astro@latest -- --template blog

Astro에서 제공하는 blog 템플릿으로 시작했다. 기본적인 구조가 잡혀있어서 편했다.

구현한 기능들

1. 반응형 레이아웃

헤더와 본문의 너비를 일관되게 맞추고, 반응형으로 구현했다.

/* 데스크탑 */
.content-wrapper {
  display: grid;
  grid-template-columns: 60vw 250px;
  gap: 2em;
  justify-content: center;
}

/* 태블릿 이하 */
@media (max-width: 1199px) {
  .content-wrapper {
    grid-template-columns: 90vw;
  }
}

데스크탑에서는 본문 60vw + 목차 250px, 태블릿 이하에서는 본문만 90vw로 표시된다.

2. 시리즈 기능

블로그 글을 시리즈로 묶어서 관리할 수 있게 했다. 먼저 content.config.ts에 스키마를 정의했다.

const blog = defineCollection({
  loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      description: z.string(),
      pubDate: z.coerce.date(),
      heroImage: image().optional(),
      series: z.string().optional(),
    }),
});

그리고 SeriesNav.astro 컴포넌트를 만들어서 같은 시리즈의 글들을 보여준다.

---
const { series, currentSlug } = Astro.props;

// 파일명에서 날짜 추출해서 정렬
function getDateFromSlug(slug: string): Date {
  const filename = slug.split('/').pop() || '';
  const match = filename.match(/^(\d{4}-\d{2}-\d{2})/);
  return match ? new Date(match[1]) : new Date(0);
}

const allPosts = await getCollection('blog');
const seriesPosts = allPosts
  .filter((post) => post.data.series === series)
  .map((post) => ({
    slug: post.id,
    title: post.data.title,
    date: getDateFromSlug(post.id),
  }))
  .sort((a, b) => a.date.getTime() - b.date.getTime());
---

처음에는 seriesOrder 필드로 순서를 관리했는데, 중간에 글이 추가되면 뒤의 모든 글 순서를 바꿔야 하는 문제가 있었다. 그래서 파일명의 날짜(2025-12-23-title.md)를 파싱해서 자동 정렬하도록 변경했다.

3. 다크모드

CSS 변수와 data-theme 속성을 활용해서 다크모드를 구현했다.

:root {
  --accent: #2337ff;
  --black: 15, 18, 25;
  --gray-dark: 34, 41, 57;
  --bg: #fff;
}

[data-theme="dark"] {
  --accent: #6b7eff;
  --black: 255, 255, 255;
  --gray-dark: 230, 235, 245;
  --bg: #1a1a2e;
}

ThemeToggle.astro 컴포넌트에서 테마를 전환하고 localStorage에 저장한다.

const theme = (() => {
  if (localStorage.getItem("theme")) {
    return localStorage.getItem("theme");
  }
  if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
    return "dark";
  }
  return "light";
})();

document.documentElement.setAttribute("data-theme", theme);

시스템 설정을 우선 감지하고, 사용자가 변경하면 localStorage에 저장해서 유지한다.

4. 목차(TOC)

글 옆에 목차가 표시되어 긴 글도 쉽게 탐색할 수 있다. Astro의 render() 함수에서 headings를 추출할 수 있다.

---
// [...slug].astro
const { Content, headings } = await render(post);
---

<BlogPost headings={headings}>
  <Content />
</BlogPost>

TableOfContents.astro에서 h2, h3만 필터링해서 표시한다.

---
const { headings = [] } = Astro.props;
const toc = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
---

<nav class="toc">
  <h4>목차</h4>
  <ul>
    {toc.map((heading) => (
      <li class={`depth-${heading.depth}`}>
        <a href={`#${heading.slug}`}>{heading.text}</a>
      </li>
    ))}
  </ul>
</nav>

클릭하면 해당 섹션으로 스크롤된다. 1199px 이하에서는 목차가 숨겨진다.

5. 페이지네이션

글이 많아지면 페이지네이션이 필요하다. Astro의 paginate() 함수로 간단하게 구현했다.

---
// [...page].astro
export const getStaticPaths = (async ({ paginate }) => {
  const posts = (await getCollection('blog')).sort(
    (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
  );
  return paginate(posts, { pageSize: 10 });
}) satisfies GetStaticPaths;

const { page } = Astro.props;
---

{page.data.map((post) => (
  <PostCard post={post} />
))}

<nav class="pagination">
  {page.url.prev && <a href={page.url.prev}>← 이전</a>}
  <span>{page.currentPage} / {page.lastPage}</span>
  {page.url.next && <a href={page.url.next}>다음 →</a>}
</nav>

파일명을 index.astro에서 [...page].astro로 변경하면 /blog, /blog/2, /blog/3 형태로 페이지가 생성된다.

폴더 구조

src/
├── content/
│   └── blog/
│       ├── general/              # 일반 글
│       │   └── 2025-12-23-astro-blog.md
│       ├── hanghae-plus/         # 항해플러스 시리즈
│       └── FE/                   # 프론트엔드 관련
├── components/
│   ├── Header.astro
│   ├── Footer.astro
│   ├── SeriesNav.astro
│   ├── TableOfContents.astro
│   └── ThemeToggle.astro
├── layouts/
│   └── BlogPost.astro
├── pages/
│   ├── blog/
│   │   ├── [...page].astro       # 블로그 목록 (페이지네이션)
│   │   └── [...slug].astro       # 개별 글
│   ├── series.astro              # 시리즈 목록
│   └── about.astro
└── styles/
    └── global.css

시리즈별로 폴더를 나누고, 파일명에 날짜를 넣어서 정렬이 자동으로 되도록 했다.

Obsidian으로 글 작성하기

글은 Obsidian에서 작성한다. Obsidian의 장점:

  • 로컬 마크다운 파일 - 그냥 .md 파일이라 Astro와 궁합이 좋다
  • 링크와 백링크 - 글 간의 연결을 쉽게 파악할 수 있다
  • 플러그인 생태계 - 필요한 기능은 플러그인으로 확장 가능
  • 빠른 검색 - 예전에 쓴 글도 금방 찾을 수 있다

워크플로우는 간단하다:

  1. Obsidian에서 글 작성
  2. src/content/blog/ 폴더에 저장
  3. frontmatter 작성 (title, description, pubDate, series)
  4. npm run dev로 확인
  5. 배포

Obsidian vault를 Astro 프로젝트 안에 두면, 글 작성과 개발을 한 곳에서 할 수 있다.

마무리

Astro는 개인 웹사이트에 정말 적합한 프레임워크다. Content Collection으로 마크다운을 쉽게 관리하고, 필요한 기능만 컴포넌트로 추가하면 된다. Obsidian과의 조합도 훌륭하다.

특히 좋았던 점:

  • 마크다운 파일만 추가하면 글이 바로 반영된다
  • JavaScript를 최소화해서 빠르다
  • 타입스크립트 지원이 잘 되어있다
  • Obsidian에서 작성한 글을 그대로 사용할 수 있다

단점이라면 Astro가 MPA(Multi-Page Application)라는 점이다. 페이지 이동할 때마다 전체 페이지가 새로고침된다. SPA처럼 부드러운 전환을 원한다면 View Transitions API를 추가로 설정해야 한다.

앞으로 꾸준히 글을 써봐야겠다.