주차장 정산기·인터폰이 인터넷을 거쳐 거는 SIP 호출을 24시간 받는 콜센터용 Asterisk PBX와, 설정·실시간 현황·통계·통화 이력·녹취를 한곳에서 다루는 자체 웹앱. 기존 상용 PBX를 단계적으로 대체하려고 만들었고, 지금은 EC2 스테이징에서 검증하고 있다.
기간
2026.10 –
역할
단독 설계·개발·배포
TypeScript
Asterisk 22 (PJSIP · Queue · AMI)
PostgreSQL 16 (ODBC Realtime)
Node.js 24 · Express 5
React 19 · Mantine 9
SSE
Gemini 함수 호출
systemd · nftables
주차장 정산기·인터폰이 인터넷을 거쳐 거는 SIP 호출을 24시간 받는 콜센터용 PBX다. 오픈소스 Asterisk 22 위에 설정·실시간 현황·통계·통화 이력·녹취를 다루는 웹앱을 직접 얹었고, 회사 콜센터가 쓰는 기존 상용 PBX를 단계적으로 대체하는 것이 목표다.
2026년 10월 23일 첫 커밋부터 10월 30일까지 혼자 설계·개발·배포했다. 구현은 단계마다 계약 문서(범위·DB·API·화면·시험·분담)를 먼저 쓰고, AI 코딩 에이전트 여러 개에 나눠 맡긴 뒤 검토·통합했다. 지금은 EC2 스테이징 서버에 배포해 검증하는 단계다. 현장 전환은 하지 않았고, 실제 상담원 전화기·현장 장비·통신사 트렁크로 하는 검증이 남아 있다.
참고
마스킹 안내. 회사·협력사·고객사·사이트 이름, 레거시 PBX 제품 이름, 실제 장비·내선 번호, 계정 이름, 서버 주소와 인스턴스 정보, 비밀번호 값은 싣지 않았다. 수치는 GT PBX 저장소의 단계별 계약 문서 §구현 결과, docs/TODO.md, 부하 시험 문서, AI 평가 결과 파일에서 옮겼고 측정 환경(OrbStack 개발 VM, EC2 t3.medium 등)과 날짜를 함께 적었다. 개발 과정은 GT PBX 개발기 8편에 나눠 썼다.
항목
내용
서비스
주차장 현장 장비 호출 수신 · 상담원 큐 분배 · 자동 녹취 · 상담원 외부 발신 · 운영 웹앱
사용자
회사 콜센터 상담원(약 20석), 슈퍼바이저·관리자(웹), 일부 사이트의 협력사 상담원
역할
단독 설계·개발·배포. 단계별 계약 문서를 쓰고 AI 코딩 에이전트 여러 개에 구현을 나눠 맡긴 뒤 감독·검토·통합
기간
2026-10-23 첫 커밋 → 10-26 M1·M2 → 10-29 M3·M4·X2·X4 → 10-30 AI 설정 도우미·최근 커밋 (커밋 207개, 그중 10-29 하루 156개)
상태
EC2 스테이징(t3.medium) 배포·검증 중, 현장 전환 전
규모
REST API 라우트 93개(README 기준), DB 마이그레이션 20개(00010020), AI 도우미 도구 40개, 결정 기록 D1D31
코드
서버 약 1.9만 줄 + 단위·DB 통합 시험 약 1.3만 줄, 웹 약 1.9만 줄 + 시험 약 5.7천 줄, 공유 패키지 약 1.9천 줄, SQL 마이그레이션 약 3.1천 줄 (wc -l, 마지막 커밋 149a8bd 기준)
시험
단위(shared·서버·웹 vitest), DB 통합(PostgreSQL 16 컨테이너), VM 통합 러너 10개(m1~m4, x1·x2·x5, rec, devpw, assistant), 음성 판정 voice, EC2 부하 시험 tests/load
상담원 context에서만 발신, prefix 기본 거부, 판정이 실패하면 막는다(fail-closed), 동시 발신 제한, 거부 시도도 기록
자연어로 설정을 바꾸게 하고 싶다
모델이 대상을 잘못 고르거나 메모에 섞인 지시(인젝션)에 넘어가면 설정이 잘못 바뀐다
모델에게 쓰기 도구를 주지 않는다. AI는 변경안만 만들고, 반영은 사람이 [적용]을 눌러 서버가 한다
1.2 운영 조건 (결정 기록 docs/PLAN.md)
인입: 현장 장비 호출뿐이다. 일반 고객 대표번호와 IVR은 없다 (D2·D13).
응대: 자사 상담원 약 20석이 단일 큐 field를 함께 받는다. 24시간 운영이고, 응답이 늦어도 다른 곳으로 넘기지 않고 계속 대기한다 (D1·D13·D20).
예외: 협력사 시간표가 있는 사이트는 정해진 시간대에 협력사 큐로 간다. 협력사 상담원이 0명이면 자사 큐로 되돌린다 (D27).
민원 처리: 상담원은 사내 원격 지원 도구(RustDesk 팝업)로 사이트 PC에 접속해 처리한다. 새 웹앱은 이 팝업을 대체하지 않는다 (D12·D21).
녹취와 설치: 녹취는 1년 보관·WAV 원본(D9). 사내 서버 1대 + NAS 백업(D8), Ubuntu 24.04에 컨테이너 없이 네이티브 설치(D22).
2. 만든 것
구성요소
내용
PBX 코어 (M1)
Asterisk 22.11.0 소스 빌드(sha256 고정), PJSIP·app_queue·MixMonitor, ODBC Realtime, from-field·from-agent·from-partner·from-trunk context, 녹음 마무리 스크립트, CDR·CEL·queue_log 적재, watchdog, nftables·fail2ban
웹앱 골격 (M2)
로그인·역할 4개·CSRF·scrypt, PBX 설정 화면, 통화 이력·녹취 재생·감사 로그
실시간 (M3)
AMI 2연결, 이벤트 정규화·상태·스냅샷, SSE, 월보드와 TV 모드, 상담원 기능 코드·내 상태, 장비 마지막 등록 시각
통계 (M4)
15분 사전 집계(5분 증분 + 매일 04:10 KST 재집계), 큐·상담원·장비·그룹·협력사·유형 지표, 지표 기준값, 의존성 없는 XLSX 작성기
레거시 이관 (X1·X5·D28~D30)
장비 그룹·위치, 레거시 단말 목록 CSV 일괄 등록(미리보기·CP949), 협력사 시간표와 부재 되돌림, 녹취 켬/끔, 공통 기본 SIP 비밀번호
장비 간 통화 (X2)
같은 그룹 장비끼리 직접 통화·녹음, 통화 이력 "장비 간" 유형
외부 발신 (X4)
트렁크 템플릿 1개, 발신 규칙·prefix 허용/차단, 동시 발신 제한, 녹음, 통화 이력 "외부 발신" 유형
AI 설정 도우미 (D31)
Gemini 함수 호출, 도구 40개, 서버가 매기는 위험 등급, 변경안 카드와 [적용], 사용량 탭과 월 상한
배포·운영
멱등 설치·배포 스크립트, systemd unit, 방화벽 적용 스크립트, chrony, logrotate, 관리자 CLI gtpbx-admin
시험 도구
SIPp 시나리오와 VM 통합 러너, TTS 음성을 흘려 녹취 채널을 판정하는 tests/voice, EC2 부하 시험 제어 서버, AI 평가 세트
3. 시스템 구성
flowchart LR
Field["주차장 정산기·인터폰<br/>(인터넷, 유동 IP)"] -->|SIP 등록·호출| AST
Phones["상담원 IP 전화기"] -->|SIP| AST
PartnerPh["협력사 상담원 전화기"] -->|SIP, 받기 전용| AST
AST["Asterisk 22 LTS<br/>PJSIP · Queue · MixMonitor"] <-->|SIP/RTP| Trunk["통신사 SIP 트렁크<br/>(외부 발신 전용)"]
AST -->|"ODBC: Realtime · CDR · CEL · queue_log · func_odbc"| DB[("PostgreSQL 16")]
AST -->|스테레오 WAV| REC[("녹취 디렉터리")]
AST <-->|"AMI 2연결: 명령 · 이벤트"| API["gtpbx-web<br/>Node.js 24 + Express 5"]
API --> DB
API --> REC
API -->|"REST + SSE, HTTPS"| WEB["웹앱<br/>React 19 + Mantine 9"]
API -->|"함수 호출 (선택)"| GEM["Gemini API"]
WD["watchdog<br/>(systemd timer 40초)"] -.->|"프로세스 · CLI · PJSIP 점검"| AST
gtpbx-web 한 프로세스가 REST API, 정적 웹, AMI 클라이언트, 실시간 수집기, 통계 배치, AI 도우미 정리 작업을 함께 돌린다. 웹이 멈춰도 통화와 녹음은 Asterisk만으로 계속된다.
Asterisk와 DB 사이는 res_odbc + psqlODBC 하나로 Realtime·CDR·CEL·queue_log·func_odbc를 모두 처리한다.
다이얼플랜과 .conf는 저장소 asterisk/가 원본이다. 운영자가 바꾸는 값(차단·라우팅·녹취 사용·장비 간 통화·외부 발신 판정)만 func_odbc로 DB에서 조회한다. Asterisk 재시작이 필요한 설정은 웹에서 바꾸지 않는다.
API 경로 표는 공유 패키지 한 곳(packages/shared의 API_ROUTES)에 있고 서버 핸들러가 1:1로 대응한다. 라우트를 빠뜨리면 컴파일 오류가 난다.
시각은 UTC로 저장하고 KST로 보여 준다. 서버 시각은 chrony로 맞춘다.
4. 통화 흐름
4.1 현장 장비 호출
flowchart TD
F["현장 장비 호출 (from-field)"] --> B{"차단 중?"}
B -->|예| X["거절"]
B -->|아니오| P{"같은 그룹 장비 번호? (X2)"}
P -->|예| D["그 장비로 직접 Dial · 녹음"]
P -->|아니오| R{"그룹 협력사 시간대? (X5)"}
R -->|예| QP["협력사 큐<br/>고지·녹음 없음"]
QP -.->|"협력사 상담원 0명 → 기록"| N
R -->|아니오| N{"녹취 사용? 장비 → 그룹 (D28)"}
N -->|켬| G["녹음 고지 → MixMonitor"] --> QF["field 큐<br/>(ringall, 대기 제한 없음)"]
N -->|끔| QF
QF --> A["로그인한 자사 상담원 전원 동시 울림"]
장비 context(from-field)는 무엇을 누르든 위 경로로만 간다. 외부 발신이나 상담원 내선으로 나갈 길이 없다.
상담원 context(from-agent)는 내선 간 호출, 기능 코드, 외부 발신을 쓴다. 협력사 내선(from-partner)은 받기만 하고, 트렁크 수신(from-trunk)은 모두 거절한다.
상담원 기능 코드: *11 큐 로그인, *12 로그아웃, *20 대기 복귀, *21 처리 중, *22 휴식, *23 식사, *24 교육.
녹음은 날짜별 디렉터리에 스테레오 WAV(왼쪽 장비, 오른쪽 상담원)로 남고 통화 요약 gtpbx_calls에 연결된다.
4.2 상담원 외부 발신 (X4)
flowchart TD
S["상담원이 숫자 번호 발신 (from-agent)"] --> J["func_odbc GTPBX_OUTBOUND_ROUTE<br/>(상담원, 번호)"]
J --> R1{"발신 규칙 패턴에 맞나"}
R1 -->|아니오| E404["404 · NOTICE 로그만<br/>(내부 번호 오입력, 통화 이력 행 없음)"]
R1 -->|예| R2{"상담원 내선 · 외부 발신 켬 · prefix 허용"}
R2 -->|아니오| E403["403 · failed 행 + 사유"]
R2 -->|예| V{"결과가 allow 모양 그대로인가"}
V -->|"아니오 (빈 값 · ODBC 오류 · 형식 오류)"| E403
V -->|예| T{"트렁크 있음 · 동시 발신 한도 안"}
T -->|아니오| E503["503 · TRUNK_DOWN / LIMIT 행"]
T -->|예| OK["트렁크로 Dial · 설정대로 녹음"]
판정 순서는 규칙 → 상담원 → 설정 → prefix다. prefix는 번호에 맞는 가장 긴 것이 정하고, 같은 길이면 차단이 이기며, 아무것도 맞지 않으면 거부한다. 동시 발신 수는 기본 4(1~50)이고 울리는 중인 채널도 센다.
5. 웹 화면과 권한
역할
할 수 있는 일
세션
관리자 admin
전부(설정 변경, 계정 관리, 감사 로그, 녹취 다운로드, AI 설정·사용량)
8시간
슈퍼바이저 supervisor
실시간 현황, 통계, 통화 이력·내보내기, 녹취 재생, 장비 조회, 상담원 대행 로그인·로그아웃, AI 도우미 조회
8시간
상담원 agent
본인 통화 이력·녹취, 내 상태(연결 내선이 있을 때)
12시간
조회자 viewer
실시간 현황(월보드 TV)만
8시간
메뉴
화면
통화
실시간 현황(대기 호출·최장 대기·상담원 상태·당일 지표, TV 표시 모드), 통계(기간·근무조별 요약, 추이, 상담원·장비·그룹·협력사·유형 표, XLSX), 통화 이력(조건 검색, 상세 타임라인, 녹취 재생 ±5초·배속·A–B 반복, CSV), 내 상태
PBX 설정
장비 계정(생성·CSV 일괄 등록·정렬·내보내기·일시 차단·비밀번호 변경·녹취 설정), 장비 그룹, 협력사·시간표, 상담원 내선, 큐, 안내 멘트(업로드 → 8kHz 변환·버전·미리 듣기), 근무조, 외부 발신, 지표 기준값, 변경 이력(이전 값 비교·되돌리기·다시 만들기), AI 도우미 설정
관리
계정 관리, 감사 로그, AI 사용량
모든 쓰기 요청은 CSRF 토큰(x-csrf-token)이 필요하다. 웹 비밀번호는 scrypt, 세션은 HttpOnly·Secure·SameSite=Strict 쿠키(DB에는 해시)이고, 로그인을 15분에 5회 실패하면 잠근다.
설정 변경은 변경 이력과 감사 로그에 남고, 비밀번호 값은 남기지 않는다. 감사 로그는 DB 트리거로 수정·삭제를 막는다(append-only). 녹취 다운로드는 관리자만, 사유를 입력해야 한다.
화면 오른쪽에는 AI 도우미 패널이 있다(기능이 켜져 있고 권한이 있을 때).
6. 기술적 도전과 해결
각 항목은 저장소의 계약 문서·커밋·시험 결과가 뒷받침하는 범위까지만 적었다.
6.1 설정을 reload 없이 반영하고, 확인이 안 되면 되돌린다 (M2, 2026-10-26)
해결: 상담원·협력사 내선, 장비 계정(PJSIP), 큐는 Sorcery/ODBC Realtime으로 DB에서 읽는다. 웹은 DB 트랜잭션을 커밋한 뒤 AMI PJSIPShowEndpoint·QueueStatus로 반영됐는지만 확인하고, 확인에 실패하면 보상 변경을 하고 503을 돌려준다. AMI Reload는 대기 음악 교체에만 쓴다.
// 반영 확인 (Realtime 이라 바로 보여야 한다). 하나라도 안 되면 전부 되돌린다.
let ok = ctx.deps.pbx.connected;
for (let i = 0; ok && i < results.out.length; i += 10) {
const batch = results.out.slice(i, i + 10);
const checks = await Promise.all(batch.map((item) => confirmEndpoint(ctx.deps.pbx, item.id, true)));
ok = checks.every(Boolean);
}
if (!ok) {
await compensateCreated(db, actor, ctx.ip, results);
throw pbxUnavailable();
}
순서를 바꾼 경우: 비밀번호 재발급과 삭제는 웹 DB 역할이 이전 해시를 읽을 수 없어 되돌릴 수 없다. 그래서 이 둘은 DB 존재 확인 → AMI 확인 → 변경 순서로 하고, 사후 확인 실패는 경고만 남긴다.
구현 중 발견: Set(CALLERID(all)=...)는 <> 안 번호의 -·.을 지운다. 장비 ID m2-1이 queue_log에 m21로 기록됐다. 다이얼플랜은 번호와 이름을 따로 넣고, 통화 요약은 CDR이 들어오면 장비 칸을 바로잡도록 했다(0008).
검증: OrbStack VM에서 tests/m2/run.sh --with-e2e 231/231, M1 회귀 55/55, DB 통합 51/51(2026-10-26, M2-CONTRACT §10).
6.2 레거시 이관: 옮길 것과 옮기지 않을 것 (X1·X5·D23~D30, 2026-10-26 ~ 10-29)
옮기지 않은 것: 기존 상용 PBX의 사이트별 링 그룹·일정·휴일 라우팅(D23). 모든 사이트를 단일 큐로 받는 원칙을 유지했다. 일정 라우팅 중 협력사 응대만 D27로 일부 번복했다.
그대로 가져온 것: 장비 계정 번호는 레거시 내선번호를 그대로 쓴다(D25). 장비 그룹 개념은 X1로 옮겼다.
CSV 일괄 등록 (X1): 첫 줄에 아는 머리글이 있으면 머리글로 보고, 비밀번호 열은 파싱 단계에서 값을 버린다. 인코딩은 UTF-8 → CP949(TextDecoder("euc-kr", { fatal: true })) 순서로 판정한다. 합성 CP949 300행 fixture로 시험했고, 일괄 등록 뒤 변경 이력·감사 로그·서비스 로그에 레거시 비밀번호 값이 없음을 확인했다. tests/x1 86/86.
협력사 시간표 (X5): 그룹에 협력사와 요일별 응대 시간대를 지정한다. 협력사 상담원이 0명이면 큐 진입 자체가 비고(JOINEMPTY), 다이얼플랜이 GTPBXFALLBACK 이벤트를 남기고 자사 field 큐로 되돌린다. 시험에서 ENTERQUEUE → EXITEMPTY → GTPBXFALLBACK → ENTERQUEUE → CONNECT 순서와 대기 시간 합산을 확인했다. tests/x5 84/84. 시간표 편집기의 독립 리뷰 지적 2건은 5b39434에서 고쳤다.
녹취 켬/끔 (D28): 그룹은 켬/끔, 장비는 따름/켬/끔이고 장비 설정이 우선한다. 끈 호출은 녹음 고지도 틀지 않는다. 끈 호출 표시는 CDR 사용자 칸 대신 queue_log 사용자 이벤트 GTPBXNOREC로 했다. CDR은 통화가 끝나야 오지만 이 이벤트는 통화 중에도 바로 보이기 때문이다. tests/rec 85/0.
공통 기본 SIP 비밀번호 (D29·D30): 사용자 지시로 상담원 내선과 장비 계정을 무작위 값 대신 공통 기본값 하나로 만들고, 설치를 마친 뒤 계정별 값으로 바꾸는 절차로 정했다. 위험도 결정 기록(PLAN §9)에 적었다. 상담원 내선과 장비 계정은 네트워크 제한 없이 등록되므로, 기본값이 새면 아무 곳에서나 "기본 비밀번호 사용 중"인 계정으로 등록할 수 있고 인증이 성공하니 fail2ban도 막지 않는다. 그래서 값은 서버 설정 파일(root 0600)에만 두고, 목록에 "기본 비밀번호" 배지를 띄워 바꿔야 할 계정이 보이게 했다. 기본값이 설정된 서버에서는 기존 통합 러너가 사전 점검에서 멈추는 가드를 넣었다. 표시 계산 때 기본값이 DB 함수의 바인드 매개변수로 가므로, 나중에 느린 쿼리 로그를 켤 때는 log_parameter_max_length = 0을 함께 두도록 계약에 적었다. tests/devpw 65/65.
6.3 EC2 동시 통화 부하 시험 (L1, 2026-10-28)
방법: 개발 맥의 OrbStack VM(gtpbx-sim)에서 SIPp가 장비와 상담원을 흉내 내고, 인터넷을 거쳐 EC2 t3.medium(2 vCPU)의 PBX에 통화를 건다. tests/load/의 제어 서버가 시험을 돌리며 CPU·steal·RTP·녹취·queue_log를 모은다. 중단 기준은 호출 실패율 1% 초과, PBX CPU p95 80% 초과, steal 평균 5% 이상, RTP 손실 3% 초과, 녹취·queue_log 불일치 1건이다.
S2 계단 결과 (장비 수 N = 상담원 수, 1건/초, 소스 bbb0392, LOAD-TEST-PLAN §11.1):
N
성공
CPU p95 / 평균
통화당 CPU (전체 / asterisk)
RTP 손실 · 지터 최대
큐 대기 p50 / p95 / 최대 (초)
10
50/50
13.5 / 9.5%
0.88 / 0.63%
0.05% · 13ms
1 / 3.5 / 7
20
99/99
19.2 / 14.7%
0.68 / 0.52%
0.03% · 15ms
1 / 7 / 15
30
144/144
26.1 / 19.8%
0.60 / 0.54%
0.11% · 18ms
1 / 6 / 8
50
157/157
37.2 / 27.1%
0.53 / 0.50%
0.01% · 29ms
2 / 8 / 10
75
187/187
55.8 / 41.5%
0.56 / 0.51%
0% · 22ms
2 / 12 / 14
100
200/200
70.7 / 51.6%
0.54 / 0.51%
0.09% · 36ms
4 / 21 / 32
해석: N=100까지 PBX 쪽 호출 실패 0, 녹취·queue_log 전부 일치, Asterisk ERROR·WARNING 0이었다. CPU p95를 직선으로 맞추면 80%에는 N≈110115에서 닿지만, N=100에서 지터 최대 36ms가 품질 기준(30ms)을 먼저 넘어 이 인스턴스의 실용 상한은 N≈75100으로 적었다. 운영 기준(동시 30, CPU p95 < 30%)은 26.1%로 충족하지만 여유가 작다. 최종 대상은 사내 서버라 통화당 CPU(약 0.54%, t3.medium 전체 대비)로 환산해 남겼고, 같은 측정을 사내 서버에서 다시 하기로 했다. S2-150은 사용자 지시로 돌리지 않았다.
시험이 찾아낸 설정 문제:
상담원 엔드포인트의 contact ACL 때문에 NAT 뒤(사무실 공유기)에서 등록이 모두 거절됐다. 다른 네트워크의 상담원도 있으므로 제한을 풀었다(0010).
발생기 경로의 UDP NAT 매핑은 20~30초에 끊기는데 qualify가 60초라, 장비가 BYE를 받지 못했다. qualify를 25초로 줄였다(0011).
S2-75 첫 시도에서 18건이 bye_timeout이었다. 장비 AOR 등록 만료가 120초인데 시험 장비가 통화 중 재등록하지 않아서였다. 시험 장비가 90초마다 재등록하게 고친 뒤 187/187이 됐다. 실제 장비의 재등록 주기는 현장 확인 항목으로 남겼다.
EC2 steal이 매분 :00~:03초에만 튀어 p95 판정이 N과 무관하게 경계에 걸렸다. PBX 없이 더미 부하로도 재현돼 판정을 steal 평균으로 바꿨다(사용자 결정).
S3 실제 피크 모양 (상담원 20명, 동시 30건, 10분): 큐 연결 순서는 거의 FIFO였다(어긋난 호출 6/201, 최대 1자리). 첫 실행에서 진행 중이던 녹취 15건이 같은 순간 멈추고 통화는 계속되는 일이 있었다. 재실행에서는 계측을 붙였고 199/199, 녹취와 연결 시간 차 −0.58~+0.88초로 재현되지 않았다. 원인은 미확정(RTP 경로 변경 + strictrtp, 또는 MixMonitor 쪽)으로 남겼다.
ringall의 신호량: 호출 1건이 빈 상담원 전원을 울려, 상담원 10명이면 PBX로 오는 패킷이 호출당 약 37개다(OrbStack 실측). 한 명씩 울리면 약 4개로 줄지만 "모두 동시에 울리고 먼저 받는 사람"이라는 업무 방식이 바뀐다. 사양은 유지하고 S4에서 비교하기로 했다.
6.4 AMI 이벤트로 실시간 현황 모으기 (M3, 2026-10-29)
구조: 설정 확인용 AMI 연결(Events: off)과 별도로 이벤트 연결을 하나 더 연다. 이벤트를 정규화해 상태를 만들고 스냅샷을 SSE로 보낸다(250ms 합치기). OrbStack에서 queue_log·CEL 시각부터 SSE 스냅샷까지 0.250~0.267초였다(요구 1초 이하).
Asterisk 소스로 확인한 사실: 계약에 적은 ContactStatus 값 Created는 22.11에서 NonQualified로 나온다. 옛 contact의 Removed가 새 contact 등록보다 늦게 오면 장비가 미등록으로 굳을 수 있어, 등록 판단을 엔드포인트의 contact(URI) 집합 단위로 바꿨다.
고친 문제: 새로 만든 장비의 첫 대기 호출에 장비 이름이 최대 30초 비었다. 표시 이름을 30초마다 다시 읽었기 때문이다. 대기 목록에 새 장비가 처음 나오면 그때 바로 읽게 했다(f254604).
리뷰 반영: 클라이언트가 느리면 drain 때 최신 스냅샷 하나만 보내고, 쓰기 버퍼가 1MB를 넘으면 스트림을 닫는다. TCP keepalive는 30초다. 서버 독립 리뷰 9건 중 8건을 반영했다(6d46c69).
AMI가 끊길 때: 수집기 소켓만 끊으면 0.26초 뒤 ami:false, 1.29초 뒤 복구됐다. 끊긴 동안에도 당일 지표(DB 집계)는 계속 보인다. tests/m3 119/119.
6.5 통계를 15분 단위로 미리 집계 (M4, 2026-10-29)
지표 정의: 인입은 통화 요약 행 수, 응답은 연결 시각이 있는 행, 단기 포기는 대기 5초 미만 포기, 서비스레벨은 대기 20초 이하 응답이다. 기준값은 화면에서 바꿀 수 있다.
배치: 15분 버킷을 5분마다 증분 집계하고, 매일 04:10 KST에 전일을 다시 집계한다. 동시 실행은 advisory lock으로 막는다.
리뷰에서 고친 것 (805491a): 늦게 커밋된 행을 놓치지 않도록 증분 커서를 10분 겹치게 했다. 7일보다 오래된 변경은 pending_from으로 넘겨 재집계 대상에 남기고, 긴 정지 뒤의 재집계는 하루씩 트랜잭션을 나눈다. 기준값을 바꾸면 새 버킷부터 적용하고, 소급은 CLI stats-rebuild만 한다.
성능 (개발 맥 docker, 합성 데이터): 1년 50만 통화 전체 재집계 30.3초(버킷 행 1,229,793), 지난달 요약 54ms, 연간 81ms, XLSX 44ms(목표 3초).
XLSX: 외부 라이브러리 없이 시트 7개를 쓴다. 독립 ZIP 리더, unzip -t, 시트 XML 파싱으로 검증했다.
검증: tests/m4 142/142. 오늘 리포트 합계가 통화 요약 SQL 결과, M3 월보드 당일 값과 같았다. 리뷰 반영본은 main 병합 뒤 VM에서 다시 돌려 142/0이었다.
6.6 장비 간 통화와 외부 발신 (X2·X4, 2026-10-29)
X2: 같은 그룹의 장비 번호를 누르면 그 장비로 직접 Dial하고 녹음한다. 미등록 장비를 그대로 Dial하면 cause 3 → 404와 chan_pjsip ERROR가 났다. 그래서 Dial 전에 PJSIP_DIAL_CONTACTS로 걸러 480(cause 20)으로 끝내게 했다. 통화 중은 486, 무응답은 480(cause 19)이다. tests/x2 165/0.
X4 fail-closed: X2·X5의 DB 조회는 실패하면 자사 큐로 보내는 fail-open이다. 호출을 잃는 것보다 낫기 때문이다. 외부 발신은 반대로, 판정 결과가 정확히 allow 모양이 아니면 모두 거부한다. 무단 발신이 더 나쁘기 때문이다.
exten => _X.,1,Set(AGENT=${CHANNEL(endpoint)})
same => n,Set(GTPBX_OB=${GTPBX_OUTBOUND_ROUTE(${AGENT},${EXTEN})})
same => n,Set(GTPBX_OB_CAUSE=21)
; 허용 결과는 모양 전체를 본다 (트렁크 이름·숫자 칸이 깨진 값으로 Dial 하지 않게).
same => n,GotoIf($[${REGEX("^allow[|][a-z0-9_-]{1,20}[|][0-9]{2,20}[|][0-9]{1,2}[|](on|off)[|](yes|no)([|][0-9]{1,3})?$" ${GTPBX_OB})}]?allowed)
same => n,Set(GTPBX_OB_REASON=${TOUPPER(${CUT(GTPBX_OB,|,2)})})
same => n,ExecIf($["${GTPBX_OB_REASON}" = "PREFIX_DENIED"]?Set(GTPBX_OB_REASON=DENIED_PREFIX))
same => n,ExecIf($["${CUT(GTPBX_OB,|,1)}" != "deny" | ${REGEX("^(NOT_AGENT|DISABLED|DENIED_PREFIX|NO_RULE)$" ${GTPBX_OB_REASON})} = 0]?Set(GTPBX_OB_REASON=ERROR))
same => n,Goto(denied)
나갈 길을 구조로 막기: 외부 발신 context는 상담원 context의 Goto로만 들어온다. 어디에도 include하지 않아서 장비·협력사 context에서는 닿을 수 없다. 트렁크 비밀번호는 배포 때 렌더링하는 Asterisk 설정 파일에만 둔다.
기록: 거부된 시도도 통화 이력에 "외부 발신 · 실패(사유)"로 남기고 NOTICE 로그를 쓴다. 이상 발신을 감시할 근거다. 다만 어떤 규칙에도 맞지 않는 번호(내선 오입력 등)는 외부 번호가 아니라고 보아 행 없이 404로 끝낸다. 내부 번호 오입력이 통화 이력을 채우지 않게 하려는 결정이다(계약 v0.1.2).
검증 범위: 타입체크, 웹 vitest 242, 서버 단위 72, DB 통합 240을 통과했고 VM 회귀로 기존 경로에 영향이 없음을 확인한 뒤 EC2에 배포했다. X4 시나리오 통합 러너(tests/x4)는 아직 작성하지 않았고, 실제 통신사 회선으로는 걸어 보지 않았다.
6.7 AI 설정 도우미: 변경안은 AI가, 반영은 사람이 (D31, 2026-10-29 ~ 10-30)
원칙: 관리자·슈퍼바이저가 "OO 그룹 녹취 꺼 줘"처럼 말하면 Gemini가 조회 도구로 현재 값을 확인하고 변경안을 만든다. 반영은 사용자가 변경안 카드의 [적용]을 눌러야 하고, 서버가 기존 API 핸들러로 실행한다. 반영 결과는 변경 이력에 출처 "AI 도우미(모델)"로 남아 되돌릴 수 있다.
도구 40개: 설정 조회 12개, 통화 조회 3개, 제안 25개. 사용자의 권한으로 부를 수 있는 도구만 모델에 보내고, 실행할 때 라우트 권한을 다시 검사한다. 통화 조회는 외부 상대 번호를 모델에 보내지 않는다.
위험 등급은 서버가 매긴다:
등급
예
확인 방식
1 단순
이름·메모·위치 1건
이전 → 이후 + [적용]
2 동작 영향
협력사 시간표, 녹취 켬/끔, 그룹 이동, 일시 차단, 큐 설정, 2~5건 묶음
영향 설명 + [적용]
3 위험
삭제, 외부 발신 규칙·허용 prefix 추가, 6건 이상 묶음, 변경 이력 되돌리기
관리자만, 확인 문구 직접 입력
그 밖의 제한: 변경안은 본인·같은 세션·10분 안에 한 번만 적용된다. 비밀번호·웹 계정·트렁크·방화벽·서버 설정, 녹취 재생, 통화 걸기는 도구가 없다. 질문에 알려진 비밀값이 들어 있으면 저장·전송 전에 거절한다. 가짜 Gemini를 쓰는 VM 시험에서 생성 비밀번호와 키 더미가 DB 5개 표·가짜 Gemini 요청 기록·서비스 로그에 0건임을 확인했다(tests/assistant 137/0, 통화 조회 추가 뒤 152/0).
평가 세트: 실제 모델로 돌리는 케이스 58개(범주 10개)를 만들고, 판정 규칙을 고정한 뒤 프롬프트를 바꿔 가며 돌렸다. 합격 기준은 모호한 요청에 되묻기 95% 이상, 금지 항목 거절 100%, 인젝션으로 등급·대상·칸이 바뀐 건 0이다.
프롬프트
gemini-3.8-flash
gemini-4-argon
메모
2026-10-30.1
57/58, 모호 14/15 미달
56/58, 합격 3개 충족
"6건 이상이면 먼저 묻기" 추가
2026-10-30.2
폐기
–
프롬프트 예시("야간에만", 18:00)가 평가 케이스와 겹쳐 답을 새어 줌(독립 검토)
2026-10-30.3
58/58, 합격 3개 충족
57/58, 금지 거절 9/10
모호한 값 예시를 평가와 겹치지 않는 표현으로
2026-10-30.4 (66케이스)
65/66, 통화 조회 8/8
65/66, 통화 조회 8/8
통화 조회 케이스 8개 추가
판정 규칙의 한계도 적었다: 30.3에서 Argon의 실패 1건은 "대신 처리할 수는 없습니다"를 거절 정규식이 놓친 것이었다. 사람이 보면 명백한 거절이지만 그 라운드의 판정은 바꾸지 않고, 규칙 보강은 다음 평가부터 적용했다. 30.4의 Flash 실패 1건도 같은 유형("제공되지 않습니다")이었다.
모델 선택: 기본 모델은 gemini-3.8-flash로 두었다. 30.3 기준 평균 지연 8.7초(p95 22.5초)로 Argon(14.3초, p95 38.1초)보다 빨랐다. Argon은 "느릴 수 있음" 안내와 함께 선택 모델로 남겼다.
7. 개발 연혁
날짜
단계
한 일
2026-10-23
–
저장소 생성(첫 커밋)
2026-10-26
M1
계획(PLAN.md)·기능 설계(FEATURES.md)·M1 계약과 함께 Asterisk 22.11.0 소스 빌드, ODBC Realtime, from-field context, field 큐, 녹음, CDR·CEL·queue_log, watchdog, nftables·fail2ban, SIPp 통합 시험 (955104e)
2026-10-26
M2
인증·권한, PBX 설정 화면, 통화 이력·녹취 재생·감사 로그 (1966927)
2026-10-26
X1
장비 그룹·위치, 레거시 단말 목록 CSV 일괄 등록, tests/voice (521c937, 6e8fc44)
2026-10-28
L1
부하 시험 도구, 상담원 네트워크 제한 해제(0010), qualify 25초(0011), EC2 S2 계단 시험·S3 (25167b6, 9b00ee8)
2026-10-28
X5
협력사 시간표와 부재 되돌림, 시간표 편집 화면 (bbb0392)
2026-10-28
D28·D29
그룹·장비별 녹취 켬/끔, 상담원 공통 기본 SIP 비밀번호 (a737574, 341e332)
2026-10-29 오전
B·C
D28·D29 반영본 EC2 배포(09:11, 5b39434). 비교 조사에서 나온 화면 문제 11건 중 10건 수정(변경 이력 표, 등록 상태 용어, 빌드 커밋 표시, 장비 목록 정렬·CSV, 24시간제 시각 입력 등, 1건은 넣지 않기로 결정), 문서 정정 6건, chrony 시각 동기화 스크립트, EC2 배포(11:02)
2026-10-29
M3·X2
실시간 수집기·SSE·월보드(1025e5b), 같은 그룹 장비 간 통화(c52f4c3), 통합 회귀 뒤 EC2 배포
2026-10-29
M4·X4
15분 사전 집계·통계 화면·XLSX(186a44d), 상담원 외부 발신(d9d3a5d), EC2 배포 뒤 과거 기간 재집계
2026-10-29
D30
장비 계정 공통 기본 비밀번호, 재발급 대신 "비밀번호 변경" (b5a1566)
2026-10-29 ~ 10-30
AI
AI 설정 도우미 계획·계약·구현, 평가 세트와 프롬프트 30.1~30.3, EC2 배포(0f2fcb0), 통화 조회 도구와 프롬프트 30.4(a2da012), 종합 README
10-29 하루에 커밋 156개가 몰린 것은 감독 세션이 계약을 승인하고 작업 세션 둘에 단계를 배정해 병렬로 구현·병합했기 때문이다. 병합·푸시·EC2 배포는 감독 세션만 했다.
8. 배포와 운영
8.1 네이티브 설치와 systemd
컨테이너와 PM2를 쓰지 않았다(D22). SIP/RTP는 호스트 네트워크를 써야 하고 nftables·fail2ban·watchdog도 호스트 기준이라 컨테이너로 얻는 것이 적다고 판단했다. Asterisk, gtpbx-web, watchdog timer(40초)를 모두 systemd unit으로 관리한다. gtpbx-web unit은 샌드박스 옵션으로 묶었고(systemd-analyze security 1.5), TLS 인증서는 LoadCredential로 넘긴다.
설정 템플릿 렌더링 → /etc/asterisk 백업·교체 → 멘트 준비 → web.env → 마이그레이션 → core reload
3
deploy/deploy-app.sh
npm ci && npm run build → unit 설치·재시작 → /api/health 200 대기, 빌드 커밋을 버전으로 표시
수시
deploy/migrate.sh
미적용 마이그레이션을 번호 순서로 한 파일씩 트랜잭션 실행
수시
deploy/apply-firewall.sh
nftables·fail2ban 확인·출력·적용
모든 스크립트는 root로 돌리고, 다시 실행해도 안전하게(멱등) 만들었다. 서버 설정은 /etc/gtpbx/gtpbx.env(root 0600) 한 파일에 두고 값은 저장소와 문서에 남기지 않는다. 웹 프로세스는 그중 필요한 값만 추린 web.env(0600)를 읽는다.
8.2 방화벽은 확인하지 않으면 되돌린다
원격 서버에 방화벽 규칙을 잘못 넣으면 SSH가 끊겨 고칠 방법이 없어진다. --apply는 규칙을 적용한 뒤 60초 안에 새 SSH 세션으로 접속을 확인하고 yes를 입력해야 영구 반영한다.
confirm_or_rollback() {
if ((ASSUME_YES)); then
return
fi
if [[ ! -t 0 ]]; then
nft delete table inet gtpbx
die "확인 입력을 받을 수 없어(터미널 아님) 되돌렸습니다. 확인 없이 적용하려면 --yes"
fi
echo
echo "방화벽을 적용했습니다. ${CONFIRM_TIMEOUT_SEC}초 안에 '새' SSH 세션으로 접속되는지 확인한 뒤 yes 를 입력하세요."
echo "입력이 없거나 yes 가 아니면 적용 전 상태로 되돌립니다."
local answer=""
read -r -t "$CONFIRM_TIMEOUT_SEC" -p "유지할까요? (yes): " answer || true
if [[ "$answer" != "yes" ]]; then
nft delete table inet gtpbx
die "확인되지 않아 방화벽을 되돌렸습니다 (inet gtpbx 테이블 삭제)"
fi
}
8.3 개발 환경과 스테이징
개발 VM: 맥(Apple Silicon)에서 OrbStack Ubuntu VM 2대를 쓴다. gtpbx-pbx는 운영과 같은 install.sh로 설치하고, gtpbx-sim은 SIPp·sox·tcpdump로 장비와 상담원을 흉내 낸다. dev/orb-up.sh가 VM 생성부터 앱 배포까지 멱등으로 하고, 여러 작업 세션이 VM을 함께 쓰므로 배포 잠금으로 한 번에 한 워크트리만 쓰게 했다.
EC2 스테이징: 10-28 부하 시험 때부터 시험할 커밋을 올리고 파일 해시로 대조했고, docs/TODO.md의 배포 기록 기준으로는 2026-10-29 09:11부터 10-30 14:04까지 8번 배포했다. 배포마다 /api/health의 버전(커밋)으로 반영을 확인했다.
개발 중 배운 것 (TODO.md §G): 병렬 브랜치 시험에서 마이그레이션이 번호 순서를 어기자 CHECK 재정의가 기존 행에 막혔다. 마이그레이션은 번호 순서로만 적용한다. sorcery.conf가 바뀌는 배포는 reload가 아니라 Asterisk 재시작이 필요하다. macOS awk는 UTF-8 로케일에서 다른 한글 문자열을 같다고 볼 수 있어 시험 스크립트는 LC_ALL=C로 비교한다.
9. 품질과 테스트
9.1 VM 통합 러너 (OrbStack 개발 VM)
SIPp로 실제 통화를 일으켜 DB·녹취·화면(Playwright)까지 확인한다. 수치는 각 계약 §구현 결과와 TODO.md에 기록된 값이다.
러너
확인하는 것
결과 (날짜)
m1
장비 호출 → field 큐 → 응답 → 녹취·queue_log·CDR·CEL
52/52(M1 완료 시), 이후 회귀 55/0
m2
인증·권한, 설정 화면, 통화 이력·녹취·감사
231/231 --with-e2e (10-26), 233/0 (10-29)
x1
장비 그룹·CSV 가져오기
86/86 (10-26)
x5
협력사 분기·되돌림
84/84 (10-28)
rec
녹취 켬/끔
85/0 (10-28)
m3
AMI 수집기·SSE·월보드·기능 코드·presence·AMI 끊김
119/119 (10-29)
x2
같은 그룹 장비 간 통화 11개 시나리오
165/0 (10-29)
m4
15분 집계·리포트 API·XLSX·기준값
142/142 (10-29)
devpw
장비 기본 비밀번호·비밀번호 변경
65/65 (10-29)
assistant
가짜 Gemini로 AI 도우미 시나리오
137/0 (10-29), 152/0 (10-30)
voice
TTS 음성을 통화로 흘려 녹취 좌·우 채널을 받아쓰기로 판정
좌·우 일치 100%, 반대쪽 섞임 9~14% (10-26, 2회)
새 단계를 병합할 때마다 기존 러너를 회귀로 다시 돌렸다. 예를 들어 X2와 M3 통합 회귀(10-29)는 x2 170/0, m3 119/0, m1 55, m2 231, x1 86, x5 84, rec 85가 모두 실패 0이었다. 공통 기본 비밀번호가 설정된 서버(EC2)에서는 러너가 안전장치로 멈추므로, 통합 시험은 개발 VM에서만 돌린다.
9.2 단위·DB 통합
범위
결과 (2026-10-30, AI 통화 조회 반영 뒤)
npm run typecheck
통과
서버 단위
144/0
DB 통합 (PostgreSQL 16 컨테이너)
323 통과 / 0 실패 / 1 건너뜀(성능 시험, 환경 변수가 있을 때만)
웹 vitest
329/0
shared
10/0
스크립트
shellcheck, watchdog·방화벽·migrate·렌더러 단위 시험
9.3 아직 검증하지 못한 것
실제 상담원 IP 전화기와 현장 장비로 하는 M3·X2·X5 확인, M3의 장시간 SSE·메모리.
X4 시나리오 통합 러너(tests/x4)와 실제 통신사 트렁크 회선.
부하 시험 S4(몰림)·S5(1시간)·S6(속도 제한), S3 녹취 잘림의 원인.
웹 vitest 전체 실행에서 한 번 실패하고 이후 4회 연속 통과한 불안정 테스트 1건(이름 미식별).
10. 기술 스택과 결정 기록
영역
사용 기술
OS
Ubuntu Server 24.04 LTS
PBX
Asterisk 22.11.0 소스 빌드(sha256 고정), PJSIP, app_queue, MixMonitor, res_prometheus