Knock Blog

블로그의 Next.js를 Astro로 변환한 여정기

7분 읽기9 조회Frontend

본 글은 Astro, Next.js, React를 어느 정도 이해하고 있는 독자분들을 대상으로 작성되었습니다. Astro에 대한 소개는 간략히 다루므로, 자세한 내용이 궁금하신 분들은 Astro 공식 문서를 참고 부탁드립니다.

1. Astro 도입을 결정하게 된 계기

기존 블로그는 Next.js로 되어 있었습니다. 그런데 사용할수록 Next.js가 꽤 무겁다고 느꼈고, 블로그 콘텐츠를 보여주는 용도로 SSR과 SEO만 보고 선택하기에는 과하지 않나 하는 생각이 들었습니다. 게다가 React 기반이다 보니 렌더링 성능을 신경 쓰지 않을 수 없어서, 코드를 대충 짤 수도 없는 노릇이었구요.

그러던 중 우연히 Astro를 알게 되었습니다. 블로그 전체를 Astro로 바꾸고, 댓글 작성처럼 정말 필요한 부분만 React로 남기면 되겠다는 생각이 들어 전환을 진행하게 되었습니다.

2. Astro가 뭐야?

Astro 공식 문서는 Astro의 정체성을 다음과 같이 이야기합니다.

Astro is best-known for pioneering a new frontend architecture to reduce JavaScript overhead and complexity compared to other frameworks. If you need a website that loads fast and has great SEO, then Astro is for you.

한마디로 빠르게 로드되고 SEO에 강한 웹사이트를 위한 프레임워크라고 스스로를 소개합니다.

Astro의 핵심: Content-driven, Server-first, Fast by default

공식 문서는 Astro의 핵심 원칙으로 Content-driven, Server-first, Fast by default를 꼽습니다. 이 중 제가 느끼기에 Astro의 정체성에 가장 부합하는 것은 Server-first와 Fast by default 두 가지입니다.

SEO는 본 글에서 다루지 않습니다. 대신 Fast by default가 실제로 얼마나 개선을 가져오는지 지표를 토대로 뒤에서 살펴보겠습니다.

어떻게 Fast by default를 달성할 수 있을까?

핵심은 JavaScript를 기본적으로 제거하고, 필요한 곳에만 넣는 방식입니다. Astro는 이를 Island architecture로 설명합니다.

Astro는 React나 Vue로 작성한 컴포넌트라도 기본적으로 HTML과 CSS로만 렌더링하고, 브라우저로 보내는 JavaScript는 제거합니다. 인터랙션이 꼭 필요한 컴포넌트만 개발자가 명시적으로 표시하면, 그 부분만 독립된 island가 되어 JavaScript가 로드됩니다. 즉 JavaScript를 줄이는 일이 나중에 하는 최적화 작업이 아니라, 넣을 때마다 의식적으로 선택해야 하는 기본값이 됩니다.

각 island는 서로 독립적으로 동작하기 때문에, 무거운 컴포넌트 하나가 페이지 전체의 인터랙션을 막지 않습니다. 또한 어떤 island를 언제 불러올지 컴포넌트 단위로 정할 수 있어서, 사용자가 보지 않는 영역의 JavaScript는 아예 로드하지 않을 수도 있습니다.

3. 기대한 개선점

Astro의 특성을 바탕으로 다음 두 가지가 개선될 것이라고 예상했습니다.

  • LCP와 TBT: 내려받을 JavaScript가 줄고, 대부분의 화면에서 hydration이 필요 없어집니다. 그래서 사용자가 가장 많이 들어오는 블로그 홈과 글 상세에서 두 지표가 좋아질 것이라고 생각했습니다.
  • JS 전송량: React 렌더링이 필요한 island가 아니라면 HTML과 CSS만 나가기 때문에, 화면별로 내려가는 JavaScript가 크게 줄어들 것이라고 예상했습니다.

