발행일

관리자 방문 통계를 한 달 새 세 번 다시 짜며 고친 집계 버그 둘 — 한 번도 안 걸리던 제외 조건과 UTC 날짜 칸

같은 통계 화면을 한 달 새 세 번 갈아엎었고, 남은 건 집계 규칙이었다

관리자 메뉴에 방문 통계 화면이 있습니다. 이 화면을 한 달 남짓 동안 세 번 다시 짰어요.

날짜커밋무엇을
9월 15일a6bdc2b5탭 5개 → 한 화면, 방문 집계 버그 두 개 수정
10월 12일98da3269같은 회사의 다른 시스템에 있는 이용 통계 화면과 같은 구성으로
10월 20일0d7c63e7서버 자원 모니터링과 한 화면으로 합침

세 번 사이에 이어진 건 화면이 아니라 무엇을 방문으로 셀지에 대한 규칙과 그 회귀 사례였습니다. 다만 10월의 집계 코드는 다른 시스템에서 옮겨 온 것이라, 9월 코드가 그대로 이어진 건 아니에요. 이 글은 그 규칙이 어떻게 정해졌는지에 대한 기록입니다.

1차(9월 15일) — 숫자부터 의심했다

옛 화면은 탭 다섯 개(개요·사용자별·성능 분석·모델별·페이지)였습니다. 레이아웃을 손보려고 열었다가 숫자부터 맞지 않는다는 걸 알았어요.

한 번도 걸리지 않던 제외 조건

"가장 인기 있는 페이지" 1위가 /__debug__/history_sidebar/였습니다. 개발용 디버그 툴바가 부르는 주소예요. 수집 미들웨어에는 이걸 빼는 조건이 분명히 있었습니다.

# __로 시작하는 경로 제외 (Django debug 관련 URL들)
if path.startswith('__'):
    return False

request.path는 항상 /로 시작합니다. 그래서 '__'로 시작하는 경로는 존재할 수 없고, 이 조건은 처음부터 한 번도 참이 된 적이 없었습니다. 같은 조건이 미들웨어·집계 쿼리·정리 명령(clean_debug_urls) 세 곳에 똑같이 복사돼 있어서, 정리 명령도 같은 조건으로 찾으니 늘 0건을 돌려줬을 거예요. 세 곳 모두 '/__'로 고쳤습니다. 이미 쌓인 행은 집계에서 거르고, 지우려면 정리 명령을 --dry-run으로 먼저 돌려 건수를 보도록 문서에 적었어요.

UTC 날짜로 만든 칸

일별 방문 추이는 이렇게 만들고 있었습니다.

start_date = timezone.now() - timedelta(days=days-1)
start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0)
# ... TruncDate('timestamp') 로 날짜별 묶음
for i in range(days):
    date = timezone.now() - timedelta(days=i)
    date_str = date.strftime('%Y-%m-%d')

설정은 TIME_ZONE = "Asia/Seoul", USE_TZ = True입니다. TruncDate는 현재 시간대(KST)로 날짜를 묶는데, timezone.now()는 UTC를 돌려줘요. 그래서 두 군데가 어긋났습니다.

  • 범위 시작: UTC 자정으로 자르니 첫날이 KST 오전 9시부터 셌습니다.
  • 날짜 칸 이름: 칸 이름을 UTC 날짜로 만드니, KST 새벽 0~9시에 화면을 열면 UTC로는 아직 어제라 오늘 칸 자체가 없었습니다. KST로 묶인 오늘 방문은 짝지을 칸이 없어 추이에서 사라졌어요.

여기에 요약 숫자는 now() - days(지금부터 거꾸로 N×24시간)로 따로 세고 있어서, 같은 "최근 7일"이 칸마다 다른 범위였습니다. 범위를 _period_visits(days) 한 곳에서 KST 자정 기준으로 만들고 요약·추이·목록이 모두 거기서 받게 바꿨어요.

start_date = timezone.localdate() - timedelta(days=days - 1)
start = timezone.make_aware(datetime.combine(start_date, time.min))
return self.model.objects.filter(timestamp__gte=start).exclude(path__startswith='/__')

테스트는 규칙 하나에 행 하나씩 심었습니다. KST 0시 30분 방문 두 건, 봇이 두드린 /wp-login.php 404 한 건, 디버그 경로 한 건, 7일 전 방문 한 건(범위 밖)을 넣고 "요약은 방문 3·방문자 3·오류 1, 오늘 칸은 3, 많이 본 페이지에는 정상 경로 하나"를 확인합니다. timestamp가 auto_now_add라 생성 인자로 시각을 못 박아서, 만든 뒤 update()로 덮어썼어요.

진짜처럼 보이던 가짜 숫자

