Devin.KR

파이썬 로그 분석 자동화 - re 와 Counter 로 오류 집계 (파이썬 자동화 4단원)

개발자 조회 1

이 단원에서 배우는 것

서비스가 느려지거나 결제가 실패했다는 연락이 오면 제일 먼저 로그를 연다. 그런데 로그는 수천, 수만 줄이다. 텍스트 편집기에서 ERROR를 검색해 하나씩 세는 대신, 로그를 숫자로 요약하는 스크립트를 만든다.

  • 정규 표현식(re)의 이름 붙은 그룹으로 한 줄에서 날짜·시각·레벨·모듈을 뽑는다.
  • collections.Counter로 모듈별·오류 코드별·시간대별 건수를 센다.
  • gzip으로 압축된 지난 로그도 풀지 않고 같은 코드로 읽는다.
  • 형식이 다른 줄(여러 줄짜리 오류 추적 등)은 버리지 않고 세어서 보고한다.

문제 상황

서점의 온라인 주문 프로그램은 한 줄에 한 사건씩 로그를 남긴다.

2026-09-22 14:07:31 ERROR payment 처리 실패 order=A1043 code=E502
2026-09-22 14:07:35 INFO  order 주문 접수 order=A1044
2026-09-22 14:08:02 WARN  search 응답 느림 1840ms

날짜, 시각, 레벨(INFO·WARN·ERROR), 모듈 이름, 메시지 순서다. ERROR 줄에는 code=E502처럼 오류 코드가 붙기도 하고 안 붙기도 한다. 로그는 하루에 한 번 다음 날짜로 넘어가면서, 지난 파일은 app.log.1.gz처럼 압축돼 보관된다. 리눅스 서버에서 흔히 쓰는 로그 회전 방식이다.

개발 담당자가 매일 아침 알고 싶은 것은 다음 세 가지다.

  • 어제 ERROR가 몇 건이었고 어느 모듈에서 많이 났는가?
  • 가장 많이 난 오류 코드는 무엇인가? (E502는 결제 대행사 응답 실패다.)
  • 오류가 특정 시간대에 몰렸는가? 몰렸다면 그 시간에 무슨 일이 있었는가?

완성 스크립트

log_errors.py로 저장한다. 사용법은 python3 log_errors.py 로그파일 [로그파일 ...]이다. 파일을 여러 개 주면 모두 합쳐서 센다.

"""애플리케이션 로그에서 ERROR 줄을 모아 모듈별·코드별·시간대별로 집계한다."""
import gzip
import re
import sys
from collections import Counter
from pathlib import Path

LINE = re.compile(
    r"^(?P<date>\d{4}-\d{2}-\d{2}) (?P<hour>\d{2}):\d{2}:\d{2} "
    r"(?P<level>[A-Z]+) +(?P<module>\w+) (?P<message>.*)$"
)
CODE = re.compile(r"code=(E\d{3})")


def open_log(path):
    if path.suffix == ".gz":
        return gzip.open(path, "rt", encoding="utf-8", errors="replace")
    return open(path, encoding="utf-8", errors="replace")


def scan(paths):
    levels = Counter()
    modules = Counter()
    codes = Counter()
    hours = Counter()
    broken = 0
    for path in paths:
        with open_log(path) as f:
            for line in f:
                m = LINE.match(line.rstrip("\n"))
                if not m:
                    broken += 1
                    continue
                levels[m["level"]] += 1
                if m["level"] != "ERROR":
                    continue
                modules[m["module"]] += 1
                hours[m["hour"]] += 1
                c = CODE.search(m["message"])
                codes[c.group(1) if c else "-"] += 1
    return levels, modules, codes, hours, broken


def bar(count, biggest, width=20):
    return "#" * max(1, round(count / biggest * width))


def main():
    if len(sys.argv) < 2:
        print("사용법: python3 log_errors.py 로그파일 [로그파일 ...]")
        sys.exit(2)
    paths = [Path(p) for p in sys.argv[1:]]
    levels, modules, codes, hours, broken = scan(paths)
    total = sum(levels.values())
    print(f"읽은 줄 {total + broken}줄 (형식이 다른 줄 {broken}줄)")
    print("레벨별:", ", ".join(f"{k} {v}" for k, v in levels.most_common()))
    if not modules:
        print("ERROR 가 없습니다.")
        return
    print("\n[모듈별 ERROR]")
    for name, count in modules.most_common():
        print(f"  {name:<10}{count:>4}")
    print("\n[오류 코드 상위 3]")
    for code, count in codes.most_common(3):
        print(f"  {code:<10}{count:>4}")
    print("\n[시간대별 ERROR]")
    biggest = max(hours.values())
    for hour in sorted(hours):
        print(f"  {hour}시 {hours[hour]:>3} {bar(hours[hour], biggest)}")


