AI 코딩 에이전트가 만든 레거시, AI 코딩 에이전트로 업그레이드하기

프로젝트가 태어날 때부터 구버전이었다
2025년 12월 23일, Claude Code(당시 Opus 4.5)에게 "풀스택 교육 플랫폼을 만들어줘"라고 했습니다. 돌아온 결과물의 package.json은 이랬습니다.
{ "next": "14.0.4", "react": "18.2.0", "tailwindcss": "^3.4.19" }
문제는 이 시점에 이미 Next.js 16이 나와 있었고, React 19는 1년 전에 안정 릴리스됐다는 겁니다.
왜 이렇게 됐을까요? AI 코딩 에이전트의 구조적 한계 때문입니다.
AI 모델의 학습 데이터 (knowledge cutoff) │ └─→ 학습 시점에 "안정적"이던 버전을 기억 │ └─→ Next.js 14 + React 18 조합을 생성 │ └─→ 이미 EOL인 프레임워크로 프로젝트 시작
LLM은 학습 데이터에 가장 많이 등장하는 "검증된 조합"을 선택합니다. Next.js 14 + React 18은 2024년 내내 가장 인기 있던 스택이었으니까요. 모델 입장에서는 합리적인 선택이지만, 2025년 12월에 그 조합을 쓰면 태어나는 순간부터 보안 지원이 끝난 프레임워크를 쓰게 됩니다.
Context7: 실시간 문서 조회가 바꾸는 것
이번 Phase 88 업그레이드에서는 Context7 MCP 서버를 활용했습니다. 이 도구는 AI 에이전트가 실시간으로 최신 라이브러리 문서를 조회할 수 있게 해줍니다.
[프로젝트 시작 시 - Context7 없이] Claude의 학습 데이터 └─→ "Next.js 14가 안정적" (학습 시점 기준) └─→ 14.0.4로 프로젝트 생성 [업그레이드 시 - Context7 사용] Context7 → Next.js 공식 문서 실시간 조회 ├─→ "14는 EOL, 15가 Maintenance LTS" ├─→ "params가 Promise로 변경됨" ├─→ "use() Hook으로 Client Component 대응" └─→ 정확한 마이그레이션 경로 수립
Context7으로 확인한 핵심 정보들:
| 라이브러리 | 조회 결과 | 실제 영향 |
|---|---|---|
| Next.js 15 | params/searchParams → Promise 타입 | codemod로 10개 파일 자동 변환 |
| React 19 | ReactElement.props가 any → {} 변경 | MarkdownViewer 6곳 타입 수정 |
| Tailwind v4 | @config 호환 모드로 기존 설정 유지 가능 | 233줄 typography 설정 그대로 사용 |
| tailwind-merge | v3부터 Tailwind v4 전용, v3 지원 중단 | 동시 업그레이드 필수 |
AI의 학습 데이터만으로는 "이론적으로 이렇게 바뀌었을 것이다"까지밖에 못 갑니다. Context7은 "지금 이 시점의 공식 문서에 이렇게 적혀있다"를 제공합니다. 이 차이가 마이그레이션의 정확도를 결정합니다.
왜 지금 업그레이드하는가
프로젝트가 84개의 Phase를 거치며 성장했습니다. 130개 이상의 라우트, 769개의 테스트, 830개의 TSX/TS 파일. 그런데 기반 프레임워크가 보안 지원 종료 상태입니다.
보안 지원 타임라인 (2026-02-10 기준) Next.js 14 ████████████░░░░░░░░ EOL (2025-10-26 종료) ↑ 3개월 이상 보안 패치 없음! Next.js 15 ████████████████████ Maintenance LTS (2026-10-21까지) Next.js 16 ████████████████████ Active LTS React 18 ████████████░░░░░░░░ 보안 패치만 React 19 ████████████████████ 활성 지원
결제 시스템(토스페이먼츠), OAuth 인증, 파일 업로드를 처리하는 프로덕션 서비스에서 보안 패치가 멈춘 프레임워크를 계속 쓸 수는 없습니다.
성능 개선도 있습니다.
| 항목 | 개선폭 |
|---|---|
| HMR (Fast Refresh) | 96% 빠름 |
| 개발 서버 시작 | 77% 빠름 |
| Tailwind 빌드 | 3.5~5x 빠름 |
| React Compiler | 자동 메모이제이션 (옵트인) |
3개를 동시에 해야 하는 이유
"하나씩 하면 안 되나요?"라는 질문이 나올 법합니다. 안 됩니다.
의존성 체인: Next.js 15 ──필수 요구──→ React 19 Tailwind v4 ──공식 지원──→ Next.js 15.2+ 따라서: React 19 먼저 → Next.js 15 가능 Next.js 15 이후 → Tailwind v4 가능 Tailwind v4만 따로? → Next.js 14에서 비공식
결국 순서가 정해집니다. React 19 → Next.js 15 → Tailwind v4. 단, React 19와 Next.js 15는 사실상 동시에 올려야 합니다. Next.js 15가 React 19를 요구하니까요.
에이전트 팀 구성
이번 업그레이드는 에이전트 팀으로 진행했습니다.
┌─────────────────────────────────────────────────┐ │ 감독자 (Team Lead) │ │ 역할: Context7으로 최신 문서 검증, 작업 분배 │ ├───────────────────┬─────────────────────────────┤ │ 백엔드 TDD 개발자 │ 프론트엔드 개발자 │ │ 1,554 테스트 실행 │ 의존성 + codemod │ │ → 변경 불필요 확인 │ TS 에러 수정 │ │ → 즉시 종료 │ Tailwind v4 마이그레이션 │ │ │ 빌드/테스트 검증 │ └───────────────────┴─────────────────────────────┘
감독자가 Context7으로 사전 조사를 하는 동안, 백엔드 에이전트는 기존 테스트를 돌리고, 프론트엔드 에이전트는 빌드 메트릭을 기록합니다. 백엔드는 API 변경이 없으니 1,554개 테스트 통과를 확인하고 바로 종료. 프론트엔드 에이전트가 본격적인 마이그레이션을 수행합니다.
Step 1: React 19 + Next.js 15
의존성 업데이트
npm install next@15 react@19 react-dom@19 npm install -D @types/react@19 @types/react-dom@19
peer dependency 충돌이 발생합니다. sandpack-react, dnd-kit 등이 아직 React 19를 공식 peer로 명시하지 않았기 때문입니다. --force로 설치 후 런타임 테스트로 확인하는 전략을 택했습니다.
Next.js codemod
npx @next/codemod@canary upgrade latest
이 도구가 10개 파일을 자동 변환합니다. Server Component의 params를 Promise 타입으로 바꾸고 await를 추가하는 작업입니다.
변환 전후를 비교하면:
// Before (Next.js 14) export default async function CourseLayout({ children, params }: { children: ReactNode; params: { course: string } }) { const items = await getContentList(params.course) } // After (Next.js 15) export default async function CourseLayout(props: CourseLayoutProps) { const params = await props.params; const items = await getContentList(params.course) }
핵심은 params가 더 이상 즉시 접근 가능한 객체가 아니라 Promise라는 점입니다. Server Component는 await, Client Component는 React 19의 use() Hook으로 풀어냅니다.
React 19 타입 변경 대응
React 19에서 ReactElement.props의 기본 타입이 any에서 {}로 바뀌었습니다. 별것 아닌 것 같지만, MarkdownViewer처럼 children의 props에 직접 접근하는 코드에서 에러가 납니다.
// Before - child.props가 any라서 문제없었음 if (isValidElement(child)) { if (child.type === 'a' && child.props?.href) { ... } } // After - 제네릭으로 props 타입을 명시 if (isValidElement<{ href?: string; children?: ReactNode }>(child)) { if (child.type === 'a' && child.props?.href) { ... } }
타입 안전성은 올라갔지만, 기존에 any로 편하게 쓰던 패턴들을 다 잡아줘야 합니다. 이런 부분은 21개 TypeScript 에러로 나타났고, 전부 수동 수정했습니다.
Step 2: Tailwind CSS v3 → v4
이 단계가 가장 많은 고민이 필요했습니다. 두 가지 전략이 있었습니다.
전략 A: 완전 CSS-first 전환 tailwind.config.ts (312줄) → globals.css의 @theme 디렉티브 + 깔끔한 최종 결과 - 233줄 typography 커스텀을 CSS로 재작성해야 함 - rounded 2,002건, shadow 255건 클래스명 변경 전략 B: @config 호환 모드 tailwind.config.ts 유지 + @config 참조 + 기존 설정 그대로 사용 + 클래스명 변경 불필요 (v3 이름 유지) - 완전한 v4 마이그레이션은 아님
전략 B를 선택했습니다. 이유는 단순합니다. 233줄의 typography 커스텀을 CSS로 재작성하는 리스크 대비, @config로 기존 설정을 그대로 쓰는 게 안전합니다. 나중에 점진적으로 전환해도 됩니다.
PostCSS 설정
// Before (v3) module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, // v4에서는 내장 }, } // After (v4) module.exports = { plugins: { '@tailwindcss/postcss': {}, }, }
CSS 진입점
/* Before (v3) */ @tailwind base; @tailwind components; @tailwind utilities; /* After (v4) */ @import 'tailwindcss'; @config '../../tailwind.config.ts';
커스텀 플러그인 → @utility
기존 JS 플러그인으로 등록하던 유틸리티를 CSS의 @utility 디렉티브로 옮겼습니다.
@utility scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; &::-webkit-scrollbar { display: none; } }
border 기본 색상 호환 레이어
v4에서 border의 기본 색상이 gray-200에서 currentColor로 바뀝니다. 65건의 bare border 사용에서 갑자기 검은 테두리가 나타날 수 있습니다. 65개 파일을 일일이 수정하는 대신, 전역 호환 레이어를 추가했습니다.
@layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { border-color: var(--color-gray-200, currentcolor); } }
이렇게 하면 기존 border 클래스가 v3과 동일하게 동작합니다. 나중에 개별 파일에 명시적 색상을 추가하면서 점진적으로 이 레이어를 제거할 수 있습니다.
테스트 코드가 보여주는 현실
빌드는 통과했지만 테스트에서 11개 스위트, 57개 테스트가 실패했습니다.
실패 원인을 분류하면:
| 원인 | 건수 | 설명 |
|---|---|---|
| mock 데이터 불완전 | 8 | 새 필드 추가 후 mock 미반영 |
| 컴포넌트 구현 변경 | 40+ | 리팩토링 후 테스트 미동기화 |
| 라이브러리 API 변경 | 4 | react-resizable-panels v2→v4 |
| 누락된 의존성 | 2 | user-event 미설치 |
흥미로운 점은 프레임워크 업그레이드 자체보다 기존에 쌓여있던 기술 부채가 테스트 실패의 주 원인이었다는 겁니다. 컴포넌트를 리팩토링하면서 테스트를 함께 업데이트하지 않았던 것들이 이번에 드러났습니다.
769개 테스트 전체 통과까지 6번의 커밋이 필요했습니다.
최종 결과
┌──────────────────────────────────────────────────┐ │ Before → After │ ├──────────────────┬───────────┬───────────────────┤ │ 패키지 │ Before │ After │ ├──────────────────┼───────────┼───────────────────┤ │ Next.js │ 14.0.4 │ 15.5.12 │ │ React │ 18.2.0 │ 19.2.4 │ │ Tailwind CSS │ 3.4.19 │ 4.1.18 │ │ TypeScript 에러 │ 0 │ 0 │ │ 테스트 │ 769/769 │ 769/769 │ │ 빌드 │ 19.7s │ 성공 │ │ First Load JS │ 83.2 kB │ 103 kB │ ├──────────────────┴───────────┴───────────────────┤ │ 백엔드 │ 변경 없음 │ 1,554 테스트 통과 │ └──────────────────────────────────────────────────┘
First Load JS가 83.2 kB에서 103 kB로 약 24% 증가했습니다. React 19의 번들 크기 증가분과 새로운 런타임 기능이 포함된 결과입니다. 이 부분은 추후 React Compiler 도입이나 코드 스플리팅 최적화로 개선할 여지가 있습니다.
AI 에이전트에게 시키는 프레임워크 업그레이드, 교훈
1. Context7 같은 실시간 문서 도구는 필수
LLM의 학습 데이터는 스냅샷입니다. 6개월 전에 학습된 모델이 "최신 안정 버전"이라고 추천하는 것이 지금은 EOL일 수 있습니다. 실시간 문서 조회 도구가 없으면 AI는 과거의 베스트 프랙티스를 현재에 적용하게 됩니다.
2. 호환 모드를 두려워하지 말자
Tailwind v4의 @config처럼, 메이저 업그레이드에는 보통 호환 모드가 존재합니다. 완벽한 마이그레이션에 집착하기보다, 안전하게 올린 뒤 점진적으로 새 패턴을 적용하는 게 프로덕션 서비스에서는 현실적입니다.
3. 기술 부채는 업그레이드 때 드러난다
테스트 57개 실패 중 대부분이 프레임워크 변경이 아닌 기존 기술 부채 때문이었습니다. 평소에 컴포넌트를 수정하면 테스트도 함께 업데이트해야 합니다. 당연한 얘기지만, AI 에이전트도 빼먹을 수 있습니다.
4. 에이전트 팀은 의존성 순서를 지켜야 한다
병렬로 돌릴 수 있는 작업(백엔드 테스트, 빌드 메트릭 기록)과 순차적으로 해야 하는 작업(React → Next.js → Tailwind)을 구분하는 게 중요합니다. 감독자 에이전트가 Context7으로 사전 조사를 마친 뒤 작업 가이드를 프론트엔드 에이전트에게 전달하는 패턴이 효과적이었습니다.
다음은?
보안 위험은 해소됐고, 개발 환경은 빨라졌습니다. 당장 활용할 수 있는 React 19 기능도 있습니다.
향후 적용 가능한 React 19 기능: useActionState → 게시글/댓글 폼 상태 관리 간소화 useOptimistic → 좋아요, 북마크 즉시 반영 use() → 조건부 데이터 로딩 ref as prop → forwardRef 제거 (새 컴포넌트부터)
Tailwind v4도 아직 @config 호환 모드를 쓰고 있으니, 시간이 되면 CSS-first 방식으로 점진 전환하는 것도 가능합니다.
프레임워크 업그레이드는 새로운 기능을 추가하는 것만큼 눈에 띄지 않습니다. 하지만 보안 패치가 끊긴 프레임워크 위에서 아무리 멋진 기능을 쌓아봐야, 언젠가는 기반부터 흔들립니다. 84개 Phase를 쌓아온 프로젝트가 앞으로도 안전하게 성장하려면, 이런 작업이 가장 먼저였습니다.

댓글
댓글을 작성하려면 이 필요합니다.