4. 변경 전 지표

전환 전에 먼저 현재 상태를 측정해 두었습니다.

측정은 Lighthouse 13.5 모바일 프리셋으로 진행했습니다. 전환 전후 빌드를 같은 PC의 로컬 workerd에 함께 띄우고, 화면마다 번갈아 5회씩 측정한 중앙값입니다. JS 전송량은 압축된 크기입니다. 실사용자 데이터가 아닌 실험실 측정이므로, 절대값보다 전후 차이를 봐주시면 좋겠습니다.

화면 Lighthouse 점수 LCP TBT JS 전송량
홈 96 2.6초 47ms 166KB
글 목록 96 2.5초 49ms 166KB
태그 목록 97 2.4초 90ms 166KB
검색 96 2.4초 72ms 166KB
글 상세 (긴 글) 87 3.6초 0ms 170KB
글 상세 (짧은 글) 93 2.9초 72ms 170KB

클릭할 것이 거의 없는 목록 화면에도 166KB의 JavaScript가 내려가고 있었습니다.

5. Astro로 마이그레이션 진행하기

Astro의 특성을 살리기 위해, 마이그레이션은 크게 두 축으로 나누어 진행했습니다.

글 읽기 / 검색하기: 서버에서 HTML만 만들어 보내기

기존에는 모든 화면이 React 컴포넌트였습니다. 그래서 글 목록처럼 클릭할 것이 거의 없는 화면에도 React 런타임(약 166KB)이 함께 내려갔습니다.

이 화면들은 .astro로 다시 작성했습니다. 요청이 들어오면 Cloudflare Worker가 D1에서 글을 읽고, Markdown을 HTML로 변환해 응답합니다. 브라우저는 완성된 HTML만 받으므로 JavaScript가 필요 없습니다.

글 쓰기 / 댓글 달기: React를 island로 남기기

에디터, 댓글, 목차처럼 상호작용이 필요한 부분은 기존 React 컴포넌트를 그대로 가져와 island로 넣었습니다. next/link, useRouter 같은 Next.js 전용 코드만 걷어냈습니다. 이제 React는 island가 있는 화면에서만 내려갑니다.

변환하면서 고려한 것

  • island는 서버 코드를 가져올 수 없습니다. 기존 댓글 컴포넌트는 useTranslations() 훅으로 번역 문구를 직접 꺼내 썼습니다. island는 브라우저에서 따로 실행되므로 이 방식을 쓸 수 없습니다. 번역 문구와 언어를 서버에서 골라 props로 넘기도록 바꿨습니다. 넘기는 값은 HTML에 그대로 실리므로, 댓글에 필요한 문구만 넘깁니다.
  • 메타 태그는 레이아웃이 담당합니다. Next.js에서는 generateMetadata를 따로 내보냈고, 그 안에서 글을 한 번 더 조회했습니다. Astro에서는 공통 레이아웃에 제목, 설명, Open Graph 값을 props로 넘깁니다. 조회는 한 번만 합니다.
  • 언어 처리는 미들웨어로 옮겼습니다. 기존에는 폴더 구조([locale])로 언어를 받아 화면마다 검증했습니다. 지금은 미들웨어가 주소에서 /en을 떼어내고 언어를 기록해 두어, 화면은 Astro.locals.locale만 읽으면 됩니다. 화면 파일을 언어별로 나눌 필요가 없어졌습니다.

5.1 글 상세 화면으로 보는 변환 과정

실제로 코드가 어떻게 바뀌었는지, 글 상세 화면 하나를 기준으로 살펴보겠습니다. 기존 파일은 src/app/[locale]/posts/[slug]/page.tsx, 바뀐 파일은 src/pages/posts/[slug].astro입니다.

1) 데이터를 가져오는 코드는 그대로 옮겼습니다

Astro 파일은 ---로 감싼 윗부분이 서버에서만 실행됩니다. 기존 서버 컴포넌트에 있던 조회 코드를 거의 그대로 가져올 수 있었습니다.