if __name__ == "__main__":
    main()

한 줄씩 해설

정규 표현식 — 한 줄의 모양을 패턴으로 적는다

6단원의 split()으로도 로그를 자를 수 있다. 하지만 레벨 뒤 공백이 INFO는 두 칸, ERROR는 한 칸이고, 메시지 안에도 공백이 있어서 "몇 번째 조각"으로는 금방 무너진다. 이럴 때 줄의 모양을 패턴으로 적는 정규 표현식을 쓴다. 이 스크립트에 필요한 기호만 정리하면 이렇다.

기호
\d숫자 한 글자\d{4} = 숫자 네 개 (연도)
\w글자·숫자·밑줄 한 글자\w+ = 모듈 이름
\S공백이 아닌 한 글자\S+ = 공백 전까지
+앞 글자가 한 번 이상 + = 공백 한 칸 이상
.*아무 글자나 0개 이상메시지 나머지 전부
^, $줄의 시작, 끝줄 전체가 모양에 맞아야 한다
(?P<이름>...)이름 붙은 그룹m["level"]로 꺼낸다

패턴 문자열 앞의 r은 역슬래시를 그대로 두라는 뜻이다(원시 문자열). 정규 표현식에는 항상 붙인다. 긴 패턴은 괄호 안에서 문자열 두 개를 나란히 적어 두 줄로 나눴다. 파이썬은 붙어 있는 문자열 리터럴을 자동으로 이어 붙인다.

re.compile로 패턴을 미리 만들어 두면 반복문 안에서 같은 패턴을 매번 해석하지 않는다. 더 중요한 이점은 패턴에 LINE, CODE라는 이름이 붙어서 읽기 쉬워진다는 점이다.

LINE 패턴의 이름 붙은 그룹이 날짜·시·레벨·모듈·메시지를 잘라 낸다. 레벨 뒤 공백 수가 달라도 ' +' 가 흡수하고, 오류 코드는 CODE 패턴이 메시지 안에서 따로 찾는다.

그림 · 정규 표현식 그룹이 로그 한 줄을 나누는 방법 — LINE 패턴의 이름 붙은 그룹이 날짜·시·레벨·모듈·메시지를 잘라 낸다. 레벨 뒤 공백 수가 달라도 ' +' 가 흡수하고, 오류 코드는 CODE 패턴이 메시지 안에서 따로 찾는다.

open_log — 압축 파일도 같은 방식으로 연다

gzip.open(path, "rt", ...)"rt"는 "읽기, 텍스트 모드"다. 이렇게 열면 압축을 푸는 과정이 보이지 않고, 평범한 텍스트 파일처럼 for line in f로 한 줄씩 읽는다. 호출하는 쪽은 파일이 압축돼 있는지 신경 쓸 필요가 없다. 7단원에서 배운 with와도 그대로 어울린다.

errors="replace"는 UTF-8로 해석할 수 없는 바이트를 만나면 멈추지 말고 문자로 바꾸라는 뜻이다. 로그에는 외부에서 들어온 이상한 바이트가 섞이기 쉽다. 한 줄 때문에 분석 전체가 UnicodeDecodeError로 멈추는 것보다, 그 줄만 조금 깨져 보이는 편이 낫다.

scan — 한 줄씩 읽고, 맞지 않는 줄은 센다

m = LINE.match(line.rstrip("\n"))
if not m:
    broken += 1
    continue

match는 줄의 맨 앞부터 패턴이 맞는지 본다. 맞지 않으면 None을 돌려준다. 여러 줄에 걸친 오류 추적(Traceback ...)이나 사람이 손으로 넣은 메모가 여기에 걸린다. 이런 줄을 조용히 버리지 않고 broken으로 세어 두면, 로그 형식이 바뀌어서 대부분의 줄이 안 맞는 사태를 바로 알아챌 수 있다. 자동화 3단원의 "숨기지 않는다" 원칙을 여기서도 지킨다.

맞은 줄은 m["level"]처럼 그룹 이름으로 값을 꺼낸다. Counter는 13단원에서 본 대로 없는 키를 0으로 시작하므로 levels[m["level"]] += 1 한 줄로 센다. ERROR가 아니면 레벨만 세고 넘어간다.

