실패했는데 종료 코드는 0 이었다 · 로그 5천 줄을 엑셀 없이 읽는다 · 이론
로그 5천 줄을 엑셀 없이 읽는다
한 줄 요약
로그 수천 줄과 CSV 백여 행에서 "몇 건·몇 퍼센트·상위 5개·p95·가장 나쁜 1분" 을 뽑는 데는 pandas 가 필요 없다. csv·collections.Counter·statistics·datetime 만으로 되고, 그렇게 만든 도구는 의존성 없이 어느 서버에서나 돈다.
왜 이게 필요했나
장애 회의에서 "5xx 가 몇 시 몇 분에 몰렸나" 를 묻자 누군가 로그를 노트북으로 복사해 엑셀에 붙였다. 5천 줄이라 됐지만, 다음 달에는 50만 줄이었다. 운영 서버에는 pandas 가 없고 인터넷도 없다. 그런데 필요한 계산은 세기·모으기·분위수·시간 구간 나누기뿐이다. 표준 라이브러리는 이 넷을 전부 갖고 있고, 그것으로 만든 도구는 배포할 것이 파일 하나다.
어떻게 동작하나
읽기. 로그 한 줄은 정규식으로 쪼갠다. nginx combined 형식은 [10/Sep/2026:03:14:07 +0900] 같은 시각, "GET /api/orders HTTP/1.1" 요청, 상태 코드, 마지막에 처리 시간이 붙는다. 파일은 한 줄씩 읽는다(for line in f) — 전체를 메모리에 올리지 않으므로 50만 줄도 같은 코드로 된다. 파싱에 실패한 줄은 세어 두되 멈추지 않는다. 로그에는 늘 깨진 줄이 있다.
CSV 는 [csv](https://docs.python.org/3/library/csv.html) 모듈의 DictReader 로 읽는다. 첫 행을 열 이름으로 삼아 행마다 딕셔너리를 준다. 쉼표가 들어간 값과 따옴표 처리를 손으로 split(",") 하면 반드시 틀린다 — 그래서 모듈이 있다. 값은 전부 문자열이라 int()·float() 변환은 직접 한다.
세기. [collections](https://docs.python.org/3/library/collections.html) 의 Counter 는 해시 가능한 값을 세는 딕셔너리다. Counter(status // 100 for ...) 이면 2·4·5 로 묶인 상태 클래스가 나오고, most_common(5) 가 상위 5개를 돌려준다. defaultdict(list) 는 서비스별로 값을 모을 때 쓴다.
분위수. [statistics](https://docs.python.org/3/library/statistics.html) 의 quantiles(data, n=100) 은 데이터를 확률이 같은 100 구간으로 나누는 99개의 절단점을 돌려준다. 그래서 p50 은 [49], p95 는 [94], p99 는 [98] 이다. 절단점은 가장 가까운 두 데이터 사이를 선형 보간한 값이고, 기본 방법은 exclusive 다. 같은 데이터라도 방법이 다르면 값이 조금 다르므로, 도구는 어떤 방법을 썼는지 적어 두어야 남이 재현할 수 있다. median() 은 중앙값 하나만 필요할 때 쓴다.
시간. [datetime](https://docs.python.org/3/library/datetime.html) 의 strptime(s, "%d/%b/%Y:%H:%M:%S %z") 가 nginx 시각을 시간대가 붙은(aware) 객체로 만든다. %z 가 +0900 을 읽는다. 시간대 없는(naive) 객체와 aware 객체는 비교할 수 없으므로, --since 같은 입력도 fromisoformat("2026-09-10T03:00:00+09:00") 처럼 시간대를 넣어 받는다. "1분 구간" 은 dt.replace(second=0, microsecond=0) 을 키로 세면 된다.
import re, statisticsfrom collections import Counterfrom datetime import datetimeLINE = re.compile(r'\[(?P<ts>[^\]]+)\] "(?P<method>\S+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) \d+ "[^"]*" "[^"]*" (?P<rt>[\d.]+)$')def parse(line): m = LINE.search(line) if not m: return None return {"ts": datetime.strptime(m["ts"], "%d/%b/%Y:%H:%M:%S %z"), "path": m["path"], "status": int(m["status"]), "rt": float(m["rt"])}by_class = Counter(); times = []for line in open("/opt/fixtures/pyops/access.log"): r = parse(line) if r: by_class[f"{r['status'] // 100}xx"] += 1 times.append(r["rt"])q = statistics.quantiles(times, n=100)print(by_class["5xx"], round(q[94], 3)) # 5xx 건수, p95내보내기. 결과는 사람용 한 줄과 기계용 JSON([json](https://docs.python.org/3/library/json.html)) 두 가지로 낸다. datetime 은 JSON 으로 바로 안 나가므로 isoformat() 문자열로 바꾼다. 부동소수점은 round(x, 3) 으로 자릿수를 정해 두어야 두 번 돌린 결과가 같아 보인다.
현장에서 만나는 모습
가장 자주 보는 실수는 평균이다. 응답 시간 평균 0.08초라는 보고 뒤에 p99 는 4초인 경우가 흔하다 — 느린 1% 가 평균에 묻힌다. 분위수를 내는 습관이 그래서 필요하다. 두 번째는 시간대다. 로그는 +0900 인데 --since 를 UTC 로 넣거나 naive 로 넣어 한 시간이 밀린 채 "그 시간엔 문제 없었다" 는 결론이 나온다. 세 번째는 파싱 실패를 조용히 버리는 것이다. 형식이 조금 바뀐 날부터 도구가 0 건을 보고하는데, parsed=0 skipped=52000 처럼 건너뛴 수를 함께 내면 그날 바로 알 수 있다.
다음 실습에서 할 것
/opt/fixtures/pyops/access.log(약 5천 줄, 03:12~03:17 에 장애 구간이 있다)와 deploys.csv 를 표준 라이브러리로 분석하는 logstat.py 와 deploys.py 를 만든다. 줄 수와 파싱 성공 수, 상태 클래스별 건수, 상위 경로, p50/p95/p99, 5xx 가 가장 많았던 1분, 서비스별 배포 실패율과 중앙값, --since/--until 필터, 그리고 전부를 담은 JSON 보고서까지 낸다.