본문으로 건너뛰기Hwanseok Sim

작성일

GT PBX 개발기 3 — 레거시 PBX에서 무엇을 옮기고 무엇을 두고 왔나

기존 상용 PBX의 화면과 데이터를 조사해 옮길 기능을 고른 기록: 장비 그룹과 CP949 CSV 일괄 등록, 사이트 라우팅을 옮기지 않은 결정과 일부 번복한 협력사 시간표, 녹취 켬/끔, 공통 기본 비밀번호의 득실, 화면 조사에서 나온 고칠 거리(2026년 10월 26~29일).

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

원칙: 기술은 가져오지 않는다

M2까지 만든 것은 새 PBX의 뼈대였다. 레거시 PBX를 대체하려면 그쪽에서 실제로 쓰는 기능을 알아야 했다. 10월 26일 레거시 PBX의 관리 웹을 분석한 문서와 이관 계획 문서를 썼다. 조사는 지금 통화를 받고 있는 서버에 부담을 주지 않게, 통화가 적은 시간에 간격을 두고 GET만 보냈다.

이관 계획의 첫 줄은 "기술은 하나도 가져오지 않는다. 화면·데이터 형식도 마찬가지다"였다. 레거시 PBX는 기반 소프트웨어 세대가 다르고, 비밀번호 저장과 녹취 접근 방식도 새 PBX의 기준과 맞지 않는다. 옮기는 것은 운영에 필요한 기능과 데이터뿐이고, 구현은 M1·M2의 기술 결정과 설정 반영 흐름을 그대로 따른다.

옮긴 것과 옮기지 않은 것

레거시 기능새 PBX단계
그룹(고객사·사이트), 단말 위치장비 그룹, 장비 위치 칸X1
단말 목록CSV 일괄 등록, 새 비밀번호 발급X1
장비끼리 내선 통화같은 그룹 안에서만 (D24)X2
트렁크·발신 규칙·차단 prefix상담원 외부 발신X4
일정 관리(업무 시간 분기)처음엔 옮기지 않음 → 협력사 시간표로 일부 (D27)X5

옮기지 않은 것도 이유와 함께 적었다. 번호 블랙리스트는 인입이 장비뿐이라 의미가 없어서 M2의 장비 일시 차단으로 대신했다. 코덱 관리는 G.711 고정이라 뺐다. 그룹 단위 일괄 삭제는 실수 한 번에 사이트 전체가 끊길 수 있어 선택 삭제만 남겼다. 단말별 녹취 켜기·끄기도 처음에는 "전부 녹음한다"며 뺐는데, 이 결정은 이틀 뒤에 뒤집혔다(아래 D28).

D23: 사이트 라우팅은 옮기지 않는다

레거시 PBX에는 사이트마다 Ring Group과 일정·휴일 라우팅이 있었다. 이것을 그대로 옮기면 1편의 D13(단일 큐)과 D20(24시간 계속 대기)이 무너져서, 10월 26일에 옮기지 않기로 했다(D23). 10월 28일에 일정 설정을 화면으로 전부 읽어 이 결정의 영향 범위를 확인했다. Ring Group은 모두 동시 울림이었다. 일정 관리를 쓰는 Ring Group도 모두 시간대에 따라 다른 Ring Group으로 넘길 뿐, 외부 번호로 착신 전환하는 곳은 없었다. 대표번호로 들어온 현장 호출을 업무 시간에는 현장 사무실로, 그 밖에는 콜센터로 넘기는 구조로 보였다.

같은 날 D23을 일부 번복했다. 어떤 사이트는 자사가 야간에만 받고 다른 시간은 협력사가 받는다. 이것은 레거시 설정에서 뽑을 수 없는 운영 사실이라, 대상 사이트와 시간대는 운영자와 확인하기로 하고 기능부터 만들었다(D27, X5). Ring Group과 휴일은 여전히 옮기지 않는다.

X1: 장비 그룹과 CSV 일괄 등록

