본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 4. · 233 Views
Logging 트러블슈팅 완벽 가이드
실무에서 자주 발생하는 로깅 문제를 진단하고 해결하는 방법을 다룹니다. 로그 레벨 설정부터 성능 최적화, 분산 환경에서의 로그 추적까지 실전 노하우를 제공합니다.
목차
1. 구조화된 로깅
시작하며
여러분이 프로덕션 환경에서 에러를 추적할 때 이런 상황을 겪어본 적 있나요? 수천 줄의 텍스트 로그 속에서 "Error: undefined"라는 메시지만 보이고, 정작 어떤 사용자가 어떤 작업을 하다가 문제가 생겼는지 전혀 알 수 없는 상황 말이죠.
이런 문제는 실제 개발 현장에서 매일 발생합니다. 전통적인 문자열 기반 로깅은 사람이 읽기에는 편하지만, 자동화된 검색과 분석에는 전혀 적합하지 않습니다.
특히 마이크로서비스 환경에서는 수백만 개의 로그 중에서 특정 조건을 만족하는 로그를 찾는 것이 거의 불가능합니다. 바로 이럴 때 필요한 것이 구조화된 로깅입니다.
JSON 형식으로 로그를 작성하면 Elasticsearch나 CloudWatch 같은 도구로 즉시 검색하고 분석할 수 있습니다.
개요
간단히 말해서, 구조화된 로깅은 로그를 일반 텍스트가 아닌 JSON 같은 구조화된 형식으로 기록하는 방식입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 특정 사용자의 모든 활동을 추적하거나, 특정 API 엔드포인트의 에러율을 계산하거나, 응답 시간이 3초 이상인 요청을 찾아야 할 때 매우 유용합니다.
예를 들어, "userId: 12345인 사용자의 지난 1시간 동안의 모든 에러 로그"를 찾는 것이 단 한 줄의 쿼리로 가능해집니다. 기존에는 grep이나 awk로 텍스트를 파싱하며 로그를 찾았다면, 이제는 JSON 필드로 즉시 필터링하고 집계할 수 있습니다.
구조화된 로깅의 핵심 특징은 첫째, 모든 로그가 일관된 스키마를 따르고, 둘째, 메타데이터가 별도 필드로 분리되며, 셋째, 자동화된 분석이 가능하다는 점입니다. 이러한 특징들이 대규모 시스템에서 문제를 빠르게 진단하는 데 결정적인 역할을 합니다.
코드 예제
// 기본 구조화 로거 설정
const winston = require('winston');
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'user-service', environment: process.env.NODE_ENV },
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// 실무 활용 예제
logger.info('User login successful', {
userId: 'usr_12345',
email: 'user@example.com',
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
loginMethod: 'oauth',
duration: 245
});
설명
이것이 하는 일: 이 코드는 Winston 라이브러리를 사용하여 모든 로그를 JSON 형식으로 기록하는 로거를 설정합니다. 각 로그 엔트리는 타임스탬프, 서비스명, 환경 정보를 자동으로 포함합니다.
첫 번째로, createLogger 설정에서 format을 정의하는 부분이 핵심입니다. winston.format.json()이 모든 로그를 JSON으로 변환하고, winston.format.timestamp()가 자동으로 시간 정보를 추가합니다.
winston.format.errors({ stack: true })는 에러 객체의 스택 트레이스까지 JSON에 포함시켜 줍니다. 왜 이렇게 하냐면, 나중에 Elasticsearch에서 "지난 1시간 동안 발생한 모든 500 에러"를 검색할 때 timestamp 필드로 즉시 필터링할 수 있기 때문입니다.
그 다음으로, defaultMeta 옵션이 실행되면서 모든 로그에 service와 environment 필드가 자동으로 추가됩니다. 내부에서는 각 로그 객체에 이 메타데이터가 병합됩니다.
이렇게 하면 마이크로서비스 환경에서 어느 서비스에서 발생한 로그인지 즉시 구분할 수 있습니다. 마지막으로, 실제 로깅을 할 때는 logger.info() 같은 메서드의 두 번째 인자로 객체를 전달합니다.
이 객체의 모든 키-값 쌍이 JSON 필드로 변환되어 최종적으로 {"level":"info","message":"User login successful","userId":"usr_12345",...} 형태의 로그를 만들어냅니다. 여러분이 이 코드를 사용하면 Kibana나 CloudWatch Insights에서 "userId가 usr_12345인 로그만 보기", "loginMethod가 oauth인 로그의 평균 duration 계산하기" 같은 복잡한 쿼리를 몇 초 만에 실행할 수 있습니다.
또한 알람 설정도 쉬워져서 "duration이 1000ms를 넘는 로그인이 발생하면 슬랙 알림 보내기" 같은 자동화도 간단히 구현할 수 있습니다.
실전 팁
💡 항상 timestamp를 ISO 8601 형식으로 기록하세요. 타임존 문제를 피하고 전 세계 어디서든 일관되게 로그를 분석할 수 있습니다.
💡 중첩된 객체는 최대 2-3 depth까지만 사용하세요. 너무 깊게 중첩하면 일부 로그 집계 도구에서 인덱싱에 실패할 수 있습니다.
💡 숫자는 문자열이 아닌 실제 숫자 타입으로 로깅하세요. duration: "245"가 아닌 duration: 245로 저장해야 나중에 평균/합계 계산이 가능합니다.
💡 boolean 필드를 적극 활용하세요. isError, isRetry, isAuthenticated 같은 필드가 있으면 필터링이 훨씬 빨라집니다.
💡 모든 에러 로그에는 errorCode 필드를 추가하세요. "AUTH FAILED", "DB TIMEOUT" 같은 코드로 에러를 분류하면 대시보드 구성이 쉬워집니다.
2. 로그 레벨 전략
시작하며
여러분이 프로덕션 환경을 운영하다 보면 이런 고민에 빠집니다. 디버깅을 위해 자세한 로그를 남기면 디스크가 금방 차고, 중요한 로그만 남기면 문제 발생 시 단서가 부족합니다.
이런 문제는 로그 레벨을 제대로 이해하지 못해서 발생합니다. 많은 개발자가 모든 로그를 info로 찍거나, 반대로 너무 많은 것을 debug로 남겨서 정작 필요할 때 찾지 못합니다.
특히 트래픽이 많은 시스템에서는 잘못된 로그 레벨 설정이 성능 저하와 비용 증가로 직결됩니다. 바로 이럴 때 필요한 것이 명확한 로그 레벨 전략입니다.
환경별로 적절한 레벨을 설정하고, 각 상황에 맞는 레벨을 선택하는 기준을 세우면 효율적인 로깅이 가능합니다.
개요
간단히 말해서, 로그 레벨은 로그의 중요도와 상세함을 구분하는 체계입니다. 일반적으로 ERROR > WARN > INFO > DEBUG > TRACE 순서로 중요도가 결정됩니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 개발 환경에서는 모든 디버그 정보를 보고 싶지만 프로덕션에서는 에러와 경고만 기록하고 싶을 때 매우 유용합니다. 예를 들어, 로컬에서는 DEBUG 레벨로 SQL 쿼리를 모두 보지만, 프로덕션에서는 INFO 레벨로 설정하여 느린 쿼리만 기록하는 경우입니다.
기존에는 주석을 달았다 지웠다 반복하며 로그를 관리했다면, 이제는 환경 변수 하나로 로그 상세도를 조절할 수 있습니다. 로그 레벨 전략의 핵심 특징은 첫째, 환경별로 다른 레벨을 적용할 수 있고, 둘째, 런타임에 동적으로 레벨을 변경할 수 있으며, 셋째, 네임스페이스별로 세밀하게 제어할 수 있다는 점입니다.
이러한 특징들이 대규모 시스템에서 로그 볼륨과 비용을 효과적으로 관리하는 데 필수적입니다.
코드 예제
// 환경별 로그 레벨 설정
const logLevels = {
error: 0,
warn: 1,
info: 2,
debug: 3,
trace: 4
};
const getLogLevel = () => {
const env = process.env.NODE_ENV;
// 프로덕션: ERROR와 WARN만
if (env === 'production') return 'warn';
// 스테이징: INFO까지
if (env === 'staging') return 'info';
// 개발: 모든 레벨
return 'debug';
};
const logger = winston.createLogger({
level: getLogLevel(),
levels: logLevels,
format: winston.format.json(),
transports: [new winston.transports.Console()]
});
// 상황별 로그 레벨 사용 예제
logger.error('Payment processing failed', { orderId, userId, amount }); // 반드시 조사 필요
logger.warn('Rate limit approaching', { userId, currentRate: 95 }); // 주의 필요
logger.info('Order created successfully', { orderId, amount }); // 비즈니스 이벤트
logger.debug('Database query executed', { query, duration: 45 }); // 개발 디버깅용
logger.trace('Entering function', { functionName, args }); // 상세 추적
설명
이것이 하는 일: 이 코드는 실행 환경에 따라 자동으로 적절한 로그 레벨을 선택하는 시스템을 구현합니다. 프로덕션에서는 warn, 스테이징에서는 info, 개발에서는 debug 레벨이 활성화됩니다.
첫 번째로, logLevels 객체에서 각 레벨에 숫자를 할당하는 부분이 중요합니다. Winston은 설정된 레벨보다 낮은 숫자(더 중요한 레벨)만 실제로 출력합니다.
즉, level: 'info'로 설정하면 info(2), warn(1), error(0)만 기록되고 debug(3)와 trace(4)는 무시됩니다. 왜 이렇게 하냐면, 프로덕션에서 불필요한 로그로 인한 성능 저하와 스토리지 비용을 방지하기 때문입니다.
그 다음으로, getLogLevel() 함수가 실행되면서 NODE_ENV 환경 변수를 확인합니다. 내부에서는 간단한 조건문으로 환경을 판단하지만, 이것이 전체 시스템의 로그 볼륨을 결정하는 핵심입니다.
프로덕션에서 debug 레벨을 활성화했다가는 하루에 수 테라바이트의 로그가 쌓일 수 있습니다. 마지막으로, 실제 로깅할 때는 상황의 심각도에 맞는 메서드를 선택합니다.
logger.error()는 즉시 조치가 필요한 문제(결제 실패, DB 연결 끊김 등), logger.warn()은 잠재적 문제(디스크 용량 80% 도달, API 속도 저하 등), logger.info()는 정상적인 비즈니스 이벤트(주문 생성, 사용자 로그인 등)에 사용합니다. debug와 trace는 개발 중 문제를 추적할 때만 활용합니다.
여러분이 이 코드를 사용하면 환경 변수 하나만 바꿔서 로그 상세도를 즉시 조절할 수 있습니다. 프로덕션에서 갑자기 문제가 생겼을 때 일시적으로 DEBUG 레벨로 변경하여 원인을 파악하고, 해결 후 다시 WARN으로 되돌리는 것도 재배포 없이 가능합니다.
또한 CloudWatch나 Datadog 같은 모니터링 도구에서 "error 레벨 로그 발생 시 알림"처럼 레벨 기반 알람을 설정할 수 있습니다.
실전 팁
💡 ERROR는 정말 에러에만 사용하세요. 404 Not Found 같은 정상적인 클라이언트 에러는 WARN이나 INFO가 적절합니다.
💡 개발 환경에서도 TRACE는 신중하게 사용하세요. 함수 진입/종료를 모두 로깅하면 초당 수만 개의 로그가 생성될 수 있습니다.
💡 환경 변수로 네임스페이스별 레벨을 제어하세요. LOG_LEVEL_DATABASE=debug처럼 설정하면 DB 관련 로그만 상세히 볼 수 있습니다.
💡 WARN 레벨은 actionable한 것만 기록하세요. 단순 정보성 메시지를 warn으로 찍으면 정작 중요한 경고를 놓치게 됩니다.
💡 로그 레벨 변경 자체도 로깅하세요. "Log level changed from INFO to DEBUG by admin"처럼 기록하면 나중에 로그 볼륨 증가 원인을 쉽게 파악할 수 있습니다.
3. 성능 영향 최소화
시작하며
여러분이 트래픽이 많은 API를 운영할 때 이런 상황을 겪어본 적 있나요? 상세한 로깅을 추가했더니 응답 시간이 50ms에서 200ms로 느려지고, CPU 사용률이 급증합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 초당 수천 건의 요청을 처리하는 시스템에서는 로그 한 줄이 전체 성능에 미치는 영향이 엄청납니다.
동기적으로 파일에 쓰거나, 복잡한 객체를 JSON으로 변환하거나, 매번 디스크 I/O가 발생하면 시스템 전체가 느려집니다. 바로 이럴 때 필요한 것이 성능을 고려한 로깅 전략입니다.
비동기 처리, 샘플링, 지연 평가 같은 기법을 적용하면 로그의 가치는 유지하면서 성능 영향을 최소화할 수 있습니다.
개요
간단히 말해서, 성능 최적화 로깅은 로그 기록 작업을 메인 비즈니스 로직과 분리하여 처리하는 방식입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 결제 처리나 주문 생성 같은 중요한 작업이 로그 쓰기 때문에 지연되면 안 되기 때문입니다.
예를 들어, 로그를 쓰다가 디스크가 느려지면 사용자는 결제 완료 응답을 받지 못하고 중복 결제를 시도할 수 있습니다. 이는 심각한 비즈니스 문제로 이어집니다.
기존에는 console.log()나 fs.writeFileSync()처럼 동기적으로 로그를 기록했다면, 이제는 백그라운드 스레드나 메시지 큐를 활용하여 비동기로 처리합니다. 성능 최적화 로깅의 핵심 특징은 첫째, non-blocking 방식으로 동작하고, 둘째, 샘플링으로 로그 볼륨을 조절하며, 셋째, 지연 평가로 불필요한 연산을 스킵한다는 점입니다.
이러한 특징들이 고성능 시스템에서 로깅과 성능을 동시에 잡을 수 있게 해줍니다.
코드 예제
// 비동기 로깅과 샘플링 적용
const pino = require('pino');
// Pino는 기본적으로 비동기, 워커 스레드 사용
const logger = pino({
level: 'info',
// 프로덕션에서는 동기 쓰기 비활성화
sync: false,
// 샘플링: DEBUG 로그는 10%만 기록
hooks: {
logMethod(inputArgs, method) {
if (inputArgs[0] === 'debug' && Math.random() > 0.1) {
return; // 90%는 스킵
}
return method.apply(this, inputArgs);
}
}
});
// 지연 평가: 로그가 실제로 기록될 때만 연산 실행
function expensiveOperation() {
// 복잡한 계산이나 객체 직렬화
return JSON.stringify(largeObject);
}
// 잘못된 방법: 항상 연산 실행
logger.debug('Data processed', expensiveOperation()); // DEBUG 꺼져있어도 연산됨
// 올바른 방법: 함수로 전달하여 지연 평가
if (logger.isLevelEnabled('debug')) {
logger.debug('Data processed', expensiveOperation()); // DEBUG 켜져있을 때만 연산
}
설명
이것이 하는 일: 이 코드는 Pino 라이브러리를 사용하여 로그를 백그라운드에서 비동기적으로 처리하고, 샘플링으로 로그 볼륨을 줄이며, 조건부 로깅으로 불필요한 연산을 방지합니다. 첫 번째로, sync: false 옵션이 핵심입니다.
Pino는 이 설정으로 로그를 메인 스레드가 아닌 별도의 워커 스레드에서 처리합니다. 비즈니스 로직은 로그 데이터를 큐에 넣고 즉시 다음 작업으로 넘어가며, 실제 파일 쓰기나 네트워크 전송은 백그라운드에서 일어납니다.
왜 이렇게 하냐면, 디스크 I/O는 밀리초 단위로 느리지만 메모리 큐에 추가하는 것은 마이크로초 단위로 빠르기 때문입니다. 초당 10,000 요청을 처리하는 시스템에서 로그 하나당 1ms만 걸려도 초당 10초의 CPU 시간이 소모됩니다.
그 다음으로, hooks.logMethod에서 샘플링이 실행됩니다. Math.random() > 0.1 조건으로 debug 레벨 로그의 90%를 무작위로 버립니다.
내부에서는 로그 메서드 호출 자체를 조기 종료하므로 직렬화나 I/O가 전혀 발생하지 않습니다. 이렇게 하면 디버그 정보는 유지하면서도 로그 볼륨을 10분의 1로 줄일 수 있습니다.
트래픽이 정상일 때는 샘플링하다가, 에러율이 높아지면 샘플링 비율을 동적으로 100%로 변경하는 고급 패턴도 가능합니다. 마지막으로, logger.isLevelEnabled() 체크가 지연 평가를 구현합니다.
expensiveOperation()이 복잡한 객체를 JSON으로 변환하는 무거운 작업이라면, DEBUG 레벨이 꺼져 있을 때는 이 함수를 아예 호출하지 않습니다. 로그 레벨 체크는 나노초 단위지만 JSON 직렬화는 밀리초 단위이므로, 큰 객체일수록 성능 차이가 극명합니다.
여러분이 이 코드를 사용하면 로그 추가로 인한 성능 저하를 거의 느끼지 못할 것입니다. 실제로 Pino는 Winston보다 5-10배 빠르며, 프로덕션 환경에서 초당 수만 건의 로그를 기록해도 CPU 사용률이 5% 미만으로 유지됩니다.
또한 샘플링을 활용하면 CloudWatch 같은 유료 로그 서비스의 비용을 크게 절감할 수 있습니다.
실전 팁
💡 Console transport는 절대 프로덕션에서 사용하지 마세요. stdout은 동기적이며 터미널 렌더링으로 인해 극도로 느립니다.
💡 로그 전송은 배치 처리하세요. 로그 1000개를 모아서 한 번에 전송하면 네트워크 오버헤드가 1/1000로 줄어듭니다.
💡 순환 참조가 있는 객체는 로깅 전에 정리하세요. JSON.stringify()가 에러를 던지면 로거가 크래시할 수 있습니다.
💡 rate limiting을 구현하세요. 같은 에러가 초당 100번 발생해도 로그는 초당 1개만 기록하도록 제한합니다.
💡 로컬 파일에 쓸 때는 append 모드를 사용하고 버퍼링을 활성화하세요. 매번 파일을 열고 닫으면 성능이 10배 이상 느려집니다.
4. 컨텍스트 추적
시작하며
여러분이 마이크로서비스 환경에서 에러를 추적할 때 이런 상황을 겪어본 적 있나요? "주문이 실패했어요"라는 고객 문의를 받고, 수십 개의 서비스 로그를 뒤지지만 어느 로그가 그 주문과 관련된 것인지 찾을 수 없습니다.
이런 문제는 분산 시스템에서 매일 발생합니다. API Gateway → 인증 서비스 → 주문 서비스 → 결제 서비스 → 배송 서비스로 요청이 전달될 때, 각 서비스는 독립적으로 로그를 남깁니다.
서로 다른 서버, 다른 시간, 다른 로그 파일에 흩어진 로그들을 하나의 요청으로 묶는 것은 거의 불가능합니다. 바로 이럴 때 필요한 것이 요청 추적 ID입니다.
모든 관련 로그에 동일한 고유 ID를 포함시키면, 단 하나의 검색 쿼리로 전체 요청 흐름을 재구성할 수 있습니다.
개요
간단히 말해서, 컨텍스트 추적은 요청이 여러 서비스를 거치는 동안 고유한 ID를 유지하며 모든 로그에 이를 포함시키는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 하나의 사용자 요청이 10개의 마이크로서비스를 거칠 때 어디서 실패했는지, 각 단계가 얼마나 걸렸는지, 어떤 데이터가 전달되었는지 추적해야 하기 때문입니다.
예를 들어, "requestId: abc-123"으로 모든 서비스의 로그를 검색하면 전체 요청 타임라인이 한눈에 보입니다. 기존에는 타임스탬프와 사용자 ID로 추측하며 관련 로그를 찾았다면, 이제는 요청 ID 하나로 정확히 식별할 수 있습니다.
컨텍스트 추적의 핵심 특징은 첫째, 요청 최초 진입점에서 ID가 생성되고, 둘째, 모든 하위 호출에 자동으로 전파되며, 셋째, 비동기 작업에서도 컨텍스트가 유지된다는 점입니다. 이러한 특징들이 복잡한 분산 시스템에서 end-to-end 가시성을 제공하는 핵심입니다.
코드 예제
// Express 미들웨어로 요청 ID 자동 추가
const { AsyncLocalStorage } = require('async_hooks');
const { v4: uuidv4 } = require('uuid');
// 비동기 컨텍스트 저장소 생성
const asyncLocalStorage = new AsyncLocalStorage();
// 미들웨어: 모든 요청에 고유 ID 할당
function requestIdMiddleware(req, res, next) {
const requestId = req.headers['x-request-id'] || uuidv4();
// 비동기 컨텍스트에 저장 - 모든 하위 호출에서 접근 가능
asyncLocalStorage.run({ requestId, userId: req.user?.id }, () => {
res.setHeader('x-request-id', requestId); // 클라이언트에 ID 반환
next();
});
}
// 어디서든 현재 컨텍스트 가져오기
function getContext() {
return asyncLocalStorage.getStore() || {};
}
// 로거에 자동으로 컨텍스트 추가
function log(level, message, meta = {}) {
const context = getContext();
logger[level](message, {
...meta,
requestId: context.requestId,
userId: context.userId
});
}
// 실사용 예제
app.post('/orders', async (req, res) => {
log('info', 'Order creation started', { items: req.body.items });
const order = await createOrder(req.body); // 내부에서도 같은 requestId 사용
log('info', 'Order created successfully', { orderId: order.id });
res.json(order);
});
설명
이것이 하는 일: 이 코드는 Node.js의 AsyncLocalStorage를 사용하여 요청별 컨텍스트를 자동으로 관리하고, 모든 로그에 requestId와 userId를 포함시킵니다. 첫 번째로, AsyncLocalStorage 생성이 핵심입니다.
이것은 Node.js의 비동기 컨텍스트 추적 API로, 명시적으로 전달하지 않아도 모든 비동기 호출 체인에서 데이터를 공유할 수 있게 해줍니다. 왜 이것이 중요하냐면, 일반 전역 변수는 동시에 처리되는 여러 요청이 섞이지만, AsyncLocalStorage는 요청마다 독립된 저장 공간을 제공하기 때문입니다.
초당 1000개 요청을 처리해도 각 요청의 requestId가 절대 섞이지 않습니다. 그 다음으로, requestIdMiddleware가 실행되면서 각 요청의 시작점에서 고유 ID를 생성합니다.
req.headers['x-request-id']를 먼저 확인하는 이유는 API Gateway나 로드 밸런서에서 이미 ID를 생성했을 수 있기 때문입니다. 내부에서는 asyncLocalStorage.run()으로 컨텍스트를 시작하고, 이 블록 안에서 실행되는 모든 코드(next() 이후의 모든 라우트 핸들러와 비즈니스 로직)는 같은 컨텍스트를 공유합니다.
res.setHeader()로 ID를 응답 헤더에 포함시키면 클라이언트도 요청을 추적할 수 있습니다. 마지막으로, log() 함수가 호출될 때마다 getContext()로 현재 컨텍스트를 가져와 자동으로 로그에 추가합니다.
개발자는 log('info', 'Something happened')처럼 간단히 호출하지만, 실제 로그에는 {"message":"Something happened","requestId":"abc-123","userId":"usr-456"}처럼 컨텍스트가 자동 포함됩니다. createOrder() 함수 내부에서, 또는 그 안에서 호출되는 데이터베이스 쿼리 함수에서도 같은 requestId가 로깅됩니다.
여러분이 이 코드를 사용하면 Kibana에서 "requestId: abc-123"으로 검색했을 때 API 게이트웨이 진입부터 데이터베이스 쿼리, 외부 API 호출, 최종 응답까지 모든 로그가 시간순으로 나타납니다. 어디서 에러가 발생했는지, 각 단계가 얼마나 걸렸는지 즉시 파악할 수 있습니다.
또한 다른 마이크로서비스를 호출할 때 HTTP 헤더에 requestId를 전달하면 전체 시스템의 분산 추적이 가능합니다.
실전 팁
💡 요청 ID는 항상 최초 진입점에서 생성하세요. API Gateway나 첫 번째 서비스에서 만들어야 중복이나 누락을 방지할 수 있습니다.
💡 ID 형식은 UUID v4를 사용하세요. 충돌 가능성이 거의 없고 보안적으로도 안전합니다. 순차 ID는 사용자 활동 추측에 악용될 수 있습니다.
💡 다른 서비스 호출 시 x-request-id 헤더를 표준으로 사용하세요. 대부분의 모니터링 도구가 이 헤더를 자동 인식합니다.
💡 상위 요청 ID와 현재 스팬 ID를 구분하세요. OpenTelemetry처럼 traceId(전체 요청)와 spanId(현재 작업)를 분리하면 더 정교한 추적이 가능합니다.
💡 컨텍스트에 너무 많은 데이터를 넣지 마세요. 메모리 사용량이 요청 수에 비례하므로 필수 정보만 저장해야 합니다.
5. 에러 스택 보존
시작하며
여러분이 프로덕션에서 에러 알림을 받았을 때 이런 상황을 경험해보셨나요? "Error: Database error"라는 메시지만 보이고, 어느 파일의 어느 라인에서 발생했는지, 호출 경로가 무엇인지 전혀 알 수 없습니다.
이런 문제는 에러 처리 과정에서 스택 트레이스를 잃어버려서 발생합니다. 많은 개발자가 catch 블록에서 new Error(err.message)로 새 에러를 만들거나, 에러 객체 대신 문자열만 로깅하여 소중한 디버깅 정보를 날려버립니다.
특히 여러 레이어를 거치는 복잡한 애플리케이션에서는 원본 에러의 위치를 찾는 것이 거의 불가능해집니다. 바로 이럴 때 필요한 것이 에러 스택 보존 기법입니다.
원본 에러를 유지하면서 추가 컨텍스트를 포함하고, 전체 호출 스택을 로그에 기록하면 문제의 근본 원인을 즉시 파악할 수 있습니다.
개요
간단히 말해서, 에러 스택 보존은 에러 객체의 stack 속성과 cause 체인을 유지하며 에러를 전파하고 로깅하는 방법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 에러가 발생한 정확한 코드 위치(파일명, 라인 넘버, 함수명)를 알아야 빠르게 수정할 수 있기 때문입니다.
예를 들어, "at OrderService.createOrder (order.service.js:45:12)"처럼 정확한 위치 정보가 있으면 30초 만에 문제를 찾지만, 없으면 30분 동안 코드를 뒤집니다. 기존에는 err.message만 로깅하거나 toString()으로 변환했다면, 이제는 전체 에러 객체와 cause 체인을 구조화하여 기록합니다.
에러 스택 보존의 핵심 특징은 첫째, 원본 에러와 추가된 에러가 모두 기록되고, 둘째, 각 레이어의 컨텍스트가 누적되며, 셋째, 스택 트레이스가 소스 맵과 연계된다는 점입니다. 이러한 특징들이 복잡한 에러 상황을 완벽히 재현하고 해결하는 데 필수적입니다.
코드 예제
// 에러 체이닝과 스택 보존
class AppError extends Error {
constructor(message, { cause, statusCode = 500, code, meta = {} } = {}) {
super(message, { cause }); // cause로 원본 에러 연결
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code;
this.meta = meta;
Error.captureStackTrace(this, this.constructor); // 스택 트레이스 캡처
}
}
// 저수준 레이어: DB 에러
async function findUserInDB(userId) {
try {
return await db.query('SELECT * FROM users WHERE id = $1', [userId]);
} catch (err) {
// 원본 에러를 cause로 보존하며 컨텍스트 추가
throw new AppError('Database query failed', {
cause: err,
code: 'DB_QUERY_ERROR',
meta: { userId, query: 'findUser' }
});
}
}
// 고수준 레이어: 비즈니스 로직
async function getUserProfile(userId) {
try {
const user = await findUserInDB(userId);
return user;
} catch (err) {
// 하위 에러를 포함하여 새로운 컨텍스트 추가
throw new AppError('Failed to get user profile', {
cause: err,
statusCode: 404,
code: 'USER_NOT_FOUND',
meta: { userId }
});
}
}
// 로깅: 전체 cause 체인 기록
function logError(err) {
const errorChain = [];
let currentError = err;
while (currentError) {
errorChain.push({
message: currentError.message,
name: currentError.name,
stack: currentError.stack,
code: currentError.code,
meta: currentError.meta
});
currentError = currentError.cause; // cause 체인 따라가기
}
logger.error('Error occurred', { errorChain });
}
설명
이것이 하는 일: 이 코드는 ES2022의 Error cause를 활용하여 에러가 여러 레이어를 거치면서도 원본 에러 정보를 잃지 않고, 각 단계의 컨텍스트를 추가로 쌓아갑니다. 첫 번째로, AppError 클래스의 생성자에서 { cause } 옵션이 핵심입니다.
super(message, { cause })로 표준 Error 객체를 생성하면 this.cause에 원본 에러가 자동 저장됩니다. Error.captureStackTrace()는 현재 위치의 스택 트레이스를 캡처하여 에러가 어디서 생성되었는지 기록합니다.
왜 이렇게 하냐면, new Error()만 호출하면 스택에 생성자 내부 코드까지 포함되어 불필요한 정보가 섞이기 때문입니다. 또한 statusCode, code, meta 같은 커스텀 속성으로 HTTP 상태 코드, 에러 분류 코드, 추가 메타데이터를 구조화합니다.
그 다음으로, findUserInDB()에서 catch 블록이 실행될 때 원본 DB 에러(PostgreSQL의 connection timeout 같은)를 cause로 전달하며 새로운 AppError를 생성합니다. 내부에서는 원본 에러가 cause 체인에 연결되어 손실되지 않습니다.
동시에 meta: { userId, query: 'findUser' }로 어떤 쿼리가 실패했는지 컨텍스트를 추가합니다. 이렇게 하면 로그에 "Database connection timeout" (원본 에러)과 "userId: 123인 사용자 조회 중 실패" (컨텍스트)가 모두 포함됩니다.
마지막으로, logError() 함수가 while 루프로 cause 체인을 따라가며 모든 에러를 수집합니다. currentError.cause가 null이 될 때까지 반복하여 최상위 에러부터 최하위 원본 에러까지 배열에 저장합니다.
최종 로그는 [{"message":"Failed to get user profile",...}, {"message":"Database query failed",...}, {"message":"connection timeout",...}] 형태가 되어 전체 에러 전파 경로를 보여줍니다. 여러분이 이 코드를 사용하면 Sentry나 Datadog 같은 에러 트래킹 도구에서 완벽한 에러 계층을 볼 수 있습니다.
"사용자가 프로필 조회 실패 → DB 쿼리 에러 → PostgreSQL 연결 타임아웃" 같은 전체 흐름이 한눈에 보이며, 각 단계의 userId, query, connection pool 정보 등이 모두 포함됩니다. 또한 스택 트레이스로 정확한 코드 위치(order.service.js:45:12)를 즉시 파악할 수 있습니다.
실전 팁
💡 절대 err.toString()이나 err.message만 로깅하지 마세요. 스택 트레이스가 사라져 디버깅이 불가능해집니다.
💡 Promise rejection은 반드시 Error 객체를 사용하세요. reject('failed')처럼 문자열을 던지면 스택 정보가 없습니다.
💡 TypeScript를 사용한다면 에러 타입을 명시적으로 정의하세요. code와 meta 속성의 타입 안정성이 보장됩니다.
💡 프로덕션에서는 소스 맵을 업로드하세요. 압축된 코드의 스택도 원본 소스 위치로 변환됩니다.
💡 cause 체인이 너무 길어지지 않도록 주의하세요. 10단계 이상 중첩되면 로그가 비대해지고 분석이 어려워집니다.
6. 로그 집계 패턴
시작하며
여러분이 10개의 마이크로서비스를 운영할 때 이런 상황을 겪어본 적 있나요? 각 서비스가 자체 로그 파일을 가지고 있어서, 문제를 추적하려면 10개의 서버에 SSH로 접속하여 grep을 돌려야 합니다.
이런 문제는 분산 시스템에서 불가피합니다. 각 서비스, 각 인스턴스가 독립적으로 로그를 생성하면 전체 시스템의 상태를 파악하기 어렵습니다.
특히 오토스케일링으로 인스턴스가 생성되고 삭제될 때, 삭제된 인스턴스의 로그는 영원히 사라집니다. 장애 발생 시 원인을 찾으려면 수십 개의 로그 파일을 수동으로 시간순으로 정렬해야 합니다.
바로 이럴 때 필요한 것이 중앙 집중식 로그 집계입니다. 모든 서비스의 로그를 하나의 저장소로 스트리밍하면 통합된 뷰에서 전체 시스템을 모니터링하고 검색할 수 있습니다.
개요
간단히 말해서, 로그 집계는 분산된 여러 소스의 로그를 중앙 저장소로 실시간 전송하여 통합 관리하는 아키텍처 패턴입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 한 번의 검색 쿼리로 모든 서비스의 로그를 조회하고, 대시보드에서 전체 시스템의 에러율을 실시간으로 확인하며, 특정 패턴을 감지하여 자동 알람을 받아야 하기 때문입니다.
예를 들어, "지난 5분간 모든 서비스에서 500 에러가 몇 번 발생했는지"를 알려면 로그가 한곳에 모여 있어야 합니다. 기존에는 각 서버의 로그 파일을 직접 열어보거나 rsync로 주기적으로 수집했다면, 이제는 실시간 스트리밍으로 즉시 중앙 저장소에 전송됩니다.
로그 집계의 핵심 특징은 첫째, 모든 로그가 단일 인터페이스로 접근 가능하고, 둘째, 실시간 검색과 분석이 가능하며, 셋째, 장기 보관과 규정 준수가 용이하다는 점입니다. 이러한 특징들이 대규모 분산 시스템의 운영과 문제 해결을 가능하게 만듭니다.
코드 예제
// Winston + Elasticsearch 로그 집계
const winston = require('winston');
const { ElasticsearchTransport } = require('winston-elasticsearch');
// Elasticsearch transport 설정
const esTransportOpts = {
level: 'info',
clientOpts: {
node: process.env.ELASTICSEARCH_URL || 'http://localhost:9200',
auth: {
username: process.env.ES_USERNAME,
password: process.env.ES_PASSWORD
}
},
index: 'logs', // 인덱스 이름
// 버퍼링: 로그 100개 모이거나 5초마다 전송
bufferLimit: 100,
flushInterval: 5000,
// 추가 메타데이터
transformer: (logData) => {
return {
'@timestamp': new Date().toISOString(),
severity: logData.level,
message: logData.message,
fields: logData.meta,
// 서비스 식별 정보
service: process.env.SERVICE_NAME,
hostname: require('os').hostname(),
environment: process.env.NODE_ENV,
version: process.env.APP_VERSION
};
}
};
const logger = winston.createLogger({
transports: [
new winston.transports.Console(), // 로컬 디버깅용
new ElasticsearchTransport(esTransportOpts) // 중앙 집계
]
});
// 사용 예제
logger.info('Payment processed', {
orderId: 'ord_123',
amount: 99.99,
paymentMethod: 'credit_card',
duration: 342
});
// Kibana에서 쿼리 예시:
// service:"payment-service" AND severity:"error" AND @timestamp:[now-1h TO now]
// 지난 1시간간 payment-service의 모든 에러
설명
이것이 하는 일: 이 코드는 Winston의 Elasticsearch transport를 사용하여 모든 로그를 로컬 파일이 아닌 중앙 Elasticsearch 클러스터로 전송합니다. 각 로그는 서비스명, 호스트명, 환경 등의 메타데이터와 함께 인덱싱됩니다.
첫 번째로, clientOpts 설정이 Elasticsearch 클러스터와의 연결을 정의합니다. node는 Elasticsearch 엔드포인트 URL이고, auth는 보안 인증 정보입니다.
왜 이렇게 중앙 서버를 사용하냐면, 수십 개의 서비스 인스턴스가 각자 로컬 파일에 쓰면 통합 검색이 불가능하기 때문입니다. Elasticsearch는 분산 검색 엔진으로 수 테라바이트의 로그를 초 단위로 검색할 수 있습니다.
그 다음으로, bufferLimit과 flushInterval 옵션이 실행되면서 배치 전송을 최적화합니다. 내부에서는 로그를 메모리에 버퍼링하다가 100개가 모이거나 5초가 지나면 한 번에 전송합니다.
이렇게 하면 개별 로그마다 네트워크 요청을 보내는 것보다 100배 이상 효율적입니다. 또한 Elasticsearch 서버가 일시적으로 다운되어도 로컬 버퍼에 저장되었다가 복구 시 자동 재전송됩니다.
마지막으로, transformer 함수가 각 로그를 Elasticsearch 친화적인 형식으로 변환합니다. @timestamp는 Elasticsearch의 시간 기반 인덱싱에 사용되고, service, hostname, environment는 멀티 테넌시 환경에서 로그를 구분하는 필터로 활용됩니다.
예를 들어, Kibana에서 "service: payment-service AND environment: production AND severity: error"로 검색하면 프로덕션 결제 서비스의 에러만 즉시 필터링됩니다. 여러분이 이 코드를 사용하면 Kibana 대시보드에서 전체 시스템의 로그를 실시간으로 볼 수 있습니다.
서비스별 에러율 차트, 느린 API 엔드포인트 순위, 특정 사용자의 활동 타임라인 등을 클릭 몇 번으로 생성할 수 있습니다. 또한 "지난 15분간 500 에러가 10회 이상 발생하면 슬랙 알림"처럼 Elasticsearch의 Watcher 기능으로 자동 알람을 설정할 수 있습니다.
인스턴스가 삭제되어도 로그는 Elasticsearch에 안전하게 보관됩니다.
실전 팁
💡 로그 전송 실패 시 로컬 파일에 백업하세요. ElasticsearchTransport와 함께 File transport를 추가하면 네트워크 장애 시에도 로그가 보존됩니다.
💡 인덱스는 날짜별로 분리하세요. index: logs-${new Date().toISOString().slice(0,10)}처럼 설정하면 오래된 로그 삭제가 쉽습니다.
💡 비용 절감을 위해 샘플링하세요. DEBUG 로그는 1% 샘플링, ERROR는 100% 전송으로 차등 적용합니다.
💡 필드 매핑을 미리 정의하세요. Elasticsearch가 자동으로 타입을 추론하면 duration이 문자열로 인덱싱될 수 있습니다.
💡 CloudWatch Logs나 Datadog을 대안으로 고려하세요. 직접 Elasticsearch를 운영하는 것보다 관리형 서비스가 더 경제적일 수 있습니다.
7. 민감정보 마스킹
시작하며
여러분이 로그를 검토하다가 이런 상황을 발견한 적 있나요? 로그 파일에 사용자의 비밀번호, 신용카드 번호, 주민등록번호가 평문으로 기록되어 있습니다.
이런 문제는 보안 사고로 직결됩니다. GDPR, PCI-DSS, 개인정보보호법 등의 규정은 민감정보를 로그에 기록하는 것을 명시적으로 금지합니다.
한 번의 실수로 로그에 신용카드 번호가 노출되면 수억 원의 벌금과 고객 신뢰 상실이라는 대가를 치러야 합니다. 또한 개발자와 운영팀이 로그를 볼 때 불필요한 민감정보에 접근하게 되어 내부 보안 위험도 증가합니다.
바로 이럴 때 필요한 것이 자동 민감정보 마스킹입니다. 로그 기록 전에 신용카드 번호, 이메일, 전화번호, 토큰 같은 패턴을 감지하여 자동으로 마스킹하면 규정 준수와 보안을 동시에 달성할 수 있습니다.
개요
간단히 말해서, 민감정보 마스킹은 로그에 기록되기 전에 개인정보와 인증 정보를 탐지하여 가려지거나 해시된 값으로 치환하는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 법적 리스크를 피하고 데이터 유출 사고를 예방하며, 감사(audit) 시 규정 준수를 증명해야 하기 때문입니다.
예를 들어, "User login failed for email: user@example.com password: mysecret123"이라는 로그는 절대 기록되어서는 안 되며, "User login failed for email: u***@example.com password: [REDACTED]"로 마스킹되어야 합니다. 기존에는 개발자가 수동으로 로그 메시지를 검토하며 민감정보를 제거했다면, 이제는 로거 레벨에서 자동으로 패턴을 탐지하고 마스킹합니다.
민감정보 마스킹의 핵심 특징은 첫째, 정규식 기반으로 다양한 패턴을 자동 감지하고, 둘째, 커스텀 마스킹 규칙을 추가할 수 있으며, 셋째, 성능 영향을 최소화하면서 동작한다는 점입니다. 이러한 특징들이 개발자의 실수를 시스템적으로 방지하고 보안 컴플라이언스를 자동화합니다.
코드 예제
// 민감정보 자동 마스킹 로거
const winston = require('winston');
// 민감정보 패턴 정의
const sensitivePatterns = [
{ name: 'creditCard', regex: /\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b/g, replace: '****-****-****-****' },
{ name: 'ssn', regex: /\b\d{3}-\d{2}-\d{4}\b/g, replace: '***-**-****' },
{ name: 'email', regex: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, replace: (match) => {
const [name, domain] = match.split('@');
return `${name[0]}***@${domain}`;
}},
{ name: 'phone', regex: /\b\d{3}-\d{3,4}-\d{4}\b/g, replace: '***-****-****' },
{ name: 'jwt', regex: /\beyJ[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\b/g, replace: '[JWT_REDACTED]' },
{ name: 'apiKey', regex: /\b(?:api[_-]?key|token|secret)["\s:=]+([A-Za-z0-9_-]{20,})/gi, replace: '[API_KEY_REDACTED]' }
];
// 민감정보 마스킹 함수
function maskSensitiveData(data) {
if (typeof data === 'string') {
let masked = data;
sensitivePatterns.forEach(pattern => {
masked = masked.replace(pattern.regex, pattern.replace);
});
return masked;
} else if (typeof data === 'object' && data !== null) {
const masked = Array.isArray(data) ? [] : {};
for (const key in data) {
// 특정 필드명은 무조건 마스킹
if (/password|pwd|secret|token|auth/i.test(key)) {
masked[key] = '[REDACTED]';
} else {
masked[key] = maskSensitiveData(data[key]); // 재귀적으로 처리
}
}
return masked;
}
return data;
}
// Winston format으로 통합
const maskingFormat = winston.format((info) => {
info.message = maskSensitiveData(info.message);
if (info.meta) {
info.meta = maskSensitiveData(info.meta);
}
return info;
});
const logger = winston.createLogger({
format: winston.format.combine(
maskingFormat(), // 마스킹 먼저 적용
winston.format.json()
),
transports: [new winston.transports.Console()]
});
// 사용 예제
logger.info('Payment processed', {
cardNumber: '4532-1234-5678-9010', // 자동으로 ****-****-****-****로 마스킹
email: 'customer@example.com', // c***@example.com
apiKey: 'sk_live_abcdefghijklmnop1234567890' // [API_KEY_REDACTED]
});
설명
이것이 하는 일: 이 코드는 Winston의 커스텀 format을 사용하여 로그가 기록되기 직전에 모든 텍스트와 객체를 스캔하고, 미리 정의된 민감정보 패턴을 찾아 마스킹합니다. 첫 번째로, sensitivePatterns 배열에 여러 종류의 민감정보 패턴을 정의합니다.
creditCard 패턴은 4자리-4자리-4자리-4자리 형식의 카드 번호를 탐지하고, jwt 패턴은 "eyJ"로 시작하는 JWT 토큰을 감지합니다. 각 패턴의 replace는 문자열 또는 함수로, email처럼 함수를 사용하면 동적으로 마스킹 방식을 결정할 수 있습니다.
왜 정규식을 사용하냐면, "Card: 1234-5678-9012-3456"처럼 어떤 문맥에서든 패턴만 일치하면 자동 감지되기 때문입니다. 그 다음으로, maskSensitiveData() 함수가 재귀적으로 실행됩니다.
문자열이면 모든 패턴의 정규식을 순회하며 replace()를 적용하고, 객체나 배열이면 각 속성을 재귀적으로 처리합니다. 내부에서 /password|pwd|secret/i.test(key) 조건으로 키 이름만 봐도 민감정보로 판단되는 필드는 값을 보지도 않고 '[REDACTED]'로 치환합니다.
이렇게 하면 { password: 'mysecret' }가 { password: '[REDACTED]' }로 변환되어 절대 평문이 로그에 남지 않습니다. 마지막으로, maskingFormat이 Winston의 format 체인에 통합되어 모든 로그가 이 필터를 거치게 됩니다.
winston.format.combine()에서 maskingFormat()을 가장 먼저 실행하면, 그 이후의 json() 같은 포맷터는 이미 마스킹된 데이터를 받습니다. 개발자는 logger.info('Card: 1234-5678-9012-3456')처럼 평범하게 로깅해도 실제 출력은 'Card: ---'가 됩니다.
여러분이 이 코드를 사용하면 개발자가 실수로 민감정보를 로깅해도 시스템이 자동으로 방어합니다. 로그 파일이나 Elasticsearch에는 마스킹된 값만 저장되므로 GDPR 감사나 PCI-DSS 인증 시 문제가 없습니다.
또한 고객 지원팀이 로그를 볼 때도 개인정보가 노출되지 않아 내부 보안 정책을 준수할 수 있습니다. 정규식 패턴은 프로젝트의 요구사항에 맞게 쉽게 추가하거나 수정할 수 있습니다.
실전 팁
💡 마스킹 룰은 환경 변수로 관리하세요. 개발 환경에서는 디버깅을 위해 일부 마스킹을 비활성화할 수 있습니다.
💡 해시를 활용하세요. 같은 이메일은 항상 같은 해시값으로 마스킹하면 사용자별 로그 추적이 가능하면서도 개인정보는 보호됩니다.
💡 성능을 고려하세요. 정규식이 복잡하거나 많으면 로깅 성능이 저하될 수 있으므로, 정말 필요한 패턴만 추가합니다.
💡 false positive를 주의하세요. 너무 공격적인 패턴은 정상 데이터까지 마스킹할 수 있습니다. 테스트 케이스로 검증하세요.
💡 마스킹된 필드 목록을 로깅하세요. { maskedFields: ['password', 'cardNumber'] }처럼 기록하면 나중에 어떤 정보가 가려졌는지 알 수 있습니다.
8. 로그 회전 전략
시작하며
여러분이 운영 중인 서버에서 이런 상황을 겪어본 적 있나요? 어느 날 갑자기 애플리케이션이 멈추고, 확인해보니 로그 파일이 디스크를 100% 채워서 더 이상 쓰기 작업을 할 수 없습니다.
이런 문제는 로그 파일을 무한정 누적하면 발생합니다. 트래픽이 많은 시스템은 하루에 수 기가바이트의 로그를 생성하는데, 이를 관리하지 않으면 디스크가 금방 차고 시스템 전체가 다운됩니다.
특히 프로덕션 환경에서 디스크 부족은 데이터베이스 쓰기 실패, 세션 저장 불가, 캐시 동작 중단 등 치명적인 문제를 야기합니다. 바로 이럴 때 필요한 것이 로그 회전(Log Rotation) 전략입니다.
오래된 로그를 자동으로 압축하고 삭제하며, 크기와 날짜 기반으로 파일을 분할하면 디스크 공간을 효율적으로 관리할 수 있습니다.
개요
간단히 말해서, 로그 회전은 로그 파일이 특정 크기나 기간에 도달하면 자동으로 새 파일로 분할하고, 오래된 파일은 압축하거나 삭제하는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 디스크 공간을 보호하면서도 필요한 기간의 로그는 보관하고, 긴급 상황에서 빠르게 로그를 찾을 수 있어야 하기 때문입니다.
예를 들어, 최근 7일 로그는 빠른 조회를 위해 보관하고, 7-30일 로그는 압축 저장, 30일 이상은 삭제하는 정책이 일반적입니다. 기존에는 cron job으로 주기적으로 스크립트를 실행하며 로그를 정리했다면, 이제는 로거 자체에 회전 정책을 내장하여 자동으로 관리합니다.
로그 회전의 핵심 특징은 첫째, 크기와 날짜 기반으로 자동 분할되고, 둘째, 오래된 파일이 자동으로 압축 및 삭제되며, 셋째, 애플리케이션 재시작 없이 동작한다는 점입니다. 이러한 특징들이 안정적인 운영과 스토리지 비용 절감을 동시에 달성하게 해줍니다.
코드 예제
// Winston Rotate 파일 전송으로 자동 로그 회전
const winston = require('winston');
require('winston-daily-rotate-file');
// 날짜 기반 회전 설정
const dailyRotateTransport = new winston.transports.DailyRotateFile({
filename: 'logs/application-%DATE%.log',
datePattern: 'YYYY-MM-DD', // 날짜 형식
zippedArchive: true, // 회전된 파일 gzip 압축
maxSize: '20m', // 파일 하나당 최대 20MB
maxFiles: '14d', // 14일간 보관, 이후 자동 삭제
level: 'info'
});
// 에러 로그는 별도 파일로 분리
const errorRotateTransport = new winston.transports.DailyRotateFile({
filename: 'logs/error-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '10m',
maxFiles: '30d', // 에러는 30일간 보관
level: 'error'
});
const logger = winston.createLogger({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
dailyRotateTransport,
errorRotateTransport,
new winston.transports.Console({ level: 'debug' }) // 개발 시 콘솔 출력
]
});
// 회전 이벤트 리스닝
dailyRotateTransport.on('rotate', (oldFilename, newFilename) => {
logger.info('Log file rotated', { oldFilename, newFilename });
});
dailyRotateTransport.on('archive', (zipFilename) => {
logger.info('Log file archived', { zipFilename });
});
// 사용 예제
logger.info('Application started');
logger.error('Critical error occurred', { errorCode: 'DB_CONN_FAIL' });
// 결과 파일 구조:
// logs/application-2025-01-01.log (오늘)
// logs/application-2024-12-31.log.gz (어제, 압축됨)
// logs/error-2025-01-01.log (오늘 에러)
설명
이것이 하는 일: 이 코드는 winston-daily-rotate-file을 사용하여 매일 자정에 새 로그 파일로 전환하고, 파일 크기가 20MB를 초과하면 즉시 회전하며, 14일이 지난 파일은 자동으로 삭제합니다. 첫 번째로, DailyRotateFile 설정의 filename: 'logs/application-%DATE%.log'가 핵심입니다.
%DATE%는 datePattern에 정의된 형식으로 치환되어 application-2025-01-01.log처럼 날짜가 포함된 파일명이 생성됩니다. 매일 자정(00:00)이 되면 자동으로 새 파일이 생성되고 이전 파일은 더 이상 쓰이지 않습니다.
왜 날짜별로 분할하냐면, 특정 날짜의 로그를 찾을 때 한 파일만 열면 되므로 검색이 훨씬 빠르기 때문입니다. 그 다음으로, maxSize: '20m'과 maxFiles: '14d' 옵션이 실행됩니다.
내부에서는 로그를 쓸 때마다 현재 파일 크기를 체크하고, 20MB를 초과하면 즉시 회전합니다. 예를 들어, 트래픽이 많은 날은 application-2025-01-01.log가 20MB가 되면 application-2025-01-01.1.log로 새 파일이 생성됩니다.
또한 파일 생성 시간을 추적하여 14일이 지나면 자동으로 삭제되므로 디스크가 무한정 커지지 않습니다. 마지막으로, zippedArchive: true 옵션으로 회전된 파일은 gzip으로 압축됩니다.
텍스트 로그는 보통 10:1 이상 압축되므로 100MB 로그가 10MB로 줄어듭니다. 'rotate' 이벤트 리스너로 회전이 발생할 때마다 로그를 남기면, 나중에 "왜 특정 날짜의 로그가 없지?"라는 의문을 해결할 수 있습니다.
에러 로그는 별도 transport로 분리하여 maxFiles: '30d'로 일반 로그보다 오래 보관합니다. 에러는 패턴 분석을 위해 장기 보존이 필요하기 때문입니다.
여러분이 이 코드를 사용하면 디스크 부족으로 인한 장애를 완전히 예방할 수 있습니다. 설정한 기간과 크기 정책에 따라 자동으로 관리되므로 수동 개입이 필요 없습니다.
또한 압축을 통해 스토리지 비용을 크게 절감할 수 있으며, 필요할 때는 gunzip으로 쉽게 압축을 풀어 로그를 조회할 수 있습니다. 클라우드 환경에서는 회전된 파일을 S3로 자동 업로드하는 스크립트를 추가하면 장기 아카이빙도 가능합니다.
실전 팁
💡 에러 로그와 일반 로그를 분리하세요. 에러는 더 오래 보관하고, 디버그 로그는 짧게 보관하여 스토리지를 절약합니다.
💡 maxSize는 너무 크게 설정하지 마세요. 10-50MB가 적당하며, 큰 파일은 열고 검색하는 데 시간이 오래 걸립니다.
💡 압축된 로그는 S3 Glacier 같은 저렴한 스토리지로 이동하세요. 장기 보관 비용을 90% 이상 절감할 수 있습니다.
💡 로그 삭제 전에 중요 이벤트는 별도 저장하세요. 결제, 주문, 인증 같은 감사(audit) 로그는 법적 요구로 몇 년간 보관해야 할 수 있습니다.
💡 로그 회전 시간을 트래픽이 적은 시간대로 설정하세요. 파일 생성과 압축 중 짧은 딜레이가 발생할 수 있으므로 새벽 시간이 안전합니다.
이 카드뉴스가 포함된 코스
댓글 (0)
함께 보면 좋은 카드 뉴스
Helm 마이크로서비스 패키징 완벽 가이드
Kubernetes 환경에서 마이크로서비스를 효율적으로 패키징하고 배포하는 Helm의 핵심 기능을 실무 중심으로 학습합니다. Chart 생성부터 릴리스 관리까지 체계적으로 다룹니다.
EFK 스택 로깅 완벽 가이드
마이크로서비스 환경에서 로그를 효과적으로 수집하고 분석하는 EFK 스택(Elasticsearch, Fluentd, Kibana)의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 정리한 가이드입니다.
보안 아키텍처 구성 완벽 가이드
프로젝트의 보안을 처음부터 설계하는 방법을 배웁니다. AWS 환경에서 VPC부터 WAF, 암호화, 접근 제어까지 실무에서 바로 적용할 수 있는 보안 아키텍처를 단계별로 구성해봅니다.
AWS Organizations 완벽 가이드
여러 AWS 계정을 체계적으로 관리하고 통합 결제와 보안 정책을 적용하는 방법을 실무 스토리로 쉽게 배워봅니다. 초보 개발자도 바로 이해할 수 있는 친절한 설명과 실전 예제를 제공합니다.
AWS KMS 암호화 완벽 가이드
AWS KMS(Key Management Service)를 활용한 클라우드 데이터 암호화 방법을 초급 개발자를 위해 쉽게 설명합니다. CMK 생성부터 S3, EBS 암호화, 봉투 암호화까지 실무에 필요한 모든 내용을 담았습니다.