Article

로그 레벨과 구조화 로깅, 운영에서 쓸 수 있는 로그의 기준

작성: 감성개발자

로그 스트림이 레벨별 채널로 정리되는 모습을 표현한 기술 일러스트

장애가 난 새벽 두 시, 로그를 열었더니 처리 완료, 에러 발생! 같은 문장만 수만 줄 쌓여 있었습니다. 이러면 로그가 있어도 없는 것과 같습니다. 어느 요청에서 난 에러인지, 같은 사용자의 바로 앞뒤 로그가 무엇인지 이을 방법이 없기 때문입니다. 정작 급할 때 검색이 안 되는 로그를 몇 번 붙잡아 보고 나서, 로그를 대하는 기준이 바뀌었습니다.

로그는 사람이 읽는 문장이 아니라 기계가 걸러낼 수 있는 데이터로 남겨야 합니다. 그 출발점은 팀이 합의한 레벨 기준, JSON 구조화, 그리고 요청 단위 식별자입니다.

아래 내용은 OpenTelemetry의 로그 사양 문서와 Node.js 로거 pino의 공식 문서를 바탕으로 정리했습니다.

로그 레벨: “누가 언제 봐야 하는가”로 정의한다

레벨 기준이 합의되어 있지 않으면 어떤 개발자는 재시도 성공을 ERROR로 남기고, 어떤 개발자는 결제 실패를 INFO로 남깁니다. 그러면 “ERROR만 모니터링한다”는 전제가 무너집니다. 레벨은 심각도라는 추상적 단어보다 “이 로그를 보고 누가 움직여야 하는가”로 정의하는 편이 실무에서 일관됩니다.

레벨정의
FATAL프로세스가 계속 갈 수 없음. 즉시 알림필수 설정 누락으로 기동 실패
ERROR요청·작업이 실패했고 조치가 필요함. 알림 대상결제 처리 실패, 처리되지 않은 예외
WARN실패는 아니지만 지켜봐야 함. 대시보드 대상재시도 후 성공, 응답 지연, 폴백 동작
INFO시스템의 주요 상태 변화 기록기동/종료, 배포 버전, 주요 도메인 이벤트
DEBUG개발·트러블슈팅용 상세 정보외부 API 요청/응답 요약, 분기 판단 근거

에이전시 시절 인수한 프로젝트마다 레벨 기준이 제각각이라, 나는 팀에 들어가면 로그 레벨 합의부터 문서로 박아둡니다. 이 기준에서 자주 어긋나는 두 가지가 있습니다. 첫째, 재시도로 회복된 실패는 ERROR가 아니라 WARN입니다. 최종적으로 성공했다면 조치할 일이 없기 때문입니다. 둘째, 사용자 입력 오류(4xx)는 대부분 ERROR가 아닙니다. 잘못된 비밀번호 입력은 시스템 실패가 아닙니다. 이런 것까지 ERROR로 남기면 알림이 소음이 되고, 소음이 된 알림은 결국 무시됩니다.

운영 환경의 기본 레벨은 INFO, 문제 추적 시에만 DEBUG를 일시적으로 켜는 운용이 일반적입니다. OpenTelemetry의 로그 데이터 모델도 로그 레코드에 심각도(severity) 필드를 표준 속성으로 정의하고 있어서, 레벨 체계를 정리해두면 추후 수집 파이프라인과도 자연스럽게 연결됩니다. (출처: OpenTelemetry - Logs)

구조화 로깅: 문장이 아니라 필드로

console.log("user 42 login failed")는 사람 눈에는 읽히지만, “사용자 42의 최근 실패”를 검색하려면 문자열 파싱이 필요합니다. 구조화 로깅은 변하는 값을 메시지에서 분리해 필드로 남깁니다.

import pino from "pino";
const logger = pino();

logger.warn(
  { userId: 42, reason: "invalid_password", attempt: 3 },
  "login failed"
);
{"level":40,"time":1748131200000,"userId":42,"reason":"invalid_password","attempt":3,"msg":"login failed"}

pino는 로그를 처음부터 JSON으로 출력하는 Node.js 로거로, 포매팅 비용을 최소화한 저오버헤드 설계를 특징으로 내세웁니다. (출처: pino - Documentation) 같은 원리는 어느 스택에나 있습니다. Python의 structlog, Java의 logback JSON encoder, Go의 slog 모두 “메시지 + 필드” 구조입니다.

구조화가 되면 할 수 있는 일이 달라집니다.

  • reason: "invalid_password" AND attempt >= 3 같은 조건 검색이 됩니다.
  • 필드 기준 집계로 “오늘 결제 실패 사유 분포” 같은 질문에 로그만으로 답할 수 있습니다.
  • 로그 기반 알림 규칙을 문자열 매칭이 아니라 필드 조건으로 걸 수 있어 오탐이 줄어듭니다.

