Next.js GitHub Pages 배포 트러블슈팅: export, basePath, configure-pages

커버 이미지

Next.js 앱을 GitHub Pages에 배포하면서 미들웨어가 동작하지 않는 문제에 부딪혔습니다. 하나를 해결할 때마다 새로운 문제가 나타나는 연쇄적인 상황이었습니다.

결론부터 말하면, 이 과정에서 겪은 대부분의 문제는 Next.js가 next.config.jsnext.config.ts보다 우선하여 로딩한다는 점에서 비롯되었습니다. 로컬에서는 직접 만든 .js 파일이, CI에서는 actions/configure-pages가 자동 생성한 .js 파일이 원인이었습니다.

1장. 미들웨어가 동작하지 않는 문제

문제는 여기서 시작되었습니다.

로컬에서 npm run dev로 개발 서버를 실행하고 middleware.ts에 사용자가 어느 페이지에 진입했는지를 알 수 있는 로그를 추가해두었는데, 터미널에 아무것도 출력되지 않았습니다. 요청은 정상적으로 전송되고 있었지만 미들웨어가 실행되지 않는 것으로 보였습니다.

// middleware.ts
export function middleware(request: NextRequest) {
  console.log("🔥 middleware called:", request.nextUrl.pathname);
  // ... 아무것도 안 찍힘
}

처음에는 코드 문제인가 싶어 여러 가지를 시도해봤는데, 터미널에서 다음 경고를 발견했습니다.

⨯ Middleware cannot be used with "output: export"

output: 'export' 옵션이 활성화되어 있으면 미들웨어가 비활성화된다는 내용이었습니다. 그런데 next.config.ts에서는 조건부로 export를 켜도록 설정해두었기 때문에, 로컬 개발 환경에서는 export가 꺼져 있어야 했습니다.

프로젝트 루트를 다시 확인해보았습니다.

next.config.js
next.config.ts

next.config.jsnext.config.ts가 둘 다 존재했습니다. Next.js는 설정 파일을 하나만 읽도록 설계되어 있고, 이 상황에서는 .js 쪽이 우선 적용되는 것으로 보였습니다.

이게 정말 맞는지 확실히 하고 싶어서 Next.js 내부 구현을 확인해봤습니다. node_modules/next/dist/esm/server/config.js를 보면 설정 파일을 찾을 때 findUp(CONFIG_FILES, { cwd: dir })를 호출하는데, CONFIG_FILES는 아래 순서로 정의되어 있습니다.

next.config.js
next.config.mjs
next.config.ts

즉, 같은 디렉터리에 .js.ts가 함께 있으면 findUp.js를 먼저 찾는 구조이고, 그 순간 .ts는 읽힐 기회가 없어지게 됩니다.

어쨌든 실제로 적용되는 next.config.js를 열어보니, output: 'export'가 하드코딩되어 있었습니다. 예전에 배포 테스트를 하면서 만들어둔 파일을 지우지 않고 남겨둔 게 원인이었습니다.

// next.config.js (이전에 만들어둔 파일)
const nextConfig = {
  output: "export",
  basePath: "/blog",
  images: { unoptimized: true },
};
 
module.exports = nextConfig;

첫 번째 교훈: 설정 파일이 여러 개 있으면 우선순위를 확인하자.

2장. 설정 파일을 삭제한 후 발생한 404 오류

원인을 찾았으니 해결은 간단해 보였습니다. next.config.js를 삭제하면 됩니다.

rm next.config.js
npm run dev

그런데 파일을 삭제하자, 이번에는 모든 페이지에서 404가 발생했습니다.

확인해보니 브라우저에서 http://localhost:3000/blog/로 접속하고 있었는데, next.config.js를 삭제하면서 basePath: "/blog" 설정도 함께 사라진 것이었습니다.

