대용량 로그 검색 — 620MB 로그에서 원하는 줄만 빠르게 뽑아내기 (파이썬 실측)

로그 파일이 수백 MB로 불어나면 에디터로 열어 Ctrl+F를 누르는 것부터 부담이 됩니다. 그래서 대용량 로그 검색을 파이썬 표준 라이브러리만으로 해결해 보려고, 이 노트북(윈도우 11, 파이썬 3.12)에서 620MB·900만 줄짜리 테스트 로그를 만들어 여섯 가지 방법을 비교했습니다. 흔히 쓰는 “한 줄씩 읽으며 in으로 찾기”는 평균 2.5~3.8초, 큰 덩어리로 읽고 bytes.find로 위치만 찾는 방법은 0.76초였습니다. 특정 시간대만 뽑을 때는 이진 탐색으로 3.49초가 0.016초가 됐습니다. 그런데 이 빠른 방법을 이 블로그의 실제 자동 발행 로그에 돌렸더니 처음엔 0줄이 나왔습니다. 그 원인과 고친 최종 스크립트까지 순서대로 정리합니다.

테스트 로그 만들기와 첫 오류

비교하려면 충분히 큰 로그가 필요해서 직접 만들었습니다. 시각순으로 쌓이고, 한글 메시지가 섞이고, 레벨 비율이 INFO 90 · WARN 7 · ERROR 3인 형태입니다. 900만 줄을 쓰는 데 64.3초가 걸렸고 크기는 약 620MB였습니다.

$ python make_log.py app.log 9000000
9,000,000줄 생성, 마지막 시각 2026-10-03 15:44:19, 64.3초

(줄 형식)
2026-10-01 00:00:00.041 INFO  [worker-0] 요청 처리 완료 user=1234 ms=56

그리고 가장 단순한 방법, 파일 전체를 readlines()로 읽어 거르는 코드를 돌리자마자 오류가 났습니다.

$ python grep_readlines.py app.log ERROR
Traceback (most recent call last):
  File "...\biglog\grep_readlines.py", line 8, in <module>
    lines = f.readlines()
UnicodeDecodeError: 'cp949' codec can't decode byte 0xec in position 41: illegal multibyte sequence

한국어 윈도우에서 파이썬 3.12의 open()은 인코딩을 지정하지 않으면 cp949로 읽습니다. 로그는 UTF-8 한글이라 첫 한글 바이트(0xEC)에서 바로 멈춘 것입니다. encoding='utf-8'을 넣어 해결했고, 이후 텍스트 모드 코드에는 errors='replace'도 붙였습니다. 로그에는 다른 프로그램이 남긴 깨진 바이트가 한 줄쯤 섞이기 쉬운데, 그 한 줄 때문에 검색 전체가 멈추면 곤란하기 때문입니다.

기본형: 한 줄씩 읽으며 찾기

인코딩을 고치자 readlines() 방식은 270,003줄을 2.65초에 찾았습니다. 문제는 메모리였습니다. 620MB 파일을 리스트로 통째로 올리니 최대 메모리가 1,361MB, 파일 크기의 2배가 넘었습니다. 파일이 몇 GB라면 그대로 메모리 부족으로 이어질 수 있는 구조입니다.

그래서 기본형은 파일 객체를 for로 한 줄씩 도는 방식입니다. 같은 검색에서 최대 메모리가 13MB였습니다. 아래 코드의 memstat은 측정용으로 만든 보조 파일이라, 그대로 쓰실 때는 import 줄과 출력의 메모리 부분을 지우면 됩니다.

import sys
import time
from memstat import peak_mb

path, keyword = sys.argv[1], sys.argv[2]
start = time.perf_counter()
count = 0
with open(path, encoding='utf-8', errors='replace') as f, \
        open('hits.txt', 'w', encoding='utf-8', newline='') as out:
    for line in f:
        if keyword in line:
            out.write(line)
            count += 1
print(f'{count:,}줄 찾음, {time.perf_counter() - start:.2f}초, 최대 메모리 {peak_mb():,.0f}MB')

출력 파일을 여는 줄의 newline=''은 처음엔 없었습니다. 나중에 다른 방식의 결과와 비교하다가 “다르다”는 결과가 나와서 추가했습니다.