필드 이름은 팀 컨벤션으로 통일해야 합니다. 같은 값이 userId, user_id, uid로 흩어지면 구조화의 이점이 사라집니다. 자주 쓰는 필드(사용자, 주문, 요청 등)의 표준 이름을 정해 로거 래퍼나 문서로 강제하는 것이 좋습니다.

요청 ID: 흩어진 로그를 하나로 잇는 열쇠

여러 서비스 단계를 왼쪽에서 오른쪽으로 가로지르며, 흩어져 있는 수많은 로그 중 하나의 요청에 속한 로그만을 밝은 선 하나가 골라 이어 붙이고, 그 선이 마지막에 유독 두드러진 한 지점으로 이어지는 흐름을 형태로 표현한 도식

운영 로그의 실전 질문은 “이 에러가 난 요청에서 그 전에 무슨 일이 있었나”입니다. 이 질문에 답하려면 한 요청에서 발생한 모든 로그가 같은 식별자를 공유해야 합니다.

  • 요청 진입 시 고유 ID를 생성하거나, 게이트웨이가 준 헤더(예: X-Request-Id)를 이어받습니다.
  • 그 요청 처리 중 남는 모든 로그에 requestId 필드를 자동으로 붙입니다. Node.js라면 AsyncLocalStorage로 요청 컨텍스트를 전파해 로거에 주입하는 패턴이 일반적입니다.
  • 외부 서비스 호출 시 같은 ID를 헤더로 전달하면 서비스 경계를 넘어 추적이 이어집니다.

이렇게 해두면 에러 로그 하나에서 requestId를 복사해 검색하는 것만으로 해당 요청의 전체 타임라인이 나옵니다. 분산 추적(tracing) 시스템을 도입하기 전 단계에서 가장 투자 대비 효과가 큰 항목이고, OpenTelemetry로 넘어갈 때도 로그에 trace 컨텍스트를 싣는 같은 구조로 자연스럽게 확장됩니다.

남기면 안 되는 것: 마스킹은 로깅 계층에서

로그는 개발자만 보지 않습니다. 수집 파이프라인, 외부 SaaS, 백업까지 흘러갑니다. 비밀번호, 토큰, 카드번호, 주민번호 같은 값이 로그에 남으면 유출 사고의 표면이 그만큼 넓어집니다.

주의할 점은 “조심해서 안 남기기”가 개인의 습관으로는 지켜지지 않는다는 것입니다. 나는 이걸 코드 리뷰로 막을 수 있다고 오래 믿었는데, 결국 새는 건 리뷰가 느슨해진 날 한 줄이었습니다. 요청 본문을 통째로 로깅하는 코드 한 줄이면 끝이기 때문입니다. 그래서 마스킹은 로깅 계층의 기본 동작으로 넣어야 합니다. pino는 redact 옵션으로 지정한 경로의 값을 자동으로 가리는 기능을 제공합니다.

const logger = pino({
  redact: {
    paths: ["password", "*.token", "req.headers.authorization"],
    censor: "[REDACTED]",
  },
});

에러 객체를 남길 때도 스택 트레이스는 DEBUG성 정보라는 점을 기억해야 합니다. 사용자 응답에는 절대 스택을 내보내지 않고, 로그에만 남깁니다.

자주 묻는 질문

Q. 로그가 너무 많아져서 비용이 걱정됩니다. 레벨 정리부터 효과가 큽니다. 운영 기본 레벨을 INFO로 올리고, 반복 대량 발생 로그(폴링 성공 등)는 샘플링하거나 DEBUG로 내립니다. 보존 기간을 로그 종류별로 다르게 가져가는 것(에러는 길게, 접근 로그는 짧게)도 일반적입니다.

Q. 개발할 때 JSON 로그는 읽기 불편한데요. 출력과 저장을 분리하면 됩니다. pino는 개발 환경에서 pino-pretty로 사람이 읽기 좋은 형태로 출력하고, 운영에서는 JSON 그대로 수집기로 보내는 구성을 권장합니다.

Q. console.log를 다 걷어내야 하나요? 애플리케이션 코드에서는 로거로 통일하는 것을 권합니다. console.log는 레벨도 필드도 요청 컨텍스트도 없어서, 수집 파이프라인에서 다른 로그와 조합되지 않는 고립된 줄이 됩니다.

팀에 남기는 기준

  • 로그 레벨은 “누가 움직여야 하는가”로 정의하고 팀이 합의합니다. 회복된 실패는 WARN, 사용자 입력 오류는 대부분 ERROR가 아닙니다.
  • 변하는 값은 메시지가 아니라 필드로 남기고, 필드 이름을 통일합니다.
  • 모든 로그에 요청 ID를 자동으로 실어 한 요청의 흐름을 검색 한 번으로 잇습니다.
  • 민감정보 마스킹은 습관이 아니라 로깅 계층의 기본 설정으로 강제합니다.

출처

댓글