작성일
GT PBX 개발기 2 — reload 없이 반영하는 설정 웹앱
M2 웹앱 골격: 인증·권한·CSRF, 설정을 DB 트랜잭션으로 커밋한 뒤 Asterisk reload 없이 AMI로 반영을 확인하고 실패하면 되돌리는 흐름, 지울 수 없는 감사 로그, 통화 이력·녹취 재생, 그리고 TTS 음성을 실제 통화로 흘려 녹취 채널을 받아쓰기로 판정하는 시험(2026년 10월 26일).
GT PBX 개발기 시리즈 2/8편. 전체 목록은 gtpbx 태그에서 볼 수 있다.
M2 계약의 완료 기준은 한 줄이었다. "관리자가 웹으로 장비 계정을 만들고, 그 장비의 통화를 검색·재생한다." 1편의 PBX 코어 위에 웹앱 골격을 얹는 단계였고, 10월 26일 낮에 커밋했다.
스택과 몇 가지 제약
| 영역 | 선택 |
|---|---|
| 런타임 | Node.js 24 LTS (운영은 공식 tarball, SHASUMS 서명과 고정 sha256으로 검증) |
| 서버 | Express 5, pg + Kysely(쿼리 빌더, ORM 없음), zod 4 |
| 웹 | React 19, Vite 8, Mantine 9, TanStack Query 5 |
| 서빙 | Node가 HTTPS를 직접 연다 (nginx 없음), systemd gtpbx-web.service |
API 경로와 권한표, 요청·응답 타입은 packages/shared에 두고 서버와 웹이 같이 쓴다. 이 패키지와 계약 문서는 코디네이터만 고친다. 작업자 넷(백엔드·DB, 웹, 배포, 통합 시험)이 병렬로 일해도 인터페이스가 흔들리지 않게 하려는 장치였다. 서버 런타임 의존성은 순수 JS만 쓰기로 했다. 네이티브 모듈이 있으면 맥에서 빌드한 node_modules를 리눅스 개발 VM에서 그대로 돌릴 수 없다.
인증과 권한
로그인과 세션은 Gem3Park 방식을 다시 썼다.
- 비밀번호: scrypt (N=2^15, r=8, p=1), 솔트 16바이트
- 세션: 무작위 32바이트 토큰을
HttpOnly; Secure; SameSite=Strict쿠키로. DB에는 토큰의 SHA-256만 저장한다 - CSRF: 세션마다 토큰을 두고 GET·HEAD 외 요청에
x-csrf-token헤더를 요구한다 - 로그인 제한: 같은 사용자 이름이나 같은 IP에서 15분 안에 5회 실패하면 15분 동안 429. 없는 사용자도 같은 응답과 같은 시간이 걸리게 scrypt를 더미로 계산한다
- 역할: 관리자, 슈퍼바이저, 상담원, 조회자. 비활성화·역할 변경·비밀번호 초기화 때 그 사용자의 세션을 모두 끊는다
DB에서도 권한을 나눴다. 웹앱이 접속하는 역할 gtpbx_web은 PJSIP·큐 테이블에 직접 INSERT·DELETE 하지 못하고, SECURITY DEFINER 함수로만 쓴다. SIP 비밀번호 해시가 있는 ps_auths는 읽지도 못한다. CDR·CEL·queue_log는 SELECT만 된다.
설정 반영: 커밋하고, 확인하고, 안 되면 되돌린다
설정 변경 흐름(F2.1)이 이 단계의 중심이었다. 웹에서 상담원 내선이나 장비 계정을 만들면 서버는 이렇게 한다.
- DB 트랜잭션 안에서 변경하고 같은 트랜잭션에 변경 이력(
gtpbx_config_changes)을 남긴다. 다른 사람이 먼저 바꿨으면 revision이 달라 409를 돌려준다. - 커밋한 뒤 AMI로 Asterisk가 그 변경을 보는지 확인한다. 엔드포인트는
PJSIPShowEndpoint, 큐는QueueStatus로 본다. - 확인에 실패하면 보상 변경으로 DB를 되돌리고 그 사실도 이력에 남긴 뒤 503
pbx_unavailable을 돌려준다.
pjsip reload나 큐 Reload는 부르지 않는다. PJSIP Realtime은 캐시를 두지 않아 DB 변경이 바로 보이고, Realtime 큐는 Queue()나 QueueStatus가 불릴 때마다 queues 행을 다시 읽으면서 동적 멤버는 건드리지 않는다. (Asterisk 22.11.0 소스로 확인했다.) reload는 대기 음악 파일을 바꿀 때 res_musiconhold.so에만 쓴다. 안내 멘트는 같은 디렉터리에 임시 파일로 쓰고 rename으로 바꾸면 다음 Playback부터 새 파일이 나간다.
상담원 내선 생성의 뒷부분이다. 앞의 트랜잭션에서 내선을 만들고 변경 이력 id와 스냅샷(created)을 받아 둔 상태에서 반영을 확인한다.
if (!(await confirmEndpoint(ctx.deps.pbx, body.extension, true))) {
await ctx.deps.db.transaction().execute(async (trx) => {
await sql`SELECT gtpbx_delete_endpoint(${body.extension}, ${AGENT_CONTEXT})`.execute(trx);
await recordChange(trx, {
actor,
ip: ctx.ip,
entityType: "agent",
entityId: body.extension,
action: "revert",
before: created.after,
after: null,
revertedChangeId: created.changeId,
});
});
throw pbxUnavailable();
}
장비 일괄 생성도 같다. 반영 확인을 10개씩 나눠 하고, 하나라도 안 되면 이번에 만든 것을 전부 되돌린다.
모든 변경이 이 순서를 따를 수는 없었다. SIP 비밀번호 재발급과 계정 삭제는 웹 역할이 이전 해시를 읽을 수 없으니, 실패해도 원래 값으로 되돌릴 방법이 없다. 그래서 이 둘은 순서를 바꿨다. DB에 계정이 있는지 보고, AMI로 PBX가 살아 있는지 먼저 확인한 다음 변경한다. 변경 뒤 확인이 실패하면 경고만 남긴다.
감사 로그는 지울 수 없게
녹취 청취·다운로드, 설정 변경, 로그인은 감사 로그에 남긴다. 웹 역할에는 INSERT와 SELECT 권한만 주고, 그와 별도로 트리거가 UPDATE·DELETE·TRUNCATE를 거부한다.
CREATE FUNCTION gtpbx_audit_log_immutable() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'gtpbx_audit_log is append-only (% not allowed)', TG_OP;
END;
$$;
CREATE TRIGGER gtpbx_audit_log_no_update_delete
BEFORE UPDATE OR DELETE ON gtpbx_audit_log
FOR EACH ROW EXECUTE FUNCTION gtpbx_audit_log_immutable();
CREATE TRIGGER gtpbx_audit_log_no_truncate
BEFORE TRUNCATE ON gtpbx_audit_log
FOR EACH STATEMENT EXECUTE FUNCTION gtpbx_audit_log_immutable();
변경 이력에는 이전 값과 바뀐 값을 저장하고(비밀번호·해시는 넣지 않는다), 되돌리기는 이전 값으로 같은 반영 흐름을 다시 탄다.
통화 이력과 녹취 재생
통화 검색은 queue_log와 CDR을 바로 읽지 않고 통화 요약 테이블 gtpbx_calls를 쓴다. 두 테이블의 INSERT 트리거가 요약을 채운다. 이 트리거 함수는 예외를 삼키고 RAISE WARNING만 한다. 요약을 만들다 실패하더라도 Asterisk의 기록 INSERT까지 실패하면 안 되기 때문이다.
시험하다 보니 장비 ID m2-1이 queue_log에는 m21로 찍혔다. Set(CALLERID(all)="x" <x>)가 <> 안 번호에서 -와 .을 지우기 때문이었다. 다이얼플랜은 CALLERID(num)과 CALLERID(name)을 따로 넣도록 고치고, 이미 들어간 요약은 CDR의 field_endpoint로 바로잡는 마이그레이션(0008)을 더했다.
녹취 재생은 HTTP Range 스트리밍이다. 플레이어에는 ±5초 이동, 0.75~1.5배속, A–B 반복, 채널 선택(장비만·상담원만·함께)을 넣었다. 채널 선택은 Web Audio의 ChannelSplitter로 1편의 좌우 분리 녹음을 나눠 듣는다. 파일 경로는 요청에서 받지 않는다. 통화 ID로 DB의 recording_file을 찾고, 그 경로가 녹취 디렉터리 안인지 realpath로 확인한다. 상담원 역할은 자기 내선이 받은 통화만 볼 수 있다.
이 단계에서 장비 일시 차단도 들어갔다. 다이얼플랜이 func_odbc로 차단 여부를 묻고, 차단이면 받지 않고 끊는다. ODBC 오류가 나면 통과시킨다(fail-open). 차단은 장난 호출 대응이고, 호출을 잃는 쪽이 더 나쁘다.
녹취의 좌우를 귀 대신 받아쓰기로 확인한다
M1 시험은 SIPp 내장 음성 파일을 써서 녹취에 소리가 있는지만 봤다. M2 기간에 장비는 600Hz, 상담원은 1400Hz 톤을 보내도록 바꿔 녹취의 왼쪽·오른쪽 방향을 주파수로 확인했다(M1 시험 55항목).
같은 날 저녁에는 사람 말소리로 한 번 더 확인하는 tests/voice를 만들었다.
Gemini TTS ─ 장비 문장(목소리 A) ─→ WAV → A-law RTP pcap ─→ SIPp 장비 ─┐
Gemini TTS ─ 상담원 문장(목소리 B) ─→ WAV → A-law RTP pcap ─→ SIPp 상담원 ─┤ field 큐 → MixMonitor
↓
웹 API로 녹취 받기 → 좌(장비)·우(상담원) 분리 → 받아쓰기 → 문장 판정
판정은 이렇다. 기대 문장의 글자 중 받아쓴 문장에 순서대로 나온 비율을 일치율로 잡는다. 왼쪽은 장비 문장과 85% 이상, 오른쪽은 상담원 문장과 85% 이상 맞아야 하고, 반대쪽 문장이 섞인 비율은 50% 이하여야 한다. 서로 다른 두 문장도 "니다" 같은 흔한 글자 때문에 10~20%는 겹치므로 상한을 넉넉히 뒀다. 합성한 원본을 먼저 받아 적어 기준을 넘는지 보고, 여기서 틀리면 PBX가 아니라 TTS나 받아쓰기 문제로 가른다. 받아쓰기가 숫자를 아라비아 숫자로 바꿔 버려서 문장에 숫자는 넣지 않았다.
처음에는 Cloud Text-to-Speech·Speech-to-Text API를 쓰려 했지만, 가진 키의 프로젝트에서 두 API가 꺼져 있었다(403). 그래서 Gemini 모델로 합성과 받아쓰기를 했다. 외부로 보내는 것은 합성 문장, 합성 음성, 이 시험 통화의 녹취뿐이다. 실제 통화 녹취는 이 도구로 보내지 않는다. 시험은 23/23으로 통과했고, 같은 날 X1 작업 뒤 두 번 돌린 결과는 제 채널 일치 100%, 반대쪽 섞임 9~14%였다.
결과와 남은 것
계약 §10에 남긴 M2 검증 결과다(OrbStack 개발 VM).
| 시험 | 결과 |
|---|---|
tests/m2/run.sh --with-e2e (API 통합 + Playwright 스모크) | 231/231 |
M1 회귀 tests/m1/run.sh | 55/55 |
서버 단위 / DB 통합(postgres:16 컨테이너) | 28/28, 51/51 |
| 웹 vitest | 47/47 |
| 시험 뒤 Asterisk 로그 ERROR·WARNING, 웹 로그 경고 | 0 |
미검증으로 적은 것도 있다. x86_64 빌드와 Node 다운로드, 방화벽 실제 적용, 실제 브라우저의 자체 서명 인증서 경고 화면, 로그인 5회 잠금과 세션 절대 만료의 실환경 확인(DB 통합 시험으로만 확인), 1년치 통화가 쌓였을 때의 검색 성능이다.