// 기존: page.tsx
export default async function PostPage({ params }: Props) {
  const { locale: rawLocale, slug } = await params;
  const locale = toAppLocale(rawLocale);
  const post = await getPostBySlug(locale, slug);
  if (!post) notFound();

  const viewCount = await getViewCount(post.id);
  const allPosts = await getPublishedPosts(locale, 100);
  // ...
}
---
// 변경: [slug].astro
const { locale } = Astro.locals;
const slug = Astro.params.slug ?? "";
const post = await getPostBySlug(locale, slug);
if (!post) return Astro.rewrite("/404");

const [viewCount, allPosts] = await Promise.all([
  getViewCount(post.id),
  getPublishedPosts(locale, 100),
]);
---

getPostBySlug 같은 조회 함수는 한 줄도 고치지 않았습니다. 프레임워크와 무관한 코드를 lib/에 모아둔 덕분입니다. 옮기는 김에 서로 기다릴 필요가 없는 두 조회는 동시에 실행하도록 바꿨습니다.

2) 마크업은 JSX에서 HTML에 가까운 문법으로

// 기존
<Link href={`/tags/${tag.slug}`}>
  <Badge variant="tag">{tag.name}</Badge>
</Link>

<Prose>
  <div dangerouslySetInnerHTML={{ __html: post.renderedHtml }} />
</Prose>
<!-- 변경 -->
<a href={href(`/tags/${tag.slug}`)}>
  <Badge variant="tag">{tag.name}</Badge>
</a>

<div class="prose max-w-none" set:html={post.renderedHtml} />

className은 class로, dangerouslySetInnerHTML은 set:html로 바뀝니다. next/link의 <Link>는 일반 <a>가 되었습니다. <Link>는 페이지 이동을 JavaScript로 처리하기 때문에, 이것을 남겨두면 JavaScript를 없앨 수 없습니다.

3) 상호작용이 필요한 곳에만 표시를 붙였습니다

// 기존: 어떤 컴포넌트가 브라우저에서 실행되는지 이 파일만 봐서는 알 수 없습니다
<CommentSection postId={post.id} />
<TableOfContents items={tocItems} />
<!-- 변경 -->
<CommentSection
  client:visible
  postId={post.id}
  locale={locale}
  messages={messages[locale].comments}
/>
<TableOfContents client:idle items={tocItems} label={t("toc")} />

client: 지시어가 없는 컴포넌트는 JavaScript가 내려가지 않습니다. 언제 불러올지도 컴포넌트마다 정했습니다.

컴포넌트 지시어 이유
댓글 client:visible 글 맨 아래에 있어, 스크롤해서 보일 때 불러와도 충분합니다
목차 client:idle 처음부터 보이지만 급하지 않아, 브라우저가 한가할 때 불러옵니다

4) 조회수 기록은 React 없이

// 기존: 조회수를 기록하려고 React 컴포넌트를 하나 두었습니다
"use client";

export function ViewTracker({ postId }: { postId?: string }) {
  const pathname = usePathname();

  useEffect(() => {
    fetch("/api/views", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ postId, path: pathname }),
    }).catch(() => {});
  }, [postId, pathname]);

  return null;
}
<!-- 변경: 인라인 스크립트 몇 줄 -->
<script is:inline define:vars={{ postId: post.id }}>
  fetch("/api/views", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ postId, path: location.pathname }),
  }).catch(() => {});
</script>

화면에 아무것도 그리지 않는 컴포넌트였지만, 요청 하나를 보내기 위해 React가 필요했습니다. 이런 코드는 일반 스크립트로 충분합니다.

6. 변경 후 지표

그렇다면, 가장 중요한 부분! 사용자가 보는 화면에서 Web vitals 관련 수치가 개선되었을까요?

