2000년 즈음, 온라인 강의 시스템을 개발하면서 진행하게 된 프로젝트 중 생긴 일입니다. 주 목적은 온라인 바둑 강좌 시스템을 구축하는 것이었습니다. 일반적인 화상회의 시스템 구조에서 강좌용 바둑판을 강사와 수강자가 공유하는 것을 추가하는 형식으로 요구사항이 마무리 되었습니다.
제가 강의했던 개발 방법론 주제에서 자주 거론 하듯이, 저는 주로 Top-Down 방식의 설계를 하는 편입니다. 하지만, 분석과 설계 과정에 중에 필요성이 발견되는 컴포넌트 모듈 중, 반드시 필요할 것으로 생각되는 것들은 분석과정 중에도 테스트 및 프로토타입을 작성하는 편입니다. Top-Down과 Bottom-Up을 조금 섞어 쓴다고 할 수 있는 정도입니다.
프로젝트 중에 사용되었던 기술(또는 콤포넌트)의 결함을 나중에 발견하게 되어 심각한 피해를 입은 경험이 있은 후로는, 개발 초기에 사용하게 될 기술을 미리 검증하는 단계를 반드시 거치고 있습니다. 그러다 보니, 분석 과정에 있는데도, 구현 모듈에 대한 프로토타입 개발 및 테스트를 병행하고 있습니다.
우선 화상 전달은 각 프레임을 JPEG으로 압축해서 보내기로 했습니다. 음성은 GSM 코덱을 사용하게 됐는데, 이때, 음성 압축은 아는 후배에게 부탁해서 외주로 제작하면서 후배가 어디선가 주어 온 소스 때문이었습니다.
간단하게 캠에서 영상을 JPEG으로 압축하고, 동시에 음원을 GSM으로 압축하여 소켓을 통해서 Receiver 모듈로 전송하고, Receiver 모듈은 전송 받은 데이터를 압축 해제하고 재생하는 간단한 테스트 모듈이었습니다.
아주 쉽게 테스트는 통과 되었고, 만족할 만한 결과라고 생각했습니다. 이후, 안정성 테스트를 위해서 강사 하나에 다수의 수강생 모듈을 접속하게 하여 하루 종일 실행시켜봤습니다. 이때, 화상은 잘 전달되고 있으나, 음성 전달이 상당히 지연되는 것을 발견하게 되었습니다.
버그가 발생하는 시나리오를 유추하기 위해서 갖 가지 상상을 하다가, 화상과 음성이 다른 결과를 가진 것을 실마리 삼아서, 데이터가 네트워크를 타고 전달되는 속도가 일정하지 않을 때, 음성이 병목을 일으킬 가능성이 있다는 것을 찾아냈습니다.
[그림 1] 음성 데이터의 발생
즉, [그림 1]과 같이 음성 데이터가 아주 정확한 시간에 발생한다고 하더라도, 네트워크를 통해 전달되면서 [그림 2]와 같은 현상을 겪을 가능성이 있다는 것 입니다. 이때, [음성 1]이 전달 되어 재생이 끝났지만, 다음 데이터를 기다리는 동안 지연이 발생합니다. [음성 2]가 전달되어 다시 재생에 들어가고, [음성 3]은 다소 일찍 들어왔지만, [음성 2]의 재생이 완료되기 전까지는 대기해야 합니다. 바로 재생한다면 음성 데이터 두 개가 섞여서 이상한 소리를 내게 될 것 입니다.
[그림 2] 음성 데이터 전달의 지연
결과적으로 네트워크 전달이 지연된 만큼 음성 재생 지연은 늘어나지만, 그로 인해서 나중에 일찍 들어온 데이터는 지연을 줄이지 못하고 대기 상태에 빠지게 된다는 것 입니다. 즉, 지연은 늘어나는 쪽으로만 진행된다는 것 입니다.
지연 현상은 네트워크를 사용하지 않고 어플리케이션 내에서 직접 데이터를 전달해도 발생합니다. 그것은 사운드 카드에서 음원을 채취하고 압축하고 해제하고 재생하는 과정 중에서도 지연이 조금씩 발생되기 때문입니다.
이것을 해결하기 위해서는 지연된 만큼 데이터를 삭제하는 수 밖에 없다고 생각했습니다. 처음에는 가장 오래된 데이터를 우선으로 삭제하여 허용된 지연 한계를 넘어선 데이터를 모두 삭제하는 방식을 사용했습니다. 그러다가, 음질을 최대한 보존하기 위해서 가장 낮은 볼륨 데이터를 삭제하는 방식으로 발전시켰고, 그 이후에는 좀 더 세련된 방식을 찾아내는데 성공했습니다. 세련된 방식이란 PSOLA를 이용해서 지연된 만큼 음성 재생 속도를 잠시 빠르게 하는 것 입니다.
이쯤 되자, 앞으로도 지연을 제거하는 방식을 계속 변경해야 할 가능성이 보이기 시작했습니다. 어떻게 해야 할까요? 그렇죠! 바로 캡슐화가 떠오르게 마련입니다. 변화 요소를 분리하여 격리 시키기 위해서 설계를 수정하기 시작했습니다.
변화에 대응하는 방법 중에는 캡슐화 이외에 다형성을 활용하는 방법도 있습니다. 저는 개인적으로 다형석도 캡슐화의 한 부류라고 생각하고 있습니다. 일단 "변화의 가능성이 보이면 격리해야 한다"를 기본으로 해서 위임 등을 통해서 해당 변화를 감추거나, 다형성을 이용하여 같은 메시지(요청)이지만 상황에 따라 다른 결과(행동)을 결정하는 것을 미룰 수 있도록 합니다.
[그림 3] 데이터 수신 이후 재생까지의 동적 분석
[그림 3]처럼 재생 지연을 제거하는 것을 Buffer 내부에 감추고 외부 객체는 데이터 지연이라는 문제를 의식하지 않고 인터페이스를 구축하도록 하였습니다. 이후 Buffer 내부에서 어떤 알고리즘을 사용한다고 해도, 그 변화가 Socket과 VoicePlayer에게는 아무런 영향을 주지 않게 됩니다.
[그림 3]의 간략한 설명은 아래와 같습니다.
- Socket 객체에서 새로운 데이터를 받고 Received라는 이벤트를 발생시킵니다.
- Socket은 Buffer에게 Add 메시지를 전달하여 데이터를 저장하게 합니다. (Buffer.Add() 메소드 호출)
- Buffer는 내부적으로 저장된 데이터가 어떤 수준 이상으로 쌓이게 되면, 이것을 지연으로 보고 데이터를 임계 수준 이하로 삭제하여 보관합니다. 이 과정은 캡슐화로 감춰져 있습니다.
- VoicePlayer는 다음 데이터를 재생해야하는 순간 NeedData 이벤트를 발생합니다. 이후, Buffer에게 데이터를 요청합니다. 이때, 데이터가 없으면 false를 리턴 받고 아무런 동작도 하지 않습니다. true를 리턴 받으면 데이터가 존재한다는 의미이며, 받아온 데이터를 재생합니다.
[그림 3]은 이해를 돕기 위해 간락하게 작성된 다이어그램 입니다. 지연 효과를 없애기 위해 볼륨 정보가 필요했기 때문에, 원래 VoicePlayer 내부에 있었던 코덱 디코더를 밖으로 꺼내야 했습니다.
설계는 [그림 4]와 [그림 5]와 같이 정리가 되었습니다.
[그림 4] Job Flow
[그림 5] Class Diagram
위의 설계는 TSocket이 TVoicePlayer의 내부를 알고 있다는 것이 문제입니다. 따라서, 캡슐화를 더욱 진행시켜보도록 하겠습니다.
필자가 실제 작업할 때는 하나의 과정으로 진행하지만, 이해를 돕기 위해서 단계를 거치면서 다듬어 가고 있습니다.
TVoicePlayer의 멤버인 Decode, Buffer, Player를 Private로 옮기고, TSocket이 TVoicePlayer에 의존적인 동작을 모두 TVoicePlayer의 Public 메소드로 구현하도록 하겠습니다. 그 결과는 아래와 같으며, 이것이 현재까지 제가 사용하고 있는 음성 통신 모듈의 기본적인 구조입니다.
[그림 6] 최종 결정된 JobFlow
[그림 7] 최종 결정된 Class Diagram
[첨부 1] 본문에 사용된 Job Flow (Visio)