Python logging

src/content/documents/python/python-logging-instead-of-print.json

결론: print는 임시 확인용, logging은 레벨·대상을 제어하는 운영 기록이다

print()는 화면에 즉시 글자를 찍을 뿐 레벨도, 저장 위치도, 끄고 켜는 방법도 없다. logging 모듈은 메시지마다 심각도(레벨)를 붙이고, 그 메시지를 콘솔·파일 등 여러 목적지(핸들러)로 서로 다른 형식(포매터)으로 동시에 보낼 수 있다. 데이터 분석 파이프라인은 배치로 오래 돌거나 예약 실행되는 경우가 많아, 실행이 끝난 뒤에도 무슨 일이 있었는지 파일에 남아 있어야 원인을 추적할 수 있다.

print()로는 부족한 것들

필요한 것

print()

logging

심각도 구분

모든 출력이 동일하게 취급됨

DEBUG~CRITICAL 5단계 레벨

끄고 켜기

코드를 지우거나 주석 처리해야 함

레벨 설정만 바꾸면 됨

파일 저장

직접 파일을 열고 닫아야 함

핸들러로 파일·회전 저장 지원

출처 구분

어느 모듈의 출력인지 알기 어려움

logger 이름·모듈·줄 번호 자동 기록

운영 중 끄기

재배포해야 반영됨

설정 변경만으로 레벨 조정 가능

개발 중 값 하나를 잠깐 확인할 때는 print가 여전히 빠르고 편하다. 다만 그 코드가 다른 사람도 쓰는 함수나, 서버·스케줄러에서 반복 실행되는 파이프라인에 남으면 나중에 무엇을 지워야 할지 알 수 없는 잡음이 된다.

logging 기본 사용법

import logging

logging.basicConfig(level=logging.INFO)

logging.info("서비스 시작")
logging.warning("주의: 설정 값이 누락되었습니다.")
logging.error("에러 발생: 파일을 찾을 수 없습니다.")
INFO:root:서비스 시작
WARNING:root:주의: 설정 값이 누락되었습니다.
ERROR:root:에러 발생: 파일을 찾을 수 없습니다.

logging.basicConfig()는 이름 없는 최상위 root 로거를 한 번만 설정하기 위한 함수다. 이미 핸들러가 설정된 뒤 다시 호출하면 기본적으로 무시된다. 여러 모듈이 각자 basicConfig를 부르는 구조는 예측하기 어려우므로, 실무에서는 프로그램 진입점 한 곳에서만 호출하고 각 모듈은 아래에서 다룰 getLogger로 이름 있는 로거를 얻어 쓴다.

로그 레벨 다섯 단계

레벨

숫자값

언제 쓰나

DEBUG

10

변수 값, 반복 진행 상황 등 개발 중 상세 정보

INFO

20

파이프라인 시작·종료, 처리 건수 등 정상 흐름 기록

WARNING

30

즉시 실패는 아니지만 확인이 필요한 상황(결측값 다수 등)

ERROR

40

특정 작업이 실패했지만 프로그램은 계속 실행 가능

CRITICAL

50

서비스 중단으로 이어질 수 있는 치명적 오류

로거나 핸들러에 레벨을 지정하면 그 값보다 낮은 레벨의 메시지는 조용히 버려진다. 예를 들어 레벨을 INFO로 두면 DEBUG 메시지는 출력되지 않는다. 개발 중에는 DEBUG로 세세히 보고, 운영에서는 INFO 이상만 남기는 식으로 코드를 고치지 않고 레벨만 바꿔 조절한다.

Logger·Handler·Formatter의 역할 분리

logging은 세 객체가 역할을 나눠 맡는다. Logger는 어떤 이름으로, 어느 레벨부터 기록을 받을지 결정하는 창구다. Handler는 그 기록을 어디로 보낼지(콘솔·파일 등) 정한다. Formatter는 시간·레벨·메시지를 어떤 문자열 모양으로 만들지 정한다. 로거 하나에 핸들러를 여러 개 붙이면 같은 메시지를 서로 다른 레벨·형식으로 동시에 여러 곳에 보낼 수 있다.

import logging
import os
from logging.handlers import TimedRotatingFileHandler

log_dir = "logs"
os.makedirs(log_dir, exist_ok=True)

logger = logging.getLogger("pipeline")
logger.setLevel(logging.DEBUG)
logger.propagate = False  # 루트 로거로 중복 전달하지 않는다

if not logger.handlers:
    # 콘솔: INFO 이상만 간단히
    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.INFO)
    console_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))

    # 파일: DEBUG 이상 모두, 자정마다 회전해 최근 7일 보관
    file_handler = TimedRotatingFileHandler(
        filename=f"{log_dir}/pipeline.log",
        when="midnight",
        backupCount=7,
        encoding="utf-8",
    )
    file_handler.setLevel(logging.DEBUG)
    file_handler.setFormatter(
        logging.Formatter("%(asctime)s | %(levelname)s | %(name)s | %(message)s")
    )

    logger.addHandler(console_handler)
    logger.addHandler(file_handler)