장비 ID는 레거시 내선번호를 그대로 쓰고 비밀번호만 새로 만든다(D25). 상담원 내선과 장비 ID는 같은 ps_endpoints 기본 키에 있어 번호가 겹치면 DB가 막는다.

X1(10월 26일)에서 장비 그룹(코드·이름·메모)과 장비 이름·위치 칸, 그리고 레거시 단말 목록을 CSV로 올려 한 번에 만드는 일괄 등록을 만들었다. 엑셀 한국어판과 레거시 PBX는 CSV를 CP949로 저장하므로 인코딩부터 판정한다.

BOM이 있으면 UTF-8로 읽고, 없으면 엄격 모드로 UTF-8을 먼저 시도한 뒤 CP949로 다시 읽는다.

apps/server/src/csv.ts
  try {
    return { text: new TextDecoder("utf-8", { fatal: true }).decode(input), encoding: "utf-8" };
  } catch {
    // CP949 로 다시 시도
  }
  try {
    return { text: new TextDecoder("euc-kr", { fatal: true }).decode(input), encoding: "cp949" };
  } catch {
    return null;
  }
}

머리글은 이름으로 찾고, 레거시 화면의 열 이름(단말 이름, 단말 위치 등)을 별칭으로 받는다. 레거시 파일에 있는 비밀번호 열은 파싱 단계에서 값을 버려 미리보기 응답, 로그, 변경 이력 어디에도 남지 않게 했다. CSV 파서의 오류 메시지에 칸 값이 들어갈 수 있어서 오류에는 줄 번호와 오류 코드만 쓴다.

등록은 두 단계다. 미리보기는 DB를 바꾸지 않고 행마다 새로 만듦·이미 있음·상담원 번호와 충돌·형식 오류를 보여 준다. 새로 만들 수 없는 행이 하나라도 있으면 등록 버튼이 꺼지고, 화면이 파일에서 지울 행 목록을 알려 준다. 상담원 전화기나 폐기할 단말은 관리자가 파일에서 지우고 다시 올린다. 등록은 그룹 생성과 장비 생성을 한 트랜잭션으로 하고, 반영 확인에 실패하면 2편의 보상 흐름대로 이번에 만든 그룹까지 지운다.

시험에는 실제 고객 데이터를 쓰지 않았다. 레거시 형식을 흉내 낸 CP949 CSV 300행을 생성기로 만들어 tests/x1/run.sh가 미리보기 → 등록 → 받은 비밀번호로 SIPp 장비 등록 → 미등록 필터에서 빠짐 → 호출 → 통화 이력 그룹 필터까지 확인한다. 결과는 86/86이었다. 실제 레거시 단말 목록 파일로는 아직 돌려 보지 않았다.

X5: 협력사 시간표와 0명일 때 되돌림

X5(10월 28일)의 동작은 이렇다. 장비 그룹에 협력사와 요일별 응대 구간을 지정한다. 협력사 상담원 전화기도 우리 PBX에 등록하고, 협력사마다 큐(partner_<코드>)를 하나 둔다. 협력사로 가는 통화는 녹음하지 않고 고지 멘트도 틀지 않는다. 협력사 내선의 context는 아무 데도 걸 수 없게 해서 받기만 한다.

어려운 부분은 협력사 상담원이 아무도 없을 때였다. 자사 상담원은 24시간 있으니, 협력사 큐에 들어가지 못하거나 기다리는 중에 상담원이 모두 빠지면 자사 field 큐로 되돌린다. 협력사 큐는 joinempty·leavewhenempty를 켜서 Asterisk가 이 판단을 하게 하고, 다이얼플랜은 Queue가 돌려준 QUEUESTATUS로 갈래를 나눈다.