c = CODE.search(m["message"])
codes[c.group(1) if c else "-"] += 1

오류 코드는 메시지 중간에 있으므로 match가 아니라 search를 쓴다. search는 문자열 전체를 훑어 처음 맞는 곳을 찾는다. 코드가 없는 오류는 "-"로 모아서, 코드 없는 오류가 얼마나 되는지도 보이게 한다.

파일을 한 줄씩 읽기 때문에 로그가 수 GB여도 메모리를 거의 쓰지 않는다. f.read()f.readlines()로 통째로 읽으면 작은 파일에서는 똑같이 동작하다가 운영 서버의 큰 로그에서 메모리가 부족해진다.

압축 여부에 따라 여는 방법만 다르고 이후는 같다. 형식이 맞지 않는 줄은 버리지 않고 세며, ERROR 줄만 모듈·시간대·오류 코드별로 센다.

그림 · 로그 한 줄이 집계되기까지 — 압축 여부에 따라 여는 방법만 다르고 이후는 같다. 형식이 맞지 않는 줄은 버리지 않고 세며, ERROR 줄만 모듈·시간대·오류 코드별로 센다.

main — 사람이 읽기 좋게 출력한다

most_common()은 많은 순서로 (키, 건수) 쌍을 돌려준다. 인자로 3을 주면 상위 3개만 준다. f-string의 {name:<10}은 왼쪽 정렬 10칸, {count:>4}는 오른쪽 정렬 4칸이다. 시간대별 막대는 가장 많은 시간대를 20칸으로 잡고 나머지를 비율로 줄인 # 문자열이다. 그래프 라이브러리 없이도 터미널에서 몰린 시간대가 한눈에 보인다. max(1, ...)은 건수가 적어도 막대가 최소 한 칸은 보이게 한다.

실행 결과

실습용 logs/app.log는 9월 22일 하루치 120줄에 오류 추적 두 줄이 섞여 있다.

$ python3 log_errors.py logs/app.log
읽은 줄 122줄 (형식이 다른 줄 2줄)
레벨별: INFO 58, ERROR 44, WARN 18

[모듈별 ERROR]
  order       17
  payment     16
  member       9
  search       2

[오류 코드 상위 3]
  E502        14
  E504        13
  -            9

[시간대별 ERROR]
  09시   6 #######
  10시   6 #######
  11시   8 #########
  14시  17 ####################
  15시   2 ##
  20시   5 ######

형식이 다른 두 줄은 끼워 넣은 오류 추적이다. 14시에 오류가 몰렸고 orderpayment 모듈이 대부분이다. 압축된 전날 로그까지 합쳐서 본다.

$ python3 log_errors.py logs/app.log logs/app.log.1.gz
읽은 줄 202줄 (형식이 다른 줄 2줄)
레벨별: INFO 102, ERROR 66, WARN 32

[모듈별 ERROR]
  payment     26
  order       23
  member      11
  search       6

[오류 코드 상위 3]
  E502        22
  E504        17
  -           11

[시간대별 ERROR]
  09시  10 #########
  10시  12 ###########
  11시  12 ###########
  14시  22 ####################
  15시   4 ####
  20시   6 #####

이틀을 합쳐도 14시 집중은 그대로다. 이제 "14시에 결제 대행사 쪽 점검이 있었나?"라는 구체적인 질문을 던질 수 있다. 수천 줄을 눈으로 훑어서는 얻기 어려운 질문이다.

표 · 모듈별 ERROR 수: 오늘 로그만 읽을 때와 압축된 어제 로그까지 읽을 때 (log-run, log-both 실행 결과)

항목app.logapp.log + app.log.1.gz
읽은 줄122202
형식이 다른 줄22
ERROR 합계4466
payment1626
order1723
member911
search26

실무에서 자주 틀리는 것

1. matchsearch를 헷갈리고, .*가 너무 많이 먹는다

import re

line = "2026-09-22 14:07:31 ERROR payment 처리 실패 order=A1043 code=E502 user=kim"

print("match  :", re.match(r"code=(E\d{3})", line))
print("search :", re.search(r"code=(E\d{3})", line).group(1))

m = re.match(r"^(?P<date>\S+) (?P<hour>\d{2}):\S+ (?P<level>[A-Z]+) +(?P<module>\w+)", line)
print("이름 붙은 그룹:", m["date"], m["hour"], m["level"], m["module"])

