로그가 하루 수 GB, 구조화 로깅과 샘플링으로 잡는 법

구조화 로깅

이 글을 읽으시면 로그가 왜 하루 만에 수 GB씩 쌓이는지, 그리고 구조화 로깅과 로그 샘플링·로테이션을 어떤 기준으로 조합해야 디스크와 조회 성능을 동시에 지킬 수 있는지 감을 잡으실 수 있습니다. 결론부터 말씀드리면, 모든 로그를 JSON 같은 구조화 형식으로 남기되 INFO 이하는 샘플링으로 걸러내고 WARNING 이상은 무조건 전량 기록한 뒤, 보관 기간을 정해 로테이션하는 조합이 실무에서 가장 잘 통합니다. 다만 이 조합이 항상 정답은 아니라서, 뒤에서 안 맞는 경우도 같이 짚어보겠습니다.

로그 용량이 하루 만에 수 GB로 불어나는 구조적 원인

로그 용량 폭증은 대개 한 가지 원인이 아니라 여러 습관이 겹쳐서 생깁니다. 가장 흔한 경우는 운영 환경에서 DEBUG 레벨을 그대로 켜 둔 채 배포하는 것입니다. 프레임워크가 요청마다 남기는 내부 처리 로그, SQL 쿼리 전문, 스택트레이스까지 전부 파일에 쌓이면 트래픽이 조금만 늘어도 로그 파일이 기하급수적으로 커집니다.

두 번째 원인은 비구조화 텍스트 로그입니다. [2026-09-03 10:12:01] 사용자 12345가 주문 98765를 결제했습니다 같은 자유 형식 문자열은 사람이 눈으로 읽기엔 편하지만, 같은 맥락(사용자 ID, 주문 ID, 요청 경로)을 매 줄마다 문장으로 반복해서 쓰다 보니 순수 텍스트 용량 자체가 불어납니다. 여기에 마이크로서비스마다 로그 파일을 따로 만들고, 재시도 로직이 실패할 때마다 같은 에러를 반복해서 기록하면 하루 수 GB는 금방 넘어갑니다.

구조화 로깅이 비구조화 로그보다 나은 이유는 무엇일까요

구조화 로깅은 로그 한 줄을 문장이 아니라 키-값 쌍(대개 JSON)으로 남기는 방식입니다. user_id, order_id, status 같은 필드를 고정된 스키마로 남기면, 나중에 grep으로 문자열을 찾는 대신 jq나 로그 수집 시스템의 쿼리 언어로 정확히 원하는 조건만 필터링할 수 있습니다. 예를 들어 “지난 1시간 동안 status가 500이고 latency가 2초 이상인 요청”을 찾을 때, 비구조화 로그는 정규식을 여러 번 조합해야 하지만 구조화 로그는 필드 조건 하나로 끝납니다.

데이터가 가득 찬 서버실 하드 드라이브, 로그 용량 폭증을 상징

다만 이 이점은 전제 조건이 있습니다. JSON 로그를 실제로 파싱해서 조회하는 도구(Elasticsearch, Loki, CloudWatch Logs Insights 등)가 뒤에 붙어 있을 때만 효과가 나옵니다. 서버 한 대에서 tail -f로 눈으로 훑어보는 게 전부라면, 사람이 읽기엔 오히려 JSON보다 정돈된 텍스트 로그가 더 편할 수 있습니다.

로그 샘플링 기준, 레벨과 트레이스 단위로 나눠보세요

로그 샘플링에서 가장 흔한 실수는 로그 한 줄마다 독립적으로 확률을 굴려서 남기는 것입니다. 이 방식은 하나의 요청에 대한 로그 다섯 줄 중 두 줄만 살아남는 식으로 컨텍스트가 끊깁니다. 장애를 추적할 때 정작 필요한 줄이 샘플링에서 빠지면 원인 분석이 불가능해집니다. 그래서 실무에서는 요청(트레이스) 단위로 샘플링 여부를 한 번만 결정하고, 그 요청에 속한 로그는 전부 같이 남기거나 같이 버리는 방식을 씁니다.

파이썬 표준 라이브러리만으로 레벨 기반 샘플링 필터를 만들면 다음과 같습니다.

import logging
import json
import random
import time
from logging.handlers import TimedRotatingFileHandler

class JsonFormatter(logging.Formatter):
    def format(self, record):
        payload = {
            "ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(record.created)),
            "level": record.levelname,
            "logger": record.name,
            "msg": record.getMessage(),
        }
        if hasattr(record, "extra_fields"):
            payload.update(record.extra_fields)
        return json.dumps(payload, ensure_ascii=False)

class SamplingFilter(logging.Filter):
    def __init__(self, sample_rate=0.1):
        super().__init__()
        self.sample_rate = sample_rate

    def filter(self, record):
        if record.levelno >= logging.WARNING:
            return True  # 경고 이상은 항상 남깁니다
        return random.random() < self.sample_rate

logger = logging.getLogger("app")
logger.setLevel(logging.INFO)

