서버 사이드 로깅 및 모니터링
서버 컴포넌트·Route Handler·Proxy에 구조화 로그를 남기고 Vercel과 APM에서 오류·지연을 관찰합니다.
Next.js 애플리케이션은 브라우저(클라이언트)와 Node.js 환경(서버)에서 함께 실행됩니다.
앞 절에서 클라이언트 디버깅을 다뤘다면, 이번 절은 서버 사이드 로깅(Server-side Logging)과 모니터링(Monitoring)에 초점을 맞춥니다.
특히 서버 컴포넌트, Route Handler, Proxy에서 발생하는 문제를 어떻게 진단하고 해결할지 실전 관점에서 정리합니다.
서버 사이드 로깅의 중요성
클라이언트 측 오류는 브라우저 개발자 도구 콘솔에서 쉽게 확인할 수 있지만, 서버 측에서 발생하는 오류나 예외는 사용자에게 직접적으로 노출되지 않을 수 있습니다.
서버 사이드 로깅은 다음과 같은 이유로 매우 중요합니다.
- 오류 및 예외 추적: 서버에서 발생하는 런타임 오류, 데이터베이스 연결 문제, 외부 API 호출 실패 등 예측 불가능한 문제를 기록하여 신속하게 진단하고 수정할 수 있습니다.
- 성능 병목 현상 식별: 특정 API 요청의 응답 시간, 데이터베이스 쿼리 시간 등을 로깅하여 성능 저하의 원인을 파악할 수 있습니다.
- 사용자 행동 분석: 사용자의 요청 패턴, 특정 기능 사용 빈도 등을 로깅하여 애플리케이션 개선을 위한 통찰력을 얻을 수 있습니다.
- 보안 감사: 비정상적인 접근 시도, 인증 실패 등을 기록하여 보안 위협을 감지하고 대응할 수 있습니다.
- 디버깅 용이성: 프로덕션 요청을 멈추는 디버깅은 서비스에 영향을 줄 수 있습니다. 로그를 메트릭·트레이스·배포 이력과 함께 사용해 원인을 좁힙니다.
Next.js에서 서버 사이드 로깅 구현
아래 예제는 Node.js 런타임을 전제로 합니다. Edge Runtime을 선택한 Route Handler는 사용 가능한 API와 로거 지원 범위를 별도로 확인해야 합니다.
기본 console.log 활용
가장 간단한 방법은 console.log, console.error, console.warn 등을 사용하는 것입니다.
-
위치
- Route Handler:
app디렉터리의route.ts에 정의한 HTTP 메서드 함수. - Server Components:
app디렉터리 내의 서버 컴포넌트. - Server Actions:
actions파일 내의 서버 액션 함수. - Proxy:
proxy.ts파일. - 데이터베이스 연결 파일:
lib/db.ts등.
- Route Handler:
-
확인 방법
- 로컬 환경: 개발 서버의 터미널에 출력됩니다. 빌드 중 사전 렌더에서 실행된 로그는 빌드 출력에, 요청 처리 로그는 실행 중인 서버에 남습니다.
- Vercel 배포 환경: Vercel 대시보드의 특정 배포에 대한 Logs 탭에서 확인할 수 있습니다.
-
예시
app/api/books/route.ts (Route Handler) import { NextResponse } from 'next/server'; import connectToDatabase from '@/lib/db'; import Book from '@/models/Book'; export async function GET() { try { console.log('GET /api/books 요청 수신'); // 서버 측 로그 await connectToDatabase(); const books = await Book.find({}); console.log(`총 ${books.length}권의 책을 찾았습니다.`); // 서버 측 로그 return NextResponse.json(books); } catch (error) { console.error('API /api/books 처리 중 오류 발생:', error); // 서버 측 오류 로그 return NextResponse.json({ error: 'Failed to fetch books' }, { status: 500 }); } }다음 코드는 조회·로그 부분만 발췌했습니다.
notFound()호출과 JSX 반환은 생략되어 있어 완성된 페이지 예제가 아닙니다. 잘못된 ID의 조회 오류 처리도 별도로 필요합니다.app/books/[id]/page.tsx (Server Component) import connectToDatabase from '@/lib/db'; import Book, { IBook } from '@/models/Book'; interface BookDetailPageProps { params: Promise<{ id: string }>; } export default async function BookDetailPage({ params }: BookDetailPageProps) { const { id } = await params; console.log(`도서 상세 페이지 로드: ID ${id}`); // 서버 측 로그 await connectToDatabase(); const book: IBook | null = await Book.findById(id).lean(); if (!book) { console.warn(`ID ${id}에 해당하는 책을 찾을 수 없습니다.`); // 서버 측 경고 로그 // notFound(); } // ... }
로깅 라이브러리 활용 (Winston, Pino 등)
레벨 필터링·일관된 포맷·전송 제어가 필요하다면 로깅 라이브러리를 검토합니다. 구조화된 값을 표준 출력에 남기고 플랫폼에서 수집하는 방식으로 요구를 충족할 수도 있습니다.
-
장점
- 로그 레벨:
debug,info,warn,error등 다양한 로그 레벨을 지원하여 중요도에 따라 로그를 필터링할 수 있습니다. - 로그 포맷: JSON, 텍스트 등 다양한 포맷으로 로그를 출력할 수 있어 로그 분석 도구와 연동하기 용이합니다.
- 전송: 파일, 데이터베이스, 외부 로깅 서비스(Datadog, Sentry, CloudWatch 등)로 로그를 전송할 수 있습니다.
- 컨텍스트: 요청 ID 등 컨텍스트를 child logger나 명시적 필드로 전달할 수 있습니다. 라이브러리를 설치하는 것만으로 요청 간 연결 정보가 자동 생성·전파되지는 않습니다.
- 로그 레벨:
-
예시 (Winston)
설치:
npm install winstonlib/logger.ts생성lib/logger.ts import { createLogger, format, transports } from 'winston'; const { combine, timestamp, printf, colorize, align } = format; const logFormat = printf(({ level, message, timestamp, stack }) => { return `${timestamp} ${level}: ${message}${stack ? `\n${stack}` : ''}`; }); const logger = createLogger({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', // 프로덕션에서는 info 이상, 개발에서는 debug 이상 format: combine( timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), logFormat, // 프로덕션에서는 JSON 포맷 권장 // process.env.NODE_ENV === 'production' ? format.json() : colorize({ all: true }) ), transports: [ new transports.Console(), // 콘솔에 출력 // 프로덕션에서는 파일 또는 외부 서비스로 전송 // new transports.File({ filename: 'error.log', level: 'error' }), // new transports.File({ filename: 'combined.log' }), ], }); export default logger;사용app/api/books/route.ts import { NextResponse } from 'next/server'; import connectToDatabase from '@/lib/db'; import Book from '@/models/Book'; import logger from '@/lib/logger'; // 로거 임포트 export async function GET() { try { logger.info('GET /api/books 요청 수신'); await connectToDatabase(); const books = await Book.find({}); logger.debug(`총 ${books.length}권의 책을 찾았습니다.`); return NextResponse.json(books); } catch (error: unknown) { const cause = error instanceof Error ? error : new Error(String(error)); logger.error({ message: `API /api/books 처리 중 오류 발생: ${cause.message}`, stack: cause.stack, }); return NextResponse.json({ error: 'Failed to fetch books' }, { status: 500 }); } }
Next · Winston example contract
Winston 설정과 관측의 범위
Node.js 예제는 레벨 필터와 timestamp·printf 텍스트를 Console에 출력합니다. 주석의 JSON·파일·외부 전송이나 요청 ID·소요 시간 계측은 현재 코드에 적용되지 않습니다.
| 항목 | 현재 코드 | 범위 |
|---|---|---|
| 레벨 | 운영: info · 개발: debug | 운영에서는 책 개수의 logger.debug가 출력 대상에서 제외됩니다. |
| 형식 | timestamp + printf | 텍스트 출력입니다. 주석 처리된 JSON 설정은 적용되지 않습니다. |
| 오류 | 명시한 message와 stack | 스택이 있으면 formatter가 덧붙입니다. 실제 오류를 실행해 관측한 결과는 아닙니다. |
| 전송 | Console transport | 파일·외부 서비스 transport는 주석 상태입니다. 장기 보관을 구현하지 않습니다. |
| 연결 정보 | 요청 ID·구간별 소요 시간 없음 | 요청 추적이나 지연 집계에는 별도의 필드·계측 구현이 필요합니다. |
- 레벨
- 현재 코드
- 운영:
info· 개발:debug - 범위
- 운영에서는 책 개수의
logger.debug가 출력 대상에서 제외됩니다.
- 형식
- 현재 코드
timestamp+printf- 범위
- 텍스트 출력입니다. 주석 처리된 JSON 설정은 적용되지 않습니다.
- 오류
- 현재 코드
- 명시한
message와stack - 범위
- 스택이 있으면 formatter가 덧붙입니다. 실제 오류를 실행해 관측한 결과는 아닙니다.
- 전송
- 현재 코드
Consoletransport- 범위
- 파일·외부 서비스 transport는 주석 상태입니다. 장기 보관을 구현하지 않습니다.
- 연결 정보
- 현재 코드
- 요청 ID·구간별 소요 시간 없음
- 범위
- 요청 추적이나 지연 집계에는 별도의 필드·계측 구현이 필요합니다.
원 로거로 남는 기록 확인하기 Winston 예제는 기본 레벨의 우선순위를 사용합니다. 공식 Winston 설명의 기본 레벨과 전송 설정에 따라, production의 info 설정에서는 debug 기록을 출력하지 않습니다. 주석으로 남긴 JSON 포맷과 File 전송은 실행되는 설정이 아닙니다.
직접 확인하기 원 GET에서 두 권 조회에 성공한 요청 하나와 DB 연결에서 실패한 요청 하나를 순서대로 처리한다고 합시다. production에서 Console에 남을 로그 호출은 몇 개이며 어느 레벨인가요? 개발 설정이라면 무엇이 추가될까요? 이 기록만으로 요청별 DB 소요 시간을 알 수 있을까요?
필터링되는 호출과 빠진 측정
production에서는 성공 요청의 info 한 개와 실패 요청의 info·error 두 개가 남아 합계 세 개입니다. 성공 뒤 호출한 debug는 걸러집니다. 개발 설정은 debug도 허용하므로 성공 요청의 도서 수 기록 한 개가 추가되어 합계 네 개입니다. 오류를 catch한 경우에는 error의 message·stack을 원 printf 포맷으로 출력합니다.
원 코드에는 DB 작업 전후의 타이머나 요청 ID 생성·전파가 없어 이 기록만으로 DB 소요 시간과 동시 요청의 연결 관계를 확정할 수 없습니다. 운영용 오류 message·stack에 연결 문자열이나 개인정보가 포함될 수 있는지도 점검하고, 공개할 필드를 정해 필요한 값을 기록합니다. Console 전송이 설정됐다는 사실만으로 파일 저장이나 외부 수집까지 완료되었다고 보지 않습니다.
추가 필드를 보내는 것과 출력하는 것은 다릅니다 원 printf는 level·message·timestamp·stack만 문자열에 넣습니다. logger에 requestId나 durationMs를 추가로 전달해도 이 formatter를 그대로 쓰면 그 필드는 출력 문자열에 나타나지 않습니다. Winston의 포맷 설명처럼 formatter가 필요한 필드를 선택하거나 JSON 포맷으로 바꾸고 실제 결과를 확인해야 합니다. 위 표는 원 설정만 설명하므로 이러한 확장이 이미 적용됐다는 뜻은 아닙니다.
요청마다 같은 의미의 완료 기록 만들기 다음 함수는 전송기가 아니라 기록할 객체를 만드는 작은 예제입니다. requestId는 서버가 요청마다 만든 개인정보가 없는 불투명 식별자라고 가정합니다. 문자 검사만으로 개인정보 여부나 요청 권한을 판정할 수 없으므로 사용자 입력을 그대로 넣지 않습니다. status는 서버가 결정한 HTTP 상태 코드이고 durationMs는 같은 요청 안에서 측정한 밀리초 값입니다.
export function completionRecord(input: {
requestId: string;
status: number;
durationMs: number;
}) {
const { requestId, status, durationMs } = input;
if (!/^[A-Za-z0-9_-]{1,64}$/.test(requestId)
|| !Number.isInteger(status) || status < 100 || status > 599
|| !Number.isFinite(durationMs) || durationMs < 0) return null;
return {
event: 'request.completed', requestId, status,
durationMs: Math.round(durationMs),
};
}반환 객체는 정해진 필드만 복사하므로 요청 body·token·임의 오류 객체를 통째로 섞지 않습니다. null이면 잘못된 측정값으로 집계하지 않고 호출 쪽에서 원인을 확인합니다. 유효한 객체를 JSON.stringify로 직렬화하면 requestId·status·durationMs가 남지만, 여기서는 Console 출력이나 외부 전송까지 구현한 것이 아닙니다. 출력 포맷과 수집기에서도 같은 필드 이름·단위·자료형을 유지해야 합니다.
시간은 요청 내부의 시작값과 종료값을 짝지어 차이를 냅니다. Node.js의 performance.now 같은 경과 시간용 시계를 쓸 수 있으며, DB 구간만 재면 전체 요청 지연이라고 부르지 않습니다. 예를 들어 A가 100에 시작해 160에 끝나면 60 ms, B가 120에 시작해 200에 끝나면 80 ms입니다. 가장 먼저 시작한 A와 가장 늦게 끝난 B를 연결한 100 ms는 어느 요청의 소요 시간도 아닙니다. 공유 전역 변수 하나에 현재 요청 ID나 시작 시간을 덮어쓰지 말고 요청별 값으로 전달합니다.
애플리케이션 모니터링
로깅은 발생한 이벤트와 오류를 기록하는 것이지만, 모니터링은 이러한 로그와 메트릭(metric)을 수집, 시각화하고, 시스템의 상태와 성능을 지속적으로 감시하는 활동입니다.
로그·메트릭·트레이스를 같은 사건에 연결하기 로그는 특정 요청의 실패 사건을 찾고, 메트릭은 같은 시간창의 요청 수·5xx 비율·지연 분포를 비교하며, 트레이스는 요청 안의 DB·외부 호출 구간이 어디서 시간을 썼는지 연결합니다. 예를 들어 10분 동안 요청 1,000개 중 5xx 응답이 20개라면 해당 정의의 오류율은 2%입니다. 로그 줄 수는 요청 수와 같지 않으므로 오류 로그 20줄을 전체 로그 50줄로 나눈 40%를 요청 오류율이라고 부르지 않습니다. 누락·샘플링·캐시 응답의 관측 범위와 분모도 확인합니다.
requestId만 기록했다고 여러 서비스의 트레이스가 자동으로 연결되지는 않습니다. OpenTelemetry 설명처럼 SDK·bridge와 활성 trace/span 문맥의 연결이 필요합니다. 한 필드의 값을 맞추는 일과 실제 계측·전파를 구현하는 일을 구분합니다.
Vercel 대시보드 및 Logs
Vercel은 Next.js 애플리케이션 배포에 최적화되어 있으며, 기본 로깅 및 모니터링 기능을 제공합니다.
-
Logs 탭
- 용도: 요청 처리 중 서버 함수와 Proxy에서 발생한 로그를 확인합니다. 빌드 때 실행된 Server Component의 로그는 해당 빌드 로그에서 찾습니다. 정적으로 제공되거나 캐시된 결과는 요청마다 같은 서버 코드를 다시 실행하지 않을 수 있습니다.
- 활용: 프로덕션 환경에서 오류가 발생했을 때 가장 먼저 확인해야 할 곳입니다. 필터링, 검색 기능을 통해 특정 요청이나 시간대의 로그를 쉽게 찾을 수 있습니다.
-
Usage 탭
- 용도: 배포된 애플리케이션의 트래픽, 함수 호출 횟수, 데이터 전송량 등 사용량 통계를 제공합니다.
- 활용: 애플리케이션의 인기도나 부하를 파악하고, 비용 예측에 활용할 수 있습니다.
-
Speed Insights
- 용도: 실제 사용자 데이터(Real User Monitoring, RUM)를 기반으로 Core Web Vitals를 포함한 페이지 성능 지표를 모니터링합니다.
- 활용: 개발 환경에서의 테스트를 넘어, 실제 사용자들이 어떤 성능을 경험하는지 객관적인 데이터를 제공하여 성능 개선의 우선순위를 정하는 데 도움을 줍니다.
외부 APM 도구 연동
대규모 애플리케이션이나 복잡한 인프라를 가진 경우, Vercel 기본 모니터링 외에 전문 APM 도구 (Application Performance Monitoring)를 연동하는 것이 좋습니다.
-
Sentry
- 용도: 실시간 오류 추적 및 성능 모니터링 도구입니다. 클라이언트 측 JavaScript 오류부터 서버 측 Node.js 오류까지 통합적으로 관리할 수 있습니다.
- 장점: 상세한 스택 트레이스, 사용자 컨텍스트, 발생 빈도, 영향도 등을 제공하여 오류 진단 및 우선순위 결정에 매우 유용합니다. Next.js와의 통합이 용이합니다.
- 연동 방법: 공식 Next.js 설정 안내에서 사용하는 Router·번들러에 맞는 절차를 선택합니다. SDK·빌드 설정뿐 아니라 클라이언트와 서버의 초기화·오류 수집 지점도 연결하고, 의도적으로 발생시킨 오류가 실제 수집되는지 확인합니다. 설정 파일에 이름을 추가한 것만으로 양쪽 계측이 완료되지는 않습니다.
-
Datadog, New Relic, Grafana + Prometheus
- 용도: 시스템 전체의 메트릭(CPU 사용량, 메모리, 네트워크, 응답 시간 등)을 수집하고 시각화하여 대시보드를 구축하며, 알림 시스템을 통해 이상 징후를 감지합니다.
- 장점: 인프라 전체에 대한 포괄적인 가시성을 제공하여 복잡한 분산 시스템의 문제 해결에 필수적입니다.
- 연동 방법: 각 도구의 에이전트나 SDK를 서버 환경에 설치하고, Next.js 애플리케이션에서 커스텀 메트릭을 전송하도록 설정합니다.
로깅 서비스
로깅 서비스 (Log Management System)는 대량의 로그를 효율적으로 수집, 저장, 검색, 분석하기 위한 시스템입니다.
-
ELK Stack (Elasticsearch, Logstash, Kibana)
- 용도: 오픈 소스 기반의 강력한 로그 관리 솔루션입니다. Logstash로 로그를 수집하고, Elasticsearch에 저장하며, Kibana로 시각화하고 검색합니다.
- 장점: 유연하고 확장성이 뛰어나며, 커스텀 대시보드와 강력한 검색 기능을 제공합니다.
-
Datadog Logs, Splunk, Sumo Logic
- 용도: 클라우드 기반의 통합 로깅 및 분석 서비스입니다.
- 장점: 설정 및 관리가 용이하며, APM, 메트릭 모니터링과 통합되어 엔드-투-엔드 가시성을 제공합니다.
효과적인 로깅 및 모니터링 전략
- 로그 레벨 활용: 개발/디버그 시에는
debug,info레벨을 사용하고, 프로덕션에서는 목적에 맞는 임계값을 정해 로그 볼륨을 관리합니다. - 구조화된 로그: JSON 등의 형식에 일관된 필드 이름·자료형·단위를 정해 기록합니다. JSON 문법이 맞아도 요청마다 duration과 durationMs를 혼용하거나 숫자와 문자열을 섞으면 같은 의미로 집계하기 어렵습니다.
- 컨텍스트 정보 포함: 요청 ID와 작업 종류 등 필요한 필드를 명시적으로 전달해 같은 요청을 연결합니다. 토큰·비밀번호·연결 문자열은 제외하고 사용자 식별 정보는 필요한 범위로 제한합니다. 오류 객체의 메시지와 스택에도 민감 값이 섞일 수 있습니다.
- 경고 및 알림 설정: 중요한 오류나 임계치 초과(예: CPU 사용량 급증, 에러율 증가) 시 Slack, 이메일 등으로 알림을 받도록 설정하여 문제에 즉시 대응할 수 있도록 합니다.
- 로그 보존 정책: 법적 요구사항이나 디버깅 필요성에 따라 로그 보존 기간을 설정합니다.
- 성능 영향 고려: 로깅 자체가 애플리케이션 성능에 오버헤드를 주지 않도록 주의합니다. 전송 대기열·배압과 함수 종료 시 전송 완료를 확인합니다. 비동기 호출 자체가 로그 전달 완료를 보장하지 않으며, 서버리스 로컬 파일은 영구 보관소로 가정하지 않습니다.
서버 사이드 로깅과 모니터링은 Next.js 애플리케이션의 안정성과 신뢰성을 보장하는 데 필수적인 요소입니다.
개발 초기부터 체계적인 로깅 전략을 수립하고, 적절한 모니터링 도구를 활용하여 애플리케이션의 건강 상태를 지속적으로 확인하는 것이 중요합니다.
이를 통해 잠재적인 문제를 사전에 감지하고, 발생한 문제를 신속하게 해결하여 사용자에게 끊김 없는 서비스를 제공할 수 있습니다.
추가 연습: 기록·측정·전송의 경계
- 원 printf를 그대로 사용하면서 info 객체에 requestId와 durationMs를 추가했습니다. Console 문자열에 두 값이 나타날까요? completionRecord에 서버 생성 ID
req_A, 상태 200, 시간 30.6 ms를 주면 어떤 필드와 시간이 반환되나요? 시간 -1과 NaN은 어떻게 되나요? - 같은 시계에서 A는 100→160, B는 120→200에 실행됐습니다. 각 요청 시간과 잘못된 전역 시작값으로 계산한 200−100을 구분하세요. ID를 전역 변수 하나에 보관하면 왜 A의 종료 로그에 B의 ID가 붙을 수 있나요?
- 동일한 10분 동안 실제 요청 1,000개 중 5xx가 20개였고, 수집된 로그는 50줄 중 오류 20줄입니다. 요청 오류율은 얼마인가요? 40%라는 계산이 나타내는 것은 무엇이며 수집 로그만으로 전체 요청 분포를 확정할 수 있을까요?
- 함수가 기록 객체를 만들고 비동기 전송을 호출한 직후 종료됐습니다. 그 기록이 외부 보관소에 저장됐다고 결론 내릴 수 있을까요? requestId 필드만 추가했을 때 분산 트레이스 연결까지 완료됐다고 볼 수 있는지도 설명하세요.
필드·동시 요청·분모·전송 풀이
- 원 formatter는 두 필드를 선택하지 않아 출력 문자열에 나타나지 않습니다. 새 함수는 event=
request.completed, requestId=req_A, status=200, durationMs=31을 반환합니다. 시간은 정수 밀리초로 반올림하며 -1과 NaN은 null입니다. 객체를 생성한 뒤에도 직렬화·출력·수집 포맷을 연결해야 실제 관측할 수 있습니다. - A는 60 ms, B는 80 ms이고 100 ms는 서로 다른 요청의 끝과 시작을 섞은 값입니다. B가 공유 ID를 덮어쓴 뒤 A가 그 변수를 읽으면 잘못 연결됩니다. 요청 내부 값으로 시작/종료를 짝짓고 동일한 요청 ID를 전달해야 합니다.
- 20÷1,000×100=2%입니다. 20÷50×100=40%는 수집된 로그 줄 중 오류 줄의 비율입니다. 한 요청의 여러 로그, 누락·샘플링과 캐시 응답 때문에 분모가 달라질 수 있으므로 전체 요청 오류율과 동일시하지 않습니다. 같은 시간창과 요청 범위의 카운터를 확보해야 합니다.
- 결론 낼 수 없습니다. 객체 생성·큐에 넣기·transport 완료·외부 저장 확인은 서로 다른 단계입니다. 해당 전송기와 호스팅의 종료·flush·오류 처리 계약을 확인해야 하며, Winston의 stream finish도 모든 외부 보관소의 장기 보존을 일괄 보장하지 않습니다. 분산 트레이스는 실제 SDK/bridge 계측과 trace/span 문맥의 전파가 필요합니다.