본문으로 건너뛰기Hwanseok Sim

작성일

GT PBX 개발기 7 — 장비 간 통화와 상담원 외부 발신

같은 그룹 장비끼리 큐를 거치지 않고 통화하게 한 X2와, 상담원만 통신사 트렁크로 외부 번호를 걸 수 있게 한 X4. 미등록 상대를 Dial 하면 404가 되던 문제, 실패하면 끊는 결정, 판정 순서와 fail-closed, 장비 context에는 나갈 길 자체가 없게 한 구조, 그리고 실제 회선 검증이 남은 상태를 정리했다.

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

같은 날, 다른 세션

5편과 6편을 한 작업 세션이 하는 동안, 다른 세션은 다이얼플랜 쪽 두 가지를 차례로 했다. X2 같은 그룹 장비 간 통화(계약 11시 13분 → 병합 12시 28분)와 X4 상담원 외부 발신(계약 12시 40분 → 병합 14시 2분)이다. 둘 다 레거시 PBX에 있던 기능을 옮기는 일이었고, 둘 다 큐를 거치지 않는 통화라 통화 이력을 남기는 방식이 같다.

X2: 같은 그룹 장비끼리

현장 장비가 같은 장비 그룹의 다른 장비 번호를 누르면 그 장비로 바로 연결한다. 허용 조건은 이렇다.

  • 누른 번호가 숫자만이다.
  • 그 번호가 장비(from-field context) 계정이다.
  • 두 장비의 그룹이 같고 NULL이 아니다.
  • 자기 자신이 아니다.

판정은 DB 함수 gtpbx_device_peer(발신, 번호) 하나에 넣고 다이얼플랜은 func_odbc로 그 결과만 받는다. 맞지 않으면 지금처럼 협력사 판단을 거쳐 자사 field 큐로 간다. 그때까지 [from-field]는 누른 번호를 버리고 있었으므로 Set(__GTPBX_DIALED=${EXTEN})으로 번호를 보존하는 줄부터 넣었다. 분기 위치는 공통 단계(엔드포인트 확인, 일시 차단, 장비당 동시 통화 1건) 뒤, 협력사 판단 앞이다.

판정 조회가 실패하면 장비 간 통화가 아닌 것으로 보고 큐로 보낸다(fail-open). 협력사 시간표(3편)와 같은 이유다. 장비의 호출을 잃는 것이 가장 나쁘다.

실패하면 끊는다

상대가 미등록이거나 통화 중이거나 받지 않으면 어떻게 할지가 결정할 거리였다. 콜센터 큐로 넘기는 방법도 있었지만 끊기로 했다. 장비가 의도한 상대는 장비지 콜센터가 아니고, 잘못 눌린 번호가 콜센터로 흘러드는 것을 막고 싶었다. 대신 장비가 통화 중 음을 듣는지를 승인 조건으로 걸었다. "실패하면 콜센터로"를 원하는 현장이 나오면 설정값으로 분기를 더하기로 하고 열린 문제에 적었다.

이 조건을 시험하다가 문제가 하나 나왔다. tests/x2 시나리오 ⑤에서 미등록 상대를 그대로 Dial하니 chan_pjsip이 "Could not create dialog to invalid URI" ERROR를 남기고 cause 3(NO_ROUTE_DESTINATION)으로 끝났다. 장비에는 404, 즉 "번호 없음"이 간다. 12시 15분 커밋(455c44a)에서 Dial 전에 PJSIP_DIAL_CONTACTS로 contact가 있는지 보고, 비어 있으면 걸지 않고 cause 20(SUBSCRIBER_ABSENT → 480)으로 끝내게 바꿨다.

