오래 전, 1:n 으로 동영상을 전송해야하는 상황을 처음으로 접하게 된 적이 있었습니다. 1:n 전송의 특성 상, 모든 사용자가 같은 품질(속도)의 네트워크 상황이 아닐 수 있다는 가정이 필요 했습니다. 따라서, 동영상 압축 품질을 개별 사용자에게 맞춰서 엔코딩을 할 수 없는 한, 다른 해결책이 필요해졌습니다.
스틸 이미지의 경우에는 아주 쉽게 해결할 수 있는 방법이 있습니다. 네트워크 속도에 맞춰서 단위 시간당 보내지는 프레임(이미지)의 수를 달리 하는 것 입니다.
[그림 1] 스틸 이미지로 전송되는 경우
[그림 1]은 동영상을 각 프레임마다 스틸 이미지로 보내는 경우를 표현한 것 입니다. 이때, 중간에 있는 이미지를 전송하지 않고 생략하더라도 마지막 결과는 같아집니다.
하지만, 동영상의 경우에는 [그림 2]와 같이, 기본적으로 변하지 않는 부분은 전송하지 않고 생략해서 크기를 줄이게 됩니다. 각 변화된 데이터를 합성하게 되면, [그림 1]의 마지막 이미지와 동일한 영상을 얻을 수가 있으며, 상대적으로 작은 양의 데이터만을 전송할 수가 있게 됩니다.
[그림 2] 동영상 압축에 의한 데이터 전송의 예
하지만, 중간 프레임을 생략하게 되면 [그림 3]과 같이 전혀 다른 결과를 얻게 됩니다. 소위, "화면이 깨졌다" 라고 합니다.
[그림 3] 동영상 전송 중 중간 프레임을 생략한 결과
따라서, 동영상의 경우에는 사용자의 네트워크 상황에 맞춰서 프레임을 떨어트리는 것을 구현하기 위해서는 다른 방법을 사용해야 합니다. [그림 4]는 이러한 과정을 추상적으로 설명하는 자료입니다.
[그림 4] 사용자의 네트워크 상태에 따라서 프레임 수를 조절하기 위한 동적 분석
우선 [그림 4]의 내용을 설명하자면 아래와 같습니다.
- Sender가 동영상 데이터를 Server에 전송하고, Server에서는 Received 이벤트가 발생합니다.
- Server는 개별 접속자들 마다 따로 생성된 객체 Connection의 버퍼에 이를 저장합니다. [그림 4]에서는 간단하게 하나만 그렸습니다.
- 데이터는 좌표와 함께 전달되며, 해당 좌표에 이전에 받은 데이터가 존재하면 이를 삭제하고 새로운 데이터를 버퍼에 둡니다. 이전 좌표의 버퍼가 비워져 있으면 그냥 저장합니다.
- 데이터를 전송하고자 하는 순간을 NeedToSend 이벤트로 표현했습니다.
- 이제 버퍼에서 데이터를 찾아서 클라이언트에게 보내 줍니다. 만약 버퍼에 데이터가 없으면 false가 리턴되어 아무 것도 하지 않습니다.
처음에는 버퍼의 형태를 2차원 배열을 사용하였습니다. 내부적으로는 1차원 배열이지만, 2차원처럼 사용 할 수 있는 인터페이스를 사용했습니다. 테스트를 해보니 상당히 잘돌아갑니다. 장시간 진행된 스트레스 테스트도 무사히 통과했습니다.
그러나, 비극은 언제나처럼 제품이 고객에게 넘겨지고 나서 발생하였습니다. 서버의 CPU 사용률이 높아지면서 안정성에 문제가 생긴 겁니다. 이슈를 보고 받고 나니 바로 문제점이 보였습니다. 데이터가 만약 하나도 없는 경우라면 최악의 상태가 되는데, 2차원 배열을 모두 뒤져야지만, 데이터가 없다는 것을 리턴하고 있었습니다. 아래는 그 당시의 소스 상태를 간단하게 표현한 것 입니다. 실행되는 코드가 아닌 의사코드 수준이라고 생각하시면 됩니다.
[Code delphi]
function Get(var Data:pointer; Size:integer):boolean;
var
LoopX, LoopY : integer;
begin
Result := false;
for LoopY := 0 to Height-1 do
for LoopX := 0 to Width-1 do begin
if Buffer[LoopX, LoopY].Data <> nil then begin
Result := true;
Data := Buffer[LoopX, LoopY].Data;
Buffer[LoopX, LoopY].Data := nil;
Size := Buffer[LoopX, LoopY].Size;
Break;
end;
end;
end;[/Code]
서비스가 급하게 진행되고 있어서 우선은 편법을 동원해서 불을 끄고 해결책을 찾기 시작했습니다.
편법이 혹시 궁굼하신 분을 위해서 간략하게 설명하자면, 버퍼 속에 존재하는 데이터의 갯수를 관리하고, 방금 전 호출에서 데이터를 검색한 곳의 인덱스를 기억하도록 하는 것 입니다. 데이터 갯수가 0으로 되면 바로 false를 리턴하고, 그 이외에는 방금 데이터를 검색한 인덱스 이후부터 뒤지는 것 입니다. 이것으로 상황은 조금 나아졌습니다.
이후 제가 찾아낸 방법은 버퍼 인덱스를 이중으로 관리하는 것이었습니다. 2차원 좌표형태로 버퍼를 관리하는 것은 그대로 두고, 추가로 List로 관리하는 인덱스를 두는 것이었습니다. List는 중복되는 것을 개의치 않고, 데이터의 좌표를 계속 저장합니다. 의사코드로 아래와 같이 표현해봤습니다. 실제 코드는 자연스러운 동작과 임계영역 처리 등에 의해서 복잡해졌습니다.
[Code delphi]
function Get(var Data:pointer; Size:integer):boolean;
var
Loop : integer;
begin
Result := false;
while (List.Count > 0) and (Sent < 1회 전송한계) do begin
Index := List[0];
List.Delete(0);
// List에 있지만, Buffer에 없다는 것은
// 데이터가 전송 이전에 중복되어 덮어 써졌다는 것이다.
if Index.Data <> Buffer[Index.X, Index.Y].Data then Continue;
Result := true;
Data := Buffer[Index.X, Index.Y].Data;
Buffer[Index.X, Index.Y].Data := nil;
Size := Buffer[Index.X, Index.Y].Size;
Break;
end;
end;[/Code]
안정성이 상당히 좋아졌습니다. 그러나, 무엇인가 수정하면 새로운 문제가 발생하기 마련 인가 봅니다. 위에서 설명한 대로 동영상 압축의 기본은 변화되지 않는 곳은 생략하는 것 입니다. 따라서, 첨부터 접속되어 있던 사용자가 아닌 경우에는 전체 영상을 담은 이미지를 한 번은 전송 받아야 합니다. 이를 위해서 서버에서는 ScreenShot 이라는 객체가 항상 최신 버전의 이미지를 저장하고 있다가, 새로운 사용자가 접속하면 해당 사용자에게만 전체 영상을 보내주도록 되어 있었습니다.
서비스가 만족할만한 수준이었기는 하지만, 프로파일링 결과, 해당 부분에서 많은 성능 저하가 일어나고 있었습니다. 사용자가 많아지고 빈번하게 들락이면서 임계영역으로 인한 병목현상이 발생하게 된 것 입니다. 역시나 서비스가 급하게 돌아가고 있는 지라, 편법을 동원해서 불을 끄고 시간을 들여서 수정하기로 결정을 내렸습니다.
사실 운영 중인 시스템을 수정한다는 것은, 달리는 자동차에 매달려서 수리하는 것과 같습니다.
편법은 Sender가 주기적으로 KeyFrame을 보내고, 처음 접속한 사용자 이외에는 이것을 무시하도록 하는 것 입니다. KeyFrame을 받은 적이 없는 사용자는 KeyFrame이 들어오기 전까지는 아무런 화면을 전송하지 않도록 하였습니다. 간단하고 효율적이긴 했지만, 마음에 들지 않았습니다.
최종적으로 선택한 해결책은 Lock-Free 알고리즘을 적용하는 것이었습니다. 아래 코드는 Lock-Free 알고리즘을 응용한 것 입니다. 데이터를 Push 하는 쪽은 스레드 하나로 정해져 있고, 받아가는 쪽은 복수 개의 스레드일 경우 사용할 수 있습니다. 델파이에서 포인터를 덮어쓰는 것은 언제나 Thread-safe 한 것을 이용한 것 입니다.
델파이만 포인터에 주소를 입력하는 과정이 Thread-safe 한 것은 아니고, 현재 대부분의 상황에서 32bit 데이터의 읽고 쓰기는 Atomic Operation이 보장 됩니다.
그리고, 덮어 써진 쪽의 경우 바로 지우지 않고, 최근 덮어 쓰여진 데이터가 다시 덮어 쓰여 질 때 지우도록 하여, 혹시나 덮어 써지기 전에 데이터를 가져간 쪽에서 Access Violation 에러가 나는 것을 방지하였습니다. 데이터를 가져가서 처리하는 속도보다 데이터를 갱신하는(덮어쓰는) 속도가 더욱 빠르다면, 삭제를 지연하는 횟수를 늘려야 하고 이로 인해서 효율성이 떨어지게 될 것이니 유의해야 합니다.
쉽게 설명하면, 삭제하는 동작을 1회 지연 시키기 위해 임시 저장소를 두어 삭제할 데이터를 그곳에 두고, 다음 프로세스에서 지우도록 하였다는 뜻 입니다.
[Code delphi]
procedure TScreenShot.Push(X,Y:integer; Data:pointer...);
begin
// 임시 저장소에서 삭제하기를 한 번 대기한 놈을 가져온다.
OldBuffer := 임시대기[X, Y];
// 임시 저장소에는 지난 번 데이터를 저장한다.
임시대기[X, Y] := Buffer[X, Y];
// 데이터 저장소에 방금 들어온 데이터를 저장한다.
Buffer[X, Y] := Data;
if OldBuffer <> nil then FreeMem(OldBuffer);
end;[/Code]
[첨부 1] 본문에 사용된 문서