숫자를 따라가다 보니 지워야 할 것도 나왔습니다.

  • 성능 분석 탭의 "95% 응답시간" 칸은 실측이 아니라 평균×1.5였습니다.
  • 불러오기에 실패하면 '조직 A', 2025-08 같은 더미 데이터로 차트를 그렸습니다. 실패가 정상 화면처럼 보였어요.
  • 사용자별 통계는 로그인한 방문만 셌는데, 이 사이트는 대부분 로그인 없이 봅니다. 사실상 관리자 본인의 방문 순위였어요.

셋 다 지웠고, JSON API 두 개를 없애 서버 렌더로 바꾸면서 더미 차트가 그려질 경로 자체가 사라졌습니다. 이때 한계로 남긴 것도 있었어요. 화면이 뒤에서 부르는 요청(트리 펼치기, 시각화 데이터 등)도 방문으로 센다는 점인데, 걸러내려면 수집 규칙을 정해야 해서 손대지 않았습니다.

2차(10월 12일) — 수집기를 옮길까, 조회할 때 거를까

10월에는 같은 회사의 다른 시스템에 있는 "이용 통계" 화면과 구성·문구를 맞췄습니다. 두 시스템의 화면이 다르게 보인다는 의견이 8월부터 있었고, 그 뒤로 화면을 맞추는 작업이 이어지고 있었어요. 이 화면도 그 연장이었습니다.

화면을 맞추는 건 어렵지 않았는데, 숫자를 맞추는 방법에서 선택이 필요했어요. 상대 시스템은 화면 요청만 골라 전용 테이블 두 개에 쌓는 수집기가 따로 있고, 방문자를 익명 세션으로 셉니다. 이쪽은 모든 요청이 쌓이는 PageVisit 한 테이블뿐이었고요.

수집기를 그대로 옮겨 보니 걸리는 게 있었습니다.

  • 미들웨어 순서: 상대 시스템처럼 인증 미들웨어 바로 뒤에 두면 검색어 기록의 30분 중복 억제가 깨졌습니다. 검색어 기록은 첫 검색을 IP와 User-Agent로, 세션이 생긴 다음 검색을 세션 키로 묶는데, 기존 테스트(test_repeat_within_window_is_suppressed)가 이 억제가 깨진 걸 잡았어요. 수집 미들웨어보다 안쪽에 두면 맞았고, 수집기를 켠 쪽과 뺀 쪽으로 전체 테스트(2,759개)를 비교했을 때 다른 회귀는 없었습니다.
  • 익명 방문자 세션: 통계를 위해 익명 방문자에게 세션을 만들어야 합니다. "통계 때문에 익명 방문자에게 세션을 만들지 않는다"는, 검색어 기록 쪽에 이미 적어 둔 원칙을 뒤집는 일이에요.
  • 세션 테이블 증가: 세션 만료가 하루라 같은 사람도 다음 날엔 새 세션이 되는데, 만료 세션을 지우는 주기 작업이 없습니다.
  • 소급 불가: 새 테이블은 배포 이후만 채웁니다. 기존 PageVisit에는 세션이 없어 옮겨 채울 수 없어요.

그래서, 그리고 이번 작업에서는 백엔드 수집 구조를 건드리지 않기로 해서, 수집기는 들이지 않고 상대 시스템이 수집할 때 거르는 규칙을 이쪽에서는 조회할 때 걸기로 했습니다. 집계 함수와 제외 목록은 상대 시스템 것을 그대로 가져왔고, 제가 바꾼 건 그 규칙을 수집이 아니라 조회 쪽에 거는 자리와 이쪽 내부 API 접두어 둘입니다.

def screen_visits(start_at, end_at):
    visits = PageVisit.objects.filter(
        timestamp__gte=start_at,
        timestamp__lt=end_at,
        method__in=("GET", "HEAD"),
        status_code__gte=200,
        status_code__lt=300,
    ).exclude(path__in=EXCLUDED_PATHS)
    for prefix in EXCLUDED_PATH_PREFIXES:
        visits = visits.exclude(path__startswith=prefix)
    for marker in BOT_USER_AGENT_MARKERS:
        visits = visits.exclude(user_agent__icontains=marker)
    return visits

관리자 화면·API·정적 파일·디버그 경로를 접두어로 빼고, 이쪽에만 있는 내부 API 접두 두 개(트리·시각화 데이터)를 더 뺐습니다. 9월에 한계로 남겼던 "화면 뒤 요청이 방문으로 섞인다"는 문제가 여기서 대부분 풀렸어요. User-Agent에 headlesschrome이 있으면 봇으로 빼는데, Playwright 기본 브라우저도 HeadlessChrome이라 e2e가 돌린 요청이 통계에 잡히지 않는 효과도 있었습니다.

대가는 방문자 기준이 다르다는 점입니다. 상대 시스템은 익명 세션, 이쪽은 IP라서 같은 사무실 사람들은 한 명으로 셀 수 있어요. 이 차이와, 세션 기준으로 바꾸려면 필요한 작업(테이블·마이그레이션·미들웨어 위치·세션 정리)을 문서에 표로 남겨 백엔드에 넘길 수 있게 했습니다.