asterisk/extensions.conf
 ; 상대 장비 통화 중: 상대가 발신 중(공통 단계의 GROUP)이거나 수신 중(gtpbx-device-connected 의 GROUP)이면 걸지 않는다.
 same => n,GotoIf($[${GROUP_COUNT(${GTPBX_DEVICE_TARGET}@fielddev)} > 0]?device_busy)
 ; 상대 미등록(AOR 에 contact 없음): 그대로 Dial 하면 chan_pjsip 이 "Could not create dialog to invalid URI" ERROR 를 남기고
 ; cause 3(NO_ROUTE_DESTINATION → 404) 로 끝나 장비가 "번호 없음" 을 듣는다. 걸지 않고 20(SUBSCRIBER_ABSENT → 480) 으로 끝낸다.
 same => n,GotoIf($["${PJSIP_DIAL_CONTACTS(${GTPBX_DEVICE_TARGET})}" = ""]?device_unavail)
 ; (녹음 판정·Dial 생략)
 same => n,Set(GTPBX_HC=${IF($["${DIALSTATUS}" = "NOANSWER"]?19)})
 same => n,ExecIf($["${DIALSTATUS}" = "CHANUNAVAIL"]?Set(GTPBX_HC=20))
 same => n,Hangup(${GTPBX_HC})

Dial 뒤에도 cause를 정리했다. 무응답(NOANSWER)은 Dial이 cause를 남기지 않아 기본값 16이 되고, SIP로는 603 Decline으로 나간다. 그래서 19(NO_ANSWER → 480)를 준다. 통화 중은 Dial이 옮겨 둔 cause 17을 그대로 써서 486이 된다. 최종 시험 결과는 미등록 480, 통화 중 486, 무응답 480이었고 200·BYE로 끝난 실패 통화는 없었다.

큐를 거치지 않으니 queue_log가 생기지 않는다. 그래서 다이얼플랜이 사용자 이벤트 3개(GTPBXDEVICE·GTPBXDEVICECONNECT·GTPBXDEVICEEND)를 queue_log에 직접 남기고, 기존 통화 요약 트리거가 그걸 읽어 call_type='device' 행을 만든다. 녹음은 발신 장비의 녹취 켬/끔 설정을 따른다. 녹취 파일 이름을 처음에는 Dial 전에 발신 채널 CDR에 넣었는데, 그러면 연결되지 않은 통화도 "녹취 있음"으로 보인다. 계약 v0.1.1에서 상대가 받은 직후 실행하는 gosub에서 넣도록 고쳤다.

tests/x2/run.sh --with-e2e는 11개 시나리오와 Playwright로 165/0이었다(OrbStack). 회귀 m1·m2·x1·x5·rec도 모두 통과했다.

X4: 상담원만 밖으로 나간다

외부 발신에서 먼저 정한 것은 누가 나갈 수 있느냐였다. 상담원 내선(from-agent)만 나갈 수 있다. 이걸 권한 검사로 막지 않고 구조로 막았다. 외부로 나가는 분기는 [from-agent]에만 있고, 장비(from-field)와 협력사 내선(from-partner) context에는 트렁크로 가는 다이얼플랜 줄이 아예 없다.

asterisk/extensions.conf
[from-agent]
include => gtpbx-agent-features

; 상담원끼리 내선 호출. 대상이 상담원 엔드포인트(context from-agent)일 때만 건다.
; 내선이 아니면(invalid) 숫자만인 번호는 외부 발신으로 보낸다 (X4 계약 §2 — 내선 판정이 먼저라 내선 번호와 충돌 없음).
exten => _XX.,1,GotoIf($[${REGEX("^[0-9]{2,10}$" ${EXTEN})}]?:invalid)
 same => n,GotoIf($["${PJSIP_ENDPOINT(${EXTEN},context)}" = "from-agent"]?:invalid)
 same => n,Dial(PJSIP/${EXTEN},30)
 same => n,Hangup()
 same => n(invalid),GotoIf($[${REGEX("^[0-9]+$" ${EXTEN})}]?gtpbx-agent-outbound,${EXTEN},1)
 same => n,Hangup(1)

트렁크는 1개만 두고 웹이 아니라 배포 설정(gtpbx.env)과 정적 pjsip 템플릿으로 만든다. 비밀번호는 /etc/asterisk의 렌더 파일에만 두고 DB에 평문으로 두지 않는다. 트렁크 섹션 이름은 모두 trunk- 접두어를 붙여 Realtime의 장비·내선 ID와 겹치지 않게 했다. 트렁크 호스트가 비어 있으면 배포 스크립트가 트렁크 파일을 지운다. 지금 EC2 스테이징이 그 상태다. 계약대로라면 이때 외부 발신은 TRUNK_DOWN 사유의 실패 행을 남기고 503으로 끝나야 하는데, 이 경로도 아래 tests/x4 러너가 없어 아직 시험하지 않았다.

