로그로 원인 찾기 · 큰 파일에서 필요한 구간만 · 이론
3분을 보려고 하루치를 읽지 않는다
한 줄 요약
시간순으로 쌓인 로그에서는 읽지 않고 찾을 수 있다. 시각 표기가 통일되어 있으면 문자열 비교가 곧 시간 비교이므로, 파일을 바이트 단위로 이진 탐색해 구간의 시작 자리를 먼저 찍고 그 뒤만 읽으면 된다.
왜 이게 필요했나
장애 회고에서 가장 자주 나오는 요청은 "사고가 난 3분만 보여 달라" 이다. 그런데 손이 먼저 하는 일은 대개 grep '03:5[89]' app.log 이고, 그 한 줄이 파일 전체를 앞에서 끝까지 읽는다. 파일이 하루치 수 GB 라면 이 한 번이 디스크 대역과 메모리를 다 쓰고, 분석하려던 파드가 먼저 멈춘다. 실습 파드만 해도 메모리 2Gi 에 임시 디스크 6Gi 다.
더 나쁜 것은 이 방식이 틀린 답도 준다는 점이다. 구간이 회전 경계에 걸쳐 있으면 app.log 만 본 결과는 반쪽이다. 사고의 앞 절반은 이미 app.log.1 로 넘어가 있고, 그보다 앞은 app.log.2.gz 안에서 압축되어 있다. 한 파일만 보고 "그 시각에는 아무 일도 없었다" 고 적는 회고가 여기서 나온다.
어떻게 동작하나
전제는 하나다 — 파일이 시간순이고 시각 표기가 통일되어 있을 것. [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339.html) 5.1절이 이 성질을 명시한다. 날짜와 시각의 구성 요소가 덜 정밀한 것에서 더 정밀한 것 순서로 놓이고, 시간대가 모두 같은 문자열로 적히고(예컨대 전부 Z), 소수 자릿수가 모두 같으면, 그 문자열들을 C 의 strcmp 로 정렬해도 시간순이 된다고 적혀 있다. 그래서 2026-04-12T03:58:30.000Z 두 개를 비교하는 일은 시각 두 개를 비교하는 일과 같다. 이 전제가 서 있는지는 [sort(1)](https://www.man7.org/linux/man-pages/man1/sort.1.html)의 -c(정렬 여부만 검사하고 정렬은 하지 않는다)로 먼저 확인할 수 있다.
전제가 서면 이진 탐색이 가능해진다. 파일 크기를 반으로 접어 그 바이트로 seek 하고, 줄 한가운데에 떨어졌을 테니 한 줄을 읽어 버려 줄 경계에 선 다음, 그 줄의 시각을 구간의 시작과 비교한다. 작으면 뒤쪽 절반, 크거나 같으면 앞쪽 절반으로 범위를 좁힌다. 20MB 파일이라면 스물몇 번이면 끝난다.
여기서 두 가지를 정확히 해야 한다. 첫째, 구간은 반열림 [시작, 끝) 으로 잡는다. 그래야 이어지는 구간을 나란히 뽑아도 경계의 줄이 두 번 세어지지 않는다. 둘째, 같은 시각의 줄이 여럿일 때 이진 탐색이 찾아야 하는 것은 그 시각의 첫 줄이다. 초당 수십 줄이 쌓이는 서비스에서 같은 밀리초에 여러 줄이 있는 것은 예외가 아니라 보통이고, 아무 줄이나 찾아 그 뒤만 읽으면 앞의 몇 줄이 조용히 빠진다. "조건을 만족하는 아무 줄" 이 아니라 "조건을 처음 만족하는 줄" 을 찾도록 써야 한다.
회전본은 먼저 시간순으로 세운다. 번호를 붙이는 기본 방식에서는 숫자가 클수록 과거이고, 확장자가 없는 파일이 지금 쓰는 것이다. [logrotate(8)](https://www.man7.org/linux/man-pages/man8/logrotate.8.html)의 dateext 를 켜면 이름이 날짜가 되는데(기본 dateformat 은 -%Y%m%d, hourly 는 -%Y%m%d%H) 그때는 이름이 작을수록 과거라 방향이 반대가 된다. 같은 man 페이지가 날짜 형식은 반드시 사전순으로 정렬 가능해야 한다고 못박는데, 이유가 재미있다 — logrotate 자신이 어느 파일이 오래된 것인지 알아내려고 회전된 파일 이름들을 정렬하기 때문이다. 이름 정렬이 시간 정렬이 되게 하는 것은 파일 안에서나 파일 이름에서나 같은 요령이다.
압축본만은 사정이 다르다. gzip 스트림은 앞의 내용에 기대어 풀리므로 임의의 지점으로 건너뛸 수 없다. [파이썬의 gzip 모듈](https://docs.python.org/3/library/gzip.html)이 주는 파일 객체는 seek 을 받아 주기는 하지만, 뒤로 가면 압축 스트림을 처음부터 다시 읽는다(직접 재 보면 원본 바이트를 다시 읽어 들이는 것이 보인다). 그러니 압축본에 이진 탐색을 걸면 같은 자리를 몇 번이고 다시 푸는 꼴이 된다. 답은 단순하다 — 앞에서부터 한 번만 훑되, 구간의 끝을 지나는 순간 멈춘다. 구간이 파일 앞쪽에 있을수록 이 절약이 커진다.
한 줄이 완결된 레코드인 [JSON Lines](https://jsonlines.org/) 형식이라면 이 모든 것이 그대로 통한다. 줄 단위로 자른 바이트 구간이 그 자체로 유효한 문서이기 때문이다. 통짜 JSON 배열은 가운데를 잘라 낼 수 없다.
현장에서 만나는 모습
전제가 깨지면 이진 탐색은 조용히 틀린다. 여러 프로세스가 한 파일에 쓰면 줄 순서가 미세하게 어긋나고, 수집기가 늦게 도착한 줄을 뒤에 덧붙이기도 한다. 그런 파일에서 이진 탐색은 오류를 내지 않고 그냥 몇 줄을 빠뜨린다. 그래서 새 파일을 처음 다룰 때는 정렬 여부를 먼저 확인하고, 어긋나 있으면 이진 탐색을 포기하거나 어긋남의 폭만큼 구간을 넓혀 잡는다.
"빨랐다" 는 증명이 아니다. 빠른 방법을 도입할 때 반드시 함께 하는 일은 느린 방법과의 대조다. 전체를 훑어 같은 구간을 뽑고, 줄 수와 해시를 견주어 같은지 본다. 이 대조는 한 번만 해 두면 되고, 그 한 번이 없으면 경계에서 줄이 빠져도 아무도 모른다.
다음 실습에서 할 것
회전까지 끝난 여섯 시간치 로그를 만들고, 파일마다 담긴 구간을 재어 오래된 것부터 세운다. 사고 구간과 겹치지 않는 파일은 아예 열지 않고, 남은 파일에서는 이진 탐색으로 시작 바이트와 끝 바이트를 찍어 그 사이만 읽는다. 압축본에는 같은 수법을 쓸 수 없으니 앞에서부터 훑되 일찍 멈춘다. 마지막에는 통째로 훑는 느린 방법과 대조해 결과가 같음을 증명하고, 방법과 근거를 보고서로 남긴다.