상대 화면에 없는 칸을 옮기는 방법

상대 화면에는 업무 처리 현황(신청·시작·완료·성공·실패) 칸이 있습니다. 이쪽에서는 그 자리에 표준데이터·OID 등록 운영 현황을 넣었는데, 신청 테이블에는 단계별 시각 칸이 없어서 그대로 셀 수 없었어요. 대신 처리 이력이 동작마다 실행 시각을 남기므로 기간 안에 실행된 동작을 셌습니다.

여기서 하나 걸린 게 있었습니다. 이 시스템에서 반려는 "반려" 상태로 가지 않고 신청자의 기록 단계로 되돌아갑니다. 그래서 신청의 현재 상태로 반려를 세면 늘 0이에요. 반려는 기록 단계로 되돌리는 반려 동작의 수로 셉니다.

9월에 넣은 걸 10월에 뺐다

맞추면서 9월에 넣었거나 그대로 둔 것들을 뺐습니다. 요약의 "오류 응답" 칸, 기간 "오늘"(9월에 "최근 1일"을 바꾼 것)·"최근 90일"(원래 있던 것), 많이 본 페이지 표의 평균 응답과 칸 안 막대예요. 상대 화면에 없거나 구성이 달라서였습니다. 집계가 2xx만 세게 됐으니 오류 응답 칸은 이 화면에서 셀 근거도 사라졌고요. 한 달 전 판단을 뒤집은 셈입니다. 9월에 만든 집계 메서드와 그 테스트도 이때 지웠습니다. 새 집계 함수는 상대 시스템 코드라 KST 날짜와 디버그 경로 제외가 그쪽에도 이미 있었고, 저는 9월에 잡은 "KST 0시 30분 방문은 오늘" 사례를 새 테스트에 다시 심어 같은 버그가 돌아오지 못하게 했어요. 디버그 경로는 제외 접두어 목록에는 있지만 새 테스트의 제외 사례에는 빠져 있습니다.

3차(10월 20일) — 모니터링과 한 화면으로

세 번째는 서버 자원 모니터링 화면과 이용 통계를 한 주소(/web-admin/system/monitoring)로 합친 것입니다. 위쪽은 5초마다 갱신되는 서버 자원, 아래쪽은 접속 현황이에요. 옛 두 주소는 통합 화면으로 리다이렉트하고 ?period=는 넘겨줍니다. 요약 첫 칸은 "현재 접속자"(만료 전 로그인 세션의 계정 수, 같은 계정의 세션 여럿은 한 명)로 바꾸고, 그 세션 판독을 접속자 목록 화면과 같은 함수로 묶었어요. 문서의 날짜 메모는 10월 14일, 커밋은 20일입니다. 왜 합쳤는지는 커밋에도 문서에도 적지 않았습니다.

합치면서 10월 12일에 상대 시스템 색에 맞췄던 청회색·남색 톤은 다른 관리 화면과 같은 무채색 토큰으로 돌렸습니다. 한 화면 안에서 위아래 톤이 달라지기 때문이에요. 8일 만에(문서 메모 기준으로는 이틀 만에) "최대한 같게"에서 한 걸음 물러난 겁니다.

같이 고친 것도 있었습니다. 서버 자원 시계열 API가 주소에 들어 있는 조회 기간(분)을 무시하고 쿼리스트링 기본값 30분을 쓰고 있었고, 숫자가 아닌 값이 오면 500이 났어요. 주소의 값을 읽고 1~60분 밖이거나 숫자가 아니면 400을 내도록 바꿨고, 응답도 화면이 실제로 쓰는 16개 칸으로 줄였습니다. 이 조회 기간 규칙도 회귀 테스트로 묶었습니다.

한계

  • 방문자는 IP 기준 근사입니다. 같은 사무실은 한 명, 프록시 뒤면 X-Forwarded-For 첫 값에 기댑니다.
  • 조회할 때 거르는 방식은 기간이 길수록 무겁습니다. PageVisit은 관리자 화면과 API 호출까지 모든 요청을 쌓으니, 기간은 상대 화면에 맞춰 7·30일만 두었어요. 더 늘리려면 운영 DB에서 조회 시간부터 재야 합니다.
  • 접두어 두 개로는 다 못 거릅니다. 화면 안에서 부르는 GET이 다른 경로에도 흩어져 있어서 일부는 아직 방문으로 섞일 수 있어요.
  • 화면이 세 번 바뀐 건 결정이 세 번 있었다는 뜻이기도 합니다. 9월에 넣고 10월에 뺀 칸들처럼, 화면 구성은 그때그때의 기준을 따라 흔들렸어요. 검색어 집계가 같은 수집기에 기대고 있다는 점도 그 문서에 같이 적었어요(검색어 빈도로 워드클라우드를 개편한 글).