판정 순서와 fail-closed

다이얼플랜은 func_odbc로 DB 함수 gtpbx_outbound_route(상담원, 번호)를 부른다. 결과는 allow|<트렁크>|<번호>|<최대 동시>|<녹음>|<고지>|<울림 초> 또는 deny|<사유>다. 판정 순서는 이렇게 정했다.

순서판정결과
1발신 규칙 패턴에 맞는가안 맞으면 NO_RULE: 행 없이 404, NOTICE 로그만
2상담원 내선인가NOT_AGENT: 실패 행, 403
3외부 발신이 켜져 있는가DISABLED: 실패 행, 403
4prefix 판정DENIED_PREFIX: 실패 행, 403
5트렁크 contact가 있는가 (다이얼플랜)TRUNK_DOWN: 실패 행, 503
6동시 발신 수 (기본 4)LIMIT: 실패 행, 503

prefix는 기본 거부다. 번호에 맞는 가장 긴 prefix가 결정하고, 같은 길이면 차단이 이긴다. 허용 prefix에 하나도 맞지 않으면 거부한다. 씨앗 값은 국내 지역번호, 010, 070, 080, 전국대표번호 15·16·18을 허용하고 국제 00과 060을 차단한다.

규칙을 맨 앞에 둔 것은 v0.1.2(13시 23분, a95d7d2)에서 바꾼 것이다. 처음 씨앗 규칙 패턴은 0X.였는데, 그러면 허용 prefix인 15·16·18로 시작하는 번호가 규칙에 맞지 않아 NO_RULE이 됐다. 패턴을 [01]X.로 바꿨다. 같이 정한 것이 NO_RULE의 처리다. 상담원이 내선이나 장비 번호를 잘못 누른 것까지 실패 행으로 남기면 통화 이력이 오입력으로 찬다. 그래서 어떤 규칙에도 맞지 않는 번호는 외부 번호가 아닌 것으로 보고 X4 이전처럼 행 없이 404를 돌려준다. 규칙에는 맞는데 거부된 시도는 모두 실패 행과 NOTICE 로그를 남긴다. 요금 사기를 감시할 근거가 된다.

판정 결과가 비었거나, ODBC 오류가 났거나, 형식이 깨졌으면 모두 거부하고 사유를 ERROR로 남긴다. 다이얼플랜은 허용 결과의 모양 전체를 정규식으로 확인한 뒤에만 Dial한다. X2와 협력사 시간표의 fail-open과 정반대다. 장비 호출은 잃는 것이 가장 나쁘지만, 외부 발신은 호출을 잃는 것보다 무단으로 나가는 것이 더 나쁘다고 봤다.

검증과 남은 것

X4는 DB 통합 시험(x4-db.it.ts, x4-outbound.it.ts)으로 판정 함수·prefix·트리거·API를 확인했다. 병합 직전 브랜치 기준으로 웹 vitest 242, 서버 단위 72, DB 통합 240이 모두 통과했다. SIPp로 통신사 트렁크를 흉내 내는 통합 러너 tests/x4는 계약에 시나리오를 ①부터 ⑮까지 적어 두었지만 러너를 쓰기 전에 작업을 멈췄다. 그래서 main에서 VM 회귀로 기존 경로(m1·m2·x1·x5·rec·x2·m3·m4)에 영향이 없는 것만 확인하고 14시 32분에 EC2에 배포했다.

  • tests/x4 러너와 X4 시나리오 시험. 실제로 트렁크까지 INVITE가 나가는 경로는 아직 한 번도 시험하지 않았다.
  • 실제 통신사 회선. 인증 방식(IP/등록), 발신번호 형식, 코덱, 접속번호 자릿수 변환은 회선 정보가 와야 맞출 수 있다.
  • 통화 이력에 외부 번호를 그대로 둘지, 뒷자리를 가릴지.
  • 내선별 발신 등급, 야간 발신 차단, 이상 발신 알림(M5).
  • X2는 SIPp 장비로만 확인했다. 실제 현장 장비 검증이 남았다.

다음 글

GT PBX 개발기 8 — AI 설정 도우미와 남은 과제