포스트 검색

제목, 태그, 카테고리로 검색합니다

Frontend

Next.js 16으로 블로그 마이그레이션하기

Next.js 15에서 16으로, ESLint에서 Biome로, npm에서 pnpm으로의 전환 기록

Mar 18, 2026
|
읽는 데 약 10

Next.js 16으로 블로그 마이그레이션하기

블로그를 마이그레이션하는 건 이번이 세 번째입니다.

처음에 Next.js 13으로 블로그를 개발했을 때는 App Router가 아직 Beta였고, contentlayer를 사용해서 MDX를 처리했었습니다. 그리고 작년에 Next.js 15로 마이그레이션하면서 App Router가 안정화되었고, next-mdx-remote로 전환하면서 shadcn/ui도 도입했습니다. 13과 15의 차이점은 이전 포스트에서 정리한 적이 있습니다.

올해 초에 next-mdx-remote를 v6로 업그레이드하기도 했는데, 이번에는 Next.js 16이 나오면서 단순히 프레임워크 버전만 올리는 게 아니라 블로그 전체를 새로 설계하는 수준의 작업을 했습니다. 솔직히 프레임워크 업그레이드 자체보다 주변 도구들을 갈아엎는 게 더 큰 작업이었습니다.

이번에 바꾼 것들

항목BeforeAfter
FrameworkNext.js 15Next.js 16
Lint & FormatESLint + PrettierBiome
Package Managernpmpnpm
Testing없음Vitest + Testing Library
CI/CD없음GitHub Actions + Dependabot + CodeQL
Release수동release-please 자동화
Design기본 shadcn 테마Indigo-Violet 커스텀 테마
Search없음Cmd+K 커맨드 팔레트

돌이켜보면 꽤 많이 바꿨는데, 각각의 전환 과정에서 겪은 것들을 정리해보겠습니다.

Next.js 15 → 16 업그레이드

걱정은 많았지만

Next.js 16은 2025년 10월에 정식 릴리즈됐습니다. 공식 블로그 포스트를 보면서 메이저 버전 업이라 당연히 Breaking Change가 있을 거라 걱정했는데요.

주요 변경점을 정리하면 이렇습니다. 자세한 내용은 공식 업그레이드 가이드에 잘 나와있습니다.

  1. 비동기 Request API 완전 필수화cookies(), headers(), params, searchParams의 동기 접근이 완전히 제거
  2. Turbopack 기본화next devnext build 모두 Turbopack이 기본 번들러로
  3. React Compiler 내장 — 자동 메모이제이션으로 useMemo, useCallback을 직접 쓸 필요가 줄어듦
  4. middleware.tsproxy.ts — 네트워크 경계를 명시적으로 분리

저의 경우에는 이미 Next.js 15에서 await params, await searchParams 패턴을 사용하고 있었고, middleware.ts를 사용하지 않았기 때문에 코드 수정 없이 버전만 올려도 동작했습니다. 이전에 15로 마이그레이션하면서 비동기 패턴으로 미리 바꿔둔 게 이번에 도움이 됐습니다.

CI에서 타입 체크 실패

유일하게 겪은 문제는 CI 환경에서였습니다.

CI 에러 로그
error TS2307: Cannot find module '../../../../public/images/chahyunwoo-profile.jpg'
error TS2307: Cannot find module '../../../public/logo/logo-dark.png'

Next.js 16에서 next-env.d.ts 구조가 바뀌면서, 빌드 전에는 이미지 타입 선언 파일이 생성되지 않는 문제였습니다. 로컬에서는 이전 빌드의 .next 디렉토리가 남아있어서 문제가 없었는데, CI에서는 클린 상태에서 시작하니까 터진 겁니다.

해결은 간단합니다. CI에서 빌드를 먼저 실행하고 타입 체크를 나중에 돌리면 됩니다.

.github/workflows/ci.yml
- name: Lint
  run: pnpm lint:ci
 
- name: Build        # 빌드를 먼저
  run: pnpm build
 
