고객 데이터 다루기 · 다 못 올리는 파일 · 이론
메모리에 다 올릴 수 없는 파일
한 줄 요약
"다 읽어서 리스트에 담는다" 는 파일이 작을 때만 맞는 설계이고, 그 가정이 깨졌는지는 상한을 걸어 봐야 알 수 있다.
왜 이게 필요했나
고객이 한 달치 이벤트 로그를 보냈다. 노트북에서는 잘 돌던 스크립트가 파드에서 죽는다. 로그에는 아무것도 없고 프로세스만 사라져 있다.
원인은 대개 한 줄이다. lines = open(path).read().splitlines() 처럼 파일을 통째로 올리는 코드다. 이 줄은 작은 파일에서 더 짧고 더 빠르기까지 해서, 처음 쓸 때는 의심할 이유가 없다. 그리고 파일이 커지는 것은 우리가 아니라 고객이 결정한다.
더 곤란한 것은 이 실패가 조용하다는 점이다. 컨테이너가 메모리 한도를 넘으면 커널이 프로세스를 죽인다. 파이썬의 예외 처리는 그 죽음을 잡지 못하고, 표준출력에는 아무것도 남지 않는다. 다음 날 아침에 보이는 것은 "작업이 안 끝났다" 뿐이다.
어떻게 동작하나
설계의 기준은 하나다. 어느 시점에도 손에 쥐고 있는 것이 몇 개인가.
집계는 한 줄이면 된다. 건수·합계·최소·최대는 방금 읽은 한 줄만 있으면 갱신된다. 파일이 10배가 되어도 메모리는 그대로다. 파이썬의 파일 객체를 그대로 순회하면 한 줄씩 읽힌다 — readlines() 나 read() 를 부르는 순간 그 성질이 사라진다.
상위 N 개는 N 개면 된다. 전체를 정렬해 앞에서 자르면 전체가 메모리에 올라가지만, 크기 N 의 최소 힙을 두고 새 값이 힙의 맨 밑보다 클 때만 밀어 넣으면 손에 쥐는 것은 언제나 N 개다. [heapq](https://docs.python.org/3/library/heapq.html) 의 heappushpop 이 이 동작을 한 번에 한다.
고유값 세기는 다르다. 한 줄씩 읽더라도 "이미 본 값" 을 기억해야 하므로 메모리가 값의 가짓수에 비례한다. 지역 코드 17개를 세는 것과 세션 아이디 50만 개를 세는 것은 같은 스트리밍 코드인데 메모리가 전혀 다르다. 그래서 고유값 세기를 설계할 때 먼저 묻는 것은 파일 크기가 아니라 가짓수다.
정렬은 나눠서 한다. 외부 정렬은 오래된 방법이다. 들어갈 만큼만 읽어 정렬해 임시 파일로 쓰고(분할·정렬), 그 파일들을 한 줄씩 꺼내며 합친다(병합). 병합 단계에서 손에 쥐는 것은 파일마다 한 줄씩뿐이다. 파이썬에서는 heapq.merge 가 이 합치기를 해 준다. 유닉스의 sort 도 같은 일을 한다 — [POSIX sort](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/sort.html)는 임시 파일을 쓰는 것을 전제로 규정되어 있고, GNU 구현에서는 -S 로 버퍼 크기를, -T 로 임시 디렉터리를 정한다. 임시 공간이 부족하면 정렬은 실패한다.
현장에서 만나는 모습
첫째, "돌려 봤더니 되던데요" 를 증명으로 쓴다. 그 말은 그때 그 파일에서 됐다는 뜻이다. 증명은 상한을 걸고 통과하는 것이다. 리눅스에는 프로세스가 잡을 수 있는 주소 공간의 상한이 있고, 파이썬에서는 [resource](https://docs.python.org/3/library/resource.html) 모듈의 setrlimit 으로 RLIMIT_AS 를 건다. 상한 아래에서 스트리밍 판은 통과하고 통째로 읽는 판은 MemoryError 로 죽는다. 이 두 종료 코드가 문장 열 줄보다 낫다.
둘째, 중간 산출물을 쌓아 놓고 검증한다. "정렬이 잘됐는지 보려고" 원본과 결과를 둘 다 읽어 리스트로 만들어 비교하면, 정렬을 스트리밍으로 한 보람이 사라진다. 대신 순서에 무관한 지문을 흘려보내며 계산한다. 줄마다 해시를 내어 전부 더하면(넘침은 버린다) 순서가 달라도 같은 값이 나오고, 줄이 하나라도 빠지거나 겹치면 달라진다. 합계와 건수도 함께 보면 충분하다.
셋째, 임시 공간을 잊는다. 외부 정렬은 메모리를 디스크로 옮기는 거래다. 파드의 임시 디스크는 6Gi 이고 그 자리도 나눠 쓴다. 청크 파일을 지우지 않으면 정렬 도중에 디스크가 먼저 찬다.
넷째, 청크 크기를 감으로 정한다. 청크가 너무 크면 메모리에서 죽고, 너무 작으면 파일 핸들이 수백 개가 되어 병합이 느려진다. 청크 크기는 "이 정도면 확실히 들어간다" 를 상한 시험으로 확인해 정한다.
실무에서 진짜 중요한 것
- 손에 쥐는 것의 개수로 설계한다. 파일 크기가 아니라 동시에 살아 있는 객체 수가 기준이다.
- 상한을 걸어 증명한다. 통과와 실패가 종료 코드로 남으면 다음 사람이 다시 묻지 않는다.
- 고유값 세기는 가짓수를 먼저 묻는다. 스트리밍이라는 말이 메모리가 일정하다는 뜻은 아니다.
- 검증도 스트리밍으로 한다. 순서에 무관한 지문과 건수와 합계면 충분하다.
다음 실습에서 할 것
30만 줄짜리 이벤트 로그를 직접 만들고, 한 줄씩 읽어 집계하는 도구를 만든다. 힙으로 상위 N 건만 유지하고, 고유값 세기의 최대 메모리가 값의 가짓수에 따라 어떻게 달라지는지 직접 잰다. 그다음 메모리 상한을 걸어 스트리밍 판과 통째로 읽는 판을 나란히 돌려 종료 코드로 차이를 남기고, 외부 정렬로 큰 파일을 정렬한 뒤 순서에 무관한 지문으로 한 줄도 잃지 않았음을 증명한다. 채점기는 자기가 만든 파일을 상한 아래에서 여러분의 도구로 돌려 답을 대조한다.