$ python compare.py hits.txt hits_find.txt
hits.txt: 20,556,124바이트, CRLF 270,003개
hits_find.txt: 20,286,121바이트, CRLF 0개
바이트가 다름
줄바꿈만 맞추면 같음

윈도우에서 텍스트 모드로 쓰면 \n이 \r\n으로 바뀝니다. 원본 로그는 LF인데 뽑아낸 결과는 CRLF가 되어, 줄마다 1바이트씩 총 270,003바이트가 늘어 있었습니다. 내용은 같아도 다른 도구로 원본과 비교하거나 다시 합칠 때 문제가 됩니다. newline=''로 열면 바이트까지 똑같아집니다.

여섯 가지 방법 속도 비교

같은 로그에 여섯 가지 방법을 번갈아 세 번씩 돌렸습니다. 흔한 키워드(ERROR, 270,003줄)와 드문 키워드(E2999, 43줄) 두 경우입니다.

$ python bench.py app.log ERROR 3        (270,003줄이 걸리는 흔한 키워드)
grep_readlines.py  평균 2.53초 (최소 2.38, 최대 2.73)
grep_iter.py       평균 3.82초 (최소 3.00, 최대 5.32)
grep_regex.py      평균 3.56초 (최소 3.45, 최대 3.74)
grep_bytes.py      평균 3.76초 (최소 3.67, 최대 3.86)
grep_chunk.py      평균 3.88초 (최소 3.85, 최대 3.93)
grep_find.py       평균 0.76초 (최소 0.75, 최대 0.77)
$ python bench.py app.log E2999 3        (43줄만 걸리는 드문 키워드)
grep_readlines.py  평균 2.47초 (최소 2.39, 최대 2.52)
grep_iter.py       평균 2.92초 (최소 2.74, 최대 3.08)
grep_regex.py      평균 3.37초 (최소 3.28, 최대 3.46)
grep_bytes.py      평균 3.66초 (최소 3.50, 최대 3.75)
grep_chunk.py      평균 2.10초 (최소 2.02, 최대 2.19)
grep_find.py       평균 0.50초 (최소 0.49, 최대 0.52)
방법흔한 키워드드문 키워드최대 메모리
readlines() 후 거르기2.53초2.47초1,361MB
한 줄씩 + in (텍스트)3.82초 (3회차 5.32초 튐)2.92초13MB
한 줄씩 + 정규식3.56초3.37초14MB
한 줄씩 + in (바이너리)3.76초3.66초13MB
8MB 덩어리, 없으면 건너뛰기3.88초2.10초35~37MB
8MB 덩어리 + bytes.find0.76초0.50초29MB

예상과 다른 결과가 두 가지 있었습니다. 첫째, 디코딩을 하지 않으니 더 빠를 거라 생각한 바이너리 모드('rb')가 텍스트 모드보다 오히려 느렸습니다. 둘째, “덩어리에 키워드가 없으면 통째로 건너뛰기”는 드문 키워드에서만 효과가 있었습니다. 345줄이 걸리는 user=4242로 먼저 돌렸을 때는 4.07초로 가장 느렸는데, 620MB면 8MB 덩어리가 74개인데 345줄이 그 안에 흩어져 있으니, 건너뛸 덩어리가 거의 없었던 것으로 보입니다. 정규식은 단순 문자열 검색보다 매번 느렸으니, 고정된 단어를 찾을 때는 in이면 충분합니다.

빠른 방법: 덩어리로 읽고 위치만 찾기

가장 빠른 방법은 발상이 다릅니다. 줄을 하나씩 꺼내 검사하면 900만 줄 각각에 대해 파이썬 코드가 한 번씩 돕니다. 대신 8MB를 한 번에 읽어 bytes.find(C로 구현돼 빠름)로 키워드 위치만 찾고, 그 위치의 앞뒤 줄바꿈까지만 잘라냅니다. 파이썬 코드는 찾은 횟수만큼만 돕니다.

"""큰 덩어리(8MB)로 읽고 bytes.find로 키워드 위치만 찾아, 그 위치가 속한 줄만 잘라냅니다.
줄마다 파이썬 코드가 도는 대신, 찾은 횟수만큼만 돕니다."""
import sys
import time
from memstat import peak_mb

