배포 전에 지우는 로그

console.log를 잔뜩 박아두고 배포 직전에 전부 지워본 적이 있다면, 그건 로깅이 아니라 디버깅이다. 둘은 목적이 다른 별개의 활동이다.

디버깅로깅
수명일시적영구적
시점개발 중운영 중에 더 중요
독자사람사람 + 기계
형식자유구조화
배포 시삭제남는다

운영에서의 로깅은 이런 요구를 만족시켜야 한다.

6개월 뒤에 누군가가 어제 새벽 2시에 무슨 일이 있었는지 검색할 수 있게 만드는 것.

그러려면 형식이 있어야 하고, 검색 가능해야 하고, 보관돼야 한다. 이 세 조건을 하나씩 짚어보려 한다.

1. 로그 레벨 — 기준은 “사람을 깨울 일인가”

가장 흔한 실수는 전부 INFO로 찍는 것이다. 그러면 정말 중요한 게 묻힌다. 실전에서 쓰는 기준은 단순하다.

DEBUG   개발/디버깅용. 프로덕션에서는 끈다.
        "변수 x = 42"

INFO    정상 흐름의 주요 이벤트.
        "유저 로그인", "결제 완료"

WARN    비정상이지만 자동 복구된 상황.
        "재시도 1회 후 성공", "fallback 경로 사용"

ERROR   실패. 사람이 봐야 한다.
        "DB 연결 실패", "결제 거절"

경계를 가르는 질문은 하나다. 자동으로 회복되는가, 사람이 개입해야 하는가.

이 구분이 중요한 이유는 ERROR가 결국 알림과 연결되기 때문이다. 재시도로 해결되는 상황을 ERROR로 찍기 시작하면 알림 채널이 노이즈로 뒤덮이고, 사람은 알림을 무시하게 되고, 그러면 알림이 없는 것과 같아진다. 알림 피로는 모니터링을 무력화하는 가장 흔한 경로다.

2. 구조화 로그 — 기계가 읽을 수 있게

같은 사건을 두 방식으로 기록해보자.

[2026-08-05 14:32:15] User u_1043 failed to login
from 203.0.113.7, reason: invalid password, attempt 3
{
  "ts": "2026-08-05T14:32:15Z",
  "level": "WARN",
  "event": "login_failed",
  "user_id": "u_1043",
  "ip": "203.0.113.7",
  "reason": "invalid_password",
  "attempt": 3
}

내용은 같지만 할 수 있는 일이 다르다. 텍스트 로그는 사람만 읽을 수 있고, JSON 로그는 필드 단위로 색인된다.

검색   level=ERROR AND user_id=u_1043
       → 이 사용자에게 발생한 모든 에러

집계   count(event=login_failed) group by ip
       → 무차별 대입 시도 IP 자동 발견

알림   count(event=payment_failed) > 10 per minute
       → 결제 장애를 1분 안에 감지

텍스트 로그로 같은 일을 하려면 정규식을 짜야 하고, 로그 문구를 한 번만 손대도 그 정규식이 깨진다. 처음부터 JSON으로 찍는 편이 훨씬 싸다. 설정도 대부분 몇 줄이다.

런타임라이브러리
Java / SpringLogback + logstash-logback-encoder
Node.jspino
Pythonstructlog
Golog/slog (표준 라이브러리)

한 가지 덧붙이면, 필드 이름을 먼저 합의하는 게 라이브러리 선택보다 중요하다. 어떤 곳은 user_id, 어떤 곳은 userId, 또 어떤 곳은 uid로 찍으면 색인은 되지만 검색이 안 된다. 이벤트 이름과 공통 필드 목록을 짧게라도 문서로 고정해두는 편이 좋다.

3. Request ID — 요청 하나를 끝까지 따라가기

운영하면서 가장 자주 받는 질문은 “저 사용자 왜 결제가 안 됐어요?“다. Request ID가 없으면 이 질문에 답할 수가 없다. 시간대로 검색하면 같은 시각의 다른 요청 로그가 전부 섞여 나온다.

진입점에서 요청마다 ID를 하나 부여하고, 그 요청이 거치는 모든 로그에 같은 ID를 박아두면 이야기가 달라진다.

[요청] ─ req_id=a1b2c3 부여 (미들웨어/인터셉터)
   │
   ├─ Controller   req_id=a1b2c3  "POST /order"
   ├─ Service      req_id=a1b2c3  "재고 확인"
   ├─ DB           req_id=a1b2c3  "SELECT ... 120ms"
   ├─ 외부 API     req_id=a1b2c3  "결제 요청 800ms"
   └─ Response     req_id=a1b2c3  "200 OK 1.1s"

ID 하나로 검색하면 그 요청의 전 과정이 시간 순으로 나온다. 어디서 느려졌는지, 어디서 끊겼는지가 바로 보인다.

구현은 언어별로 “요청 범위 컨텍스트"를 쓰면 된다.

런타임컨텍스트
JavaMDC (SLF4J)
Node.jsAsyncLocalStorage
Pythoncontextvars
Gocontext.Context

