본문으로 건너뛰기Hwanseok Sim

작성일

GT PBX 개발기 1 — 레거시 PBX를 대신할 Asterisk 코어 세우기

주차장 콜센터의 기존 상용 PBX를 대체하려고 요구와 결정부터 적고, 단계별 계약 문서로 일을 나눠 M1 PBX 코어(Asterisk 22 소스 빌드, ODBC Realtime, 장비 전용 context, 스테레오 녹음, watchdog, 방화벽, SIPp 통합 시험)를 세운 2026년 10월 23~26일 기록.

GT PBX 개발기 시리즈 1/8편. 전체 목록은 gtpbx 태그에서 볼 수 있다.

무엇을 대체하는가

회사 콜센터는 주차장 현장 장비(정산기·인터폰)에서 걸려 오는 SIP 호출만 24시간 받는다. 일반 고객이 거는 대표번호는 없다. 지금은 기존 상용 PBX가 이 호출을 받고, 상담원은 사내 원격 지원 도구(RustDesk) 팝업으로 사이트 PC에 접속해 요금 정산이나 차단기 개방 같은 민원을 처리한다. GT PBX는 이 레거시 PBX를 오픈소스 Asterisk로 단계적으로 대체하고, 설정·통계·녹취·장비 상태를 자체 웹앱 하나에서 다루려는 프로젝트다.

저장소는 2026년 10월 23일에 열었다. 코드를 쓰기 전에 docs/PLAN.md에 요구와 결정을 번호를 붙여 적었다. 이 시점에 정한 것 중 이후 구조를 결정한 것은 다음과 같다.

#결정이유
D1상담원 약 20석용량 산정의 기준 (피크 동시 통화 25~30)
D4FreePBX 같은 배포판 대신 웹앱을 직접 만든다설정·통계·녹취·장비 상태를 웹앱 하나에서 다룬다 (PLAN §1)
D13인입은 현장 장비 호출뿐, IVR 없이 단일 큐 field로모든 상담원이 모든 사이트를 함께 받는다
D2024시간 운영, 응답이 늦어도 다른 곳으로 넘기지 않고 계속 대기운영시간 외 분기가 없다
D12상담원 화면의 사이트 표시·원격 접속 팝업은 대체하지 않고 함께 쓴다새 웹앱은 같은 팝업을 다시 만들지 않는다
D22컨테이너 없이 호스트에 네이티브 설치, 모두 systemd로 관리SIP/RTP가 호스트 네트워크를 써야 하고 nftables·fail2ban·watchdog도 호스트 기준이다

현장 장비는 대부분 유동 IP로 인터넷을 거쳐 PBX에 직접 등록한다(D14·D19). IP 허용 목록을 쓸 수 없으니 장비는 인증된 SIP 계정으로만 식별하고, 장비 계정은 전용 다이얼플랜으로만 걸 수 있게 해서 계정이 털려도 외부 발신 회선에는 닿지 않게 했다(PLAN §9.1).

이전에 만든 Gem3Park도 Asterisk 위에서 돌았다. 그때 pjsip.conf와 extensions.conf가 저장소 밖에 있어 환경을 다시 만들 수 없었다. 그래서 이번에는 PLAN §4.1에 원칙을 먼저 박았다. 내선·장비 계정·큐는 DB에서 읽고(Realtime), 다이얼플랜과 나머지 .conf는 저장소의 템플릿을 배포 스크립트로 /etc/asterisk에 반영한다.

계약 문서로 일을 나눈다

구현은 단계마다 계약 문서(docs/impl/M1-CONTRACT.md 같은)를 먼저 쓰고, AI 코딩 에이전트 여러 개에 파일 범위를 나눠 맡긴 뒤 내가 감독·통합하는 방식으로 했다. 계약 문서 첫 줄은 "여기 적힌 이름·경로·변수를 바꾸지 않는다. 바꿔야 하면 코디네이터에게 먼저 묻는다"였다. 경로, 환경 변수, DB 함수 이름, context 이름, 큐 이름을 표로 고정해 두면 작업자끼리 서로의 결과를 보지 않고도 맞물린다.

M1의 파일 소유는 이렇게 나눴다.