path, keyword = sys.argv[1], sys.argv[2].encode('utf-8')
size = 8 * 1024 * 1024
start = time.perf_counter()
count = 0
rest = b''
with open(path, 'rb') as f, open('hits.txt', 'wb') as out:
    while True:
        block = f.read(size)
        if not block:
            block, rest = rest, b''          # 파일 끝: 줄바꿈 없는 마지막 줄 처리
            if not block:
                break
        else:
            block = rest + block
            cut = block.rfind(b'\n') + 1     # 마지막 줄바꿈 뒤는 다음 덩어리로 넘김
            block, rest = block[:cut], block[cut:]
        pos = block.find(keyword)
        while pos != -1:
            s = block.rfind(b'\n', 0, pos) + 1
            e = block.find(b'\n', pos)
            e = len(block) if e == -1 else e + 1
            out.write(block[s:e])
            count += 1
            pos = block.find(keyword, e)     # 같은 줄에 두 번 나와도 한 줄만
print(f'{count:,}줄 찾음, {time.perf_counter() - start:.2f}초, 최대 메모리 {peak_mb():,.0f}MB')

덩어리 경계에서 줄이 잘리지 않도록, 마지막 줄바꿈 뒤의 조각은 다음 덩어리 앞에 붙입니다. 결과가 정말 같은지는 한 줄씩 방식의 결과 파일과 바이트 단위로 비교해 확인했습니다(20,286,121바이트, 완전히 같음). 흔한 키워드에서 0.76초, 드문 키워드에서 0.50초로, 한 줄씩 방식보다 3~6배 빨랐습니다.

특정 시간대만 뽑기: 이진 탐색으로 0.016초

장애가 났을 때 실제로 가장 많이 필요한 건 “12시부터 12시 10분까지”처럼 특정 시간대입니다. 로그는 시각순으로 쌓이니, 처음부터 읽을 필요가 없습니다. 파일 중간 위치로 seek해서 다음 줄의 시각을 보고, 찾는 시각보다 이르면 뒤쪽 절반, 늦으면 앞쪽 절반으로 좁혀 가는 이진 탐색을 쓰면 됩니다. 줄 맨 앞이 2026-10-02 12:00:01 형태라면 문자열 비교가 곧 시각 비교라서 날짜 변환도 필요 없습니다.

$ python time_scan.py app.log "2026-10-02 12:00" "2026-10-02 12:10"
23,471줄 찾음, 3.4850초, 최대 메모리 13MB

$ python time_range.py app.log "2026-10-02 12:00" "2026-10-02 12:10"
23,471줄 찾음, 0.0158초, 최대 메모리 13MB

$ python compare.py hits.txt hits_scan.txt
hits.txt: 1,615,708바이트, CRLF 0개
hits_scan.txt: 1,615,708바이트, CRLF 0개
바이트 그대로 같음

620MB를 처음부터 훑은 3.49초가 0.016초가 됐습니다. 약 200배입니다. 다시 돌려도 0.017~0.018초였고, 뽑아낸 23,471줄은 처음부터 훑은 결과와 바이트까지 같았습니다.

실제 로그에서는 0줄: 시각 없는 줄과 BOM

테스트 로그에서 잘 되길래 이 블로그의 자동 발행 로그(하루 한 파일, 51개)를 이어 붙여 날짜 하나를 뽑아봤습니다. 결과는 0줄이었습니다.

$ python concat_logs.py
파일 51개 → publish_all.log 218,555바이트, 2,987줄 (시각으로 시작하는 줄 584개)

$ python time_range.py publish_all.log 2026-10-04 2026-10-05
0줄 찾음, 0.0008초

$ python time_scan.py publish_all.log 2026-10-04 2026-10-05
10줄 찾음, 0.0024초

