Astro 6 업그레이드, AI와 삽질하며 배운 실전 노하우 (feat. 10년차 개발자의 필드 가이드)
2026. 5. 4.
Astro 6 업그레이드, AI와 삽질하며 배운 실전 노하우 (feat. 10년차 개발자의 필드 가이드)
솔직히 고백하자면, 제 개인 사이트(harshil.dev)의 Astro 6 업그레이드는 몇 주째 미뤄두던 숙제였습니다. 릴리즈 노트를 탭에 띄워두고도, 매번 '다음에 해야지' 하며 다른 작업으로 도망치기 일쑤였죠. 하지만 더 이상 핑곗거리가 없어져 버린 어느 오후, 마음먹고 npx @astrojs/upgrade를 실행했습니다. 별다른 문제 없이 술술 풀릴 거라 기대했죠.
하지만 현실은 달랐습니다. 개발 서버는 tailwindcss 패키지가 없다는 알 수 없는 오류를 뿜으며 즉시 뻗어버렸습니다.
에러 메시지를 멍하니 바라보던 저는 2026년의 합리적인 개발자라면 누구나 할 법한 일을 했습니다. AI 코딩 에이전트를 불러내 모든 문제를 해결해달라고 부탁했죠.
이 글은 제가 그때 간절히 바랐던 '실전 필드 가이드'이자, AI 에이전트가 엄청나게 유용하면서도 위험할 정도로 자신감이 넘친다는 사실을 상기시켜주는 기록입니다. 결국, 코드를 돌리는 건 당신이니까요.
시작점
업그레이드 전 제 개인 사이트의 주요 스택은 다음과 같았습니다.
- Astro 5.x (여러 패치 릴리즈 버전)
@astrojs/cloudflarev12.x@astrojs/tailwindv5.x- 레거시 콘텐츠 컬렉션 (
src/content/config.ts,type: 'content'/'data') astro-expressive-codev0.38.xastro-iconv1.1.5
도달점
수많은 에러와 씨름한 끝에 도달한 최종 스택입니다.
- Astro 6.2
@astrojs/cloudflarev13- Tailwind CSS v4 (
@tailwindcss/vite) astro-expressive-codev0.41.7- 커스텀 인라인 SVG 아이콘 컴포넌트 (
astro-icon대체)
에러 1: Cannot find package 'tailwindcss'
Cannot find package 'tailwindcss' imported from
.../node_modules/@astrojs/tailwind/dist/index.js
무슨 일이?
업그레이드 명령이 @astrojs/tailwind v6를 설치했지만, 이 패키지의 package.json에는 tailwindcss가 피어(peer) 의존성으로만 명시되어 있었습니다. 즉, 자체적으로 번들링하지 않는다는 이야기죠. 제 프로젝트의 dependencies에 tailwindcss가 없었기 때문에 Node는 이 패키지를 찾을 수 없었던 겁니다. 실무에서 의존성 문제를 워낙 많이 겪어서 바로 냄새를 맡았지만, 업그레이드 툴이 이걸 잡아주지 않는다는 게 좀 의외였습니다.
AI 에이전트의 진단
에러를 에이전트에 붙여넣고 무슨 문제인지 물었습니다. 에이전트는 피어 의존성 문제를 즉시 진단했고, 곧바로 PostCSS로 넘어가야 한다고 자신감 있게 제안했습니다. postcss.config.mjs 파일과 autoprefixer를 추가하고, @tailwind 지시어를 삽입했죠. 사이트는 다시 작동하긴 했지만, 접근 방식이 완전히 틀렸습니다.
저는 에이전트에게 TailwindCSS를 유지하고 PostCSS로 마이그레이션하지 말라고 지시했습니다. Astro 공식 문서를 확인하고 다시 답변해달라고 요청했죠. 에이전트는 도구를 활용해 두 가지 옵션을 제시했습니다.
옵션 A: Tailwind 3 유지 (가장 간단)
기존 통합(@astrojs/tailwind)과 함께 tailwindcss@3을 설치하고 나머지는 그대로 둡니다.
npm install tailwindcss@3
astro.config.mjs는 변경 없이 유지됩니다.
import tailwind from '@astrojs/tailwind';
// ...
integrations: [tailwind()]
이것은 Astro 문서에서 레거시 Tailwind 3 프로젝트에 권장하는 경로입니다.
옵션 B: Tailwind 4로 업그레이드
Astro 5.2 이상에는 astro add tailwind 명령이 포함되어 있습니다. 이 명령은 공식 Vite 플러그인인 (@tailwindcss/vite)을 설치하며, 이는 Astro에서 Tailwind 4를 사용하는 새로운 권장 방식입니다.
npx astro add tailwind
이 명령은 다음을 설정합니다.
@tailwindcss/vite를 Vite 플러그인에 추가합니다 (astro.config.mjs경유).src/styles/global.css에@import "tailwindcss";를 추가합니다.tailwind.config.mjs가 필요 없어집니다 — v4는 CSS 기반 설정을 사용합니다.
이 경로를 선택한다면, @astrojs/tailwind를 완전히 제거하고 Tailwind v4 업그레이드 가이드에 따라 커스텀 테마를 CSS 변수로 마이그레이션할 수 있습니다.
그 후: 올바른 접근 방식
저는 에이전트에게 옵션 B로 진행하라고 지시했고, npx astro add tailwind를 실행하여 올바른 Vite 플러그인을 설정했습니다. 그 후 커스텀 테마를 global.css의 새로운 @theme 블록으로 마이그레이션했습니다.
@import "tailwindcss";
@theme {
--color-primary: #f97316;
--color-hover: #ea580c;
--color-light: rgba(249, 115, 22, 0.15);
--color-secondary: #fdba74;
}
@variant dark (&:where(.dark, .dark *));
Tailwind 3를 유지하고 싶다면: 위 옵션 A를 사용하고 @astrojs/tailwind가 작업을 처리하게 두세요.
Tailwind 4를 사용하고 싶다면: 옵션 B와 공식 Vite 플러그인을 사용하세요. PostCSS를 수동으로 연결할 필요는 없습니다.
에러 2: LegacyContentConfigError
Tailwind 문제가 해결되자, 저는 낙관적인 기분으로 개발 서버를 재시작했습니다. 그때 다음 에러가 터졌습니다.
[LegacyContentConfigError] Found legacy content config file in
"src/content/config.ts". Please move this file to
"src/content.config.ts" and ensure each collection has a loader defined.
무슨 일이?
Astro 5는 콘텐츠 레이어 API를 도입했지만, 이전 컬렉션에 대한 자동 하위 호환성을 유지했습니다. Astro 6는 그 안전망을 완전히 제거해 버렸습니다. type: 'content' 및 type: 'data'를 사용하던 제 src/content/config.ts 파일은 더 이상 유효하지 않게 된 것입니다.
해결책
에이전트는 이 마이그레이션을 능숙하게 처리했습니다. 파일을 옮기고, import 문을 다시 작성하고, 로더를 구성했습니다.
변경 전:
import { defineCollection, z } from 'astro:content';
const writings = defineCollection({
type: 'content',
schema: z.object({ ... })
});
const projects = defineCollection({
type: 'data',
schema: z.array(z.object({ ... }))
});
export const collections = { writings, projects };
변경 후:
import { defineCollection } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';
const writings = defineCollection({
loader: glob({ pattern: '**/[^_]*.mdx', base: './src/content/writings' }),
schema: z.object({ ... })
});
const projects = defineCollection({
loader: file('src/content/projects/projects.json'),
schema: z.object({ ... }) // not z.array(...)
});
export const collections = { writings, projects };
주요 변경 사항은 다음과 같습니다.
- 파일 위치:
src/content/config.ts→src/content.config.ts(src/내부 프로젝트 루트). zimport:astro:content→astro/zod.type제거:type: 'content'또는type: 'data'는 더 이상 사용되지 않습니다. 대신loader를 명시적으로 선언합니다.- 데이터 컬렉션: JSON 파일과 함께
type: 'data'를 사용했다면, 새로운file()로더는 최상위 객체당 하나의 항목을 반환합니다. 스키마는z.object(...)(항목 하나)여야 하며,z.array(...)(전체 파일)가 아닙니다. slug→id: 이전 API에서는entry.slug가 자동으로 파생되었습니다. 새 API에서는entry.id가 식별자입니다. 따라서 제 URL도 업데이트해야 했습니다.
// 변경 전
href={`/writings/${post.slug}`}
// 변경 후
href={`/writings/${post.id}`}
여기서 에이전트가 좀 지나쳤습니다. 제 게시물이 folder/index.mdx 구조에 있기 때문에, glob()이 파일 경로에서 migrating-astro-5-to-astro-6/index와 같은 ID를 생성할 것이라고 했습니다. 그래서 /.replace(/index$/, '')를 추가하여 접미사를 제거하라고 제안했습니다.
href={`/writings/${post.id.replace(/\/index$/, '')}`}
뭔가 이상했습니다. writings 컬렉션의 모든 게시물은 이미 프론트매터에 slug 필드를 가지고 있었고, Astro가 이것을 id로 사용할 것이라는 강한 예감이 들었습니다. 에이전트에게 Astro 문서를 다시 확인해달라고 요청했죠. 경험상, 항상 AI의 말을 맹신하기보다 제 프로젝트의 특성과 공식 문서를 다시 한번 확인하는 습관이 이런 불필요한 삽질을 막아주더군요.
문서를 확인한 결과, 프론트매터에 slug가 있을 때 /index가 실제로 추가되는지에 대한 확신을 얻을 수 없었습니다. 그리고 실제로 post.id는 이미 migrating-astro-5-to-astro-6였고, migrating-astro-5-to-astro-6/index가 되는 일은 없었습니다. .replace()는 제 설정에서는 불필요했고, 단순한 post.id로 완벽하게 작동했습니다.
참고: 항상 에이전트의 출력을 자신의 코드 및 빌드 출력과 비교하여 검증하세요. 에이전트는 빠르지만, 결국 코드를 배포하는 것은 당신입니다.
지금까지는 순조로웠습니다. 에이전트 덕분에 몇 시간은 절약했죠. 그러자 에이전트는 자신만만해지기 시작했습니다.
에러 3: Property 'runtime' does not exist on type 'Locals'
콘텐츠 컬렉션 문제가 해결되었습니다. 이제 사이트가 렌더링될 차례였습니다. 하지만 Cloudflare 어댑터는 다른 계획을 가지고 있었습니다.
ts(2339): Property 'runtime' does not exist on type 'Locals'.
무슨 일이?
@astrojs/cloudflare v13은 Astro.locals.runtime을 완전히 제거했습니다. 새로운 공식 접근 방식은 다음과 같습니다.
import { env } from 'cloudflare:workers';
const kv = env.MY_KV;
저는 API 라우트를 이 패턴으로 마이그레이션했습니다. 하지만 개발 환경에서 런타임 에러에 부딪혔습니다.
module is not defined
이 에러는 Cloudflare Vite 플러그인의 워커 러너에서 발생했습니다.
에이전트는 캐시 지우기, import 순서 변경, Vite 설정 확인 등 여러 임의의 수정 사항을 한참 동안 제안했지만, 아무것도 작동하지 않았습니다. 결국 제가 직접 추적했습니다. @astrojs/cloudflare v13의 개발 서버는 workerd 내부에서 실행되며, astro-icon 통합과 호환성 문제가 있었습니다. astro-icon의 <Icon> 컴포넌트가 처음 렌더링될 때 workerd 모듈 러너는 이 알 수 없는 module is not defined 에러로 인해 충돌하며 사이트의 모든 라우트를 망가뜨렸습니다. 개발 서버에서만 발생하는 이런 희귀 케이스는 디버깅이 까다롭거든요. 제가 다른 프로젝트에서 유사한 module is not defined 에러를 만났을 때, 대부분 가상 모듈이나 번들링 과정의 문제였던 걸 떠올리고 이 부분을 집중적으로 파고들었습니다.
이 시점에서 AI는 지쳤습니다. 계획했던 것보다 이미 더 많은 시간을 보냈거든요. 그래서 @astrojs/cloudflare v12로 다운그레이드하여 일단 작동하게 만들었습니다.
하지만 여기서 멈추지 않았습니다. 에이전트는 @iconify-json/mdi에서 SVG 경로를 인라인으로 가져오는 작은 커스텀 컴포넌트로 astro-icon을 완전히 대체할 것을 제안했습니다. 30줄의 코드, 가상 모듈 없음, workerd 호환성 문제 없음. 우리는 그것을 시도했고, 다시 v13으로 전환했고, 그것이 문제 해결의 열쇠였습니다.
해결책: v13 마이그레이션 제대로 완료하기
먼저 wrangler.toml을 업데이트하여 v13 엔트리포인트를 사용하도록 합니다.
main = "@astrojs/cloudflare/entrypoints/server"
그다음 모든 코드를 Astro.locals.runtime.env에서 import { env } from 'cloudflare:workers' 패턴으로 마이그레이션합니다.
변경 전:
const { env } = Astro.locals.runtime;
const kv = env.VOTES;
변경 후:
import { env } from 'cloudflare:workers';
const kv = env.VOTES;
TypeScript의 경우, src/env.d.ts에서 Cloudflare.Env를 확장하여 wrangler.toml에 선언되지 않은 시크릿들(예: YOUTUBE_API_KEY, GITHUB_TOKEN)을 추가해야 했습니다.
declare namespace Cloudflare {
interface Env {
YOUTUBE_API_KEY: string;
PLAYLIST_ID: string;
GITHUB_TOKEN: string;
}
}
참고: 에이전트는 wrangler types 명령어를 몰랐습니다. 이 명령은 타입을 자동으로 생성하여 수동으로 추가하는 번거로움을 덜어줄 수 있었을 것입니다. 사실 AI가 이 명령어를 몰랐다는 건 좀 충격이었습니다. 클라우드플레어 워커를 많이 다뤄본 사람이라면 당연히 알 법한 부분인데 말이죠. 이런 부분에서 '아, 역시 AI는 학습 데이터 기반이라 실전 경험은 부족하구나' 하고 느꼈습니다.
이것은 import { env } from 'cloudflare:workers'가 프로젝트 수준의 Env 인터페이스가 아닌 전역 Cloudflare.Env 인터페이스에 대해 타입이 지정되기 때문에 필요합니다.
마지막으로, package.json 및 astro.config.mjs에서 astro-icon을 제거하고, <Icon name="mdi:github" /> 사용을 커스텀 컴포넌트로 대체했습니다. 문제 해결!
에러 4: astro-expressive-code 피어 의존성 불일치
가장 큰 싸움이 끝났다고 생각했습니다. 이제 모든 것이 해결되었다고 확신했죠. 그때 npm run build가 통합 기능에는 자체적인 타임라인이 있다는 것을 다시 한번 상기시켜 주었습니다.
peer astro@"^4.0.0-beta || ^5.0.0-beta || ^3.3.0" from astro-expressive-code@0.38.3
무슨 일이?
업그레이드 명령이 astro-expressive-code를 업데이트하지 않았기 때문에, 여전히 Astro 6를 제외하는 피어 의존성 범위가 설정되어 있었습니다.
해결책
npm install astro-expressive-code@0.41.7
v0.41.7은 Astro 6를 공식적으로 지원합니다.
이 문제는 에이전트가 첫 시도에 바로 맞췄습니다. 이런 작은 승리라도 감사하게 생각해야죠.
에러 5: Buffer<ArrayBufferLike>' is not assignable to parameter of type 'BodyInit'
의존성 전쟁이 끝났다고 생각하는 순간, TypeScript가 마지막 놀라움을 선사했습니다.
제 OG 이미지 생성 엔드포인트에는 다음과 같은 코드가 있었습니다.
const screenshot = await page.screenshot({ ... });
return new Response(screenshot, { ... });
업그레이드 후 TypeScript는 Buffer를 Response 바디로 전달하는 것을 거부하기 시작했습니다. 이는 런타임 문제는 아니었습니다. Puppeteer는 여전히 Buffer를 반환하지만, astro check (따라서 npm run build)가 이를 오류로 표시했습니다.
해결책
Response에 전달하기 전에 Uint8Array로 변환했습니다.
return new Response(new Uint8Array(screenshot), { ... });
이로써 Workers 런타임 타입과 TypeScript의 엄격한 검사를 모두 만족시켰습니다.
최종 체크리스트
| 명령어 | 상태 |
|---|---|
npm install | ✅ (--legacy-peer-deps 없이) |
npm run dev | ✅ workerd에서 모든 라우트 렌더링 |
npm run build | ✅ |
npm run preview | ✅ |
이번 마이그레이션에서 배운 것들
-
npx @astrojs/upgrade는 Astro 코어 버전만 올립니다. 통합 기능들은 종종 자체적인 타임라인을 가지고 있습니다. 업그레이드 후에는 항상npm ls로 피어 의존성 경고를 확인하세요. -
v6에서는 콘텐츠 컬렉션 마이그레이션이 필수입니다. Astro 5는 유예 기간을 주었지만, Astro 6는 그렇지 않습니다. 새로운
loaderAPI는 익숙해지면 훨씬 명확하고 깔끔합니다. -
어댑터 업그레이드가 가장 위험한 부분입니다.
@astrojs/cloudflarev13은 환경 바인딩 방식에 큰 변화를 주었고, 개발 서버를workerd내부로 옮겼습니다. 장점은 개발 환경이 이제 프로덕션과 거의 동일하다는 것이지만, 단점은 일부 통합 기능(astro-icon같은)이 아직workerd의 모듈 로딩과 호환되지 않는다는 것입니다. -
빌드 != 개발 서버. 제 사이트는 개발 서버가 작동하기 한참 전에 성공적으로 빌드되었습니다. v13 Cloudflare 어댑터는
workerd내부에서 코드를 실행하는 방식 때문에dev(astro dev)에서만 문제가 발생했습니다. 항상 두 가지를 모두 테스트해야 합니다. -
통합 기능이 깨지면, 해결책을 찾기 전에 공식 문서를 확인하세요. 저는 이미
@astrojs/tailwind를 제거하고tailwindcss를 직접 설치했지만, 에이전트가@tailwindcss/vite– Tailwind CSS v4를 위한 적절한 Vite 플러그인 – 를 알려주었습니다. Astro의npx astro add tailwind명령은 v4에 대해 이를 자동으로 설정해주며, 이것이 지원되는 경로입니다. 이 부분을 테스트해 봤을 때, 어설프게 따라가면 나중에 더 큰 문제를 만들 수 있다는 걸 직감했습니다. -
import { env } from 'cloudflare:workers'가 새로운 표준입니다.Astro.locals.runtime.env를 완전히 대체합니다. v13을 사용한다면 이 방식을 받아들이세요. 다만wrangler types가Env인터페이스를 생성하더라도,cloudflare:workers는Cloudflare.Env를 읽기 때문에 시크릿에 대한 네임스페이스를 확장해야 할 수도 있다는 점을 기억하세요. -
AI 에이전트는 훌륭한 팀원이지만, 나쁜 팀장입니다. 그들은 자신감 있게 잘못된 접근 방식을 제안하고, 근본 원인을 놓치며, 마이그레이션 세부 사항에 대해 환각 증세를 보일 수도 있습니다. 당신은 반박하고, 주장을 검증하며, 전략을 지시할 만큼의 지식을 가지고 있어야 합니다.
요약
Astro 5에서 6으로의 업그레이드는 단일 명령어로 끝나지 않습니다. 코어 업그레이드는 순조롭지만, 그 주변의 통합 기능들(Tailwind, Cloudflare, Expressive Code)은 각자 자체적인 중대한 변경 사항을 가지고 있습니다. 만약 제가 이 작업을 다시 한다면 이렇게 할 것입니다.
- 콘텐츠 컬렉션 마이그레이션(
src/content/config.ts→src/content.config.ts)부터 시작합니다. - 업그레이드를 실행하기 전에 Tailwind 3를 유지할지, 4로 업그레이드할지 결정합니다. v4의 경우
npx astro add tailwind를 사용하거나, 레거시 v3의 경우tailwindcss@3을 설치합니다. - 바로
@astrojs/cloudflarev13으로 넘어갑니다.Astro.locals.runtime→import { env from 'cloudflare:workers'마이그레이션은 기계적인 작업입니다. npm run build만이 아니라npm run dev도 테스트합니다.workerd는 Node보다 엄격합니다.
v13의 workerd 개발 서버는 전체적으로 긍정적인 변화입니다. 이제 로컬 환경이 거의 프로덕션과 동일하게 작동하니까요. 하지만 용서가 없습니다. module is not defined와 같은 낮은 수준의 에러가 발생하면, 어떤 통합 기능이 이를 트리거하는지 추적하세요. 제 경우 astro-icon을 커스텀 30줄 SVG 컴포넌트로 교체하는 것만으로도 호환성 문제의 한 유형을 완전히 제거할 수 있었습니다.
추가 자료
혹시 이 마이그레이션을 직접 계획하고 있다면, 어떻게 진행되었는지 정말 궁금합니다. 제가 다루지 않은 벽에 부딪히거나, 이 에러들에 대한 더 깔끔한 해결책을 찾았다면 X/트위터를 통해 언제든지 저에게 알려주세요. 앞으로 더 많은 실전 경험 공유 글을 기대해주세요!
원문: https://dev.to/harshil1712/migrating-from-astro-5-to-astro-6-a-real-world-breakdown-2d0c 수집일: 2026-05-04 01:28:42