- name: Type Check   # 타입 체크를 나중에
  run: pnpm tsc --noEmit
 
- name: Test
  run: pnpm test:run

Next.js 16 업그레이드 자체는 정말 수월했습니다. 이미 15에서 비동기 패턴을 제대로 사용하고 있었다면, 코드 수정 없이 버전만 올려도 될 가능성이 높습니다.

ESLint + Prettier → Biome 전환

사실 이 작업이 Next.js 업그레이드보다 더 큰 작업이었습니다.

왜 Biome를 선택했는가

프리랜서로 새 프로젝트를 시작하면서 기술 스택을 조사하다가 Biome를 알게 됐습니다. ESLint + Prettier 조합은 설정 파일이 여러 개 필요하고(eslint.config.mjs, .prettierrc), 가끔 둘이 충돌하는 경우도 있어서 불편했거든요.

Biome는 lint와 format을 하나의 도구로 통합하면서 설정도 biome.json 하나로 끝납니다. 실제로 몇 번 써보니까 확실히 빠르고 가볍고, 무엇보다 ESLint랑 Prettier가 서로 충돌나는 일이 없어서 그 이후로 쭉 쓰고 있습니다. 이전에 Biome에 대해 정리한 포스트도 있으니 참고하시면 좋겠습니다.

전환 과정

  1. ESLint, Prettier, @eslint/eslintrc, eslint-config-next 패키지 전부 제거
  2. @biomejs/biome 설치
  3. 기존에 사용하던 biome.json 설정을 기반으로 프로젝트에 맞게 조정
  4. biome check --write .로 전체 코드 포맷 변환
  5. 남은 lint 에러 하나씩 수정

포맷이 바뀌면서 거의 모든 파일이 변경됩니다. double quote → single quote, 세미콜론 제거, trailing comma 추가 등이 전체 코드에 적용되었습니다.

Biome 전환 시 포맷 변경으로 대부분의 파일이 수정됩니다. git blame이 무의미해질 수 있으니, 가능하면 포맷 변환 커밋을 별도로 분리하고 .git-blame-ignore-revs에 등록하는 것을 추천합니다.

주의할 점이 있었는데, Biome의 룰이 ESLint보다 엄격한 부분이 꽤 있었습니다. 기존 코드에서 noForEach, noArrayIndexKey, noDangerouslySetInnerHtml 같은 경고가 많이 터졌습니다.

저의 경우에는 설정을 건드리기보다는 가능한 한 코드를 수정해서 해결하는 방향으로 갔습니다. 다만 JSON-LD 삽입에 필수적인 dangerouslySetInnerHTML처럼 어쩔 수 없는 것만 off 처리했고, 스켈레톤 같은 정적 리스트의 noArrayIndexKey는 warn으로 내렸습니다.

npm → pnpm 전환

숨어있던 의존성 문제

pnpm으로 전환한 가장 큰 이유는 의존성 관리의 엄격함입니다. 이전에 npm, yarn, pnpm을 비교한 포스트를 쓴 적이 있는데, 직접 겪어보니까 더 확실하게 차이를 느꼈습니다.

npm은 hoisting 때문에 package.json에 명시하지 않은 패키지도 사용할 수 있는 경우가 있습니다. 실제로 전환하면서 이런 문제가 드러났습니다.

pnpm 전환 후 발생한 에러
error TS2307: Cannot find module 'fast-glob' or its corresponding type declarations.

코드에서 fast-glob을 import하고 있었는데, package.json에는 glob 패키지만 있었습니다. npm에서는 glob의 하위 의존성으로 설치된 fast-glob을 암묵적으로 가져다 쓸 수 있었지만, pnpm에서는 이런 걸 허용하지 않습니다.

fast-glob을 직접 의존성으로 추가하고, 실제로 사용하지 않던 glob 패키지는 제거했습니다. 이렇게 숨어있는 문제를 발견하게 해준다는 점에서 pnpm 전환은 확실히 가치가 있었습니다.