실제 10월 4일 로그는 79줄입니다. 원인은 로그 모양에 있었습니다. 2,987줄 중 시각으로 시작하는 줄은 584개뿐이고, 나머지는 자동화가 중간에 남긴 보고 문장·표·빈 줄이었습니다. 첫 버전은 이런 줄도 시각처럼 문자열 비교를 했습니다. 예를 들어 한글로 시작하는 줄은 바이트 값이 2026...보다 커서 “이미 끝 시각을 지났다”고 판정되고, 그 자리에서 추출이 끝나 버립니다. 한 줄씩 시각을 비교하는 방식은 반대로 시각 없는 줄을 전부 버려서 10줄만 남았습니다. 트레이스백이나 여러 줄짜리 오류 메시지가 있는 로그라면 어디서든 생기는 문제입니다.

그래서 “시각으로 시작하지 않는 줄은 바로 위 시각 줄에 딸린 것”으로 처리하도록 고쳤습니다. 이진 탐색 중에는 다음 시각 줄까지 건너뛰어 비교하고, 끝 판정도 시각 줄에서만 합니다. 그러자 줄 수는 79줄로 맞았는데, 원본 파일과 비교하니 이번엔 2바이트가 달랐습니다.

$ python compare.py range_1004.txt logs/publish-2026-10-04.log
range_1004.txt: 5,359바이트, CRLF 79개
publish-2026-10-04.log: 5,357바이트, CRLF 79개
바이트가 다름

$ python diff_lines.py range_1004.txt logs/publish-2026-10-04.log
1번째 줄 다름
  B: b'\xef\xbb\xbf2026-10-04 10:58:47  === 시작 ...'
(중략)
79번째 줄 다름
  A: b'\xef\xbb\xbf2026-10-07 13:42:50  === 시작 ...'

날짜별 로그 파일마다 맨 앞에 UTF-8 BOM(EF BB BF)이 붙어 있었습니다. 로그를 남기는 쪽이 윈도우 파워셸 스크립트라 거기서 붙은 것으로 보입니다. BOM 때문에 각 파일의 첫 줄이 “시각 줄”로 인식되지 않아, 10월 4일의 시작 줄은 빠지고 다음 파일(10월 7일)의 시작 줄이 끝에 딸려 들어왔습니다. 줄 수가 우연히 같아서 개수만 봤다면 놓쳤을 차이입니다. 정규식이 줄 앞의 BOM을 허용하도록 고친 뒤, 51일치 전부를 날짜별로 뽑아 원본 파일과 바이트 단위로 비교했습니다.

$ python compare.py range_1004.txt logs/publish-2026-10-04.log
range_1004.txt: 5,357바이트, CRLF 79개
publish-2026-10-04.log: 5,357바이트, CRLF 79개
바이트 그대로 같음

$ python verify_days.py
일치 51개, 불일치 0개

최종 스크립트: loggrep.py

키워드 검색(덩어리 + find)과 시간 구간 검색(이진 탐색, 시각 없는 줄·BOM 처리)을 한 파일로 합쳤습니다. 표준 라이브러리만 씁니다. 시간 구간과 키워드를 함께 주면 구간을 먼저 좁힌 뒤 그 안에서만 키워드를 찾습니다. 화면 대신 파일로 받으려면 -o, 개수만 보려면 -c를 씁니다.

"""대용량 로그에서 원하는 줄만 뽑기 (표준 라이브러리만 사용)

  python loggrep.py app.log -k ERROR                       키워드가 든 줄
  python loggrep.py app.log -k ERROR -k WARN               여러 키워드(하나라도 있으면)
  python loggrep.py app.log --since "2026-10-02 12:00" --until "2026-10-02 12:10"
  python loggrep.py app.log --since "2026-10-02 12:00" --until "2026-10-02 12:10" -k ERROR
  -o 결과파일 (기본: 화면 출력), -c 개수만

시간 구간은 로그가 시각순으로 쌓여 있다는 가정으로 이진 탐색합니다.
시각으로 시작하지 않는 줄(트레이스백, 여러 줄 메시지)은 바로 위 시각 줄에 딸린 것으로 봅니다."""
import argparse
import os
import re
import sys
import time

STAMP = re.compile(rb'(?:\xef\xbb\xbf)?(\d{4}-\d{2}-\d{2}[ T].*)', re.S)   # 파일 맨 앞 BOM 허용
BLOCK = 8 * 1024 * 1024


