안녕하세요. 우아한형제들 중계시스템 팀에서 안심번호(050) 시스템 개발을 담당하고 있는 김용찬이라고 합니다.
중계시스템 팀에서는 주문을 중계하는 역할 뿐만 아니라, 050 전화도 중계하고 있는데요.
새롭게 만들어가고 있는 안심번호(050) 시스템에 대해 소개해보려고 합니다.

배달의민족 안심번호(050) 시스템

배달의민족 앱에서 050 전화로 주문을 해 본 경험이 누구나 한 번쯤은 있을 텐데요. 배민앱에 있는 가게의 050 번호로 전화를 하게 되면 가게로 전화 연결을 하는 동안 통화연결음(배민컬러링)이 들리게 됩니다. 이를 통해 고객들은 자연스럽게 배민을 통해 주문을 하는 것을 알 수 있게 되고, 반대로 음식점 사장님들도 주문 전화를 받게 되면 배달의민족 콜~ 이라는 배민콜멘트를 듣게 됨으로써, 배민을 통해 들어온 주문이구나~ 하고 알 수 있게 됩니다. 음식을 주문하기 위해 음식점으로 전화 주문을 하는 050 전화, 음식점에서 혹은 배달 라이더가 고객에게 거는 050 전화 모두 저희 배달의민족 안심번호(050) 시스템에서 관리하고 있습니다.

whatisthe050system


저희 안심번호(050) 시스템은요

안심번호(050) 시스템은 VoIP 기술을 사용하고 있는데요. Call signal 을 담당하는 시그널 서버와 Media stream 을 제어하는 미디어 서버로 나누어 집니다.

시그널 서버는 무엇인가요?

세션 개시 프로토콜(Session Initiation Protocol, SIP) 이라고 해서 음성 혹은 화상 통화 같은 멀티미디어 세션을 제어하기 위해 사용되는 전화 시그널을 담당하는 서버입니다. 일종의 전화를 걸고 받고 끊는데 필요한 모든 기능을 담당합니다.

예 : 전화 걸기, 전화 받기, 통화 중, 통화대기, 통화종료

미디어 서버는 무엇인가요?

실시간 전송 프로토콜(Real-time Transport Protocol, RTP) 이라고 해서 실시간으로 음성 및 영상을 전송하는 역할을 담당하는 서버입니다. 050 전화에서 시그널 서버가 전화를 걸고 받는데 필요한 기능이라면 미디어 서버는 오디오 데이터 자체를 전송하는데 필요한 기능이라고 보면 됩니다.

예 : 통화연결음(컬러링), 콜멘트, 음성 통화를 위한 데이터 압축 및 패킷 전송

external_solution

저희 안심번호(050) 시스템은 그동안 외주 개발사를 통해 구축된 시그널 서버와 미디어 서버를 운영해 왔는데요. 배달의민족 서비스의 폭발적인 성장과 함께 점진적으로 트래픽이 늘어남에 따라 안정성, 확장성 제약에 대한 이슈를 맞닥뜨리면서 내재화를 고민하게 되었습니다.


내재화를 진행하다 🔥

외부 솔루션을 오픈소스로 대체하다. 대체 어떻게?

시그널 엔진

  • Open Source : OpenSIPS (2.4)
  • Github Repository : https://github.com/OpenSIPS/opensips

미디어 엔진

  • Open Source : RTP Engine (mr 7.4)
  • Github Repository : https://github.com/sipwise/rtpengine

맨땅에 헤딩하는 느낌으로 내재화를 시작해보았는데요. 시그널 서버에 포팅할 엔진은 OpenSIPS(이하 시그널엔진) 를, 미디어 서버 포팅할 엔진은 RTP Engine(이하 미디어엔진) 오픈소스를 선택하여 걱정 반, 설렘 반으로 시스템 구축을 시작해보았습니다.


안심번호(050) 시스템의 기본적인 콜 시나리오부터 파악하자

