로그 분석기 - 오류 빈도와 시간대별 분포
이 장에서 배우는 것
로그 파일에는 프로그램이 정상적으로 처리한 일과 실패한 일이 시간순으로 섞여 있다. 오류 한 줄을 찾는 일은 어렵지 않지만, 여러 줄을 읽으며 어떤 오류가 반복되는지, 어느 시간대에 몰리는지 판단하려면 집계 기준이 필요하다. 이 장에서는 정규식(regular expression)으로 로그 한 줄의 구조를 확인하고, 날짜와 시간을 검증한 뒤, 오류의 빈도와 시간대별 분포를 출력하는 프로그램을 만든다.
앞 장에서 파일을 옮기기 전에 대상을 미리 살펴보았다면, 이번에는 데이터를 요약하기 전에 해석할 수 있는 줄인지 확인한다. 잘못된 줄을 조용히 버리지 않고 별도로 세는 것도 분석의 일부다. 완성 프로그램은 임시 폴더에 예제 로그를 만들고 읽은 뒤 종료한다. 외부 파일을 준비하거나 현재 시각을 맞출 필요 없이 같은 결과를 확인할 수 있다.
- 정규식의 이름 있는 그룹으로 시각, 수준, 메시지를 나눈다.
- 줄의 형식 검사와 날짜·시간의 의미 검사를 구분한다.
- 서로 다른 UTC 오프셋을 같은 기준으로 바꾸어 시간대별 오류를 센다.
- 오류 메시지의 빈도와 동률 정렬 기준을 정하고 텍스트 막대로 표시한다.
- AI가 만든 정규식을 정상 입력, 경계값, 잘못된 입력으로 검사한다.
문제 상황
작은 예약 처리 프로그램을 운영한다고 가정한다. 이용자는 간헐적으로 저장 실패를 겪지만, 담당자는 하루가 끝난 뒤 로그를 전달받는다. 로그에는 처리 시작을 뜻하는 INFO, 주의가 필요한 WARN, 실패를 뜻하는 ERROR가 섞여 있다. 전달 과정에서 내용이 비어 있는 줄이나 일부가 잘린 줄도 들어왔다.
담당자가 알고 싶은 것은 세 가지다. 읽은 줄 가운데 정상적으로 해석한 줄은 몇 개인가, 오류는 어느 UTC 시간대에 집중되었는가, 가장 자주 나타난 오류 메시지는 무엇인가다. 이 프로그램은 장애의 원인을 단정하지 않는다. 반복되는 현상을 찾고 다음 조사를 시작할 근거를 만든다.
요구사항은 출력에 드러나는 수준까지 구체적으로 정한다. 여기서 시간대별 집계는 날짜를 제외한 UTC 시각의 0시부터 23시까지를 기준으로 한다. 여러 날짜의 파일을 넣으면 같은 시각끼리 합쳐진다. 날짜별 추이를 보려면 집계 키를 바꾸어야 한다.
| 항목 | 정한 규칙 | 확인할 결과 |
|---|---|---|
| 한 줄의 구조 | [시각] 수준 메시지이며 구분 공백은 한 칸이다. | 줄 전체가 형식에 맞아야 한다. |
| 시각 | 초와 숫자 UTC 오프셋을 반드시 적는다. | 실제 달력에 없는 날짜도 거부한다. |
| 수준 | INFO, WARN, ERROR만 허용한다. | 소문자나 다른 수준은 깨진 줄로 센다. |
| 메시지 | 앞뒤 공백을 제외한 내용이 있어야 한다. | 정리한 메시지 전체를 같은 오류의 기준으로 삼는다. |
| 집계 | ERROR만 오류로 세며 UTC 시각으로 묶는다. | 유효한 로그가 있는 시간대만 출력한다. |
| 순위 | 빈도 내림차순, 동률이면 메시지 오름차순이다. | 상위 세 메시지의 순서가 고정된다. |
다음 요청문을 AI에게 보낸다. 함수 이름만 나열하기보다, 받아들일 입력과 거부할 입력, 정렬 기준, 실행 조건을 함께 적는다.
Python 3.12 이상에서 표준 라이브러리만 사용하는 로그 분석기를 main.py 한 파일로 작성하라. 실행하면 tempfile의 임시 폴더에 UTF-8 예제 로그를 직접 쓰고 읽은 뒤 종료하게 하라. 네트워크와 현재 시각을 사용하지 말라.
입력 형식은 [2026-10-30T09:00:00+00:00] ERROR 연결 시간 초과와 같다. 정규식으로 시각·수준·메시지를 분리하고, 줄 전체를 검사하라. 실제 날짜와 시간도 검증하라. 숫자 오프셋이 필수이며 수준은 INFO, WARN, ERROR만 허용한다. 빈 줄도 전체 줄과 깨진 줄에 포함하라.
UTC로 변환한 뒤 유효 줄 수와 오류 수를 시간별로 집계하라. 유효한 로그가 있는 시간대만 오름차순으로 보여 주고, 오류 한 건당 # 하나를 표시하라. 오류 메시지는 앞뒤 공백을 제거한 전체 문자열로 묶고, 빈도 내림차순과 메시지 오름차순으로 정렬하여 세 개까지 출력하라. 입력이 비어 있거나 오류가 없는 경우도 검사하라.
줄을 나누는 검사와 날짜를 확인하는 검사
정규식은 줄의 모양을 확인하는 데 적합하다. 대괄호가 있는지, 수준이 허용한 단어인지, 날짜의 각 자리에 숫자가 들어갔는지 확인할 수 있다. 그러나 2월 30일처럼 모양만 맞는 날짜까지 하나의 정규식으로 처리하려 하면 읽고 고치기 어려워진다. 이 프로그램은 정규식으로 구조를 검사하고 datetime으로 날짜와 시간의 유효성을 검사한다.
시각 패턴에 쓰는 [0-9]는 이 파일 형식에서 허용하는 숫자를 분명하게 나타낸다. 역슬래시가 들어가는 패턴은 원시 문자열(raw string)로 작성한다. Python 문자열의 이스케이프 처리와 정규식의 이스케이프 처리를 겹쳐 생각할 일을 줄일 수 있다.
이름 있는 그룹은 괄호에 역할을 붙인다. stamp는 시각, level은 수준, message는 메시지다. 그룹의 순서가 달라져도 이름으로 값을 꺼낼 수 있어 수정할 때 확인하기 쉽다. 줄 전체의 일치 여부는 fullmatch로 판단한다. 메시지 부분은 자유로운 텍스트이므로, 메시지 안의 구두점이나 대괄호는 그대로 받아들인다.
파일에서 읽은 줄에는 끝의 줄바꿈 문자가 들어 있다. 이 문자만 제거한 뒤 해석 함수에 전달한다. 줄 전체에 strip을 적용하면 잘못 들어간 선행 공백까지 없어져, 원래 형식을 어긴 줄을 정상 줄로 받아들일 수 있다. 메시지의 앞뒤 공백을 정리하는 일과 줄의 외형을 바꾸는 일은 구분한다.
해석 함수는 성공하면 시각·수준·메시지를 반환하고, 실패하면 None을 반환한다. 파일을 읽는 함수는 None을 받은 횟수를 깨진 줄 수에 더한다. 정규식 불일치와 잘못된 날짜를 이번 보고서에서는 같은 항목으로 합친다. 실패 이유를 따로 보여 주는 기능은 확장 과제로 남긴다.
시간 기준과 정렬 기준을 먼저 고정한다
09시라는 숫자만 보고 두 로그가 같은 시간에 발생했다고 판단하면 안 된다. +00:00의 09시와 +01:00의 09시는 서로 다른 순간이다. 시각을 해석한 뒤 UTC로 변환하고 나서 hour를 꺼내야 같은 기준으로 묶인다. 변환 과정에서 날짜가 전날이나 다음 날로 바뀔 수도 있다.
예제에는 다음 날 00시 10분에 +01:00으로 기록된 로그가 있다. 이를 UTC로 바꾸면 전날 23시 10분이다. 이 줄이 00시가 아니라 23시에 집계되는지 검사하면, 단순히 문자열에서 시 부분만 잘라 쓰는 구현을 걸러낼 수 있다.
시간별 유효 줄 수와 오류 수는 따로 센다. WARN도 정상적으로 해석한 로그이므로 유효 줄 수에는 들어가지만 오류 수에는 들어가지 않는다. 이렇게 하면 로그가 아예 없는 시간대와 로그는 있지만 오류가 없는 시간대를 구별할 수 있다. 출력 대상은 유효 줄 수가 있는 시간대이며, 오류가 없으면 막대 자리에 하이픈을 쓴다.
오류 메시지는 앞뒤 공백만 제거한다. 메시지 내부의 공백, 숫자, 문장부호는 바꾸지 않는다. 따라서 ‘항목 17 저장 실패’와 ‘항목 18 저장 실패’는 다른 메시지다. 식별자를 지우면 더 큰 범주로 묶을 수 있지만, 무엇을 같은 오류로 볼지 결정한 뒤 적용해야 한다.
빈도만으로 정렬하면 동률 메시지의 순서가 입력 순서에 영향을 받을 수 있다. 이 장에서는 빈도에 음수를 붙인 값과 메시지를 정렬 키로 사용한다. 메시지 오름차순은 Python 문자열 비교 기준이다. 사람이 기대하는 사전식 배열을 별도로 구현하지 않는다. 규칙이 단순해도 명시되어 있으면 같은 입력에서 같은 결과를 얻을 수 있다.
AI의 첫 결과를 실행과 비교로 고친다
첫 결과가 실행된다고 해서 요구사항을 모두 만족하는 것은 아니다. 다음은 초안 검토에서 확인할 문제와 수정 방향이다. 정규식만 따로 읽는 대신, 그 정규식이 받아들이면 안 되는 줄을 넣어 보는 방식으로 검사한다.
| 문제 | 발견 방법 | 고친 방향 |
|---|---|---|
| 시각의 숫자 모양만 검사하여 2월 30일을 받아들였다. | 존재하지 않는 날짜를 해석 함수에 넣었다. | datetime.fromisoformat으로 의미도 검사했다. |
| 오프셋을 무시하고 원문에 적힌 시로 묶었다. | +01:00의 다음 날 00시 입력이 00시 집계에 나타났다. | UTC 변환 후 hour를 읽었다. |
| 빈 줄을 건너뛰어 전체 줄 수가 줄었다. | 12줄인 예제에서 전체가 11줄로 나왔다. | 줄 수를 먼저 늘리고 해석 실패를 따로 셌다. |
| 동률 메시지가 먼저 읽힌 순서로 출력되었다. | 같은 빈도의 메시지를 역순으로 입력했다. | 메시지 오름차순을 두 번째 정렬 기준으로 넣었다. |
| 문자 인코딩을 실행 환경의 기본값에 맡겼다. | 한글 메시지를 쓰고 읽는 두 위치를 검토했다. | 파일 쓰기와 읽기에 UTF-8을 명시했다. |
수정 요청에도 관찰한 현상을 포함한다. ‘시간 처리를 개선하라’보다 어느 입력이 어디에 들어가야 하는지 적는 편이 확인하기 쉽다.
현재 구현은 [2026-10-31T00:10:00+01:00] ERROR 파일 없음을 00시에 집계한다. UTC 기준으로는 23시여야 한다. 시각을 UTC로 바꾼 뒤 집계하도록 수정하라. 빈 줄을 포함한 전체 줄 수와 깨진 줄 수를 유지하고, 동률 메시지는 메시지 오름차순으로 정렬하라. 해당 조건을 확인하는 assert도 추가하라.
검사는 함수 단위와 전체 실행으로 나눈다. 해석 함수에는 존재하지 않는 날짜, 오프셋 누락, 소문자 수준, 공백뿐인 메시지, 줄 앞의 불필요한 문자를 넣는다. 집계 함수에는 빈 입력과 오류 없는 입력을 넣는다. 순위 함수에는 같은 빈도의 ‘가’와 ‘나’를 역순으로 넣어 정렬 기준을 확인한다.
완성 코드에 포함한 검사는 성공할 때 아무것도 출력하지 않는다. 실패하면 assert가 실행을 멈추므로 잘못된 보고서를 정상 결과처럼 보여 주지 않는다. 예제 실행은 최적화 옵션 없이 수행한다. assert가 제거되는 실행 방식을 운영용 입력 검증의 근거로 삼지 않는다. 실제 입력의 거부 처리는 parse_line 안에 별도로 구현되어 있다.
수정 전후에는 버전 관리의 diff로 변경 내용을 검토한다. 정규식의 허용 수준이 넓어지지 않았는지, UTC 변환이 집계보다 먼저 수행되는지, 빈 줄을 건너뛰는 조건이 남아 있지 않은지 확인한다. 마지막으로 경고를 오류로 취급한 컴파일과 일반 실행을 수행한다. 명령은 각각 python3 -W error -m py_compile main.py와 python3 main.py다.
완성 코드
아래 코드를 main.py로 저장한다. Python 3.12 이상에서 외부 패키지 없이 실행할 수 있다. 입력 파일은 임시 폴더 안에서 만들고, 보고서 문자열까지 만든 뒤 폴더를 정리한다. 임시 경로는 출력하지 않아 실행할 때마다 경로가 달라져도 결과는 같다.
import re
import tempfile
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
STAMP = (
r"[0-9]{4}-[0-9]{2}-[0-9]{2}"
r"T[0-9]{2}:[0-9]{2}:[0-9]{2}"
r"[+-][0-9]{2}:[0-9]{2}"
)
LINE = re.compile(
rf"\[(?P<stamp>{STAMP})\] "
r"(?P<level>INFO|WARN|ERROR) (?P<message>.+)"
)
SAMPLE = (
"[2026-10-30T09:00:00+00:00] INFO 처리 시작\n"
"[2026-10-30T09:10:00+00:00] ERROR 연결 시간 초과\n"
"[2026-10-30T08:20:00-01:00] ERROR 연결 시간 초과\n"
"[2026-10-30T10:00:00+00:00] WARN 재시도 예정\n"
"[2026-10-30T10:10:00+00:00] ERROR 권한 거부\n"
"[2026-10-30T10:20:00+00:00] ERROR 연결 시간 초과\n"
"[2026-10-30T11:00:00+00:00] INFO 처리 완료\n"
"[2026-10-30T11:10:00+00:00] ERROR 권한 거부\n"
"[2026-10-31T00:10:00+01:00] ERROR 파일 없음\n"
"형식이 잘린 줄\n"
"[2026-02-30T12:00:00+00:00] ERROR 잘못된 날짜\n"
"\n"
)
@dataclass
class Summary:
total: int = 0
broken: int = 0
valid_hours: Counter[int] = field(default_factory=Counter)
error_hours: Counter[int] = field(default_factory=Counter)
messages: Counter[str] = field(default_factory=Counter)
def parse_line(line: str) -> tuple[datetime, str, str] | None:
match = LINE.fullmatch(line)
if match is None:
return None
message = match["message"].strip()
if not message:
return None
stamp_text = match["stamp"]
offset_hour = int(stamp_text[-5:-3])
offset_minute = int(stamp_text[-2:])
if offset_hour > 23 or offset_minute > 59:
return None
try:
stamp = datetime.fromisoformat(stamp_text)
stamp = stamp.astimezone(timezone.utc)
except (ValueError, OverflowError):
return None
return stamp, match["level"], message
def analyze(text: str) -> Summary:
summary = Summary()
for line in text.splitlines():
summary.total += 1
parsed = parse_line(line)
if parsed is None:
summary.broken += 1
continue
stamp, level, message = parsed
summary.valid_hours[stamp.hour] += 1
if level == "ERROR":
summary.error_hours[stamp.hour] += 1
summary.messages[message] += 1
return summary
def top_messages(summary: Summary) -> list[tuple[str, int]]:
ordered = sorted(
summary.messages.items(),
key=lambda item: (-item[1], item[0]),
)
return ordered[:3]
def render(summary: Summary) -> str:
valid = summary.total - summary.broken
errors = sum(summary.error_hours.values())
rows = [
"로그 분석 보고서",
f"전체 줄: {summary.total}",
f"유효 줄: {valid}",
f"깨진 줄: {summary.broken}",
f"오류 줄: {errors}",
"",
"UTC 시간대별 집계 (# = 오류 1건)",
]
if not summary.valid_hours:
rows.append("집계할 유효 로그 없음")
for hour in sorted(summary.valid_hours):
count = summary.error_hours[hour]
bar = "#" * count if count else "-"
rows.append(
f"{hour:02d}시 | 유효 {summary.valid_hours[hour]} "
f"| 오류 {count} | {bar}"
)
rows.extend(["", "상위 오류 메시지"])
if not summary.messages:
rows.append("오류 메시지 없음")
for rank, (message, count) in enumerate(top_messages(summary), 1):
rows.append(f"{rank}. {count}건 | {message}")
return "\n".join(rows)
def self_check() -> None:
prefix = "[2026-10-30T09:00:00+00:00]"
assert parse_line(f"{prefix} ERROR 정상 메시지") is not None
assert parse_line(f"{prefix} error 소문자 수준") is None
assert parse_line(f"{prefix} ERROR ") is None
assert parse_line(f"x{prefix} ERROR 앞에 문자가 있음") is None
assert parse_line(
"[2026-02-30T09:00:00+00:00] ERROR 날짜 오류"
) is None
assert parse_line(
"[2026-10-30T09:00:00] ERROR 오프셋 누락"
) is None
assert parse_line(
"[2026-10-30T09:00:00+00:60] ERROR 오프셋 오류"
) is None
assert parse_line(
"[2026-10-30T24:00:00+00:00] ERROR 시각 오류"
) is None
shifted = parse_line(
"[2026-10-31T00:10:00+01:00] ERROR 파일 없음"
)
assert shifted is not None
assert shifted[0].isoformat() == "2026-10-30T23:10:00+00:00"
empty = analyze("")
assert empty.total == 0
assert "집계할 유효 로그 없음" in render(empty)
assert "오류 메시지 없음" in render(empty)
blank = analyze("\n")
assert (blank.total, blank.broken) == (1, 1)
info_only = analyze(f"{prefix} INFO 완료\n")
assert info_only.valid_hours[9] == 1
assert sum(info_only.error_hours.values()) == 0
assert "09시 | 유효 1 | 오류 0 | -" in render(info_only)
tied = analyze(
f"{prefix} ERROR 나\n"
f"{prefix} ERROR 가\n"
)
assert top_messages(tied) == [("가", 1), ("나", 1)]
sample = analyze(SAMPLE)
assert (sample.total, sample.broken) == (12, 3)
assert sample.valid_hours == Counter({9: 3, 10: 3, 11: 2, 23: 1})
assert sample.error_hours == Counter({9: 2, 10: 2, 11: 1, 23: 1})
assert top_messages(sample) == [
("연결 시간 초과", 3),
("권한 거부", 2),
("파일 없음", 1),
]
assert sum(sample.valid_hours.values()) + sample.broken == sample.total
assert sum(sample.messages.values()) == sum(sample.error_hours.values())
def main() -> None:
self_check()
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "sample.log"
path.write_text(SAMPLE, encoding="utf-8")
text = path.read_text(encoding="utf-8")
report = render(analyze(text))
print(report)
if __name__ == "__main__":
main()
줄별 해설
먼저 import 부분은 문자열 해석, 임시 파일, 개수 집계, 날짜 계산에 필요한 표준 라이브러리를 불러온다. Counter는 존재하지 않는 키를 읽으면 0을 돌려주므로, 오류가 없는 시간대에도 별도 초기화 없이 오류 수를 읽을 수 있다. Path는 임시 폴더와 파일 이름을 결합한다.
STAMP는 시각의 외형을 세 조각으로 적은 문자열이다. 인접한 문자열은 하나로 이어진다. 날짜, 시각, 숫자 오프셋이 모두 있어야 맞는 패턴이다. 소수 초나 Z 표기는 이번 입력 형식에 포함하지 않았다. 실제 로그가 그 형식을 사용한다면 요구사항과 검사를 함께 바꾸어야 한다.
LINE은 STAMP를 대괄호 안에 넣고 수준과 메시지를 붙인다. 수준의 선택지는 세 단어로 한정한다. 메시지의 .+는 적어도 한 문자를 요구하지만 공백 한 문자도 받아들이므로, 뒤에서 strip 결과가 비었는지 한 번 더 검사한다. 정규식 하나만 읽고 빈 메시지가 모두 차단되었다고 판단하면 놓치기 쉬운 지점이다.
SAMPLE은 고정된 입력이다. 마지막의 빈 문자열 줄바꿈은 빈 로그 한 줄을 만든다. 정상 로그 아홉 줄, 구조가 깨진 줄 하나, 날짜가 잘못된 줄 하나, 빈 줄 하나를 합쳐 열두 줄이다. 문자열을 여러 조각으로 적어도 파일에는 연결된 내용이 기록된다.
Summary는 집계 상태를 모은다. default_factory=Counter는 Summary를 만들 때마다 새로운 Counter를 생성한다. 집계용 가변 객체를 여러 분석 결과가 공유하지 않도록 하는 장치다. 전체 줄과 깨진 줄은 정수로, 시간별 개수와 메시지별 개수는 각각의 Counter로 관리한다.
parse_line의 첫 번째 분기는 구조 검사다. 통과한 줄에서 메시지를 정리한 뒤, 오프셋의 시와 분 범위도 확인한다. 날짜 해석기가 입력 형식보다 넓은 처리를 허용할 수 있으므로, 이 파일에서 정한 숫자 오프셋의 범위는 직접 제한한다. 그다음 fromisoformat이 달력과 시각을 검사하고 astimezone이 UTC로 변환한다.
예외 처리는 날짜 해석과 변환 부분에만 놓았다. ValueError는 잘못된 날짜나 시각을, OverflowError는 표현 가능한 날짜 범위를 벗어나는 변환을 처리한다. 파일 읽기 실패나 코드의 다른 오류까지 깨진 로그로 숨기지 않는다. 반환값이 None인 경우만 입력 한 줄의 해석 실패다.
analyze는 splitlines로 줄바꿈을 제거하며 입력을 나눈다. 빈 문자열은 0줄이고 줄바꿈 하나는 빈 줄 1개다. 파일 끝의 일반적인 줄바꿈을 별도의 추가 줄로 세지는 않는다. 전체 수를 먼저 늘리므로 깨진 줄도 분모에서 빠지지 않는다. 이 예제는 짧은 파일을 대상으로 하여 파일 전체를 읽는다.
top_messages는 집계와 표현을 잇는 작은 함수다. 정렬 키의 첫 항목은 음수 빈도이므로 많이 나타난 메시지가 앞선다. 두 번째 항목은 메시지이므로 빈도가 같으면 문자열 순서로 정한다. 앞에서 세 개를 자르기 때문에 오류 종류가 셋보다 적어도 그대로 동작한다.
render는 출력할 줄들을 리스트에 쌓은 뒤 줄바꿈으로 합친다. 데이터를 해석하는 함수 안에서 print를 호출하지 않아, 출력 문구를 검사하기 쉽다. 막대는 오류 개수만큼 #을 반복한다. 이번처럼 작은 예제에 적합한 방식이며, 큰 로그에서는 출력 폭을 제한하는 규칙이 필요하다.
self_check는 개별 경계값과 예제의 합계를 확인한다. 특히 ‘유효 줄 합계 + 깨진 줄 = 전체 줄’과 ‘메시지 빈도 합계 = 시간별 오류 합계’는 집계 경로가 서로 어긋나지 않았는지 확인하는 조건이다. main은 이 검사를 먼저 실행한 뒤 파일을 만들고 보고서를 출력한다.
실행 결과
main.py가 있는 폴더에서 다음 명령을 실행한다. 검사가 모두 통과하면 검사 자체의 출력 없이 보고서가 나온다.
python3 main.py
로그 분석 보고서
전체 줄: 12
유효 줄: 9
깨진 줄: 3
오류 줄: 6
UTC 시간대별 집계 (# = 오류 1건)
09시 | 유효 3 | 오류 2 | ##
10시 | 유효 3 | 오류 2 | ##
11시 | 유효 2 | 오류 1 | #
23시 | 유효 1 | 오류 1 | #
상위 오류 메시지
1. 3건 | 연결 시간 초과
2. 2건 | 권한 거부
3. 1건 | 파일 없음
전체 열두 줄 가운데 세 줄을 해석하지 못했다. 유효한 아홉 줄 가운데 ERROR는 여섯 줄이다. 09시의 세 줄에는 원문 시각이 08시인 로그도 포함된다. -01:00 오프셋을 반영하면 UTC 09시이기 때문이다. 같은 방식으로 다음 날 00시의 +01:00 로그는 UTC 23시에 들어간다.
보고서에서 12시가 보이지 않는 것은 오류가 없다는 증거가 아니다. 이 입력에는 유효한 12시 로그가 없다는 뜻이다. 반면 INFO만 있는 12시 로그를 추가하면 ‘유효 1, 오류 0’인 행이 생긴다. 로그 수집 누락과 오류 부재를 혼동하지 않도록 출력 규칙을 읽어야 한다.
실무에서 자주 틀리는 것
줄 전체를 정리하다가 형식 오류를 감춘다
다음은 실행 가능한 비교 예다. 첫 번째 방식은 줄 앞의 불필요한 공백까지 지운다. 두 번째 방식은 줄바꿈만 제거하므로 형식 오류가 드러난다. 완성 코드에서는 splitlines가 줄바꿈 제거를 담당한다.
raw = " [2026-10-30T09:00:00+00:00] INFO 완료\n"
wrong = raw.strip()
fixed = raw.removesuffix("\n").removesuffix("\r")
assert wrong.startswith("[")
assert fixed.startswith(" ")
메시지에 적용하는 strip은 요구사항에 따른 정리다. 줄 전체에 적용하는 strip은 입력 형식을 바꾼다. 같은 함수라도 적용 위치에 따라 의미가 달라진다.
문자열에서 시만 잘라 집계한다
아래의 wrong은 원문에 적힌 시를 읽는다. 오프셋이 다른 기록을 함께 분석할 때는 fixed처럼 기준을 바꾼 뒤 시를 읽는다.
from datetime import datetime, timezone
text = "2026-10-31T00:10:00+01:00"
wrong = int(text[11:13])
fixed = datetime.fromisoformat(text).astimezone(timezone.utc).hour
assert wrong == 0
assert fixed == 23
오프셋을 포함한 시각과 지역 이름을 사용하는 시간 규칙은 구별해야 한다. 이 프로그램은 로그에 적힌 숫자 오프셋만 처리한다. 지역의 계절별 시각 변경을 추정하거나 보정하지 않는다.
빈도만 정렬하여 동률 순서를 놓친다
다음의 첫 정렬은 빈도가 같으면 입력 리스트의 순서를 유지한다. 두 번째 정렬은 메시지까지 비교하므로 요구사항에 정한 순서가 나온다.
items = [("나", 2), ("가", 2)]
wrong = sorted(items, key=lambda item: -item[1])
fixed = sorted(items, key=lambda item: (-item[1], item[0]))
assert wrong == [("나", 2), ("가", 2)]
assert fixed == [("가", 2), ("나", 2)]
입력 순서를 바꾼 검사가 필요한 이유가 여기에 있다. 우연히 원하는 순서로 작성된 예제만 확인하면 정렬 기준이 빠진 구현도 통과한다.
읽기 오류를 무시하여 원문 손상을 감춘다
디코딩 오류를 무시하면 읽기는 계속되지만, 일부 바이트가 빠진 메시지끼리 같은 오류로 묶일 수 있다. 아래 예는 잘못된 바이트를 버리는 방식과 정상 UTF-8 입력을 엄격하게 해석하는 방식을 비교한다.
damaged = b"ERROR \xff"
wrong = damaged.decode("utf-8", errors="ignore")
assert wrong == "ERROR "
original = "ERROR 권한 거부".encode("utf-8")
fixed = original.decode("utf-8")
assert fixed == "ERROR 권한 거부"
완성 코드는 UTF-8 읽기에 실패하면 예외를 그대로 드러낸다. 파일 자체를 해석하지 못하는 문제와, 읽은 텍스트에서 줄 형식이 깨진 문제는 다르다. 실제 파일을 도입할 때는 생성 측의 인코딩을 확인하고 그 조건을 요구사항에 적는다.
한눈에 보기
| 단계 | 사용한 방법 | 확인할 조건 |
|---|---|---|
| 구조 검사 | 이름 있는 그룹과 fullmatch | 줄 앞의 문자, 잘못된 수준, 오프셋 누락을 거부한다. |
| 의미 검사 | 오프셋 범위 검사와 datetime | 존재하지 않는 날짜와 잘못된 시각을 거부한다. |
| 시간 통일 | UTC 변환 후 hour 추출 | 날짜가 바뀌는 변환도 반영한다. |
| 집계 | 용도별 Counter | 깨진 줄을 포함해 전체 수가 맞는다. |
| 표현 | 시간 오름차순과 텍스트 막대 | 유효 로그가 있는 시간대만 보여 준다. |
| 순위 | 빈도 내림차순과 메시지 오름차순 | 동률이어도 입력 순서에 의존하지 않는다. |
로그 분석의 품질은 막대를 얼마나 보기 좋게 그렸는지보다, 어떤 줄을 받아들였고 무엇을 같은 항목으로 묶었는지에 달려 있다. 입력 규칙, 시간 기준, 동률 기준을 먼저 정하면 AI의 구현을 검사할 기준도 함께 생긴다. 다음 장에서는 줄 단위 텍스트 대신 저장된 웹 페이지의 구조를 읽는다.
연습 문제
확장할 때도 먼저 요청문에 결과를 적고, 기존 검사가 유지되는지 확인한다. 아래 요청문은 각 문제에서 AI에게 보낼 수 있는 출발점이다.
-
실패 이유를 ‘형식 오류’, ‘메시지 없음’, ‘시각 오류’로 나누어 세어라. 기존 깨진 줄 합계도 유지하라.
parse_line의 실패 결과에 이유를 포함하도록 수정하라. 세 이유의 합이 깨진 줄 수와 같은지 검사하고, 한 줄은 한 이유에만 포함하라. 기존 예제의 전체 줄 수와 오류 순위는 유지하라.
-
여러 날짜의 같은 시각이 합쳐지지 않도록 UTC 날짜와 시를 함께 집계하라.
시간별 집계 키를 UTC 날짜와 시의 조합으로 바꾸어라. 원문 날짜가 아니라 UTC 변환 후 날짜를 사용하라. 서로 다른 두 날짜의 09시 로그가 별도 행에 나오는 검사를 추가하라.
-
오류가 많아도 막대가 20칸을 넘지 않게 바꾸어라. 0건은 하이픈, 양수는 최소 한 칸이며 실제 건수도 표시하라.
최대 오류 수에 비례하여 막대 길이를 1칸부터 20칸까지 정하라. 올림 계산을 사용하고 오류가 전혀 없는 경우도 처리하라. 0건, 1건, 최대 건수의 막대 길이를 검사하라.
-
빈 입력과 깨진 줄만 있는 입력을 보고서에서 구별하라.
전체 줄이 0이면 ‘입력 로그 없음’, 전체 줄은 있지만 유효 줄이 0이면 ‘유효 로그 없음’을 출력하라. 두 경우 모두 오류 메시지 없음은 유지하라. 검사와 출력 변경 부분을 diff로 확인할 수 있게 수정 범위를 설명하라.
정답과 해설
첫 문제는 반환값을 성공과 실패로 구별할 수 있게 바꾸는 것이 핵심이다. 예를 들어 성공 데이터와 실패 이유를 한 쌍으로 반환하고, 성공하면 이유를 None으로 둘 수 있다. 정규식 불일치는 형식 오류, 일치 후 strip 결과가 비면 메시지 없음, 오프셋 범위나 날짜 변환 실패는 시각 오류로 정한다. 검사 순서가 곧 분류 우선순위다.
현재 패턴의 .+는 빈 메시지 자체를 허용하지 않는다. 구분 공백 뒤에 아무 문자도 없는 경우까지 ‘메시지 없음’으로 분류하려면 메시지 그룹을 .*로 바꾸고, 빈 내용은 후속 검사에서 거부해야 한다. 이 수정은 정상 입력의 범위를 넓히려는 것이 아니라 실패 이유를 더 정확히 나누려는 것이다. 정규식 변경 뒤 기존 거부 입력이 여전히 거부되는지 확인한다.
두 번째 문제에서는 UTC 변환을 마친 stamp로 (stamp.date(), stamp.hour)를 만든다. 유효 줄과 오류 줄 모두 같은 키를 사용해야 한다. 출력에서는 날짜를 ISO 형식으로 적고 시를 두 자리로 붙인다. 예제의 다음 날 00시 10분 로그가 UTC 기준 전날 23시로 출력되는 조건도 유지한다. Counter의 키 타입 표기도 함께 수정한다.
세 번째 문제는 최대 오류 수를 먼저 구하는 방식으로 해결한다. 양수인 최대값을 maximum, 현재 오류 수를 count라고 할 때, 올림한 비례 길이는 (count * 20 + maximum - 1) // maximum이다. count가 0이면 계산 대신 하이픈을 선택한다. maximum이 0인 경우 나눗셈을 수행하지 않는 분기도 필요하다. 아래는 길이 계산만 확인하는 독립적인 검사다.
def bar_width(count: int, maximum: int) -> int:
if count == 0:
return 0
return (count * 20 + maximum - 1) // maximum
assert bar_width(0, 0) == 0
assert bar_width(1, 100) == 1
assert bar_width(50, 100) == 10
assert bar_width(100, 100) == 20
이 함수는 0 이상인 건수와 그 이상인 최대값을 받는다는 전제가 있다. 보고서에서는 Counter에서 구한 값을 사용하므로 그 전제를 만들 수 있다. 막대 한 칸의 뜻이 달라졌으므로 기존 ‘# = 오류 1건’ 문구도 ‘최대 20칸, 건수 비례’로 바꾸어야 한다. 계산만 수정하고 설명을 그대로 두면 보고서를 잘못 읽게 된다.
네 번째 문제는 render의 유효 시간대가 없는 분기에서 전체 줄 수를 확인하면 된다. total이 0이면 입력이 없고, total이 양수인데 valid_hours가 비었으면 읽은 모든 줄이 깨진 경우다. analyze("")와 analyze("\n")를 각각 검사하면 두 상태를 구별할 수 있다. 집계가 같아 보이는 상황에서도 원인이 다르면 출력 문구를 달리할 이유가 있다.
확장 후에는 새 조건만 검사하지 않는다. 기존 예제의 전체·유효·깨진 줄 수가 의도한 대로 유지되는지 확인하고, 출력 형식을 바꾸었다면 예상 결과도 함께 고친다. AI가 수정한 코드, 직접 실행한 결과, diff에서 확인한 변경 범위가 서로 맞아야 작업을 마친다.