작성일
GT PBX 개발기 6 — 15분 사전 집계로 통계 만들기
콜센터 지표를 표로 먼저 정하고, 15분 버킷 사전 집계(5분 증분 + 매일 04:10 KST 재집계)와 리포트·XLSX를 만든 M4. 리뷰에서 고친 증분 커서 10분 겹침·pending_from·하루씩 트랜잭션, 의존성 없는 XLSX 작성기, 바꾼 때부터 적용되는 지표 기준값을 정리했다.
GT PBX 개발기 시리즈 6/8편. 전체 목록은 gtpbx 태그에서 볼 수 있다.
같은 날 오후, 통계
M3(5편)를 병합한 직후인 10월 29일 12시 35분에 M4 통계 계약 초안을 커밋했다. 서버 집계는 13시 10분, 리뷰 반영은 13시 39분, main 병합은 14시 3분이었다. EC2 스테이징에는 14시 32분에 X4(7편)와 함께 배포하고 10월 28일치를 재집계했다.
완료 기준은 세 가지였다. 모의 데이터의 지표 계산이 손으로 계산한 기대값과 같을 것, 같은 기간의 리포트 숫자가 M3 월보드 당일 지표 및 queue_log 사후 집계와 같을 것, 1년치 데이터의 월간 리포트가 3초 안에 나올 것.
지표를 먼저 표로 정했다
코드보다 지표 정의 표를 먼저 계약에 넣었다. 일부만 옮긴다. T_sa는 단기 포기 기준(기본 5초), T_sl은 서비스레벨 기준(기본 20초)이다.
| 지표 | 정의 |
|---|---|
| 인입 | 자사 큐 행 수 (route가 own 또는 fallback) |
| 응답 | connected_at이 있는 행 |
| 단기 포기 | 포기 중 대기 < T_sa |
| 응답률 | 응답 ÷ (인입 − 단기 포기) |
| 서비스레벨 | 대기 ≤ T_sl인 응답 ÷ (인입 − 단기 포기) |
| 평균 응답 속도(ASA) | 응답의 대기 시간 평균 |
| AHT | (통화 시간 합 + 처리 중 시간 합) ÷ 응답 |
| 가동률 | (통화 + 처리 중) ÷ (로그인 − 처리 중을 뺀 이석) |
| 재호출 | 같은 장비가 직전 통화 종료 뒤 10분 안에 다시 인입 |
협력사 시간표에서 자사로 되돌린 통화(3편)는 자사 인입에 넣고 건수를 따로 센다. 장비 간 통화(device)와 외부 발신(outbound)은 큐 지표에서 빼고 통화 유형 표에만 보인다. 외부 발신은 같은 시각 다른 세션이 작업 중이었다. 그래서 모르는 route 값도 유형 표에 행으로 나오게 해서 X4가 없어도 동작하도록 했다.
비율은 버킷마다 계산한 비율을 평균내지 않고, 분자와 분모를 합산한 뒤 나눈다. 계산은 packages/shared의 순수 함수로 두고 M3의 당일 지표 계산(computeToday)도 이 함수를 부르게 바꿨다. 월보드와 리포트가 같은 함수를 쓰니 같은 기간이면 계산 방식 차이로 숫자가 어긋나지 않는다.
export function addCall(
counts: QueueCounts,
call: StatsCallInput,
thresholds: Pick<StatsThresholdValues, "shortAbandonSec" | "serviceLevelSec">,
): QueueCounts {
counts.offered += 1;
if (call.route === "fallback") counts.fallback += 1;
if (call.repeat) counts.repeatCalls += 1;
const wait = call.waitSec;
if (isAnswered(call)) {
counts.answered += 1;
counts.talkSum += call.talkSec ?? 0;
if (wait !== null) {
counts.waitSumAnswered += wait;
counts.waitCountAnswered += 1;
if (wait <= thresholds.serviceLevelSec) counts.answeredInSl += 1;
}
} else if (call.result === "abandoned") {
counts.abandoned += 1;
if (wait !== null && wait < thresholds.shortAbandonSec) counts.shortAbandoned += 1;
} else if (call.result === "other") {
counts.other += 1;
}
// (대기 평균·최대 누적 생략)
return counts;
}
상담원 로그인·이석·처리 중 시간은 별도 상태 이력 테이블을 만들지 않고 queue_log의 ADDMEMBER·PAUSE·UNPAUSE·REMOVEMEMBER를 상담원별로 훑어 구간을 만든다. 5편에서 웹 대행과 기능 코드가 같은 queue_log 모양을 남기게 한 이유가 이것이다.
15분 버킷
통화는 entered_at이 속한 15분 버킷에 센다. 버킷 경계는 UTC로 맞추는데, KST는 +9시간이라 15분 경계가 그대로 맞는다. 일·주(월요일 시작)·월은 KST 경계로 버킷을 합친다. 표는 큐×그룹, 상담원, 통화 유형별 15분 표와 장비별 일 표, 진행 위치를 담는 한 행짜리 상태 표다. 호출이 없는 버킷은 행을 만들지 않는다.
장비 그룹은 집계할 때의 현재 소속으로 정했다. 통화 이력의 그룹 필터와 같은 기준이다. 그룹을 옮기면 재집계 전까지 과거 버킷은 옛 그룹으로 남는다. 통화 당시 소속을 저장하는 방법도 있었지만 통화 요약 트리거를 다시 정의해야 해서 고르지 않았다.
5분 증분, 매일 04:10 재집계
집계는 웹 서버 안의 타이머가 돌린다.
- 5분마다 증분:
gtpbx_calls.updated_at이 커서보다 늦은 통화와 마지막으로 본 id 뒤의queue_log행이 걸친 버킷만 다시 계산한다. - 매일 04:10 KST: 전날 0~24시 전체를 다시 계산해 늦게 들어온 기록을 보정한다.
- PostgreSQL advisory lock 하나로 웹 프로세스와 CLI가 동시에 돌지 않게 한다.
- 버킷 범위마다 "원천 다시 읽기 → DELETE + INSERT → 진행 위치 저장"을 한 트랜잭션에 넣는다. 같은 범위를 몇 번 돌려도 결과가 같다.
처음 실행은 오늘만 계산한다. 과거는 gtpbx-admin stats-rebuild --from --to로 하루씩 채운다.
리뷰에서 고친 것
서버 구현 뒤 코드 리뷰에서 나온 지적을 13시 39분 커밋(805491a)에 반영했다. 셋을 적는다.
첫째, 커서 겹침이다. 처음에는 커서보다 60초 앞부터 겹쳐 읽었다. 그런데 updated_at은 트랜잭션 시작 시각(now())이다. 오래 걸린 트랜잭션이 늦게 커밋하면 그 행은 커서보다 앞선 시각으로 보이고, 겹침보다 오래 걸렸으면 영영 읽히지 않는다. 겹침을 10분으로 늘렸다. 겹친 행은 그 버킷을 한 번 더 계산할 뿐이다. 바뀐 버킷 목록과 다음 커서도 두 문장이 아니라 한 문장(같은 스냅샷)에서 구하게 바꿨다. 두 문장이면 그 사이에 커밋된 행이 커서만 올리고 버킷에서는 빠진다.
/** 증분: 이 시간보다 오래된 버킷은 다시 계산하지 않는다 (오래 멈춰 있었으면 전일 재집계·CLI 로) */
const INCREMENTAL_MAX_LOOKBACK_MS = 7 * DAY_MS;
/**
* 증분 커서 겹침: updated_at 은 트랜잭션 시작 시각(now())이라 오래 걸린 트랜잭션이 늦게 커밋하면 커서보다 앞선 값으로 보인다
* → 이만큼 겹쳐 읽는다 (겹친 행은 그 버킷을 한 번 더 계산할 뿐이다)
*/
const CURSOR_OVERLAP_MS = 10 * 60_000;
둘째, 7일 하한에 걸린 변경이다. 증분은 7일보다 오래된 버킷을 다시 계산하지 않는다. 처음 구현은 그런 변경을 그냥 버렸다. 이제 버린 버킷의 가장 이른 KST 날짜를 상태 표의 pending_from에 남기고, 전일 재집계가 그 날짜부터 그저께까지를 한 번에 최대 7일씩 하루 단위로 다시 계산한 뒤 비운다. 값은 /api/stats/status로도 보인다.
셋째, 긴 정지 뒤의 증분이다. 서버가 오래 멈췄다가 돌아오면 증분 범위가 커진다. 이걸 KST 하루씩 트랜잭션으로 나누고 하루마다 이벤트 루프에 양보하게 했다. 같은 커밋에서 상담원 시작 상태를 찾는 31일 lookback을 재귀 CTE와 queue_log 부분 인덱스로 바꿨고, stats-rebuild가 실패해도 처리한 범위와 오류를 감사 로그에 남기게 했다.
기준값은 바꾼 때부터
단기 포기·서비스레벨·월보드 경고 기준은 설정 화면에서 바꾼다. 바꾸면 그때부터 적용한다. 그래서 버킷 행마다 계산에 쓴 short_abandon_sec·sl_sec를 같이 저장한다. 기준값이 다른 버킷이 섞인 기간을 조회하면 화면에 "기간 중 기준값 변경" 안내가 뜬다.
구현하면서 하나를 더 정했다. 증분이나 전일 재집계가 이미 행이 있는 버킷을 다시 계산할 때는 그 버킷에 저장된 기준값을 그대로 쓴다. 새 기준을 과거에 소급하는 것은 CLI stats-rebuild뿐이다. 통합 시험에서 서비스레벨을 20초에서 10초로 바꾸면 다음 버킷부터 적용되고 월보드에도 반영되는 것을 확인했다.
의존성 없는 XLSX 작성기
리포트는 시트 7개(요약·추이·상담원·장비·그룹·협력사·유형)짜리 XLSX로 내려받는다. 런타임 의존성을 최소로 둔다는 M2의 원칙에 따라 exceljs를 넣지 않고 작성기를 직접 만들었다. node:zlib의 deflateRawSync로 압축하고 ZIP 로컬 헤더·중앙 디렉터리·EOCD를 직접 쓴다. ZIP64와 암호화는 쓰지 않는다. 통계 XLSX는 수 MB라 4GiB 한도에 닿지 않는다.
// 최소 ZIP 작성기 (XLSX 용, 의존성 없음). 항목마다 로컬 헤더 + deflate(node:zlib deflateRaw) 데이터, 끝에 중앙 디렉터리·EOCD.
// ZIP64·암호화·데이터 디스크립터는 쓰지 않는다 (항목·전체 크기 4GiB, 항목 수 65535 미만 — 통계 XLSX 는 수 MB).
// 형식: PKWARE APPNOTE.TXT 4.3.7(로컬 헤더)·4.3.12(중앙 디렉터리)·4.3.16(EOCD).
import { deflateRawSync } from "node:zlib";
export function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (let i = 0; i < data.length; i++) crc = (CRC_TABLE[(crc ^ (data[i] ?? 0)) & 0xff] ?? 0) ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
직접 만든 파일 형식은 직접 만든 코드로만 검증하면 믿기 어렵다. 그래서 시험에서 작성기와 따로 만든 ZIP 리더로 풀고, unzip -t로 확인하고, 시트 XML을 파싱해 문자열·숫자·날짜 셀과 XML 이스케이프, 1만 행을 확인했다. openpyxl로 열어 보는 시험도 넣었지만 개발 맥의 기본 python3에 openpyxl이 없어 건너뛰었고, 임시 venv의 openpyxl 3.1.5로 따로 열어 셀 값을 확인했다.
그래프는 ECharts를 썼다. 통계 페이지 청크에만 들어가게 지연 로딩해서 StatsPage 청크가 gzip 189kB, 첫 화면 index는 gzip 315kB였다.
숫자
성능 시험은 맥 docker의 PostgreSQL 16에서 모의 데이터 1년치(통화 50만 건)로 돌렸다. 실제 콜센터 데이터가 아니다.
| 항목 | 결과 |
|---|---|
| 1년 전체 재집계 | 30.3초 (하루 83ms, 버킷 행 1,229,793) |
| 지난달 요약 (일 단위) | 54ms |
| 연간 요약 | 81ms |
| 상담원·장비 표 | 12ms |
| XLSX 생성 | 44ms |
목표는 3초였다. OrbStack VM 통합 시험 tests/m4/run.sh --with-e2e는 142/142였다. 오늘 리포트 합계가 gtpbx_calls SQL 집계, M3 월보드의 당일 지표와 같았고, CLI 재집계 두 번과 5분 증분 뒤의 버킷 덤프가 같았다. 이 시험은 리뷰 반영 전 커밋으로 먼저 돌렸고, 병합 뒤 main으로 VM에서 다시 돌려 142/0을 확인했다.
남은 것
- 실제 콜센터 데이터 규모에서 집계 시간과 리포트 응답.
- 리포트 메일 자동 발송은 M5 알림과 함께 하기로 미뤘다.
- 개발 VM의
gtpbx-admin래퍼가 설치 당시 체크아웃 경로를 가리키는 문제가 있었다. EC2에서도 래퍼가 배포 경로를 가리키는지 확인이 필요하다.