basic_scenario

콜 시나리오는 시그널 서버가 발신 측과 수신 측의 세션을 맺고 끊기 위한 시그널링을 통해 통화 연결 및 종료를 해주는데요. 미디어 서버는 컬러링과 콜멘트를 재생시켜주고, 통화를 제어합니다. 우선, 발신 측이 전화를 걸게 되면 시그널을 거쳐 수신 측에 벨이 울리게 되고, 이 때, (1) 미디어 서버가 발신 측에 컬러링을 재생시켜줍니다. 수신 측이 전화를 받게 되면 (2) 미디어 서버가 수신 측에 콜멘트를 재생시켜줍니다. 수신 측의 콜멘트가 약 2초간 재생된 후 종료가 되면, 발신 측의 컬러링도 중지가 되면서, 상호 통화가 이루어지게 되는데요. 이 후, 발신 측 혹은 수신 측이 전화를 끊게 되면 끊었다는 시그널과 그에 대한 응답을 받으며, 통화가 완전히 종료됩니다.

모듈의 함수를 직접 구현

위에서 설명한 콜 시나리오 대로 시스템을 구현하기 위해서는 시그널 엔진의 opensips.cfg 스크립트에서 시그널링에 대한 처리와 미디어 제어를 위해 rtpengine 모듈의 함수를 호출합니다. 즉, 미디어 엔진의 함수를 사용하기 위해서는 시그널 엔진의 rtpengine.so 라이브러리에서 offeranswer 같은 함수들이 제공되는데요. 시그널 엔진의 당시 버전(2.4) 에는 미디어 엔진에 대한 기본적인 함수들(offer, answer…)은 존재했지만, 미디어를 제어(play media, block media, stop media)하기 위한 함수들은 구현되어 있지 않아서 해당 모듈의 함수를 직접 구현해야만 했습니다.

module

play media : 발신 측 혹은 수신 측에 미디어를 재생시켜주는 역할

stop media : 재생 중인 미디어를 중지시켜주는 역할

block media : 발신 측/수신 측에 상호 음성을 차단하는 역할

unblock media : block media 의 반대, 음성 차단을 해제하는 역할

구현한 함수들을 사용하기 위해 시그널 엔진을 다시 빌드하여 rtpengine.so 모듈을 다시 만들어줍니다.


scenario1_1

구현한 함수들을 콜 시나리오에 맞게 적용시켜 봅시다.

(1) 전화 발신 : 시그널 엔진에서 block media, play media 사용

(2) 전화 수신 : 시그널 엔진에서 play media 사용

(3) 콜멘트 종료 : 시그널 엔진에서 stop media, unblock media 사용


(1) 전화 발신 : 시그널 엔진에서 block media, play media 사용

scenario1_2

발신 측이 수신 측에 전화를 걸게 되면 “뚜~” 와 같은 기본 링백톤이 들리게 되는데요. (1-1) 발신 측에 기본 링백톤을 들려주는 것을 차단하기 위해 미디어 서버로 block media 를 호출하고, (1-2) 수신 측도 미디어 서버로 block media 를 합니다. (수신 측에 block media 를 해주지 않으면, 수신 측이 전화를 받았을 때 콜멘트 재생 없이 곧바로 통화가 이루어집니다.)

# opensips.cfg 스크립트 작성 예시
loadmodule "rtpengine.so"
...
block_media("from-tag=$ft");
block_media("from-tag=$tt");
...

(1-3) 발신 측에 컬러링을 재생시키기 위해서 미디어 서버로 play media 를 호출합니다. play media 의 파라미터에는 재생시키고자 하는 파일의 경로from-tag 를 명시해줍니다. from-tag 는 파일을 재생시키기 위한 방향을 발신 측으로 설정해주기 위한 파라미터입니다. (재생시키고자 하는 파일의 경로는 미디어 서버의 로컬 경로에 미리 업로드 해두어야 합니다.)