def stamp(line):
    """시각으로 시작하는 줄이면 BOM을 뗀 줄, 아니면 None."""
    m = STAMP.match(line)
    return m.group(1) if m else None


def next_stamped(f):
    """현재 위치부터 시각으로 시작하는 첫 줄의 (위치, 줄). 없으면 (None, b'')."""
    while True:
        pos = f.tell()
        line = f.readline()
        if not line:
            return None, b''
        s = stamp(line)
        if s is not None:
            return pos, s


def find_start(f, size, since):
    lo, hi = 0, size
    while lo < hi:
        mid = (lo + hi) // 2
        f.seek(mid)
        if mid:
            f.readline()                      # 줄 중간이면 다음 줄 처음으로
        pos, line = next_stamped(f)
        if pos is not None and line[:len(since)] < since:
            lo = mid + 1
        else:
            hi = mid
    f.seek(lo)
    if lo:
        f.readline()
    pos, _ = next_stamped(f)
    return size if pos is None else pos


def by_time(f, size, since, until, keys):
    f.seek(find_start(f, size, since) if since else 0)
    for line in f:
        s = stamp(line) if until else None
        if s is not None and s[:len(until)] >= until:
            break
        if not keys or any(k in line for k in keys):
            yield line


def by_keyword(f, keys):
    """8MB씩 읽어 bytes.find로 키워드 위치만 찾고, 그 줄만 잘라냅니다."""
    rest = b''
    while True:
        block = f.read(BLOCK)
        if not block:
            block, rest = rest, b''
            if not block:
                return
        else:
            block = rest + block
            cut = block.rfind(b'\n') + 1
            block, rest = block[:cut], block[cut:]
        hits = sorted({p for k in keys for p in iter_find(block, k)})
        end = -1
        for pos in hits:
            if pos < end:                     # 이미 낸 줄 안의 위치(같은 줄에 키워드 여러 개)
                continue
            s = block.rfind(b'\n', 0, pos) + 1
            end = block.find(b'\n', pos)
            end = len(block) if end == -1 else end + 1
            yield block[s:end]


def iter_find(block, key):
    pos = block.find(key)
    while pos != -1:
        yield pos
        pos = block.find(key, pos + 1)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('path')
    ap.add_argument('-k', '--keyword', action='append', default=[])
    ap.add_argument('--since')
    ap.add_argument('--until')
    ap.add_argument('-o', '--out')
    ap.add_argument('-c', '--count', action='store_true')
    a = ap.parse_args()
    keys = [k.encode('utf-8') for k in a.keyword]
    since = a.since.encode() if a.since else None
    until = a.until.encode() if a.until else None
    if not keys and not since and not until:
        ap.error('-k 또는 --since/--until 중 하나는 필요합니다')

    start = time.perf_counter()
    out = open(a.out, 'wb') if a.out else sys.stdout.buffer
    n = 0
    with open(a.path, 'rb') as f:
        lines = by_time(f, os.path.getsize(a.path), since, until, keys) if since or until \
            else by_keyword(f, keys)
        for line in lines:
            n += 1
            if not a.count:
                out.write(line)
    if a.out:
        out.close()
    print(f'{n:,}줄, {time.perf_counter() - start:.3f}초', file=sys.stderr)


if __name__ == '__main__':
    main()

620MB 테스트 로그에 돌린 결과입니다.

$ python loggrep.py app.log -k ERROR -c
270,003줄, 0.659초
$ python loggrep.py app.log -k ERROR -k WARN -c
899,456줄, 1.498초
$ python loggrep.py app.log -k E2999 -c
43줄, 0.515초
$ python loggrep.py app.log --since "2026-10-02 12:00" --until "2026-10-02 12:10" -c
23,471줄, 0.023초
$ python loggrep.py app.log --since "2026-10-02 12:00" --until "2026-10-02 12:10" -k ERROR -o range_err.txt
728줄, 0.030초

키워드 두 개(ERROR 또는 WARN, 899,456줄)도 1.5초 안에 끝났고, 10분 구간 안의 ERROR는 0.03초였습니다. 시간 구간 검색은 로그가 시각순으로 쌓여 있다는 전제가 있습니다. 여러 프로세스가 한 파일에 쓰면서 시각이 뒤섞이는 로그라면 이진 탐색 결과를 믿을 수 없으니, 그때는 키워드 검색만 쓰세요.