CI 워크플로우 변경

CI에서도 npm → pnpm으로 바꿔야 합니다. pnpm/action-setup 액션을 추가하고, cache를 pnpm으로 변경했습니다.

.github/workflows/ci.yml
- uses: pnpm/action-setup@v4
 
- uses: actions/setup-node@v6
  with:
    node-version: 20
    cache: "pnpm"
 
- run: pnpm install --frozen-lockfile

테스트 환경 구축

블로그에 테스트가 필요한가 싶었는데, 유틸 함수나 서비스 로직을 CI에서 자동 검증하는 게 생각보다 유용했습니다. 특히 formatDate처럼 포맷을 변경했을 때 다른 곳에서 깨지는 걸 미리 잡아줍니다.

Vitest를 선택한 이유는 Vite 기반이라 빠르고, Jest와 거의 동일한 API여서 러닝 커브가 없기 때문입니다.

src/__tests__/lib/utils.test.ts
describe('formatDate', () => {
  it('should format date in English', () => {
    const result = formatDate('2025-01-15')
    expect(result).toContain('Jan')
    expect(result).toContain('15')
    expect(result).toContain('2025')
  })
})
 
describe('estimateReadingTime', () => {
  it('should return at least 1 minute', () => {
    expect(estimateReadingTime('short')).toBe(1)
  })
 
  it('should estimate based on word count', () => {
    const words = Array(400).fill('word').join(' ')
    expect(estimateReadingTime(words)).toBe(2)
  })
})

CI/CD 자동화

이전에는 아무런 자동화가 없었습니다. 수동으로 빌드하고, 수동으로 푸시하고, Vercel이 알아서 배포하는 구조였습니다.

이번에 추가한 것들을 정리하면 이렇습니다.

도구역할
GitHub Actions CIPR마다 Biome lint, type-check, Vitest, build 자동 실행
CodeQL코드 보안 취약점 자동 분석 (매주 월요일 + PR)
Lighthouse CIPR마다 성능/접근성/SEO 점수 감사
Dependabotnpm, GitHub Actions 의존성 자동 업데이트 PR 생성
release-please커밋 메시지 기반 CHANGELOG.md + GitHub Release 자동 생성
Branch Protectionmain에 PR 필수 + CI 통과 필수

release-please를 사용하면 feat: 커밋은 minor 버전, fix: 커밋은 patch 버전으로 자동 올라갑니다. BREAKING CHANGE를 포함하면 major 버전이 됩니다. 커밋 메시지만 잘 쓰면 릴리즈 관리가 자동화됩니다.

Dependabot이 처음 활성화되자마자 PR이 한꺼번에 올라와서 당황했던 기억이 있습니다. 대부분은 CI 통과해서 바로 머지했는데, recharts 2→3이나 shiki 3→4 같은 메이저 업데이트는 CI에서 실패해서 일단 닫았습니다.

디자인 전면 리뉴얼

기존 블로그는 기본 shadcn 테마에 가까워서 솔직히 좀 밋밋했습니다. 이번에 Indigo-Violet 기반의 커스텀 테마로 전면 변경했습니다.

컬러 테마

라이트/다크 모드 모두 oklch 색공간 기반으로 설계했습니다. primary 컬러를 indigo-violet 계열로 통일하면서, 기존 MDX 컴포넌트들의 색상(Callout의 blue/green/amber, Highlight의 fuchsia, 인라인 코드의 orange)과 조화롭게 어울리도록 조정했습니다.

사이드바 구조 변경

이전에는 카테고리 아래에 서브태그가 아코디언 형태로 다 펼쳐지는 구조였는데, 포스트가 늘어날수록 메뉴가 너무 길어지는 문제가 있었습니다.

이번에는 사이드바를 세 섹션으로 나눴습니다.

  • Categories — 메인 카테고리만 아이콘과 함께 표시
  • Tags — 상위 태그를 뱃지로 표시하고, 선택 시 active 하이라이트
  • Recent — 최근 포스트를 카테고리 뱃지 + 날짜와 함께 표시