화면 Lighthouse 점수 LCP TBT JS 전송량
홈 96 → 95 2.6초 → 2.3초 47ms → 0ms 166KB → 0KB
글 목록 96 → 98 2.5초 → 1.9초 49ms → 0ms 166KB → 0KB
태그 목록 97 → 100 2.4초 → 1.4초 90ms → 0ms 166KB → 0KB
검색 96 → 98 2.4초 → 2.0초 72ms → 0ms 166KB → 0KB
글 상세 (긴 글) 87 → 98 3.6초 → 2.0초 0ms → 0ms 170KB → 63KB
글 상세 (짧은 글) 93 → 98 2.9초 → 2.0초 72ms → 0ms 170KB → 63KB

가장 확실하게 달라진 것은 JavaScript입니다. 목록 화면은 JavaScript를 전혀 받지 않게 되었고 (당연한 이야기지만), 글 상세는 170KB에서 63KB로 줄었습니다. 남은 63KB는 댓글과 목차 island가 쓰는 JavaScript입니다. 실행할 JavaScript가 사라지면서 TBT도 거의 모든 측정에서 0ms가 되었습니다.

LCP는 측정한 14개 화면 모두에서 중앙값이 줄었습니다. 공개 화면 평균으로는 2.7초에서 1.9초입니다. 특히 가장 무거웠던 긴 글 상세 화면은 3.6초에서 2.0초로 줄었습니다.

다만 수치를 읽을 때 주의할 점은, Lighthouse는 같은 화면도 잴 때마다 값이 변할 수 있다는 것입니다. 5회의 범위가 전혀 겹치지 않은 화면은 14개 중 5개였습니다. 그래서 LCP는 전반적으로 줄었다까지가 말할 수 있는 범위이고, Lighthouse 점수는 대부분 편차 안의 차이였습니다.

7. 마치며

아직 남은 숙제

모든 지표가 좋아진 것은 아닙니다. 화면에 첫 내용이 그려지는 시점인 FCP는 평균 1.86초에서 1.91초로, 나아지지 않았습니다. 홈 화면은 1.7초에서 2.3초로 오히려 중앙값이 늘었습니다. JavaScript를 없앤 것만으로는 첫 화면이 더 빨리 그려지지 않는다는 뜻입니다. 원인은 아직 확인하지 못했고, 외부 CDN에서 받는 폰트를 먼저 살펴보려 합니다.

에디터도 과제로 남아 있습니다. 여전히 344KB의 JavaScript가 내려가고, 기존 글 편집 화면은 LCP 4.8초, 점수 63에 머물러 있습니다. 관리자만 쓰는 화면이라 우선순위는 낮지만, 에디터 island를 더 잘게 나누거나 늦게 불러오는 방법을 시도해 볼 생각입니다.

마지막으로, 이 글의 수치는 모두 로컬에서 잰 실험실 값입니다. 실제 방문자가 체감하는 속도는 아직 확인하지 못했습니다. 실사용자 데이터를 모아 같은 방향인지 확인하는 것이 다음 단계입니다.

Next.js를 쓰고 계신 분들께

이번 전환으로 느낀 점은, 콘텐츠를 보여주는 것이 중심인 사이트라면 Astro는 Next.js의 충분히 합리적인 대안이라는 것입니다. 기존 React 컴포넌트를 island로 그대로 가져올 수 있어서, 처음부터 다시 만들 필요 없이 점진적으로 옮길 수 있다는 점도 컸습니다.

물론 Astro가 모든 경우에 정답은 아닙니다. 페이지 이동 간 상태를 유지해야 하거나, 화면 대부분이 인터랙션으로 이루어진 앱이라면 여전히 Next.js가 더 잘 맞습니다. 결국 "내 사이트는 사용자가 읽는 곳인가, 조작하는 곳인가"를 먼저 따져 보는 것이 프레임워크 선택의 출발점이라고 생각합니다.

댓글

댓글을 불러오는 중...