INFO, WARN, ERROR만으로는 부족하다
console.log에서 JSON 로그로 가는 건 시작일 뿐이다. 로그 레벨을 모호하게 정의하면 알람이 무의미해지고, 스키마 없이 쌓은 로그는 검색조차 불가능하다. 마스킹을 미루면 개인정보가 로그 플랫폼에 그대로 노출된다. 급증하는 로그 비용도 간과할 수 없다. 이 글은 로그 레벨 기준, 공통 필드, 마스킹, 비용 거버넌스 등 구조화 로깅 도입 전에 반드시 정해야 할 결정들을 기록한다.
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
ERROR, WARN, INFO만으로 로그 레벨을 정의했다면, 운영 첫 주 안에 분류 체계가 무너진다. 개발자가 스스로 레벨을 고르게 두면 대부분 INFO에 몰리고, 막상 장애 상황에서 봐야 할 ERROR는 INFO에 파묻힌다. Datadog에서 로그를 추적해 본 팀이라면 공감할 것이다.
레벨 기준을 문서화하지 않은 팀의 전형적인 진행이다. 배포 직후 500 에러가 쏟아지는데, 몇 초 만에 사라진다. 개발자가 "이건 4xx 복구라 ERROR가 아니라 WARN이 맞다"고 판단한다. 하지만 본인이 직접 보낸 4xx가 아니라, 인프라 단의 헬스체크 실패를 포함해 실제 장애 원인이 섞여 있다. 레벨을 엄격하게 나누지 않으면 알람 임계치를 조정할 수 없고, 결과적으로 알람 피로도만 높아진다.
레벨 기준을 enforce하는 방법은 생각보다 단순하다. INFO는 사용자 행동 추적, WARN은 예상 가능한 실패(재시도 성공, 레이트 리밋 근접), ERROR는 서비스 불능 또는 데이터 정합성 붕괴로 고정한다. 표현을 보고 고르는 게 아니라, 어떤 조치가 필요한지 기준으로 삼는다. ERROR면 당번자에게 즉시 전달되고, WARN은 대시보드 위젯으로만 확인한다. 이 기준이 명확하지 않으면 PagerDuty 룰을 작성할 수 없다.
공통 필드를 정의하지 않으면 쿼리가 불가능하다
JSON 로그를 찍기 시작한 팀은 금세 깨닫는다. message 필드에 "User login failed" 같은 문자열만 있고, userId, requestId, errorCode 같은 키가 없다면 Datadog에서 "로그 검색"은 결국 grep과 다를 게 없다.
공통 필드 스키마는 로그 수집 파이프라인의 첫 번째 관문이다. 모든 서비스가 공유해야 할 최소 필드를 정해야 한다. timestamp, service, version, environment, traceId(또는 correlationId), severity, message. 여기에 HTTP 요청이 있는 서비스라면 method, path, statusCode, latency_ms를 더한다.
문제는 "선택적 필드"의 범위가 커지는 지점에서 발생한다. 한 팀은 dbQueryTime을 항상 포함하고, 다른 팀은 결과 필드만 기록한다. 대시보드에서 P50/P99 쿼리 시간을 추적하려고 했더니, 절반의 로그에 해당 필드가 없어서 통계가 왜곡된다. 공통 스키마 문서에는 "가능하면 포함"이라고 적혀 있었고, 아무도 이를 강제하지 않았다.
스키마를 강제하는 방법은 세 가지다. 첫째, 공유 라이브러리(logger SDK)를 통해 필드 템플릿을 주입한다. 둘째, CI 단계에서 JSON 스키마 validation을 건다. 셋째, 로그 수집기(Logstash, Vector)에서 누락 필드를 탐지해 경고를 발생시킨다. 셋 중 하나도 선택하지 않으면 반년 후 수집된 로그의 키 분포를 보고 충격받게 된다.
마스킹은 절대 "나중에" 하면 안 된다
민감정보 마스킹을 구조화 로깅 도입 시점에 함께 정의하지 않는 팀이 많다. "우리 서비스는 민감 정보를 로그에 안 찍는다"고 믿지만, 실제로는 HTTP Authorization 헤더, 이메일, 전화번호, 주민등록번호 유사 값이 예외 처리 로그에서 여과 없이 노출된다.
문제는 로그 플랫폼에 한 번 전송된 데이터를 지우는 것이 거의 불가능하다는 점이다. Datadog의 민감 데이터 스캐너는 사후 검출만 할 뿐, 이미 저장된 로그를 자동으로 삭제하지 않는다. 삭제 요청을 하려면 지원 티켓을 열어야 하고, 보존 기간이 지나야 완전히 사라진다.
실전에서 가장 많이 누락되는 패턴을 꼽아보면 이렇다.
먼저 예외 객체의 stack trace에 포함된 입력값이다. Error(Invalid input: ${req.body.email}) 같은 코드는 body 전체를 문자열로 변환해 로그에 남긴다. 객체의 모든 필드 중에 민감값이 섞여 있어도 개발자가 이를 인지하지 못한다. logger SDK에서 Error 객체를 직렬화할 때 특정 필드를 자동 차단하도록 등록해야 한다.
두 번째는 쿼리 파라미터와 URL 경로 변수다. REST API에서 /api/users/{email} 같은 패턴은 URL 자체에 이메일이 포함된다. 웹 서버 액세스 로그는 이 URL을 전부 기록한다. 구조화 로깅으로 전환했다면, request.path에서 민감 패턴을 제거하는 미들웨어를 라우터 레벨에서 적용해야 한다.
세 번째, 데이터베이스 쿼리 로그에 바인딩된 파라미터 값이 그대로 남는 경우다. ORM 로그에서 INSERT INTO users VALUES (?, ?, ?) 대신 실제 값이 기록된다. 로그 레벨을 DEBUG로 낮추는 것만으로는 부족하다. 운영 환경에서는 쿼리 파라미터를 항상 마스킹하도록 데이터베이스 드라이버나 ORM 설정을 변경해야 한다.
마스킹 규칙은 소스 코드에 패턴으로 박아야 한다. 정규식 기반 필터는 이메일, 전화번호, 카드 번호 등을 탐지하지만, 예외 케이스(예: 테스트 환경의 더미 데이터)에서 오탐지가 발생한다. 오탐지를 줄이려면 allowlist와 결합하거나, 특정 필드 이름(예: email, phone, ssn)에 대해서만 마스킹을 적용하는 전략을 섞는다.
로그 비용이 예산을 초과하는 순간
로그 비용은 생각보다 빠르게 증가한다. Datadog의 로그 요금은 수집량(ingested volume)과 보관량(indexed volume)을 따로 계산한다. JSON 로그 한 줄이 console.log("user logged in")보다 5~10배 더 많은 용량을 차지하는 것은 기본이고, 엔지니어가 디버깅 편의를 위해 불필요한 필드를 추가하면 비용이 눈덩이처럼 불어난다.
실제 예를 들어보자. 초당 1000 TPS를 처리하는 API 서버에서 요청당 평균 로그 3줄을 JSON(약 500바이트)으로 기록한다고 가정한다. 하루 수집량은 대략 130GB, 월간으로는 4TB에 가깝다. INFO 레벨만 필터링해 Datadog에 인덱싱해도 1TB를 훌쩍 넘긴다. Datadog의 표준 요금제에서 월 1TB 인덱싱은 상당한 비용 항목이다.
비용을 통제하는 세 가지 레버가 있다.
첫 번째, 로그 레벨별로 수집과 인덱싱을 분리한다. ERROR는 모두 인덱싱하고, WARN은 일부만, INFO는 인덱싱하지 않고 수집만 한다(필요 시 재수집 가능). Datadog은 Index Filter와 Exclusion Filter를 제공한다. "ERROR가 아닌 로그 모두 제외"가 아니라 "실패율 5% 이상일 때만 INFO 수집" 같은 조건부 제외를 걸 수 있다.
두 번째, 중요 필드만 인덱싱한다. 전체 JSON 본문을 인덱싱하지 않고, 검색에 필요한 필드(service, statusCode, duration_ms, error.type)만 인덱스에 포함한다. 나머지는 _source에만 저장했다가 필요할 때만 조회한다. 이 전략은 인덱스 크기를 60~80% 줄여준다.
세 번째, 장기 보관 정책을 레벨과 서비스 중요도에 따라 차등 적용한다. 프로덕션 결제 서비스의 ERROR 로그는 90일, 개발 환경의 DEBUG 로그는 3일. 대부분의 로그 플랫폼이 retention 기간별 요금을 매기므로, 불필요한 보관 기간을 줄이는 것이 곧바로 비용 절감으로 이어진다.
비용보다 더 위험한 건 로그가 갑자기 폭증할 때 예산 초과로 수집 자체가 중단되는 상황이다. Datadog은 일일 할당량을 초과하면 새로운 로그 수집을 중단한다. 장애 상황일수록 로그가 가장 많이 발생하는데, 그 순간 로그가 사라진다. daily quota를 80%에 도달하면 알람을 걸고, 100% 도달 시에도 일부 레벨(ERROR)은 수집을 유지하도록 설정하는 것이 안전하다.
로그 구조를 코드로 강제하라
지금까지 논의한 결정들(레벨 기준, 공통 필드, 마스킹, 비용 통제)은 모두 문서만으로는 유지되지 않는다. 코드 레벨에서 강제해야 한다. 구체적으로는 공유 로거 라이브러리를 만들고, 여기에 레벨 상수, 필드 템플릿, 마스킹 직렬화기를 내장한다. 매 서비스마다 JSON 로거를 직접 구성하게 두면 규칙이 조금씩 달라진다.
// logger.ts - 공유 로깅 라이브러리 const logger = pino({ level: env.LOG_LEVEL, redact: { paths: ['req.headers.authorization', 'req.body.email', 'req.body.phone', 'err'], censor: '[REDACTED]' }, serializers: { err: pino.stdSerializers.err, req: pino.stdSerializers.req, }, formatters: { level(label) { return { severity: label }; }, bindings(bindings) { return { service: env.SERVICE_NAME, version: env.COMMIT_SHA }; } } });
이 라이브러리를 npm 패키지나 git submodule로 관리하고 모든 서비스가 동일한 버전을 사용하도록 강제한다. 마이너 버전 업데이트만으로 전체 서비스의 로그 구조가 일관되게 바뀌는 구조를 만드는 것이다.
운영에 반영하기 전에 검증 단계가 필요하다. 로그 수집 파이프라인에 테스트 로그를 흘려보내서 필드 누락, 스키마 위반, 마스킹 누수를 자동으로 감지하는 CI 스텝을 추가한다. YAML 하나로 검증하지 말고, 실제 스테이징 환경에서 로그가 Datadog(또는 EFK)에 도착했을 때의 최종 형태를 확인해야 한다. "로컬에서 잘 나오는데요"라는 말은 운영 로그와 전혀 상관없다.
구조화 로깅으로 전환하는 일은 단순히 JSON 포맷터를 끼우는 작업이 아니다. 로그가 데이터 파이프라인의 일부라는 인식의 전환이 필요하다. 콘솔에 출력된 문자열이 Datadog을 거쳐 대시보드와 알람으로 이어지는 전체 경로를 설계해야 한다. 로그 한 줄을 추가할 때마다 그게 비용과 인시던트 대응 시간에 미치는 영향을 계산하는 습관이, 결국에는 Observability 예산과 장애 복구 속도를 동시에 잡는 지름길이다.INFO, WARN, ERROR. 이 세 단어를 로그 레벨로 쓰기 시작한 팀은 대부분 석 달 안에 레벨 체계가 무너진다. 개발자는 장애를 일으킨 코드 경로에 WARN을 붙이고, 단순한 healthcheck 성공에도 INFO 대신 DEBUG를 생략한다. 결과적으로 ERROR 로그만 모아봐도 알람을 걸 만한 게 절반도 안 된다. 레벨을 고르는 주체를 사람이 아닌 행동의 결과로 바꿔야 한다. 즉, ERROR는 당번자에게 삐지()가 울리는 조건, WARN은 대시보드 위젯에서 모니터링하는 조건, INFO는 검색을 위해 남기는 조건으로 각각 정의한다. 이 기준이 없으면 PagerDuty 룰을 쓸 수 없다.
레벨 기준이 모호하면 생기는 첫 번째 파편화는 알람 임계치 산정이다. "5분간 ERROR 10건 이상"으로 알람을 걸었는데, 재시도 가능한 503이 ERROR로 분류되어 있다면 새벽 3시마다 의미 없는 전화를 받게 된다. Datadog Monitor에서 error.type:timeout 같은 조건부 필터를 걸 수는 있지만, 근본적으로 레벨 분류가 잘못되어 있으면 필터를 아무리 추가해도 노이즈가 사라지지 않는다. 레벨을 행동 기준으로 구분하는 것, 그중에서도 ERROR를 "인간의 즉시 개입이 필요한 상태"로 좁히는 것이 첫 단추다.
공통 필드가 없으면 그냥 JSON 찍기일 뿐
구조화 로깅은 JSON.stringify({ message: "hello" })로 끝나는 작업이 아니다. 공통 필드가 정의되지 않은 상태에서 각 모듈이 마음대로 키를 추가하면, Datadog Log Explorer의 facet 목록이 수백 개로 불어난다. correlationId가 어떤 서비스는 trace_id, 어떤 서비스는 requestId로 기록된다면 트랜잭션 추적이 불가능하다.
최소 필드는 timestamp, service, version, environment, trace_id, severity, message의 7개로 시작한다. HTTP 계층이 있다면 http.method, http.path, http.status_code, duration_ms를 더한다. 여기까지는 대부분의 팀이 합의한다. 문제는 확장 필드에서 시작된다. DB 쿼리 시간을 보내겠다는 팀이 db.query_time을 쓰고, 다른 팀은 db.duration을 쓴다. 한 글자 차이가 대시보드의 필드 선택 드롭다운에서 발견되지 않고, 결과적으로 두 필드는 각자 따로 데이터가 쌓인다.
스키마 강제는 공유 라이브러리로 해결하는 게 가장 실용적이다. 각 서비스가 pino나 winston을 직접 import하지 않고, 내부 패키지에서 래핑한 logger를 통해 필드 템플릿을 주입한다. CI에서 JSON 스키마 validation을 추가하는 것도 방법이지만, "필드가 없다"는 사실을 배포 직후에야 알게 된다. 배포 전 검증과 배포 후 모니터링을 병행하는 것이 실제 운용에서 더 효과적이다.
민감정보는 로깅 파이프라인에 도달하기 전에 차단해야 한다
가장 많이 하는 실수는 "로그에 민감 정보를 안 찍으면 된다"고 믿는 것이다. 문제는 개발자가 인지하지 못하는 경로로 민감값이 흘러간다는 점이다. 예외 객체 하나를 로깅할 때 err.stack에 포함된 함수 인자에 비밀번호가 들어 있을 수 있다. JSON.stringify(error, Object.getOwnPropertyNames(error)) 같은 코드는 객체의 모든 enumerable 속성을 평탄화해서 로그에 노출한다.
Datadog의 Sensitive Data Scanner는 사후 검출 도구다. 이미 저장된 로그는 자동으로 지우지 않으며, 발견된 필드에 대해 알림만 보낸다. 삭제는 지원 티켓을 열어야 하고, retention 기간이 지나야 비로소 사라진다. GDPR이나 CCPA 규제 아래에서는 이 몇 주의 시간이 문제가 될 수 있다.
마스킹 적용 위치는 수집 파이프라인이 아니라 애플리케이션 레이어가 더 안전하다. 로거 라이브러리에서 redact 옵션으로 특정 필드 경로를 차단한다. HTTP 요청의 Authorization 헤더, req.body의 password, creditCard, ssn 패턴을 등록한다. 문제는 오탐이다. 실제로 민감하지 않은 필드(예: phone이 아닌 phoneType)까지 마스킹될 수 있다. 이를 해결하려면 단순 패턴 매칭보다는 paths 기반 redact + 정규식 검증을 조합한다. 예를 들어 pino의 redact.paths로 구조적 위치를 지정하고, JSON 스키마의 sensitive: true 메타데이터로 검증하는 방식이다.
더 자주 누락되는 지점은 HTTP 액세스 로그와 DB 쿼리 로그다. GET /api/users/abc@example.com 요청이 들어오면 웹 서버는 URL 전체를 req.path에 기록한다. 액세스 로그 미들웨어에서 URL에서 이메일 패턴을 감지해 [FILTERED]로 치환하는 transformer를 넣어야 한다. ORM 로그도 마찬가지다. Knex의 sql 출력에 바인딩된 실제 파라미터가 노출되므로, 운영 환경에서는 query formatter에서 값을 마스킹하거나 로그 레벨 자체를 DEBUG로 내리고 수집 대상에서 제외한다.
비용은 예상의 두 배, 그리고 급증
JSON 로그는 plain text보다 평균 5~10배 용량이 크다. 초당 500 request를 처리하는 서비스에서 요청당 로그 2줄(각 600바이트)을 Datadog에 보낸다고 가정하면, 하루 수집량은 약 100GB를 넘는다. Datadog은 수집량(ingested)과 인덱싱량(indexed)을 각각 과금하는데, 보통 인덱싱 비용이 더 비싸다.
비용 통제의 핵심은 필터링 우선순위를 ERROR > WARN > INFO 순으로 두는 것이다. ERROR는 전량 인덱싱, WARN은 10% 샘플링, INFO는 인덱싱 제외(수집만)로 설정한다. Datadog의 Exclusion Filter에서 severity:info를 제외하면 INFO는 검색이 불가능해지지만, 로그 익스플로러에서 제외되더라도 Storage에 원본은 남아 있어 로그 아카이브(S3, GCS)를 통해 재수집할 수 있다.
더 위험한 건 갑자기 로그가 10배로 폭증하는 상황이다. 장애가 발생할수록 로그가 많이 찍히는데, 그 순간 Datadog의 일일 할당량(usage quota)을 초과하면 로그 수집 자체가 멈춘다. 할당량에 80% 도달 시 경고를 보내고, 100% 도달 시에도 ERROR 로그만은 수집을 유지하도록 Retention Filter를 설정해야 한다. 플랫폼 설정에서 daily quota hard limit을 끄는 것도 방법이지만, 예산 초과분에 대한 과금을 감수해야 한다.
// Datadog에서 ERROR만 90일 보관을 유지하면서 예산 초과를 방지하는 필터 예시
// Retention Filter: prod-errors-90day
query: service:myapp AND severity:ERROR
retention: 90d
// Exclusion Filter: drop-info-and-debug
query: service:myapp AND (severity:INFO OR severity:DEBUG)
보관 기간도 서비스 중요도에 따라 차등을 둔다. 결제 서비스의 ERROR는 90일, dev 환경의 DEBUG는 3일. 대부분의 로그 플랫폼이 보관 기간에 선형으로 요금을 매기므로 "일단 30일"보다 "역할별로 다르게"가 비용 효율이 훨씬 좋다.
로그도 제품이다
로그 구조 설계는 한 번 정하면 끝이 아니다. 신규 서비스가 추가되거나, 스키마가 변경되거나, 규제 요건이 바뀔 때마다 로그 파이프라인도 함께 바뀐다. 로그를 제품처럼 다루는 팀은 공통 라이브러리의 버전을 올리는 것만으로 모든 서비스의 로그 포맷을 일관되게 유지한다. 매 서비스마다 pino 설정을 직접 고치게 놔두면, 세 달 안에 세 개의 서비스가 세 가지 포맷으로 로그를 쏘아댄다.
CI에서 "테스트 로그를 수집 파이프라인에 흘려 실제 스키마를 검증하는 단계"를 추가하는 것도 실전에서 효과를 본 방법이다. 유닛 테스트로는 검증할 수 없는 부분(로그 메시지가 실제로 Datadog에 도착했을 때의 최종 형태)을 잡아낼 수 있다. 스테이징 환경의 로그를 스크립트로 긁어서 facet 목록의 일관성을 확인하는 것도 같은 맥락이다.
console.log에서 JSON으로 바꾸는 건 가장 쉬운 단계일 뿐이다. 레벨의 기준, 스키마의 일관성, 민감정보의 차단, 비용의 통제까지 고려해야 구조화 로깅이 진짜 효용을 발휘한다. 그중 하나라도 놓치면 결국 평문 로그보다 더 비싸고 더 지저분한 형태로 남게 된다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
삽질 없이 CI를 줄이는 캐시 3종 세트
GitHub Actions에서 의존성 캐시와 빌드 캐시를 도입했는데도 정작 빌드가 느리거나, 캐시가 오히려 잘못된 결과를 재사용하며 깨지는 경험을 해봤다면 이 글이 답이다. cache와 setup-*의 동작 차이, 매트릭스 분할 전략, 캐시 무효화 판단 기준을 함정과 함께 정리해 실패 없이 CI 시간을 단축하는 법을 다룬다.
프론트엔드 개발자가 배포에서 벗어나는 순간
Vercel이 'Develop. Preview. Ship.'으로 압축한 것은 단순한 마케팅 문구가 아니다. 로컬 개발부터 프로덕션 배포까지 원클릭으로 연결하는 경험은 프론트엔드 개발 문화를 재정의하고 있다. 이 글에서는 Vercel이 만들어낸 배포의 투명화와 그 이면에 있는 기술적 트레이드오프, 그리고 팀이 겪는 현실적인 도전을 짚어본다.
이전 글
앱에선 안 되는데 SQL 에디터에선 되는 RLS의 반전
다음 글
DB 폴링과 작별할 시간을 아는 기술
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.