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