asterisk/extensions.conf
 same => n,Queue(${GTPBX_PQ})
 same => n,GotoIf($["${QUEUESTATUS}" = ""]?pq_done)
 same => n,GotoIf($["${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL"]?fb_join)
 same => n,GotoIf($["${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fb_leave)
 same => n,Log(WARNING,GTPBX partner queue ${GTPBX_PQ} returned QUEUESTATUS=${QUEUESTATUS} endpoint=${FIELD_ENDPOINT} - sending to own queue without fallback record)
 same => n,Goto(own)
 same => n(pq_done),Hangup()
 same => n(fb_join),Set(GTPBX_FB_REASON=joinempty)
 same => n,Goto(fallback)
 same => n(fb_leave),Set(GTPBX_FB_REASON=leaveempty)
 same => n(fallback),QueueLog(field,${UNIQUEID},NONE,GTPBXFALLBACK,${GTPBX_PQ:8}|${GTPBX_FB_REASON})
 same => n,Log(NOTICE,GTPBX partner fallback endpoint=${FIELD_ENDPOINT} partner=${GTPBX_PQ:8} reason=${GTPBX_FB_REASON})

되돌린 통화는 세 곳에 남는다. queue_log의 사용자 이벤트 GTPBXFALLBACK, 시스템 로그의 NOTICE, 그리고 상담원 전화기 화면의 발신 이름 앞에 붙는 FB다. 대기 시간은 처음 협력사 큐에 들어간 때부터 잰다. 장비 쪽 사람이 실제로 기다린 시간이 그것이기 때문이다. 응대처 조회가 DB 오류로 비면 자사 큐로 보내되, 되돌림으로 기록하지는 않는다.

Asterisk 22.11 소스를 보니 JOINUNAVAIL·LEAVEUNAVAIL은 설정되지 않아서 호환용으로만 같이 받는다. 개발 VM에서 tests/x5/run.sh는 84/84였다. 같은 VM에서 부하 시험과 겹쳐 돌린 한 번은 81/84였는데, 두 시험이 모두 SIPp를 끄고 큐 멤버를 정리해서 생긴 간섭이었다.

협력사 응대 시간표 편집 화면에는 10월 28일에 클라우드 다중 에이전트 리뷰(/ultrareview)를 돌렸다. 두 건이 나왔고 코드와 대조해 둘 다 실제로 일어나는 것을 확인했다. 큰 쪽은 요일별 시간표를 "평일·토·일" 묶음으로 바꿀 때였다. 화~금 중 한 요일에 입력을 끝내지 않은 시간대가 있으면 비교에서 빠져 월요일과 같다고 판정되고, 버튼을 누르면 그 시간대가 안내 없이 사라졌다. 10월 29일에 입력 중인 줄이 있으면 전환을 막고 따로 안내하도록 고쳤다.

D28: 녹취를 끌 수 있게

10월 28일 밤에는 녹취를 장비 그룹별·장비별로 켜고 끄는 기능(D28)을 넣었다. 그룹은 켬/끔, 장비는 그룹 따름/켬/끔이고 장비 설정이 우선한다. 그룹이 없는 장비의 "따름"은 켬이다. 끈 호출은 고지 멘트도 틀지 않는다.

장비 일시 차단과 반대로, 녹취 설정 조회가 실패하면 켬으로 본다. 차단 조회 실패를 통과시킨 것은 호출을 잃지 않기 위해서였고, 녹취 조회 실패를 녹음으로 처리한 것은 녹취 누락보다 불필요한 녹음이 낫다고 봤기 때문이다. 끈 호출은 queue_log 사용자 이벤트 GTPBXNOREC로 표시해 통화 이력에 "설정으로 녹음 안 함"으로 보인다. CDR은 통화가 끝나야 들어오지만 queue_log는 통화 중에도 바로 보여서 CDR 사용자 칸 대신 이쪽을 골랐다. 개발 VM 시험 tests/rec/run.sh는 85/85였다.

D29·D30: 공통 기본 비밀번호의 득실

같은 시기에 SIP 비밀번호 정책도 바꿨다. 원래는 계정마다 무작위 비밀번호를 만들어 한 번만 보여 줬다. 일괄 등록이면 비밀번호 CSV를 딱 한 번 내려받는다. 10월 28일에 상담원 내선(D29), 29일에 장비 계정(D30)을 공통 기본값 하나로 만들도록 결정했다. 협력사 내선은 지금처럼 무작위다.

위험은 PLAN §9에 그대로 적었다. 상담원 내선은 4편에서 다룰 부하 시험 중에 네트워크 제한을 풀었고, 현장 장비는 인터넷에 노출돼 있다. 공통 기본값이 새면 기본값을 그대로 쓰는 모든 계정으로 어디서든 등록할 수 있다. 비밀번호가 맞으니 인증 실패가 없어 fail2ban도 막지 못한다.

안전장치는 이렇게 뒀다.

  • 값은 저장소·문서·로그·감사 로그에 남기지 않고 서버 설정 파일(/etc/gtpbx/gtpbx.env, 0600)에만 둔다.
  • 목록에 "기본 비밀번호" 배지를 띄운다. 웹 역할은 해시를 읽을 수 없으니, 저장된 다이제스트가 기본값으로 계산한 값과 같은지 비교하는 DB 함수를 따로 두었다.
  • 설치를 마친 장비와 전화기는 계정별 비밀번호로 바꾸는 운영 절차를 둔다. 장비의 무작위 "재발급"은 없애고 직접 입력하는 "비밀번호 변경"으로 바꿨다.
  • 기본값은 12~64자에 허용 문자를 좁혔다. 설정 값이 따옴표 없이 bash로 넘어가서 ~로 시작하면 틸드 확장으로 바뀔 수 있다.

남은 위험도 하나 적어 뒀다. 배지를 계산할 때 기본값이 DB 함수의 바인드 매개변수로 PostgreSQL에 간다. 지금은 문장 로그를 켜지 않아 남지 않지만, 나중에 느린 쿼리 로그를 켜면 매개변수가 찍힐 수 있다. 그때는 log_parameter_max_length = 0을 같이 켠다.

화면 조사에서 나온 고칠 거리

10월 29일에는 레거시 PBX와 EC2 스테이징의 새 PBX 화면을 나란히 조사한 비교 보고서에서 화면 문제 11건을 뽑았다. 그날 오전에 10건을 고쳤고, 1건(장비 그룹의 위치 칸)은 레거시에도 없고 위치는 장비 단위라 넣지 않기로 했다. 몇 가지를 고르면 이렇다.

#문제처리
B5웹에서 만든 상담원 내선이 큐 멤버 "아님"으로 보였다버그가 아니라 설계였다. 상담원이 전화기 *11/*12로 직접 로그인하고 웹은 읽기만 한다. "큐 로그인" 열과 안내만 더했다
B8장비 목록에 정렬과 내보내기가 없었다열 정렬과 CSV 내보내기. 앞 6열을 일괄 등록이 아는 머리글로 맞춰, 내려받은 파일을 고쳐 다시 올릴 수 있다
B11한국어 Chromium에서 시각 입력이 12시간제(오후 06:00)로 보였다24시간제 텍스트 입력 칸을 직접 만들어 근무조·시간표 편집에 같이 썼다
B4되돌리기가 수정 이력에만 있었다생성 이력에 "이 항목 열기", 삭제 이력에 "이 값으로 다시 만들기"를 두었다. 저장은 사람이 누른다

이관에서 아직 하지 않은 것도 있다. 레거시 PBX의 과거 녹취·CDR 가져오기(X3, D26)는 서버 접근 권한을 확인하기 전이라 계획만 있다. 협력사 응대 사이트와 시간대, 그룹·장비별 녹취 켬/끔 값은 운영자와 정해서 입력해야 한다. 실제 협력사 전화기와 현장 장비로 확인하는 일도 남았다.

다음 글

GT PBX 개발기 4 — EC2에서 동시 통화 100건까지 걸어 보기