# opensips.cfg 스크립트 작성 예시
loadmodule "rtpengine.so"
...
# 183 Session Progress...
play_media("file=/usr/share/media/hello.wav from-tag=$ft"); # 컬러링 경로 & from-tag 설정
...

(2) 전화 수신 : 시그널 엔진에서 play media 사용

scenario1_3

(2-1) 수신 측이 전화를 받았을 경우, 수신 측에 콜멘트를 재생시키기 위해서 미디어 서버로 play media 를 호출합니다. play media 파라미터에 재생시키고자 하는 파일의 경로to-tag 를 명시해줍니다. to-tag 는 파일을 재생시키기 위한 방향을 수신 측으로 설정해주기 위한 파라미터입니다. (재생시키고자 하는 파일의 경로는 미디어 서버의 로컬 경로에 미리 업로드 해두어야 합니다.)

# opensips.cfg 스크립트 작성 예시
loadmodule "rtpengine.so"
...
# 200 OK...
play_media("file=/usr/share/media/callment.wav to-tag=$tt"); # 콜멘트 경로 & to-tag 설정
...

(3) 콜멘트 종료 : 시그널 엔진에서 stop media, unblock media 사용

scenario1_4

(3-1) 수신 측으로 재생된 길이 2초짜리 콜멘트가 종료되고 나면 재생 중인 발신 측 컬러링도 종료하기 위해 미디어 서버로 (3-2) 컬러링에 대한 stop media 를 호출합니다.

# opensips.cfg 스크립트 작성 예시
loadmodule "rtpengine.so"
...
stop_media("from-tag=$ft"); # 발신 측 컬러링 종료? 언제? 콜멘트가 언제 종료되는데?
...

여기서 난관에 봉착하게 되는데요. 발신 측에 재생되는 컬러링의 종료시점을 시그널 서버 관점에서는 찾을 수가 없었습니다…😱

콜멘트의 종료 시점을 알아야 (3-2) 발신 측에 재생되던 컬러링도 종료를 할 수 있고, (3-3), (3-4) 발신 측/수신 측에 unblock media 후, 통화가 이루어질 수 있을 텐데요.

일단 이렇게 된 거 정상적으로 통화라도 해보자… 해서 컬러링 강제 중지 및 unblock media 를 해보았지만, 컬러링 또한 제어할 수가 없는 상황이었습니다.

  1. 수신 측 200 OK 시그널 이후에 컬러링을 종료하기 위한 시도를 해보았는데요. 콜멘트가 끝나는 시점에 하나의 Flag 를 설정해두고, Flag 값이 유효했을 때 컬러링을 종료시켜보려고 했지만, 200 OK 시점에서 한 트랜잭션이 종료되어버리기 때문에 Flag 값도 초기화되어 이미 트랜잭션이 끝난 컬러링을 더 이상 제어할 방법이 없었습니다.
  2. 콜멘트가 종료되었다는 이벤트를 미디어 서버에 전달할 방법도 없었습니다.
  3. 결국, 모듈의 함수들 중에서 play media 를 제외한 나머지 함수들의 제어를 미디어 엔진에서 직접 구현하는 방법을 고민하게 되었습니다.

끝날 때까지 끝난 게 아니다. 미디어 엔진을 직접 수정

scenario2_1

따라서, 콜 시나리오에서는 모듈의 함수들에 대한 역할을 시그널 엔진과 미디어 엔진에 나눕니다. 즉, play media 를 제외한 부분들은 미디어 엔진에서 직접 수정 및 제어하도록 하고, 콜멘트 재생 시점 또한 제어할 수 있도록 구현합니다.

(1) 전화 발신 : 미디어 엔진에서 block media 구현 + 시그널 엔진에서 play media 사용

(2) 전화 수신 : 시그널 엔진에서 play media 사용

(3) 콜멘트 종료 : 미디어 엔진에서 stop media, unblock media 구현


