tech.junha.kr

Next.js 15 instrumentation이 프로덕션에서 안 돌아간다면

Next.js 앱에서 서버 시작 시점에 딱 한 번만 실행되는 초기화 로직이 필요했다. 데이터베이스 연결, 캐시 설정, 외부 서비스 연결 같은 것들 말이다. 공식 문서를 보니 instrumentation.ts 파일을 만들면 앱 시작 시점에 한 번 돌아가는 기능이 있었고 register 함수를 export하면 된다고 나와있었다.

// instrumentation.ts
export async function register() {
  console.log('서버 초기화 중...');
  // 초기화 로직
}

개발 환경에서는 잘 되더라. npm run dev 하면 콘솔에 로그도 잘 찍히고, 초기화도 정상적으로 실행됐다. 그런데 프로덕션 빌드 후 실행하니까 아무것도 안 된다.

npm run build
npm start

콘솔에 아무런 로그도 없고, 초기화 코드도 실행되지 않았다. 뭔가 이상해서 GitHub 이슈들을 찾아봤더니 나만 겪는 문제가 아니었다.

발견한 이슈들

Next.js 공식 레포지토리에서 관련 이슈들을 여러 개 발견했다.

특히 Next.js 14.0.5-canary.29에서 발견한 건데, 프로덕션 빌드 후 서버를 시작할 때 register 함수가 자동으로 호출되지 않고, 첫 번째 라우트를 방문해야만 실행되는 문제였다. 하지만 나는 Next.js 15를 사용했고, instrumentation 기능이 정식으로 안정화되어서 experimental.instrumentationHook 설정을 config에 작성하지 않아도 기본적으로 동작한다.

이 기능 자체가 성능 추적이나 디버깅, 관찰 가능성 도구 통합 목적으로 만들어진 거라 어느 정도는 예상된 동작이라고 생각했다.

문제는 서버 시작과 동시에 실행되어야 할 초기화 로직이 사용자의 첫 번째 HTTP 요청에 의존한다는 점이었다. 서버가 시작되자마자 데이터베이스 연결이나 환경 설정 같은 걸 초기화해야 하는데, 누군가 웹사이트에 접속해야만 그 코드가 돌아간다니... 결국 다른 방법을 찾아야 했다.

해결책 찾기

instrumentation 기능 자체가 불안정하고, next 팀에서도 동작 방식을 블랙 박스로 숨겨놓으니 다른 방법을 써야겠다고 생각했다. 여러 방법을 시도해봤는데, 각각 장단점이 있었다.

1. Next.js 커스텀 서버

가장 확실한 방법은 Next.js 내장 커스텀 서버를 사용하는 것이다.

// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');

const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();

async function initialize() {
  console.log('서버 초기화 중...');

  // 여기에 초기화 로직 작성
  // await connectDatabase();
  // await setupCache();

  console.log('✅ 초기화 완료');
}

app.prepare().then(async () => {
  await initialize();

  createServer((req, res) => {
    const parsedUrl = parse(req.url, true);
    handle(req, res, parsedUrl);
  }).listen(3000, () => {
    console.log('> Ready on http://localhost:3000');
  });
});

package.json에서 start 스크립트도 바꿔준다:

{
  "scripts": {
    "start": "node server.js"
  }
}

이렇게 하면 Next.js 서버가 준비된 직후에 초기화 함수가 확실히 실행된다.

2. 모듈 레벨에서 실행

좀 더 간단한 방법도 있다. Next.js 앱 내에서 모듈이 처음 로드될 때 초기화하는 방식이다.

// lib/server-init.ts
let initialized = false;

async function initializeApp() {
  if (initialized) return;

  console.log('앱 초기화 중...');

  // 초기화 로직

  initialized = true;
  console.log('✅ 앱 초기화 완료');
}

// 서버에서만 실행
if (typeof window === 'undefined') {
  initializeApp();
}

그리고 app/layout.tsx에서 이 모듈을 import만 해주면 된다.

// app/layout.tsx
import '@/lib/server-init'; // 이것만 추가

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>{children}</body>
    </html>
  );
}

3. 좀 더 정교한 조건 처리

빌드 타임과 런타임을 확실히 구분하고 싶다면 이런 방법도 있다.

// 런타임에서만 실행 (빌드 타임 제외)
if (
  typeof window === "undefined" &&
  process.env.NEXT_PHASE !== "phase-production-build" &&
  process.env.NEXT_PHASE !== "phase-export" &&
  !process.env.NEXT_BUILD_ID
) {
  process.nextTick(() => {
    initializeApp().catch((error) => {
      console.error("앱 초기화 중 오류 발생:", error);
    });
  });
}

이렇게 하면 Next.js가 빌드 중일 때는 실행되지 않고, 실제 서버가 돌아갈 때만 실행된다.

이 방법이 최초 1번만 실행되는 이유는 Node.js의 모듈 캐싱 시스템 때문이다. Node.js는 한 번 로드된 모듈을 메모리에 캐시해두고, 같은 모듈을 다시 import할 때는 새로 실행하지 않고 캐시된 결과를 재사용한다. 따라서 lib/server-init.ts 파일이 처음 import될 때만 최상위 레벨의 코드가 실행되고, 그 이후에는 아무리 많은 컴포넌트에서 이 모듈을 import해도 초기화 코드는 다시 실행되지 않는다.

이는 RSC(React Server Components)와는 직접적인 관련이 없는, Node.js 자체의 기본 동작이다. 다만 Next.js App Router 환경에서는 layout.tsx에서 모듈을 import할 때 서버에서 렌더링되면서 초기화가 실행되는 것뿐이다. 여기에 initialized 플래그를 추가로 사용해서 혹시 모를 중복 실행도 방지하고, typeof window === 'undefined' 체크로 서버 환경에서만 실행되도록 보장하는 이중 안전장치를 마련한 셈이다.

결론

Next.js 15에서 instrumentation.ts는 개발 환경에서는 잘 되지만 프로덕션에서는 신뢰할 수 없다. 특히 standalone 빌드나 Docker 환경에서는 더더욱 그렇다. 당분간은 커스텀 서버를 사용하거나 모듈 레벨에서 초기화하는 방식을 쓰는 게 좋겠다. 개인적으로는 커스텀 서버 방식을 추천한다. 좀 더 명시적이고 제어 가능하기 때문이다. 어차피 프로덕션에서는 확실한 게 최고다.