Next.js를 실행하면 클라이언트 측 로그는 브라우저의 콘솔에 나타나지만, 서버 측 로그는 제대로 된 로그를 출력해주지 않았다. 이번에 프로젝트에 Next.js의 node 환경에서 log를 찍기 위해 라이브러리를 사용하면서 만난 트러블슈팅을 기록한다.
먼저 pino라는 라이브러리를 사용했는데, pino는 node 진영에서 winston과 함께 많이 사용되는 로깅 라이브러리다. 짧게 이야기하면 로깅 라이브러리는 비동기를 사용하는 pino로 결정. 그리고 pino-pretty 라이브러리는 pino로 찍은 로그를 더 알아보기 쉬운 형태로 포매팅 해주는 도구다.
pino를 Next.js 프로젝트에 예쁘게 입혔지만 포맷이 마음에 들지 않았기 때문에 pino-pretty 라이브러리를 추가로 설치하고 프로젝트에 설정해주었다.
로컬에서 두 도구를 사용해 API Routes, SSR 측 fetching API 로그를 콘솔에서 확인 후에 테스트를 해보기 위해 테스트 서버로 올렸다. 하지만, 로컬에선 멀쩡히 예쁘게 찍히던 pino 로그가 테스트 서버로 올리자마자 아래 에러와 함께 /500 페이지로 이동했다.
error: unable to determine transport target for "pino-pretty"
처음에는 설정이 틀렸나? 했다가, 한참을 돌아보니 Next.js의 standalone 빌드 방식 + pino의 transport 로딩 구조 + pnpm의 심볼릭 링크 특성이 합쳐져 터진 문제였다. 일단 pino 설정 코드부터 보자.
pino 설정 코드
import pino from 'pino';
const logger = pino({
// level 등은 생략
transport: {
target: 'pino-pretty', options: {
colorize: true,
translateTime: 'SYS:HH:MM:ss',
customColors: 'err:red,info:blue',
...(isLocal ? {} : { singleLine: true }),
},
},
});
pino는 transport 옵션을 줄 수 있는데, 로그를 전송하고 변환할 수 있게 해줄 수 있는 옵션이다. 포매팅을 넣고 싶다면 target
에 적어줄 수 있다(공식 문서).
target에 문자열로 pino-pretty를 적어주는 방법이다.
위 사진과 같이 이 방법으로 로컬에서 문제 없이 동작했다. 하지만, 테스트 서버에서의 로그는 unable to determine transport target for “pino-pretty” 라고 에러를 뱉고 있었다. (물론 빌드도 문제 없이 성공했다)
처음엔 몇 가지 해결책을 시도해봤다.
- devDependencies → dependencies로 승격
pino-pretty 라이브러리는 운영에 배포되면 안되겠다는 가벼운 생각 때문에 devDependencies로 설치했지만, 생각해보니 코드 모두 운영에 올라가야 맞다는 생각으로 dependencies로 옮겨주었다. 여전히 해결되지 않았다.
- import로 라이브러리 가져오기
문자열로 선언해서 라이브러리를 가져오지 못할 것이라 예상해서 아래 코드와 같이 먼저 import 구분으로 가져왔다.
import pinoPretty from 'pino-pretty';
transport: {
target: pinoPretty, // ...
}
프로젝트는 package.json에 type module을 통해 프로젝트 전체를 ESM으로 사용하고 있었기 때문에 import 구문이 문제가 없을 것이라 생각했지만,
⨯ TypeError: The "path" argument must be of type string. Received function build
at eval (src/shared/server/common/config/loggerConfig.ts:15:20)
...
> 15 | const logger = pino({
| ^
17 | transport: {
18 | target: pinoPretty, {
code: 'ERR_INVALID_ARG_TYPE',
이런 에러가 발생했다. ERR_INVALID_ARG_TYPE 에러는 Node.js에서 함수나 메서드에 넘긴 인자의 타입이 예상한 타입과 다를 때 발생하는 에러인데, path에 function 타입 대신 string 타입을 넣어야 한다는 뜻이다.
node_modules에 있는 pino-pretty/index.js 파일을 까보았다.
function build (opts = {}) {
const pretty = prettyFactory(opts)
return abstractTransport(function (source) {
// ...
module.exports.default = build
역시 pino 및 pino-pretty는 Node.js 서버 환경에서 주로 사용하는 라이브러리라 그런지 CommonJS 형태로 작성되어 있다.
default로 export 하는 것은 함수다. 따라서 공식문서에서 권장하는 대로 string 값인 ‘pino-pretty’ 를 적어주는게 맞았던 것이다. CommonJs 문법으로 작성된 걸 보니 혹시나 싶어 require로 가져오도록 해봤지만 여전히 해결되지 않았다.
왜 로컬에서는 동작하고, 테스트 서버 환경에서는 동작하지 않는지 곰곰이 생각해보니 Next.js를 standalone으로 배포하고 있다는게 떠올랐다.
Next.js의 standalone 빌드
Next.js output 공식문서에 따르면 standalone은 node_modules에서 프로덕션 배포에 필요한 파일만 복사하는 방식을 사용한다.
즉, 어플리케이션을 실행하는데 필요한 최소한의 코드만 node_modules에 가져가고, 이는 빌드 시 .next/standalone
하위에 저장하고 사용하겠다는 뜻이다. 즉, 사용하지 않은 코드 및 모듈은 모두 트리쉐이킹 되어 artifact에 포함되지 않는다. 그러면 docker image에 파일크기가 적은 상태로 빌드 되고, image 용량이 줄기 때문에 빌드 시간 및 EKS에 도달하는 속도도 획기적으로 줄일 수 있게 된다.
테스트 서버에서 pino-pretty 모듈을 찾지 못했기 때문에 standalone으로 빌드된 artifact에 pino-pretty가 없을 것 같았다. 얼른 빌드해 보았다.
역시나 .next/standalone/node_modules
에서 pino-pretty는 찾을 수 없었다. 원인을 찾았으니 이제 해결만 하면 된다. .next/standalone/node_modules에 pino-pretty 모듈만 넣어줄 수 있다면 문제는 해결 될 것이다.
outputFileTracingIncludes
공식문서에 따르면 next.config.js 파일에 outputFileTracingIncludes 설정을 하면 추적에 필요한 파일을 standalone으로 빌드한 artifact에 포함시켜줄 수 있다고 한다.
// next.config.js
outputFileTracingIncludes: {
'/': ['./node_modules/pino-pretty/**/*'],
},
위와 같이 현재 로컬에 있는 ./node_modules/pino-pretty
디렉토리를 찾아 하위에 있는 파일을 모두 artifact에 넣도록 설정한다.
그리고 다시 빌드.
드디어 잘 들어왔다.
다시 문제 발생
기대하는 마음을 안고 다시 테스트 서버로 배포해봤다. 하지만 이번엔 에러 메시지가 바뀌었다.
uncaughtException: [Error: Cannot find module 'colorette']
colorette
는 뭐지? 프로젝트에서는 사용하지 않는 라이브러리다. 아마도 pino-pretty의 내부 종속성이 아닐까? pino-pretty/index.js에서 사용하고 있는 모듈이었다. 그러면 next.config.js에 outputFileTracingIncludes
에 pino-pretty에서 사용하고 있는 모듈을 넣어주면 그만이다.
종속성을 파악해보자.
node -e "console.log(Object.keys(require('pino-pretty/package.json').dependencies || {}))"
[
'colorette', 'dateformat',
'fast-copy',
'fast-safe-stringify',
'help-me',
'joycon',
'minimist',
'on-exit-leak-free',
'pino-abstract-transport',
'pump',
'readable-stream',
'secure-json-parse',
'sonic-boom',
'strip-json-comments'
]
하위 종속성이 꽤 많다. 프로젝트는 pnpm을 사용하고 있으므로, colorette를 포함한 하위 종속성은 로컬의 node_modules/.pnpm
디렉토리 하위에 저장된다. 따라서 해당 경로를 찾아 artifact에 포함시켜야 한다.
outputFileTracingIncludes: {
'/': [
'./node_modules/pino-pretty/**/*',
'./node_modules/.pnpm/pino-pretty@*/*',
'./node_modules/.pnpm/pino-pretty@*/node_modules/**',
],
},
하지만 여전히 테스트 서버에서는 실패한다. 이상하다. 생각해보면 pino-pretty가 로컬에서 내부적으로 모듈을 사용할 때 require 문법으로 모듈을 가져올텐데, .pnpm
하위 모듈을 어떻게 가져올 수 있을까?
pnpm의 node_modules 저장 방식
모든 프로젝트에서 단말기 저장장치의 효율 및 속도 때문에 pnpm 을 패키지 매니저로 사용하고 있다.
node_modules는 일반적으로 flat(hoisted) 구조로 패키지에서 사용하는 종속성 하위 구조 모두 flat하게 저장하지만, pnpm은 심볼릭 링크를 통해 전역의 pnpm store에 저장된 모듈을 단순히 연결시켜주는 역할을 하고 있다.
만약 패키지 매니저의 설치 매커니즘을 조금 더 깊게 알고 싶다면 아래의 블로그를 보면 도움이 될 것이다.
지금 빌드 산출물에 포함된 .pnpm/node_modules
로 들어간 pino-pretty 하위 종속성은 심볼릭 링크만 가지고 들어간 것이다.
그렇다면, pnpm으로 패키지 설치 시 npm과 yarn과 같이 심볼링 링크 형태가 아니라 flat 구조로 설치하고 종속성을 가지고 들어가면 되지 않을까?
여기서 그냥 GitHub Actions에서 pnpm으로 설치한 node_modules 전체를 artifact에 포함시킬까도 고민했지만 비효율이 발생하므로 그러고 싶지 않았다.
pnpm의 node-linker
pnpm도 npm과 yarn과 마찬가지로 flat하게 설치할 수 있다. pnpm의 node-linker 에서 hoisted
설정을 해주면 심볼릭 링크 대신 flat한 구조로 설치해준다.
로컬에서 확인할 때는 .npmrc
파일을 프로젝트 root에 만들고 아래와 같이 적어주자.
node-linker=hoisted
그리고 pnpm install
로 패키지를 다시 설치해주면 flat 구조로 node_modules가 구성되었고, 종속성 모두 새롭게 설치된 것을 볼 수 있다.
하지만, 이렇게 hoisted 모드를 사용하게 되면 pnpm을 사용하는 이유가 사라진다. 글로벌 store의 의미는 퇴색된다(물론 다른 장점도 있긴 하지만..). 그래서 로컬 환경은 hoisted 모드를 사용하는 대신, GitHub Actions에서만 hoisted 모드를 사용하도록 한다.
- name: pnpm hoisted linker setting
run: pnpm config set node-linker hoisted --location=project
이제는 .pnpm
하위 디렉토리를 만들지 않을 것이므로 next.config.js 설정을 다시 바꿔주도록 하자.
outputFileTracingIncludes: {
'/': [
'./node_modules/pino-pretty/**/*',
// 그리고 하위 종속성 모두를 포함하는 경로를 다 적어주어야 함.
],
},
문제는, 하위 종속성 모두 다 적어주어야 한다는 것이다. 그리고 하위 종속성의 하위 종속성까지.. 의존성 트리 전체를 다 들고와야 한다. 그리고 pino-pretty를 업데이트라도 하는 날에는 다시 모든 의존성 트리를 검색하고, 설치한다음 다시 들고 들어가야한다.
현실적으로 pino-pretty를 서비스가 죽을 때까지 버전 유지하지 않는 이상 불가능하다. 무조건 Next.js에서 빌드할 때 자동으로 추척하도록 만들어 하위 트리를 묶어 들어갈 수 있도록 조치해야만 한다.
Next.js standalone 모드에서 의존성 트리를 추적하는 방법
기본적으로 번들러는 import
및 require
구문을 찾고 구문이 만약 node_modules에 존재한다면 해당 모듈을 빌드 artifact로 복사한다. Next.js standalone 모드에서의 추적은 @vercel/nft 라이브러리가 담당하고 있다.
문서를 들여다보면, 상용 번들러와 마찬가지로 import
, require
등을 추적하고 있다. 우리가 Next.js를 patch-package 해서 사용하지 않는 이상 고치는 것은 유지보수에 힘들 것이다(Next.js는 아주 잦은 업데이트가 있기 때문에…).
결국 Next.js에서 제대로 pino-pretty를 추적하도록 만들어줘야만 한다.
@vercel/nft는 의존성 트리를 어떻게 추적할까? 먼저 파일을 AST로 파싱해서 ‘정적’ 참조를 따라간다. 용어 정리를 해보자.
1. 정적(static), 동적(dynamic) import
정적 import
import foo from 'foo';
import { bar } from './bar.js';
from절 뒤에 리터럴로 패키지를 명확하게 명시.
동적 import
import('foo');
import 함수를 사용하여 파일 상단이 아닌 조건부로 패키지를 가져온다.
ECMAScript proposal: import() 에서 import()를 dynamic import라고 부름.
2. 정적 참조(Statically analyzable reference), 동적 참조(Dynamic reference)
정적 참조
공식 용어는 아니지만, 번들러에서 쓰는 개념.
require('lodash'); // ✅ 정적 참조
await import('pino-pretty'); // ✅ 정적 참조
소스 코드에서 모듈 경로가 문자열 리터럴로 고정되어 있어 빌드 타임에 추적 가능한 참조.
동적 참조
const name = process.env.LIB;
require(name); // ❌ 빌드 시 추적 불가
await import(name); // ❌ 빌드 시 추적 불가
따라서 정적 import와 동적 import는 ‘정적 참조’ 이므로(리터럴 문자열로 패키지명을 적었으므로) 번들러 및 nft가 추적이 가능하다. 하지만, pino-pretty는 왜 동적참조인가?
pino-pretty를 사용하는 pino 라이브러리를 살짝 까보자.
// node_modules/pino/lib/transport.js
function fixTarget(origin) {
// ...
for (const filePath of callers) {
try {
const context = filePath === 'node:repl' ? process.cwd() + sep : filePath; fixTarget = createRequire(context).resolve(origin); break;
} catch (err) {
continue;
}
}
if (!fixTarget) {
throw new Error(`unable to determine transport target for "${origin}"`);
}
return fixTarget;
}
createRequire는 Node.js의 함수로, 특정 파일/디렉토리 경로를 기준으로 require.resolve를 수행할 수 있게 해준다. 결국 이건 Node의 모듈 해석 알고리즘을 따르고 있다.
즉, ‘pino-pretty’ 를 문자열로 적어주면 require.resolve('pino-pretty')
가 되어 node_modules/pino-pretty 디렉토리를 찾아 절대 경로를 반환하게 된다. 하지만, 변수로 ‘pino-pretty’ 라는 문자열을 만들어냈으므로 이는 동적 참조이고, @vercel/nft가 추적하지 못하게 된 것이다.
결국 우리에게 필요한건, 코드 베이스에 import pinoPretty from 'pino-pretty'
또는 import(pino-pretty)
이다.
해결 방법
허무하지만 정말 간단하다.
import pino from 'pino';
if (process.env.NEXT_PUBLIC_APP_ENV !== 'localhost') { await import('pino-pretty');}
const logger = pino({
transport: {
target: 'pino-pretty',
// ...
},
});
테스트 서버 및 운영 서버에서 동적 import를 사용해서 ‘정적 참조’ 시키면 된다.
flowchart TD
classDef bad fill:#ffe6e6,stroke:#ff4d4f,color:#a8071a;
classDef good fill:#e6fffb,stroke:#36cfc9,color:#006d75;
A1["transport.target: 'pino-pretty' ← 문자열"] --> B["Next build (standalone)"]
A2["await import('pino-pretty') ← 리터럴"] --> B["Next build (standalone)"]
B -->|target 문자열만 있음| C["@vercel/nft 정적 추적 실패<br/>- 리터럴 import 없음(변수처리 되어 resolve)<br/>- 추적 누락"]
B -->|리터럴 import 있음| C2["@vercel/nft 정적 추적 성공<br/>- pino-pretty 의존성 포함"]
C --> D[".next/standalone/node_modules<br/>- pino: O<br/>- pino-pretty: X<br/>- deps: X"]
C2 --> D2[".next/standalone/node_modules<br/>- pino: O<br/>- pino-pretty: O<br/>- deps: O"]
D --> E["런타임: transport 로드<br/>require('pino-pretty') 시도"]
D2 --> E2["런타임: transport 로드<br/>require('pino-pretty') 성공"]
E --> F["에러: unable to determine transport target for 'pino-pretty'"]
E2 --> F2["예쁜 로그 정상 출력"]
class F bad;
class F2 good;
아주 이쁘다..
정리
핵심 개념 1: pino, pino-pretty, 그리고 transport 로딩
- pino: 빠른 JSON 로거. 기본 출력은 머신 친화적(JSON) 이라서 수집 파이프라인(ELK, Loki, CloudWatch 등)에 좋다.
- pino-pretty: pino 로그를 사람이 읽기 좋게 변환해주는 트랜스포트(transport).
- 코드 설정: pino({ transport: { target: ‘pino-pretty’, options: {…} } })
- transport target 동작: pino의 transport는 target에 적힌 문자열을 런타임에 require()로 해석한다. -> 동적 참조이다.
- 즉, 서버가 실제로 구동될 때 모듈을 찾는다.
- 그런데 구동할 때 찾는다는 말은, 이미 빌드 산출물에 그 모듈이 들어있어야 한다는 뜻.
여기서 Next.js standalone과 충돌이 난다.
핵심 개념 2: Next.js standalone과 @vercel/nft의 정적 추적
- output: ‘standalone’ 빌드 시, .next/standalone 안에 실행에 필요한 최소 파일만 모여든다.
- 무엇이 필요한가?를 결정하는 엔진이 [@vercel/nft].
- 빌드할 때 코드를 AST로 파싱해서, 정적으로 판별 가능한 import/require만 따라간다.
- 포함됨: import ‘x’, import x from ‘x’, require(‘x’), await import(‘x’) (문자열 리터럴)
- 누락됨: require(‘pino-’ + env), await import(variable) (표현식/변수)
- 중요한 포인트: transport.target: ‘pino-pretty’는 런타임 문자열이기 때문에 빌드 타임 정적 추적 대상이 아니다. → 그래서 standalone 산출물에 pino-pretty가 안 들어간다.
실제로 .next/standalone/node_modules를 보면, pino는 있는데 pino-pretty는 없다는 걸 확인할 수 있다.
핵심 개념 3: pnpm의 구조와 왜 더 까다로워지는지
- pnpm은 .pnpm 디렉터리에 패키지를 설치하고, node_modules는 심볼릭 링크로 구성한다. (공간 효율 최고)
- npm/yarn처럼 flat하게 파일이 쫙 깔리는 게 아니라, 링크 체인을 타고 들어간다.
- 이 구조는 @vercel/nft의 파일 추적이나, Next의 outputFileTracingIncludes에서 경로 패턴/심볼릭 링크 처리가 까다로워지게 만든다. → 겉보기엔 들어온 것 같은데 실행 시점엔 여전히 못 찾는 상황이 생기기 딱 좋다.
마치며
이번 트러블슈팅을 하며 아무 생각 없이 썼던 import 구문에서 어떤 처리들이 일어나고 있는지를 알게 되었고, standalone 빌드 환경과 pnpm과 Next.js 빌드 관계에 대해 다시 한 번 짚어볼 수 있는 시간이었다. 아직까지도 사용하고 있는 Gatsby도 config 파일에서 문자열로 라이브러리를 등록해 사용하고 있는데, pino와 마찬가지로 리터럴 문자열로 node_modules에 있는 라이브러리를 참조하고 있다는 사실을 추가로 알게 되었다.
참고로, 테스트 서버에서는 pino-pretty를 사용해 로깅하지만 운영 서버에서는 pretty를 실시간성 로그에만 사용하고, pino-pretty를 이용하지 않은 pure log를 파일로 저장해두는 것이 좋을 것 같다.
프로젝트에서 Next.js의 page router를 사용하고 있어 SSR 환경에서의 API fetching이 일어날 때 자동으로 로깅을 남겨주지 않는다. 언젠가 app router로 넘어가게 될텐데, 중요한 부분 및 파일로 로그를 적재해야할 부분은 pino로 남겨두어야겠다.
덧) 사실 이런 방법도 있단다..