logger.debug("행 3개 읽음")
logger.info("파이프라인 시작")
logger.warning("결측값 12건 발견")
logger.error("파일을 찾을 수 없음: data/missing.parquet")
[INFO] 파이프라인 시작
[WARNING] 결측값 12건 발견
[ERROR] 파일을 찾을 수 없음: data/missing.parquet

콘솔에는 레벨을 INFO로 둬 DEBUG 메시지가 보이지 않지만, 같은 시각 logs/pipeline.log 파일에는 DEBUG부터 전부 남는다.

2026-08-10 11:30:55,017 | DEBUG | pipeline | 행 3개 읽음
2026-08-10 11:30:55,017 | INFO | pipeline | 파이프라인 시작
2026-08-10 11:30:55,017 | WARNING | pipeline | 결측값 12건 발견
2026-08-10 11:30:55,017 | ERROR | pipeline | 파일을 찾을 수 없음: data/missing.parquet

logger.propagate = False는 이 로거의 기록이 부모(root) 로거로 다시 전달돼 중복 출력되는 것을 막는다. if not logger.handlers: 조건은 같은 이름의 로거를 여러 번 가져올 때(예: 모듈을 다시 import) 핸들러가 계속 쌓여 로그 한 줄이 여러 번 찍히는 문제를 막는다.

모듈별 로거: logging.getLogger(name)

여러 파일로 나뉜 프로젝트에서는 각 모듈이 자기 이름으로 로거를 얻는 것이 관례다. __name__은 그 모듈의 점(.)으로 구분된 전체 경로이므로, 로그만 보고도 어느 모듈에서 났는지 구분할 수 있고, 로거 이름 계층을 따라 상위 로거에서 하위 전체 레벨을 한 번에 조절할 수도 있다.

# pipeline/loader.py
import logging

logger = logging.getLogger(__name__)  # 예: "pipeline.loader"

def load(path):
    logger.info("로딩 시작: %s", path)

메시지를 만들 때는 f"로딩 시작: {path}"처럼 f-string으로 미리 합치지 않고 logger.info("로딩 시작: %s", path)처럼 값을 인자로 넘기는 방식을 권장한다. 이 레벨이 실제로 출력되지 않을 때는 문자열 합치기 자체를 건너뛰어 불필요한 연산을 줄인다.

예외를 로그로 남기기: logger.exception

17번 글의 safe_load 예제는 실패를 print로만 표시했다. except 블록 안에서 logger.exception(메시지)를 호출하면 ERROR 레벨로 기록하면서 현재 처리 중인 예외의 트레이스백까지 자동으로 함께 남긴다.

import logging

logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)

try:
    1 / 0
except ZeroDivisionError:
    logger.exception("0으로 나누기 실패")
[ERROR] 0으로 나누기 실패
Traceback (most recent call last):
  File "...", line 6, in <module>
    1 / 0
ZeroDivisionError: division by zero

logger.exception(...)except 블록 안에서만 의미가 있다. 그 밖에서 부르면 트레이스백 없이 logger.error(...)와 같아진다. except 블록 밖에서 예외 정보를 남기려면 logger.error(메시지, exc_info=True)처럼 직접 지정한다.

자주 발생하는 문제

  • logging.basicConfig를 여러 모듈에서 각각 호출하면 처음 실행된 것만 적용되고 나머지는 조용히 무시된다. 설정은 프로그램 시작점 한 곳에서만 한다.

  • 같은 이름의 로거를 다시 만들 때마다(예: 모듈 재로드, 함수 안에서 매번 getLogger 뒤 addHandler) 핸들러가 계속 쌓여 로그 한 줄이 여러 번 출력된다. 핸들러 추가는 if not logger.handlers:로 한 번만 하거나 애플리케이션 시작 시점에 한 번만 실행되게 한다.

  • 레벨을 너무 낮게(DEBUG) 켠 채로 운영에 배포하면 로그 파일이 빠르게 커진다. 운영 기본은 INFO 이상으로 두고 필요할 때만 일시적으로 낮춘다.

  • 비밀번호·API 키·개인정보를 그대로 로그 메시지에 넣으면 로그 파일 자체가 새로운 유출 경로가 된다. 민감한 값은 마스킹하거나 아예 로그에 남기지 않는다. 비밀정보를 코드와 분리해서 관리하는 방법은 다음 글에서 다룬다.

  • printlogging을 섞어 쓰면 레벨로 한꺼번에 끄고 켤 수 없는 출력이 남는다. 남겨야 할 정보는 모두 logging으로 통일한다.

다음 글: 환경변수와 .env로 비밀정보 관리하기

이번 글에서 로그에 비밀정보를 남기지 말아야 한다고 했는데,애초에 비밀번호·API 키 같은 값을 코드에 직접 적지 않으면 이 문제 자체가 줄어든다. 다음 글에서는 환경변수와 .env 파일로 설정과 비밀정보를 코드에서 분리하고 Git에 실수로 올리지 않도록 막는 방법을 다룬다.

참고 자료

댓글 0

댓글을 불러오는 중…