(1) 전화 발신 : 미디어 엔진에서 block media 구현 + 시그널 엔진에서 play media 사용

scenario2_2

(1-1) block media 를 해야하는 시점을 미디어 엔진에서 직접 구현합니다. 언제? 미디어가 실행되기 전에… 즉, 시그널 엔진에서는 미디어 엔진에 play media 를 호출하게 되면 media_player_play_file 함수의 media_player_play_start 함수를 통해 미디어가 재생될 텐데, 미디어를 시작(media_player_play_start)하기 전에 block media 를 구현해 줍니다. (1-2) 도 마찬가지로 콜멘트가 재생되기 전 시점에 block media 를 해주면 되는데 구현하고자 하는 부분이 같은 시점이라 콜멘트에 해당하는 block media 도 동시에 해결이 되었습니다. (음원을 재생시키기 위해 media_player 구조체를 이 함수에 들고 들어오기 때문에 컬러링이 되었든 콜멘트가 되었든 이 함수에서 미디어 재생이 실행되기 때문이죠)

int media_player_play_file(struct media_player *mp, const str *file) {
  ...
	// block media
	c->block_media = 1;
	__call_unkernelize(c);
  ...
	media_player_play_start(mp);
	...
}

그리고 block media 를 미디어 엔진에서 구현해야만 하는 이유가 한 가지 더 있습니다. 기존에는 시그널 엔진에서 play media 전에 block media 를 해주고 있었는데요. 만약 컬러링을 사용하고 싶지 않다면? block media 도 할 필요가 없게 됩니다. 그런데, 시그널 엔진에서는 컬러링 사용 여부와 관계없이 block media 를 호출하고 있는 상황이라 미디어를 재생시키지 않으면 기본 통화연결음(“뚜~”)도 block 이 되어 들리지 않게 되겠죠. 따라서, 컬러링 사용 여부에 따라 block/unblock media 여부를 결정할 수 있도록 미디어 엔진에서 구현 및 제어를 해줘야 합니다.

(1-3) 발신 측으로 컬러링을 재생시키기 위해 시그널 엔진에서 미디어 서버로 play media 를 호출합니다. from-tag 는 파일을 재생시키기 위한 경로를 발신 측으로 설정해줍니다. (재생시키고자 하는 파일의 경로는 미디어 서버의 로컬 경로에 미리 업로드 해두어야 합니다.)

# opensips.cfg 스크립트 작성 예시
loadmodule "rtpengine.so"
...
# 183 Session Progress...
play_media("file=/usr/share/media/hello.wav from-tag=$ft"); # 컬러링 경로 & from-tag 설정
...

(2) 전화 수신 : 시그널 엔진에서 play media 사용

scenario2_3

이 부분도 기존과 동일하네요. (2-1) 수신 측에서 전화를 받았을 경우, 수신 측으로 콜멘트를 재생하기 위해 미디어 서버로 play media 를 호출합니다. to-tag 는 파일을 재생시키기 위한 경로를 수신 측으로 설정해줍니다. (재생시키고자 하는 파일의 경로는 미디어 서버의 로컬 경로에 미리 업로드 해두어야 합니다.)

# opensips.cfg 스크립트 작성 예시
loadmodule "rtpengine.so"
...
# 200 OK...
play_media("file=/usr/share/media/callment.wav to-tag=$tt"); # 콜멘트 경로 & to-tag 설정
...

(3) 콜멘트 종료 : 미디어 엔진에서 stop media, unblock media 구현

scenario2_4

