AI·

5분 버킷과 '봉인' — 소비해도 안전한 적재 포맷 설계

실시간으로 들어오는 변경 로그를 어떤 파일에 어떻게 쌓아야 안전하게 꺼내 쓸 수 있을까. 하나의 로그 파일을 5분 단위 버킷으로 끊고, '봉인' 개념으로 쓰기·읽기 경합을 없앤 설계 기록입니다.

지난 글에서 변경이 실시간으로 흘러들어오게 됐습니다. 이제 이걸 어디에 쌓느냐가 문제였습니다. 그냥 로그 파일 하나에 계속 이어 쓰면 될 것 같았지만, "이걸 나중에 꺼내 쓴다"를 진지하게 생각하는 순간 골치 아픈 지점이 드러났습니다.

시작은 로그 파일 하나였다

처음엔 단순했습니다. 변경이 들어올 때마다 changes.jsonl에 한 줄씩 append. 쌓기엔 완벽했죠. 어디까지 처리했는지는 .state.json 같은 상태 파일에 **마지막 지점(워터마크)**을 적어 두면 된다고 생각했습니다.

그런데 이 파일을 소비(요약)하는 쪽을 상상하니 문제가 보였습니다.

  • 소비처가 파일을 읽는 바로 그 순간에도 수집기는 새 줄을 쓰고 있습니다.
  • 어디까지 소비했는지 표시해도, 그 사이 또 쌓입니다. 매번 "어디부터 새로 읽지?"를 따져야 합니다.
  • 소비가 끝나고 처리분을 비우려는데, 그 사이 들어온 새 줄까지 같이 날아갈 위험이 있습니다.

한 파일에 쓰기와 읽기가 동시에 일어나는 구조는, 언젠가 반드시 경합으로 터집니다. "언제 소비하고, 소비한 뒤 그동안 쌓인 걸 또 어떻게 찾느냐"가 계속 발목을 잡았습니다.

두 갈래의 선택지

정리하니 방법은 크게 둘이었습니다. (1) 파일 하나를 계속 쓰면서 소비 지점을 관리하거나, (2) 애초에 파일을 주기별로 쪼개는 것. 첫 번째는 방금 본 경합 문제를 계속 안고 가야 했습니다. 그래서 두 번째로 갔습니다.

기준은 시각이었습니다. 로그가 찍힌 시각을 기준으로 5분마다 새 파일이 열리게 한 거죠. 이렇게 하면 "지금 쓰는 파일"과 "이미 다 쓴 파일"이 자연스럽게 갈립니다.

data/
├── items-20260808-1400.json   # 14:00~14:05 변경들
├── items-20260808-1405.json   # 14:05~14:10 변경들
└── items-20260808-1410.json   # 지금 쓰는 중(열린 버킷)

사소하지만 중요했던 두 결정

형식jsonl에서 json으로 바꿨습니다. jsonl은 한 줄씩 흘려 쓰는 스트리밍에 좋지만, 이 파일은 이제 통째로 읽어 한 번에 소비할 대상입니다. 그렇다면 파일 하나가 온전한 JSON 객체인 편이 다루기 쉬웠습니다.

시각은 KST로 통일했습니다. 처음엔 파일명이 items-20260810-0100.json처럼 엉뚱한 시각으로 찍혔는데, 알고 보니 UTC 기준이었습니다. 새벽 1시에 만든 것도 아닌데 0100이 붙으니 사람이 혼란스러웠죠. 파일명·로그·버킷 경계 모두 KST 기준으로 못박고 나서야, 파일 이름만 보고도 "언제 구간인지"가 바로 읽혔습니다.

참고로 5분 버킷이면 하루에 288개(24시간 × 12)의 파일이 생깁니다. 아카이브 정리를 함께 설계해야 하는 이유이기도 합니다.

핵심 개념: '봉인(sealed)'

버킷을 시각으로 끊으면 자연스럽게 봉인이라는 상태가 생깁니다.

지금 시각이 속한 버킷은 아직 열려 있습니다(계속 쓰일 수 있음). 그보다 이전 구간의 버킷은 더 이상 쓰일 일이 없으니 봉인된 것으로 봅니다.

규칙은 딱 하나입니다 — 소비처는 봉인된 버킷만 읽는다. 열린 버킷(현재 구간)은 절대 건드리지 않습니다. 이러면 "쓰는 중인 파일을 읽는" 상황이 원천적으로 사라집니다. 수집기는 열린 버킷에만 쓰고, 소비처는 봉인된 버킷만 읽으니, 둘이 같은 파일을 만질 일이 없습니다. 파일을 잠그거나 복잡한 동기화를 걸 필요도 없어졌습니다 — 시간이 경계를 대신 그어 주니까요.

설계할 때 가장 걱정한 시나리오가 하나 있었습니다. 소비처가 버킷을 읽고 아카이브로 옮기려 이름을 바꾸는 바로 그 순간, 수집기가 같은 파일에 새 변경을 쓰면 어떻게 될까요. 파일이 사라졌다며 터지거나, 변경 하나가 조용히 증발할 수도 있습니다. 봉인 규칙은 이 위험을 애초에 없앱니다. 수집기가 쓰는 대상은 언제나 "지금 열린 버킷"이고 그건 아직 봉인 전이라 소비처가 손대지 않습니다. 반대로 소비처가 만지는 버킷은 이미 봉인돼 수집기가 다시 열지 않습니다. 두 손이 닿는 파일이 항상 다르니, 이름을 바꾸든 옮기든 부딪칠 일이 없습니다.

5분 경과 오래된 순 완료 후 열림 (쓰는 중) 봉인 (sealed) 소비 archive/
버킷 생명주기 — 열린 버킷은 아무도 읽지 않고, 봉인된 것만 소비된다

소비하고 나면 치운다

소비가 끝난 버킷은 data/archive/옮깁니다. 원본 자리에서 사라지니, 다음 소비 때 "이미 처리한 걸 또 볼" 일이 없습니다. 처리 대상 폴더에는 아직 소비 안 된 봉인 버킷만 남습니다. (중간에 임시로 두던 _hold 같은 폴더는 오히려 흐름을 복잡하게 만들어, 결국 없앴습니다. 상태는 단순할수록 좋습니다.)

아카이브는 보존 기간을 길게 두지 않았습니다. 하루치만 남기고, 매일 아침 정해진 시각(예: 7시)에 전날 것을 지웁니다. 같은 시각에 산출물 폴더(docs/)도 초기화합니다. 문제 추적용으로 잠깐 남겨 두는 용도라, 그 이상은 디스크만 차지할 뿐이었습니다.

정리

  • 한 파일에 쓰기·읽기를 동시에 두지 마라 → 시각으로 버킷을 끊는다.
  • 소비 대상이니 jsonl보다 json, 파일명엔 **날짜·시각(KST)**을 박아 자기설명적으로.
  • 봉인: 소비처는 지난 구간(봉인된) 버킷만 읽는다 → 잠금 없이 경합 제거.
  • 소비한 버킷은 archive/로 이동, 하루치만 보존 + 매일 초기화.

적재 포맷이 안정되니, 이제 누가 언제 이 버킷들을 꺼내 소비할지가 남았습니다. 다음 글에서 cron 대신 fixedDelay로 소비 루프를 도는 이유를 다룹니다.