Zettelkasten

hot 데이터 이관은 시간 기준이면 파티션 DROP, 개수 기준이면 capped eviction이다

·수정 2

요약

  • hot→cold 이관 기준이 시간이냐 유저별 개수냐에 따라 메커니즘이 완전히 달라진다.
  • 시간 기준은 전역 컷라인이라 파티션 통째 DROP(벌크·쌈)으로 끝나지만, 개수 기준은 컷라인이 유저마다 달라 벌크가 안 통하고 유저별로 잘라내야 한다.
  • 개수 기준은 write 시점 capped eviction(ring buffer)으로 풀고, hot 크기 상한이 보장되며 페이징 경계가 깔끔해진다.

본문

"최근 데이터는 자주, 옛날 데이터는 가끔" 접근 패턴에서 hot 저장소를 작게 유지하려면 오래된 데이터를 cold(S3/BigQuery 등)로 내려야 한다. 이때 "무엇을 오래됐다고 볼 것인가" 의 기준이 두 가지고, 둘은 운영 메커니즘이 정반대다.

시간 기준 (time-based)

created_at 으로 파티셔닝하고, 만료된 파티션을 통째로 cold로 보낸다.

  • 전역 컷라인: "2024-03 파티션" 은 모든 유저에게 동시 적용된다.
  • 그래서 벌크 연산이 통한다 — DELETE 수십억 건이 아니라 DROP PARTITION 한 방. 메타데이터 락만 잡고 즉시(거의)다 — row를 하나씩 지우지 않고 파일/세그먼트를 떨궈낸다. (S3 쪽은 date prefix로 스토리지 클래스 전환/삭제를 lifecycle로 자동화.)
  • 단점: hot에 남는 양이 유저마다 들쭉날쭉. 헤비 유저는 최근 파티션에도 수천 건이 쌓여 hot 크기 상한이 없다.
  • 페이징 경계(최근 N개월의 N번째 아이템)가 유저마다 다른 인덱스에 걸린다.

개수 기준 (count-based / 유저당 고정 N개)

"유저당 최근 N개(=고정 페이지 수)만 hot" 정책.

  • 컷라인이 유저마다 다르다: 헤비 유저의 N번째는 2달 전, 라이트 유저의 N번째는 3년 전. → 깔끔한 시간 파티션을 못 떨어내므로 DROP PARTITION 류 벌크가 안 통한다. 유저별로 잘라야 한다.
  • 해법은 write 시점 capped eviction (ring buffer): 저장할 때마다 카운트가 N을 넘으면 가장 오래된 1건을 밀어내 cold-write 큐(Kafka/SQS→S3)로 흘려보낸다. 배치 스윕으로 전체 유저를 훑지 않고, 넘친 유저만 넘친 순간 1건 처리.
  • hot 크기가 유저 수 × N으로 상한 보장 — 시간 기준의 "무한정 증가" 문제가 사라진다.
  • Redis sorted set이 이 패턴에 딱 맞는다 (score=created_at):
ZADD user:{id}:saved {created_at} {post_id}
ZREMRANGEBYRANK user:{id}:saved 0 -(N+1)   # N개 초과분 트림 → cold로

두 명령은 각각만 원자적이고 별개 호출이라 그룹 원자성은 없다. 사이에 다른 쓰기가 끼어들 수 있으므로, "트림이 원자적이어야 한다"면 MULTI/EXEC나 Lua로 묶는다 (보통 eviction 1건이 한 틱 늦는 정도라 실무상 무시 가능).

  • 페이징은 오히려 더 깔끔하다. N을 page_size 배수로 잡으면 hot/cold 경계가 페이지 경계에 정렬돼, "한 페이지가 hot 18 + cold 2로 걸침" 문제가 사라진다. page 1~K는 hot, K+1부터 cold.

트레이드오프 요약

시간 기준 개수 기준
이관 파티션 통째 DROP (벌크·쌈) 유저별 eviction (write 시점)
hot 크기 무한정 ↑ 가능 유저당 N으로 상한 보장
운영 단순 (전역 컷) 유저별 처리
페이징 경계 유저마다 들쭉날쭉 페이지 정렬·고정

둘을 섞어 "최근 N개 또는 최근 M개월, 먼저 걸리는 쪽" 으로 갈 수도 있으나, 운영 단순성을 위해 보통 한쪽만 명확히 정한다.

관련 노트

참고