드. 디. 어. 아까의 난관이 있던 곳으로 다시 돌아왔습니다. 수신 측에 재생되는 콜멘트의 종료 시점을 알 수가 없어서 발신 측에 재생되는 컬러링도 제어할 수가 없었고, 그 이후 (3-3), (3-4) 모두 구현할 수 없는 어려움이 있었는데요. 이를 해결하기 위한 방법으로 (3-1) 길이 2초짜리 콜멘트 재생이 종료되는 시점을 알기 위해 미디어 엔진의 미디어를 제어하는 부분을 직접 찾아보았습니다. 해당 함수는 File 을 패킷 단위로 읽어 들이고 있는 함수인데요. 이 File 의 끝을 확인하는 시점인 (ret == AVERROR_EOF) 에서 현재 재생되고 있는 파일이 컬러링인지 콜멘트인지 확인을 한 후에 콜멘트가 재생되고 있을 경우, 모든 음원을 종료시키기 위한 Flag 를 세팅합니다.

static void media_player_read_packet(struct media_player *mp) {
	...
	int ret = av_read_frame(mp->fmtctx, &mp->pkt);

	if (ret < 0) {
		if (ret == AVERROR_EOF) { // end of file
			ilog(LOG_DEBUG, "EOF reading from media stream");
			...
			if(mp->is_callment)   // end of callment file
				call->all_stop_media = 1; // 구조체 flag 변경
			return;
		}
		...
	}
	...
}

해당 함수는 미디어를 재생시켜주는 스레드에서 호출해주고 있는 함수인데요. 수신 측의 콜멘트가 종료된 시점을 위와 같이 알 수 있으므로, 발신 측의 컬러링을 종료시켜주기 위해 (3-2) stop media 를 직접 구현해주고, (3-3) unblock media 도 구현해줍니다.

static void media_player_run(void *ptr) {
	...
	media_player_read_packet(mp);

	if(call->all_stop_media && !mp->is_end){ // 콜멘트가 끝났을 경우
		// stop media
		media_player_stop(mp);

		// unblock media
		call->block_media = 0;
		for (GList *l = call->monologues.head; l; l = l->next) {
			mp->ml = l->data;
			mp->ml->block_media = 0;
		}
		__call_unkernelize(call);
	}
	...
}

마찬가지로 미디어를 재생시켜주는 스레드에서 수신 측의 콜멘트가 끝나는 시점에 (3-4) unblock media 도 직접 구현해줍니다.

static void media_player_run(void *ptr) {
	...
	else if(call->all_stop_media && mp->is_end) { // 콜멘트가 끝났을 경우
		// unblock media
		call->block_media = 0;
		for (GList *l = call->monologues.head; l; l = l->next) {
			mp->ml = l->data;
			mp->ml->block_media = 0;
		}
		__call_unkernelize(call);
	}
}

이 후, 발신 측과 수신 측이 정상적으로 세션이 맺어지고 통화가 이루어지게 됩니다. 🙌

정리해보아요.

  1. 발신 측이 수신 측에 전화를 걸어요.
  2. 발신 측/수신 측에 상호 음성을 차단하고요. (block media)
  3. 발신 측에 컬러링을 재생시켜요. (play media)
  4. 수신 측이 전화를 받게 되면 수신 측에 콜멘트를 재생시켜요. (play media)
  5. 수신 측에 재생되던 콜멘트가 종료가 되면, 발신 측에 재생중인 컬러링도 중지시켜요. (stop media)
  6. 발신 측/수신 측 상호 음성 차단을 해제해요. (unblock media)
  7. 정상적으로 음성 통화​가 이루어져요.

버그 😱

여러 케이스에서 테스트를 하다 보니 버그 하나가 있었는데요. 미디어 엔진에서는 한 콜이 끝나게 되면 정상적으로 콜이 종료되었다는 신호와 함께 상세한 목록들이 로그에 남겨지고 있습니다. 이런 로그들이 간헐적으로 출력되지 않고 있는 것을 발견했는데요. 이것은 콜이 정상적으로 종료가 되지 않았음을 뜻합니다. 이를 파악하기 위해 미디어 엔진에서 제공하는 CLI 로 현재 세션 정보를 확인해야 하는데요. 해당 CLI 에 접근하기 위해서는 미디어 엔진의 rtpengine.conf 파일에서 CLI 에 대한 리스닝 IPPort 도 설정해주어야 합니다.