basePath가 없으면 Next.js는 /blog/* 경로를 인식하지 못합니다. 접속 URL을 http://localhost:3000/으로 변경하니 정상적으로 동작했습니다.

이 시점에서 문제를 정리할 필요가 있었습니다.

  • 로컬 개발: /에서 동작해야 함 (basePath 없이)
  • GitHub Pages 배포: /blog에서 동작해야 함 (basePath 필요)

두 환경이 서로 다른 설정을 필요로 합니다. 이를 해결하기 위해 next.config.ts에서 환경변수로 분기하는 방식을 도입했습니다.

const isExport =
  process.env.NEXT_PUBLIC_EXPORT === "true" ||
  process.env.GITHUB_ACTIONS === "true";
 
const nextConfig: NextConfig = {
  output: isExport ? "export" : undefined,
  basePath: isExport ? "/blog" : "",
  assetPrefix: isExport ? "/blog" : "",
  // ...
};

로컬에서는 환경변수가 없으니 /로 동작하고, CI에서는 GITHUB_ACTIONS=true가 자동으로 설정되니 /blog로 빌드됩니다.

두 번째 교훈: 설정을 삭제할 때는 그 설정이 영향을 미치는 모든 지점을 확인하자.

3장. 이미지 경로 문제

조건부 설정을 적용한 후 라우팅은 정상적으로 동작했습니다. 그런데 이번에는 이미지가 전부 404였습니다.

개발자 도구를 확인해보니 이미지 요청이 /blog/images/posts/...로 가고 있었습니다. 로컬 서버는 /에서 동작하고 있는데, 이미지 경로에 /blog가 포함되어 있어 파일을 찾을 수 없었습니다.

MDX 파일과 컴포넌트를 확인해보니 이미지 경로가 다음과 같이 작성되어 있었습니다.

![커버 이미지](/blog/images/posts/xxx/cover.png)
<img src="/blog/images/default-cover.png" />

배포 환경에 맞춰 경로를 하드코딩해둔 것입니다. 배포 환경에서는 정상적으로 동작하지만, 로컬에서는 모두 깨집니다.

이 문제를 해결하려면 콘텐츠 작성자가 basePath를 신경 쓰지 않아도 되도록 만들어야 합니다. 이를 위해 withBasePath 헬퍼를 만들었습니다.

// lib/base-path.ts
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
 
export const withBasePath = (path: string) => {
  if (!path) return BASE_PATH || "";
  if (/^https?:\/\//i.test(path)) return path; // 외부 URL은 그대로
  if (BASE_PATH && path.startsWith(BASE_PATH)) return path; // 이미 붙어있으면 그대로
  if (BASE_PATH && path.startsWith("/")) return `${BASE_PATH}${path}`;
  return path;
};

그리고 다음과 같은 규칙을 정했습니다: MDX와 컴포넌트에서는 /images/... 형식으로만 작성하고, basePath는 시스템이 자동으로 처리하도록 합니다.

![커버 이미지](/images/posts/xxx/cover.png)
const coverImage = withBasePath(post.coverImage);

세 번째 교훈: 환경에 따라 달라지는 값은 하드코딩하지 말고, 한 곳에서 관리하자.

4장. CI에서 out 폴더가 생성되지 않는 문제

로컬 환경은 정리가 되었습니다. 다음은 GitHub Actions로 배포할 차례였습니다.

푸시 후 Actions 탭을 확인했는데, 빌드가 실패했습니다.

touch: cannot touch './out/.nojekyll': No such file or directory

.nojekyll 파일을 생성하려는데 out/ 디렉터리 자체가 존재하지 않는다는 오류입니다. out/ 디렉터리는 output: "export"로 빌드해야 생성되는 폴더이므로, export 빌드가 실행되지 않았다는 의미입니다.

next.config.ts에서 GITHUB_ACTIONS 환경변수로 export를 활성화하도록 설정해두었는데, 왜 동작하지 않는 것인지 확인이 필요했습니다.

const isExport =
  process.env.NEXT_PUBLIC_EXPORT === "true" ||
  process.env.GITHUB_ACTIONS === "true"; // CI에서는 이게 true일 텐데...

환경변수 타이밍 문제일 가능성을 고려하여, npm 스크립트에서 명시적으로 환경변수를 설정하는 방식으로 변경했습니다.

{
  "scripts": {
    "build:export": "NEXT_PUBLIC_EXPORT=true next build"
  }
}

워크플로우에서도 npm run build:export를 사용하도록 변경하고 다시 푸시했습니다.

여전히 out/ 디렉터리가 생성되지 않았습니다.

5장. CI 환경에서만 존재하는 설정 파일

로컬에서 npm run build:export를 실행하면 out/ 디렉터리가 정상적으로 생성되는 것을 확인했습니다. 동일한 코드와 명령어인데 CI에서만 동작하지 않는 것이었습니다. 그래서 디버깅을 위해 워크플로우의 두 지점에 로그 스텝을 추가한 후, checkout 직후와 Setup Pages 실행 후에 각각 설정 파일 상태를 출력하도록 설정하니 아래처럼 출력되었습니다.

- name: Debug config files (after checkout)
  run: |
    ls -la next.config.*
    git status -sb
    if [ -f next.config.js ]; then cat next.config.js; fi
 
- name: Setup Pages
  uses: actions/configure-pages@v3
  with:
    static_site_generator: next
 
- name: Debug config files (after setup pages)
  run: |
    ls -la next.config.*
    git status -sb
    if [ -f next.config.js ]; then cat next.config.js; fi

다시 푸시하고 로그를 확인했습니다. 먼저 checkout 직후의 로그입니다.

-rw-r--r-- 1 runner runner 518 Jan 16 08:44 next.config.ts
## main...origin/main

next.config.ts만 존재하고, next.config.js는 없습니다. git status도 깨끗합니다.

다음은 Setup Pages 실행 후의 로그입니다.

-rw-r--r-- 1 runner runner 182 Jan 16 08:44 next.config.js
-rw-r--r-- 1 runner runner 518 Jan 16 08:44 next.config.ts
## main...origin/main
?? next.config.js
// Default Pages configuration for Next
const nextConfig = {images: {unoptimized: true},experimental: {images: {unoptimized: true}},basePath: "/my-site"}
module.exports = nextConfig

next.config.js가 새로 생성되었습니다. git status에서 ?? next.config.js로 표시되는 것을 보면, 이 파일이 git에 추적되지 않는 새 파일임을 알 수 있습니다. "Default Pages configuration for Next"라는 주석이 달려 있고, 직접 작성한 파일이 아닙니다.

checkout과 Setup Pages 사이에 next.config.js가 생성되었다는 것이 명확해졌습니다.

6장. configure-pages가 원인

워크플로우에서 actions/configure-pages@v3를 사용하고 있었습니다.

- name: Setup Pages
  uses: actions/configure-pages@v3
  with:
    static_site_generator: next

이 액션이 Next.js 프로젝트를 위해 설정 파일을 자동으로 생성하고 있었습니다. actions/configure-pages소스 코드를 확인해보면, 설정 파일이 존재하지 않을 경우 기본 템플릿으로 새 파일을 생성하는 로직이 있습니다.

// Node.js 파일 시스템 모듈(fs)을 이용해 파일 존재 여부 확인/읽기/쓰기 작업을 실행
if (!fs.existsSync(this.configurationFile)) {
  // GitHub Actions 로그에 "기본 빈 설정을 사용한다"는 안내를 출력
  core.info("Using default blank configuration");
 
  // blankConfigurationFile(템플릿 파일) 내용을 UTF-8 문자열로 읽어옴
  // 예: next.config.js가 없을 때 기본 템플릿을 복사해 넣기 위한 원본
  const blankConfiguration = fs.readFileSync(blankConfigurationFile, "utf8");
 
  // configurationFile 경로에 템플릿 내용을 그대로 써서 파일을 생성
  // (이미 파일이 있으면 if에 걸리지 않으므로 덮어쓰지 않음)
  fs.writeFileSync(this.configurationFile, blankConfiguration, {
    encoding: "utf8", // 파일 인코딩을 UTF-8로 저장
  });
}

문제는 이 액션이 .ts 확장자를 인식하지 못한다는 점입니다. 관련 GitHub 이슈에 나와있듯이, next.config.ts가 존재하더라도 액션은 이를 인식하지 못하고 next.config.js를 새로 생성하게 됩니다.

결과적으로 다음과 같은 상황이 발생합니다.

  1. configure-pagesnext.config.js를 생성
  2. Next.js는 .js.ts보다 우선 로딩
  3. 조건부 설정(output: isExport ? "export" : undefined)이 무시됨
  4. 하드코딩된 설정이 적용되는데, 여기에는 output: "export"가 없음
  5. export 빌드가 실행되지 않아 out/ 디렉터리가 생성되지 않음

1장에서 로컬 환경에서 겪었던 문제가 CI에서 동일하게 재현된 것입니다. 다만 이번에는 직접 만든 파일이 아니라 액션이 생성한 파일이었습니다.

7장. 해결

해결책은 빌드 전에 next.config.js가 존재하면 삭제하는 스텝을 추가하는 것입니다.

- name: Setup Pages
  uses: actions/configure-pages@v3
  with:
    static_site_generator: next
 
- name: Ensure TS config is used
  run: |
    if [ -f next.config.js ]; then
      echo "Removing generated next.config.js so next.config.ts is used."
      rm next.config.js
    fi
 
- name: Build
  run: npm run build:export

이 변경 후 out/ 디렉터리가 정상적으로 생성되었고, 배포가 성공했습니다.


돌이켜보면, 이 모든 문제는 설정 파일 우선순위라는 하나의 원인에서 시작되었습니다.

  • 1장: 로컬에 .js.ts가 공존하여 미들웨어가 동작하지 않음
  • 2장: .js를 삭제하면서 basePath 설정도 함께 사라짐
  • 3장: basePath 하드코딩으로 인한 이미지 404
  • 4-6장: CI에서 .js가 자동 생성되어 동일한 문제가 재발함

Next.js는 .js.ts보다 먼저 읽는다는 사실을 사전에 인지하는 것이 디버깅 시간 단축의 핵심이었습니다.

최종 구성

next.config.ts

import type { NextConfig } from "next";
 
const isExport =
  process.env.NEXT_PUBLIC_EXPORT === "true" ||
  process.env.GITHUB_PAGES === "true" ||
  process.env.GITHUB_ACTIONS === "true";
 
const nextConfig: NextConfig = {
  output: isExport ? "export" : undefined,
  basePath: isExport ? "/blog" : "",
  assetPrefix: isExport ? "/blog" : "",
  trailingSlash: isExport,
  images: {
    unoptimized: isExport,
  },
  env: {
    NEXT_PUBLIC_BASE_PATH: isExport ? "/blog" : "",
  },
};
 
export default nextConfig;

package.json

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "build:export": "NEXT_PUBLIC_EXPORT=true next build"
  }
}

GitHub Actions workflow

- name: Setup Pages
  uses: actions/configure-pages@v3
  with:
    static_site_generator: next
 
- name: Ensure TS config is used
  run: |
    if [ -f next.config.js ]; then
      echo "Removing generated next.config.js so next.config.ts is used."
      rm next.config.js
    fi
 
- name: Build
  run: npm run build:export
 
- name: Add .nojekyll file
  run: touch ./out/.nojekyll

마무리

핵심 원칙은 명확합니다. 로컬에서 동작하는데 CI에서 동작하지 않는다면, CI에만 존재하는 무언가를 의심해야 합니다. actions/configure-pages처럼 편의를 위해 설정 파일을 자동 생성하는 도구들이 있고, 이것이 기존 설정과 충돌할 수 있습니다. 디버깅 시 파일이 언제, 어떤 주체에 의해 생성되었는지 확인하는 것이 중요합니다.


참고 자료