print("탐욕적  :", re.search(r"order=(.*) ", line).group(1))
print("최소    :", re.search(r"order=(\S+)", line).group(1))
$ python3 demo_regex.py
match  : None
search : E502
이름 붙은 그룹: 2026-09-22 14 ERROR payment
탐욕적  : A1043 code=E502
최소    : A1043

첫 줄이 None인 이유는 match가 줄의 맨 앞에서만 찾기 때문이다. code=는 줄 중간에 있다. 마지막 두 줄은 주문 번호를 뽑으려고 order=(.*) 라고 쓴 경우와 order=(\S+)라고 쓴 경우다. .*가능한 한 길게 맞추려고 해서 마지막 공백 직전까지 전부 가져간다. 값 하나를 뽑을 때는 "공백이 아닌 글자들"인 \S+처럼 경계를 분명히 적는다.

2. 대소문자·공백 차이로 줄이 통째로 빠진다

로그 라이브러리 설정이 바뀌어 ERRORerror로 찍히거나, 레벨 뒤 공백 수가 달라지면 패턴이 맞지 않는다. 이 스크립트는 레벨 뒤 공백을 +(한 칸 이상)로 적어 INFOERROR의 공백 차이를 흡수했다. 그래도 형식이 크게 바뀌면 broken 줄 수가 갑자기 늘어난다. 그 숫자를 매일 보는 것이 가장 싼 감시 장치다.

3. 한글 열 맞춤이 어긋난다

{name:<10}글자 수로 10칸을 맞춘다. 한글은 터미널에서 두 칸을 차지하므로 모듈 이름에 한글이 섞이면 줄이 어긋난다. 6단원에서 본 문제와 같다. 이 스크립트에서 코드 없는 오류를 "(코드없음)" 대신 "-"로 표시한 것도 그 때문이다. 표를 예쁘게 맞춰야 한다면 결과를 CSV로 저장해 엑셀에서 여는 편이 낫다(연습 과제 3).

4. 로그 파일 전체를 매번 다시 읽는다

하루에 한 번 요약하는 용도라면 문제없지만, 5분마다 돌리면서 매번 수 GB를 처음부터 읽으면 서버에 부담이 된다. 그럴 때는 로그 회전으로 넘어간 지난 파일만 대상으로 하거나, 자동화 7단원처럼 날짜를 지정해 그날 줄만 세는 방식으로 범위를 좁힌다.

스스로 확인하기

  1. WARN 줄의 1840ms 같은 응답 시간을 모아 평균과 최댓값을 출력하려면 어떤 패턴을 추가하고 어떤 값을 모아야 하는가?
  2. 같은 오류가 1초에 수십 번 반복돼 집계가 부풀려진다. 같은 주문 번호(order=A1043)의 오류는 한 번만 세고 싶다. 무엇을 추가하겠는가?
  3. 모듈별 ERROR 건수를 module_errors.csv로도 저장하라. 자동화 3단원에서 쓴 방법 중 무엇을 가져오면 되는가?

정답

  1. SLOW = re.compile(r"(\d+)ms")를 만들고, 레벨이 WARN인 줄에서 SLOW.search(m["message"])가 맞으면 int(s.group(1))을 리스트에 모은다. 끝에서 sum(times) / len(times)max(times)를 출력한다. 리스트가 비어 있을 때 나눗셈 오류가 나지 않도록 먼저 검사한다.
  2. 주문 번호를 ORDER = re.compile(r"order=(\S+)")로 뽑아 seen = set()에 넣고, 이미 있는 주문 번호면 continue한다. 셋은 3단원에서 본 대로 포함 여부 검사가 빠르다. 다만 이렇게 하면 "같은 주문이 몇 번 재시도됐는가"라는 정보는 사라지므로, 원래 건수와 중복 제거 건수를 둘 다 출력하는 편이 좋다.
  3. csv.writerutf-8-sig 인코딩, newline=""을 그대로 가져온다. writer.writerow(["모듈", "건수"]) 다음에 writer.writerows(modules.most_common()) 한 줄이면 된다. most_common()이 이미 (키, 건수) 튜플의 리스트라서 그대로 넘길 수 있다.

다음 단원에서는 분석 대신 보관을 자동화한다. 작업 폴더를 날짜가 붙은 압축 파일로 백업하고, 오래된 백업은 정해진 개수만 남기고 지운다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.