사이드바는 sticky로 고정해서 스크롤해도 항상 접근할 수 있게 했습니다.

포스트 카드 리디자인

기존 카드를 좀 더 인터랙티브하게 바꿨습니다.

  • 썸네일 위에 카테고리 뱃지 오버레이
  • 호버 시 카드가 살짝 올라오면서 그림자 + 보더 변화
  • 하단에 날짜와 예상 읽기 시간 표시
  • 태그는 3개까지 노출하고, 더 있으면 +N 뱃지에 호버하면 나머지가 툴팁으로 표시

페이지네이션도 추가했습니다. 블로그에서는 무한스크롤보다 페이지네이션이 낫다고 판단했는데, SEO 관점에서도 그렇고 뒤로가기 했을 때 위치를 유지하기 쉽기 때문입니다.

검색 기능 (Cmd+K)

cmdk 기반의 커맨드 팔레트를 구현했습니다. Zustand 스토어로 검색 상태를 관리하고, 디바운스를 적용했습니다. 검색 결과는 카테고리별로 그룹핑해서 보여줍니다.

검색어를 입력하기 전에는 안내 메시지만 보여주고, 타이핑을 시작하면 결과가 나타납니다. 지금은 포스트 메타데이터를 클라이언트로 내려서 필터링하는 방식인데, 나중에 포스트가 많아지면 서버 API로 전환할 생각입니다.

포스트 상세 페이지

개인적으로 가장 신경 쓴 부분입니다.

  • 본문 너비 — 기존보다 넓혀서 코드블록이 답답하지 않게
  • 우측 sticky TOC — 본문 오른쪽에 떠서 따라다니며, 현재 읽고 있는 섹션이 하이라이트
  • 모바일 TOC — 우하단 플로팅 버튼, 누르면 바텀시트로 목차 표시
  • 읽기 프로그레스 바 — 헤더 아래에 얇은 바로 읽기 진행률 표시

기존에는 목차가 포스트 상단에 있어서 스크롤하면 사라지는 구조였는데, 이게 사실 쓸모가 없었거든요. 읽다가 다른 섹션으로 이동하고 싶을 때 매번 맨 위로 올라가야 했으니까요. sticky TOC로 바꾸니까 확실히 편해졌습니다.

프로그레스 바는 requestAnimationFrame으로 매끄럽게 동작하도록 했고, useRef로 직접 DOM을 제어해서 불필요한 리렌더를 방지했습니다.

About 페이지 리뉴얼

경력을 타임라인 형태로 바꾸고, 개인 프로젝트 섹션을 카드 형태로 분리했습니다. 스킬은 레이더 차트에서 카테고리별 뱃지로 변경했는데, 레이더 차트는 보기에는 예쁘지만 실제로 정보를 전달하기엔 뱃지가 더 직관적이라고 생각합니다.

성능 최적화

마지막으로 Lighthouse 점수를 최대한 끌어올리는 작업을 진행했습니다.

데드코드 정리

리뉴얼하면서 더 이상 사용하지 않는 컴포넌트와 패키지가 꽤 생겼습니다. 레이더 차트를 없애면서 recharts가 통째로 불필요해졌고, 아코디언 사이드바를 없애면서 collapsible도 필요 없어졌습니다. 이런 것들을 전부 찾아서 정리했습니다.

코드가 줄어드니까 번들도 줄어들고, 빌드도 빨라집니다. 안 쓰는 코드는 바로바로 지우는 게 맞습니다.

이미지 최적화

  • next.config.mjs에서 images.formats: ['image/avif', 'image/webp'] 설정
  • LCP 이미지에 fetchPriority="high" 명시
  • MDX 이미지에 responsive sizesloading="lazy" 추가

폰트 CLS 제거