작업자소유
A — Asterisk 설정·DBasterisk/**, db/**
B — 설치·배포·보안·운영deploy/**, scripts/**
C — 개발 환경·테스트dev/**, tests/**
코디네이터docs/**, 루트 파일, 통합 수정

작업자는 커밋하지 않고, 다른 작업자 파일이 필요하면 계약대로 존재한다고 가정하고 작업한다. 통합과 검증, 계약의 "구현 결과" 절을 채우는 일은 내 몫이었다. 이 방식은 M2 이후 모든 단계에서 그대로 썼다.

Asterisk 22를 소스로 빌드하고 버전을 고정한다

우분투 기본 패키지의 Asterisk는 버전이 낮아 22 LTS를 소스로 빌드했다. 10월 23일 기준 22 LTS 최신 정식판은 22.11.0이었고 22.12.0은 아직 RC였다. 설치 스크립트에 버전과 sha256을 함께 박고, 받은 tarball을 고정값과 배포처가 공개한 .sha256 두 값 모두와 대조한다.

deploy/install.sh
    # 1) 스크립트에 고정한 값, 2) 배포처가 공개한 .sha256 값 둘 다와 일치해야 한다
    actual="$(sha256sum "$tarball" | awk '{print $1}')"
    [[ "$actual" == "$ASTERISK_SHA256" ]] || die "sha256 불일치 (고정값): $tarball"
    published="$(curl -fsSL --retry 3 "$ASTERISK_RELEASES_URL/asterisk-$ASTERISK_VERSION.sha256" | awk '{print $1}')"
    [[ "$actual" == "$published" ]] || die "sha256 불일치 (공개값): $tarball"

빌드는 BUILD_NATIVE를 끄고 번들 pjproject·jansson을 쓰며, 필요한 모듈은 계약의 목록대로 menuselect로 켠다. 같은 버전이 이미 있으면 빌드를 건너뛰어 다시 돌려도 안전하다.

DB에서 읽는 PJSIP와 큐

Asterisk와 PostgreSQL 16 사이는 res_odbc 연결 하나로 묶었다. PJSIP 엔드포인트·인증·AOR과 큐는 Sorcery/ODBC Realtime으로 읽고, CDR·CEL·queue_log도 같은 연결로 DB에 쓴다. Realtime 스키마는 22.11.0의 Alembic head를 PostgreSQL 16에 적용한 결과를 SQL 마이그레이션 0001로 고정했다.

SIP 비밀번호는 평문으로 저장하지 않는다. Asterisk 22.2부터 쓸 수 있는 password_digest에 'MD5:' || md5(사용자:realm:비밀번호)를 넣고, 평문 칸과 md5_cred는 비워 둔다. 계정을 만드는 DB 함수 gtpbx_upsert_agent·gtpbx_upsert_field_device가 해시를 계산하므로 시험 시드도, 나중의 웹앱도 같은 함수를 부른다.

장비는 무엇을 눌러도 큐로만 간다

엔드포인트의 종류는 이름이 아니라 context로 나눴다. 장비는 from-field, 상담원은 from-agent다. from-field에는 include도 Dial도 없다.

asterisk/extensions.conf
[from-field]
exten => s,1,Goto(gtpbx-field-call,s,1)
exten => _[*#+0-9A-Za-z],1,Goto(gtpbx-field-call,s,1)
exten => _[*#+0-9A-Za-z].,1,Goto(gtpbx-field-call,s,1)

[gtpbx-field-call]
exten => s,1,Set(FIELD_ENDPOINT=${CHANNEL(endpoint)})
 same => n,GotoIf($["${FIELD_ENDPOINT}" = ""]?reject)
 ; 장비당 동시 통화 1건
 same => n,Set(GROUP(fielddev)=${FIELD_ENDPOINT})
 same => n,GotoIf($[${GROUP_COUNT(${FIELD_ENDPOINT}@fielddev)} > 1]?busy)
 ; 발신 표시는 장비가 보낸 값 대신 인증된 엔드포인트 이름
 same => n,Set(CALLERID(all)="${FIELD_ENDPOINT}" <${FIELD_ENDPOINT}>)
 same => n,Set(CDR(field_endpoint)=${FIELD_ENDPOINT})
 same => n,Set(_GTPBX_REC_REL=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${CHANNEL(linkedid)}-${UNIQUEID}.wav)
 same => n,Answer()
 same => n,Playback(gtpbx/recording-notice)
 same => n,MixMonitor(${GTPBX_REC_TMP}/${UNIQUEID}.raw,bD,${GTPBX_REC_FINALIZE} ${UNIQUEID} ${GTPBX_REC_REL})
 same => n,Queue(field,,,,,,gtpbx-queue-connected)

실제 처리를 별도 context로 넘기는 이유는 주석에 적었다. 통화가 끊긴 뒤 from-field의 패턴이 h 확장으로 다시 실행되는 일을 막기 위해서다. 발신 표시는 장비가 보낸 Caller ID를 믿지 않고 인증된 엔드포인트 이름으로 덮는다. field 큐는 ringall(동시 울림), 상담원별 울림 15초, 대기 시간 제한 없음, 상담원이 없어도 들어가서 기다리기(joinempty=yes)로 잡았다. D20의 "계속 대기"를 큐 설정으로 옮긴 것이다.

녹음: 연결된 구간만, 좌우 채널을 나눠서

녹음은 MixMonitor의 b(상담원과 브리지된 구간만)와 D(두 방향을 스테레오로 섞지 않고 저장) 옵션을 쓴다. D는 raw 형식만 지원해서 통화 중에는 tmp/<uniqueid>.raw로 쓰고, 통화가 끝나면 recording-finalize.sh가 sox로 스테레오 WAV(왼쪽 장비, 오른쪽 상담원)로 바꿔 /data/recordings/YYYY/MM/DD/로 옮긴다. 상담원이 받지 않은 통화는 raw가 비어 지우고, 변환에 실패한 파일은 failed/에 남긴다. b 덕분에 대기 음악과 안내 멘트는 녹취에 들어가지 않는다.

CDR에서 하나 걸린 것이 있었다. 녹음 고지 멘트를 틀려면 먼저 Answer()를 해야 하는데, 그러면 상담원과 연결되지 않은 통화도 CDR disposition이 ANSWERED가 된다. 그래서 녹취 여부는 disposition이 아니라 recording_file 칸으로 판단하기로 하고, 그 칸은 상담원이 받은 CDR 행에만 넣었다. 큐가 상담원 채널에서 실행하는 gosub(gtpbx-queue-connected)에서 값을 넣으면 그 행에만 남는다.

watchdog과 방화벽

watchdog은 Gem3Park의 것을 옮겼다. 프로세스, CLI 응답, PJSIP 수신 순으로 점검하고, 이상하면 pjsip reload, 그래도 안 되면 재시작하되 5분 안에 3회까지만 한다. systemd timer로 40초마다 돈다. Gem3Park에서는 sudo 비밀번호를 .env에 두고 파이프로 넘겼는데, 이번에는 root systemd 서비스로 돌려 그 방식을 없앴다.

방화벽은 nftables 템플릿으로 만들었다. 기본 정책은 drop, SSH는 관리망만, SIP·RTP 포트만 연다. SIP는 상담원망 밖 출발지 IP마다 초당 패킷 수를 제한한다. AMI(5038), HTTP(8088), PostgreSQL(5432)은 모두 서버 안(localhost)에서만 받고 방화벽에서도 막는다. fail2ban은 Asterisk 보안 로그의 SIP 인증 실패를 보고 차단한다. 방화벽 규칙을 적용하는 스크립트는 자동으로 실행되지 않게 따로 뒀다.

SIPp로 장비와 상담원을 흉내 낸다

개발 환경은 맥의 OrbStack VM 두 대다. gtpbx-pbx에 운영 서버와 같은 설치 스크립트로 PBX를 올리고, gtpbx-sim에서 SIPp로 장비(시험 계정 900001900010)와 상담원(20012003)을 흉내 낸다. tests/m1/run.sh의 시나리오는 이렇다.

#내용확인
1상담원 0명일 때 장비가 걸고 8초 뒤 끊음ENTERQUEUE 1, ABANDON 1
2상담원 3명 등록·큐 로그인큐 멤버 3명 Not in use
3장비 10대 동시 호출, 상담원 3명이 8초씩 통화ENTERQUEUE 10, CONNECT 10, ABANDON 0
4인증 없는 INVITE401/403, 큐 진입 없음
5같은 장비의 동시 2통화두 번째는 거절

마지막에 응답된 통화마다 녹취 파일의 소유자, 2채널 여부, 길이(3초 이상), 채널별 소리 크기를 보고, 포기한 호출에는 파일이 없는지, tmp가 비었는지까지 확인한다.

SIPp 쪽에서 시간을 꽤 썼다. Ubuntu 패키지의 SIPp 3.7.2는 인증 정보의 중첩 치환이 깨져 계정마다 프로세스를 따로 띄웠다. 대기 루프 사이에 PBX의 BYE가 도착하면 SIPp 기본 동작이 호출을 실패로 끊어서 -default_behaviors all,-abortunexp로 바꿨고, 300회 중 2회쯤 나던 실패가 900회 중 0회가 됐다.

계약 §11에 남긴 결과는 OrbStack arm64 VM에서 tests/m1/run.sh 52/52 통과(3회), 스크립트 단위 시험 통과, shellcheck 경고 0, 시험 뒤 Asterisk 로그 ERROR·WARNING 0이다. 구현 결과는 10월 23일에 적었고, 커밋은 10월 26일 오전에 했다. 같은 표에 미검증 항목도 적어 뒀다. 방화벽 실제 적용과 fail2ban 실제 차단, x86_64 빌드, 실제 IP 전화기와 NAT, 장비 등록 만료 뒤 재등록이다. 이 가운데 x86_64 빌드와 NAT는 4편의 EC2 부하 시험에서 다시 만난다.

다음 글

GT PBX 개발기 2 — reload 없이 반영하는 설정 웹앱