두 가지만 주의하면 된다.

  • 비동기 경계에서 끊긴다. 스레드 풀에 작업을 던지거나 콜백으로 넘어가면 컨텍스트가 따라가지 않는 경우가 있다. Java의 MDC는 새 스레드로 전파되지 않으므로 래핑이 필요하다.
  • 서비스 경계도 넘겨야 한다. 외부 호출 시 X-Request-Id 같은 헤더로 실어 보내고, 받는 쪽은 헤더에 값이 있으면 새로 만들지 않고 그대로 쓴다. 그래야 서비스가 나뉘어도 하나의 흐름으로 이어진다.

4. 남기면 안 되는 것

로그 설계에서 정작 중요한 건 무엇을 남길지보다 무엇을 남기지 않을지다. 로그 시스템은 대개 데이터베이스보다 접근 통제 등급이 낮다. 조회 권한이 넓고, 외부 수집기로 흘러가고, 백업으로 복제된다. 거기에 민감정보가 들어가는 순간 사고다.

남긴다남기지 않는다
Request ID비밀번호 (평문·해시 전부)
사용자 식별자(ID)카드번호, CVC
이벤트 이름주민등록번호, 전화번호 원본
상태 코드 / 에러 코드액세스 토큰, API 키
응답 시간세션 쿠키
쿼리 (개인정보 제외)본인인증 코드

원칙은 사용자를 식별할 수 있는 최소한의 정보 + 사건의 맥락만 남기는 것이다. 이메일보다 내부 ID를 쓰고, 꼭 필요하면 마스킹한다.

john***@example.com
sk_live_a1b***

여기서 자주 놓치는 지점이 하나 있다. 예외 스택트레이스와 요청 본문 덤프다. 평소에는 아무것도 안 남기다가 에러 상황에서 요청 전체를 통째로 찍는 코드가 의외로 많다. 사고는 대개 그 경로로 난다. 에러 핸들러에서 남기는 필드도 정상 경로와 똑같이 화이트리스트로 관리하는 게 맞다.

5. 어디에 쌓을 것인가

규모에 맞지 않는 선택이 가장 비싸다. 대략 이 정도 기준으로 잡는다.

  • 초기 / 단일 서버: 파일 + 로테이션. 다만 서버에 들어가야 볼 수 있다는 게 한계다.
  • 성장기 / 여러 노드: 매니지드 로그 서비스(CloudWatch Logs, Loki, Datadog 등). 노드가 둘 이상이 되는 순간 “어느 서버였지"를 매번 따지는 비용이 로그 비용보다 커진다.
  • 검색·집계가 본격적으로 필요할 때: Elasticsearch/OpenSearch 계열.

여기서 진짜 결정해야 하는 건 도구가 아니라 보관 기간과 볼륨이다. 전부 30일 보관하면 비용이 감당이 안 되고, 전부 3일 보관하면 정작 필요할 때 없다. 레벨별로 나누는 게 현실적이다. ERROR/WARN은 길게, INFO는 짧게, DEBUG는 프로덕션에서 아예 끈다.

마지막으로, 로그는 자기 자신을 지켜주지 않는다

여기까지 다 해두고도 헛수고가 되는 경우가 있다. 로그를 남기는 설정과 로그를 지우는 설정이 따로 관리될 때다.

로테이션 주기가 관찰 주기보다 짧으면 로그는 정상적으로 쌓이는데 필요한 시점에 남아 있지 않다. 그 상태에서 “로그에 아무것도 없다"를 “아무 일도 없었다"로 읽으면 판단을 완전히 그르친다.

이 이야기는 따로 정리할 만큼 사례가 많아서, 로그가 있어도 못 보는 경우에 이어서 썼다.

자주 묻는 질문

Q. 로그 레벨은 어떤 기준으로 나눠야 하나요?

사람이 즉시 봐야 하는 실패는 ERROR, 비정상이지만 재시도나 fallback으로 자동 복구된 상황은 WARN, 정상 흐름의 주요 이벤트는 INFO, 개발 중에만 필요한 값 출력은 DEBUG입니다. ERROR는 알림과 연결되므로 자동 복구되는 상황을 ERROR로 찍으면 알림이 노이즈로 뒤덮여 정작 중요한 실패를 놓치게 됩니다.

Q. 구조화 로그(JSON)를 써야 하는 이유는 무엇인가요?

텍스트 로그는 사람만 읽을 수 있지만 JSON 로그는 필드 단위 색인이 가능합니다. 특정 사용자의 에러만 검색하거나, 실패 이벤트를 IP별로 집계하거나, 분당 실패 건수가 임계를 넘으면 알림을 보내는 일이 모두 쿼리 한 줄이 됩니다. 텍스트 로그로 같은 일을 하려면 정규식 파싱이 필요하고 형식이 조금만 바뀌어도 깨집니다.

Q. Request ID는 어떻게 구현하나요?

요청 진입점의 미들웨어나 인터셉터에서 UUID를 생성해 요청 범위 컨텍스트에 저장하고, 로거가 모든 로그에 자동으로 그 값을 붙이도록 설정합니다. Java는 MDC, Node.js는 AsyncLocalStorage, Python은 contextvars가 그 역할을 합니다. 외부 호출 시에는 헤더로 함께 전달해 서비스 경계를 넘어서도 추적이 이어지게 합니다.