이게 꽤 까다로웠습니다. Pretendard 웹폰트가 로드되기 전에 시스템 폰트로 텍스트가 먼저 렌더링되면서 레이아웃 시프트(CLS)가 발생했습니다. 폰트가 바뀌면서 텍스트 크기가 달라지니까 아래 콘텐츠가 밀리는 거죠.

이걸 해결하기 위해 fallback 폰트의 metrics를 Pretendard에 최대한 맞춰서 정의했습니다.

src/styles/globals.css
@font-face {
  font-family: "Pretendard Fallback";
  src: local("Arial");
  ascent-override: 95%;
  descent-override: 25%;
  line-gap-override: 0%;
  size-adjust: 104%;
}

이렇게 하면 시스템 폰트가 Pretendard와 거의 같은 공간을 차지하게 되어서, 폰트가 교체되어도 레이아웃이 밀리지 않습니다.

접근성 개선

  • 모든 아이콘 버튼에 aria-label 추가
  • 터치 타겟 최소 44x44px 보장
  • 색상 대비 개선

결과

Lighthouse Score
PageSpeed Insights 측정 결과
CategoryScore
Performance99
Accessibility95
Best Practices100
SEO100

로컬 Lighthouse와 실제 배포 환경(Vercel)의 점수 차이가 큽니다. 로컬에서 Performance가 79점이었는데, Vercel 배포 후에는 99점이 나왔습니다. CDN의 성능 영향이 크기 때문에, 성능 측정은 반드시 배포 환경에서 하는 것이 정확합니다.

정리

좋았던 점

  • Biome — lint와 format 설정이 하나로 통합되어 관리 포인트가 줄어듦. 충돌도 없고 빠름
  • pnpm — 숨어있던 의존성 문제를 발견할 수 있었음
  • CI/CD 자동화 — PR을 올리면 lint, type-check, test, build가 자동으로 돌아가니까 마음이 편함
  • release-please — 커밋 메시지만 잘 쓰면 CHANGELOG와 릴리즈가 알아서 관리됨
  • Lighthouse 99/95/100/100 — 처음 측정했을 때 69점이었던 Performance를 여기까지 올린 게 뿌듯함

아쉬웠던 점

  • Biome 전환 시 포맷 변경으로 대부분의 파일이 수정되어 git blame 이력이 깨짐
  • Vercel의 dynamic 페이지에 Cache-Control: no-store가 자동 설정되는데, 이건 제어할 방법이 없음
  • unused JS 경고가 Lighthouse에 남아있는데, Next.js 프레임워크 자체 번들이라 코드로 제거 불가

앞으로 할 것

가장 큰 계획은 콘텐츠 관리 방식의 전환입니다.

지금은 MDX 파일이 블로그 레포지토리 안에 전부 들어가 있습니다. 포스트를 쓸 때마다 소스코드와 콘텐츠가 같은 레포에 섞이게 되는데, 시간이 지날수록 레포가 지저분해지는 게 느껴집니다.

최근에 맥미니로 홈서버를 구축했는데, 이걸 활용해서 Docker로 PostgreSQL을 띄우고 포스트 데이터를 따로 저장한 뒤, 블로그에서는 API로 불러오는 구조로 바꿀 계획입니다.

이렇게 분리하면 몇 가지 장점이 있습니다.

  • 레포지토리의 성격이 명확해짐 — 블로그 레포는 순수하게 프론트엔드 코드만, 콘텐츠는 DB에서 관리
  • 포스트 관리가 유연해짐 — CMS 없이도 DB에서 직접 CRUD 가능하고, 나중에 관리 페이지를 만들 수도 있음
  • 빌드 시간 단축 — MDX 파일이 빠지면 빌드가 가벼워짐
  • 검색 고도화 — DB에서 Full-text search로 검색하면 클라이언트에서 전체 데이터를 들고 있을 필요가 없음

그 외에도 Lighthouse Accessibility 100점 도전이나, 포스트가 더 쌓이면 검색을 서버 API로 전환하는 것도 계획하고 있습니다.

관련 포스트

참고 자료

Tags:
Next.jsReactBiomepnpm