# rtpengine.conf 스크립트 작성 예시
[rtpengine]
...
listen-cli = 10.81.xxx.xxx:xxxx # IP, Port 설정
...

이제 rtpengine-ctl 유틸을 다음 사용법을 통해 CLI 를 실행해봅니다.

# rtpengine-ctl 사용법
rtpengine-ctl [ -ip <ipaddress>[:<port>] -port <port> ] list [ numsessions ]

이런… 통화 종료 후에도 미디어 세션이 정상적으로 종료되지 않고 세션이 아직 남아있는 상태네요.

# 현재 릴레이 되고 있는 미디어 세션의 결과
Current sessions own: 2
Current sessions foreign: 0
Current sessions total: 2

테스트하면서 세션이 계속 남아있는 케이스를 찾아보았는데요. 발신자가 수신자에게 전화를 거는 도중에 발신자가 전화를 종료하는 487(Request terminated) 일 경우와 수신자가 전화를 거절하는 603(Decline) 의 경우에 버그가 있었습니다.

# opensips.cfg 스크립트 작성 예시
failure_route[...] {
				...
        if (t_was_cancelled()) {
                ...
                rtpengine_delete(); # rtpengine 종료
                exit;
        }
        rtpengine_delete(); # rtpengine 종료
}

저런… failure route 에서 통화 실패했을 경우와 통화 취소일 경우에 대한 예외처리를 해주지 않고 있었군요. 해당 케이스마다 rtpengine_delete 로 현재 릴레이 되는 미디어 세션을 정상적으로 종료시킬 수 있도록 합니다.

다시 세션을 조회해 봅니다.

# 현재 릴레이 되고 있는 미디어 세션의 결과
Current sessions own: 0
Current sessions foreign: 0
Current sessions total: 0

성능테스트

sipp_3000calls

SIPp 라는 트래픽 생성 테스트 툴을 통해서 우리 서비스에 맞는 콜 시나리오를 작성하여 CPS(Call Per Second)에 따른 동시 통화에 대한 성능 테스트를 진행해보았습니다. (CPS 는 초당 콜 처리 수인데요. 만약 CPS가 10이었을 경우, 테스트를 초당 10콜씩 진행한다는 의미입니다. 또한, 1콜당 세션을 맺는 시간은 10초(set up), 통화 길이는 40초(duration)로 시나리오가 작성되어 있습니다. 즉, 1콜당 총 50초가 소요되므로, 동시 통화는 CPS x 50이 됩니다.)

현재 성능 테스트는 CPS 60에서 동시 통화 테스트를 진행한 결과입니다.

위 결과에서 보시는 바와 같이 CPS 60(동시 통화 3,000콜)에서 30분 동안 재전송, 데드콜, 타임아웃 등에 대한 에러 없이 100,000콜 이상을 정상적으로 처리하는 모습입니다.


마치며…

안심번호(050) 시스템을 직접 구축하기 위해 시그널 엔진(OpenSIPS)과 미디어 엔진(RTP Engine) 오픈소스를 포팅하고, 기존의 비즈니스대로 구현하기 위해서 필요했던 모듈들을 추가하는 형태로 기능을 보완하였습니다. 그 결과 CPS 60(동시 통화 3,000콜)에서 30분 동안 수많은 콜을 CPU, Memory 모두 매우 안정적인 수준에서 무난하게 처리할 수 있었고요. 이는 시그널 서버의 경우에는 동시 통화 약 10,000콜 이상을! 미디어 서버의 경우에는 동시 통화 약 2,000콜 정도를 무난하게 처리할 수 있는 수준인데요. 시그널 서버 1대에 미디어 서버 4~5대 로드밸런싱을 해둔다면 동시 통화 약 8,000~10,000콜 정도 처리가 가능할 것으로 보입니다.

긴 글 읽어주셔서 감사합니다.