이 블로그 발행 로그에 써보니

마지막으로 실제 운영 로그에서 쓸모를 확인했습니다. -k "(exit 1)"로 찾으니 실패로 끝난 회차가 8번(8/14, 8/20~22, 8/27~29, 9/21) 나왔습니다(0.002초). 그중 8월 21일 아침 한 시간을 시간 구간으로 뽑았습니다.

$ python loggrep.py publish_all.log --since "2026-08-21 09" --until "2026-08-21 10"
2026-08-21 09:00:02  === 시작 (Burst 2편, 슬롯 D+1~D+2 09:00) ===
2026-08-21 09:00:02  project : D:\03. 프로그램\18. 애드센스
2026-08-21 09:00:10  아침 계획 알림: SENT: 콘텐츠 발행 / 오늘의 발행 계획 (1건 전송)
2026-08-21 09:00:10  --- 슬롯 1/2 (예약 목표: 2026-08-22T09:00:00) ---
You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message
2026-08-21 09:00:15  시도 실패 (exit 1)
2026-08-21 09:00:15  재시도 1/2: 2분 대기
You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message
2026-08-21 09:02:17  시도 실패 (exit 1)
2026-08-21 09:02:17  재시도 2/2: 5분 대기
You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message
2026-08-21 09:07:19  시도 실패 (exit 1)
2026-08-21 09:07:20  알림: SENT: 콘텐츠 발행 / 발행 실패 (1건 전송)
2026-08-21 09:07:28  알림: SENT: 콘텐츠 발행 / 일일 작업 결과 보고 (1건 전송)
2026-08-21 09:07:28  === 종료 (exit 1) ===

16줄, 0.000초

$ python time_scan.py publish_all.log "2026-08-21 09" "2026-08-21 10"
11줄 찾음, 0.0023초

실패 원인인 “monthly spend limit” 줄은 시각이 없는 줄입니다. loggrep은 16줄을 다 보여줬지만, 한 줄씩 시각으로 거르는 방식은 11줄만 남기면서 정작 원인 줄 세 개를 버렸습니다. 로그에서 시간대를 뽑을 때 시각 없는 줄을 어떻게 다루는지가 결과의 쓸모를 가른다는 걸 이 출력이 잘 보여줍니다.

키워드 검색은 반대 방향의 주의가 필요합니다. -k "종료 (exit"로 70줄을 찾았는데, 그중 한 줄은 로그가 아니라 보고 문장 안에 그 글자가 들어 있던 줄이었습니다. 키워드는 가능하면 === 종료 (exit 1)처럼 로그 형식까지 포함해 좁게 잡는 편이 안전합니다.

정리

  1. 윈도우에서 로그를 읽을 때는 encoding='utf-8'(필요하면 errors='replace'), 결과를 쓸 때는 newline=''을 지정하세요. 안 하면 cp949 오류가 나거나 줄바꿈이 CRLF로 바뀝니다.
  2. readlines()는 620MB 파일에 1,361MB를 썼습니다. 큰 로그는 한 줄씩 또는 덩어리로 읽으세요.
  3. 가장 빠른 건 8MB 덩어리 + bytes.find였습니다. 한 줄씩 방식 2.5~3.8초 대비 0.5~0.76초.
  4. 시간대 검색은 이진 탐색으로 3.49초가 0.016초가 됐습니다. 단, 로그가 시각순이어야 합니다.
  5. 실제 로그에는 시각 없는 줄과 파일 앞 BOM이 있습니다. 이걸 처리하지 않으면 0줄이 나오거나 원인 줄이 빠집니다.

가장 작은 첫걸음은 위 loggrep.py를 저장한 뒤 가지고 있는 로그 하나에 python loggrep.py 로그파일 -k ERROR -c로 개수부터 세어 보는 것입니다. 개수가 많다면 --since로 의심 가는 시간대를 좁혀 -o로 저장하면 됩니다.

다음 글에서는 이렇게 뽑아낸 에러 로그를 AI에게 넘겨 원인을 찾게 하는 방법을 다루겠습니다.

댓글 남기기