문제 — "하루"의 경계가 암묵적이다

daily-cap("하루 N건까지"), 랭킹 기준일, 일일 집계 — 전부 "하루"라는 버킷 경계가 필요하다. 그런데 그 경계가 어디인지 명시하지 않으면, 대개 두 기본값 중 하나로 조용히 떨어진다.

  • UTC 자정: datetime.utcnow().date() 또는 floor(ts / 86400)
  • 서버 로컬 자정: 서버 타임존에 따라 달라짐 (배포 환경마다 다를 수 있음)

둘 다 "사용자가 체감하는 하루"와 어긋난다. KST(UTC+9) 사용자에게 UTC 자정은 오전 9시다. 운영팀이 보는 하루, 사용자가 느끼는 하루, 코드가 세는 하루가 전부 다른 시각에 리셋된다.

자정 선점 — 경계 직전 항목이 다음 날을 먹는다

가장 사고가 잘 나는 지점은 경계 근처다.

하루 cap = 3, 경계 = UTC 자정
23:56 UTC  알림 1  → 오늘 버킷  (오늘 1건)
23:58 UTC  알림 2  → 오늘 버킷  (오늘 2건)
23:59 UTC  알림 3  → 오늘 버킷  (오늘 3건 = cap 도달)
00:00 UTC  알림 4  → 내일 버킷  (내일 1건)  ← 4분 만에 4건이지만 cap 미위반

사용자는 4분 안에 4건을 받았는데 daily-cap은 모두 통과한다. 오늘 버킷은 cap(3)까지 정확히 찼고, 4번째 알림은 자정을 막 넘긴 "내일" 버킷에 선점(preempt) 됐기 때문이다. 카운팅 로직은 한 건도 틀리지 않았는데, 경계가 사용자 의미와 어긋난 탓에 실제 체감은 "4분에 4건"이 된다.

이건 정렬·재시도·timeout 같은 분산 문제가 아니라 버킷 경계를 잘못 그은 문제다. 카운팅 로직이 아무리 정확해도, 경계가 사용자 의미와 어긋나면 cap은 의도대로 동작하지 않는다.

해결 — 의미 있는 운영 경계에 명시적으로 앵커

"하루"를 자정이 아니라 운영상 의미 있는 시각에 앵커한다. 예를 들어 알림이 오전에 시작되는 서비스라면, 사용자 타임존 기준 이른 아침을 하루 경계로 잡는다. 핵심은 경계가 "아무도 활동하지 않는 시각"에 오도록 골라, 경계 폭발이 실제 사용자에게 닿지 않게 하는 것이다.

(아래는 개념 전달용 일반 예시다. 특정 서비스의 코드가 아니다.)

from datetime import time, date, timedelta
from zoneinfo import ZoneInfo  # Python 3.9+ 표준 라이브러리

# 경계를 코드 상수로 박는다 — 자정이 아니라 운영 시작 시각
BUSINESS_TZ = ZoneInfo("Asia/Seoul")
DAY_ANCHOR = time(7, 0)  # 매일 07:00 KST에 새 "하루"가 시작

def business_day(ts) -> date:
    """경계 이전이면 전날로 귀속시킨 '비즈니스 기준일'을 돌려준다."""
    local = ts.astimezone(BUSINESS_TZ)
    if local.time() < DAY_ANCHOR:
        local -= timedelta(days=1)
    return local.date()

그리고 모든 비교를 이 기준일로 한다. cap 키, 집계 GROUP BY, 랭킹 기준일이 전부 business_day(ts)를 거치게 만든다. 일부만 자정 기준이고 일부만 앵커 기준이면 경계가 둘로 쪼개져 더 나쁘다.

key = f"cap:{user_id}:{business_day(now)}"  # date()가 아니라 business_day()

왜 상수로 박아야 하나

경계 시각을 환경 변수나 요청 타임존에서 동적으로 끌어오면, 배포·리전·서버 시간대에 따라 경계가 흔들린다. 경계가 흔들리면 같은 사건이 환경마다 다른 버킷에 들어가 디버깅이 불가능해진다.

  • 타임존을 IANA 이름으로 (Asia/Seoul), UTC+9 같은 오프셋 하드코딩 금지 — DST·서머타임 변경을 라이브러리가 처리한다 (KST는 DST가 없지만, 이 원칙은 DST 있는 타임존에서 사고를 막는다)
  • 앵커 시각을 명시 상수로 — 코드를 읽는 사람이 "하루가 언제 시작하는지" 한 줄로 안다
  • 단위 테스트로 경계를 고정 — 앵커 직전/직후 타임스탬프가 의도한 기준일로 가는지 검증. 이게 "자정 선점이 다시 들어왔는지"를 자동으로 잡는 결정론적 루프다

실제 사례 — 알림 봇의 daily-cap

개인 자동화(공개 레포)의 알림 봇에서 daily-cap 기준일을 KST 07:00 시작으로 앵커해 자정 선점을 막은 적이 있다(2026-06-10). UTC 자정 기준일 때는 KST 오전 9시에 카운터가 리셋돼, 이른 아침 알림과 전날 밤 알림이 같은 "하루"로 묶이지 않는 어긋남이 있었다.

floor(ts / 86400)(UTC 자정) → business_day(ts, anchor=07:00 KST)로 바꾼 것이 전부다. 로직은 한 함수만 건드렸고, 경계 직전/직후 타임스탬프에 대한 테스트가 회귀를 막는 장치가 됐다. 핵심은 단순 자정이 아니라 운영 시작 시각(07:00)에 맞춘 것 — 사람이 활동하지 않는 시각이 경계여야 경계 폭발이 사용자에게 닿지 않는다.

함정

  • 암묵적 UTC 자정: utcnow().date()는 KST 사용자에게 오전 9시 경계다. "하루"의 시작을 코드가 결정하게 두지 말고 명시한다.
  • 서버 로컬 시간 의존: 배포 환경 타임존에 따라 경계가 바뀐다. 같은 코드가 다른 버킷을 만든다.
  • 오프셋 하드코딩: UTC+9 직접 더하기는 DST 있는 타임존에서 깨진다. IANA 이름 + 표준 라이브러리로 (위 "왜 상수로 박아야 하나" 참조).
  • 경계 = 사용자 활동 피크: 앵커를 사람이 활발한 시각에 두면 경계 폭발이 그대로 사용자에게 노출된다. 한산한 시각으로 잡아야 선점이 사용자에게 닿지 않는다.
  • 경계 테스트 누락: 앵커 직전/직후 타임스탬프 테스트가 없으면 자정 선점 회귀를 못 잡는다. 이 한 쌍의 테스트가 결정론적 안전장치다.

핵심

"하루"는 자정이 아니다. 일일 카운트의 경계는 사용자·운영이 체감하는 의미 있는 시각에 명시적으로 앵커하고, 그 경계를 코드 상수로 박아 모든 비교를 한 기준으로 통일한다.

관련

/notes/frequency-capping-distributed — 고정 윈도우 자정 리셋의 경계 폭발과 슬라이딩 윈도우 /essays/same-defect-class-across-repos — 경계 가정 오류가 여러 봇에 반복되는 결함 클래스