handler = TimedRotatingFileHandler(
    "app.log", when="midnight", backupCount=14, encoding="utf-8"
)
handler.setFormatter(JsonFormatter())
handler.addFilter(SamplingFilter(sample_rate=0.1))
logger.addHandler(handler)

for i in range(5):
    logger.info("주문 처리 완료", extra={"extra_fields": {"order_id": i}})

이 코드에서 sample_rate=0.1은 INFO 로그 열 줄 중 한 줄만 실제로 파일에 씁니다. WARNING 이상은 filter 함수 첫 줄에서 무조건 통과시키므로 에러 추적에는 영향이 없습니다. 실행하면 app.log에는 확률적으로 0~5줄 사이의 JSON 로그가 남고, 매일 자정마다 새 파일로 넘어가면서 14일치만 보관됩니다. 트레이스 단위 샘플링이 필요하다면 이 필터에 request.trace_id를 해시해서 판단하는 방식으로 바꾸면, 같은 요청의 로그는 항상 함께 남거나 함께 빠집니다.

로그 로테이션은 삭제가 아니라 보관 전략입니다

터미널 화면에 출력된 JSON 구조화 로그 코드

위 예시에 쓴 TimedRotatingFileHandler는 파이썬 표준 라이브러리 공식 문서에 나온 그대로, when 값과 backupCount로 회전 주기와 보관 개수를 정합니다. 리눅스 서버라면 애플리케이션 코드 대신 logrotate 유틸리티로 파일 단위 회전과 압축을 맡기는 방법도 널리 씁니다. 두 방식의 선택 기준을 정리하면 아래와 같습니다.

기준 시간 기반 회전(예: 매일) 크기 기반 회전(예: 100MB마다)
예측 가능성 언제 새 파일이 생기는지 명확함 트래픽 급증 시 회전 주기가 불규칙
디스크 보호 트래픽 폭증 시 단일 파일이 무한정 커질 위험 있음 파일 크기 상한이 확실함
로그 분석 편의성 "어제 로그" 조회가 쉬움 하루에 여러 파일로 쪼개져 조회가 번거로움

압축(gzip)까지 같이 걸면 텍스트 로그는 보통 80~90%까지 줄어듭니다. 다만 로테이션을 "며칠 지나면 지운다"로만 이해하면 위험합니다. 결제·인증 관련 로그는 내부 감사나 법적 보관 요건 때문에 몇 달 이상 남겨야 하는 경우가 있으므로, 로컬 디스크에서는 짧게 회전시키더라도 오래된 파일은 삭제 대신 S3 같은 객체 스토리지의 저장 등급으로 옮기는 것이 안전합니다. 로그 관리의 뿌리가 된 표준 프로토콜인 Syslog도 원래는 전송과 우선순위 구분에 집중했을 뿐 장기 보관 정책까지 규정하지는 않으므로, 보관 기간은 서비스 쪽에서 별도로 설계해야 합니다.

구조화 로깅이 손해가 되는 상황과 오늘 바꿀 설정 세 가지

트래픽이 낮고 서버가 한두 대뿐인 초기 서비스라면, 구조화 로깅과 정교한 샘플링을 도입하는 비용이 얻는 이득보다 클 수 있습니다. JSON 직렬화는 문자열 포맷보다 CPU를 조금 더 씁니다. 초당 로그 건수가 매우 많은 핫패스(예: 초당 수만 건)에서는 이 비용이 누적돼 응답 지연으로 이어질 수 있으므로, 이런 구간에서는 logging.handlers.QueueHandler로 로그 기록을 별도 스레드에 넘기거나, 애초에 로그 레벨을 더 보수적으로 잡는 편이 낫습니다.

샘플링도 만능은 아닙니다. 샘플링 비율을 10%로 잡아두면, 사고 직전 몇 분 동안 발생한 이상 징후가 하필 샘플에서 빠질 수 있습니다. 이를 보완하려면 에러율이나 지연 시간이 임계치를 넘는 순간 자동으로 샘플링 비율을 100%로 올리는 적응형 로직을 넣거나, 최소한 WARNING 이상은 예외 없이 전량 기록하는 규칙을 지켜야 합니다.

오늘 바로 손댈 수 있는 설정은 세 가지로 좁혀집니다. 로그 레벨별로 전량 기록과 샘플링 대상을 나누는 것, 파일 핸들러를 시간 기반 로테이션으로 바꾸고 backupCount를 실제 디스크 여유 공간에 맞춰 정하는 것, 그리고 규정상 장기 보관이 필요한 로그는 로컬에서 삭제하기 전에 별도 스토리지로 옮기는 파이프라인을 확인하는 것입니다. 이 세 가지만 정리해도 하루 수 GB씩 쌓이던 로그가 실제로 필요한 신호만 남기는 방향으로 정리됩니다.

Windows에서 파이썬 cp949 오류 없애기: 로그·파일·서브프로세스 3곳 총정리

Leave a Comment