본문 바로가기

.Net Technology/Comet

(3) 코멧을 이용한 웹 채팅 만들기

월간 마소에 2010년 8월호에 기고한 원고입니다. 

연재순서
1회 | 2010. 06 | 코멧의 소개와 활용전략
2회 | 2010. 07 | 닷넷을 이용한 코멧의 구현
3회 | 2010. 08 | 코멧을 이용한 웹 채팅 만들기


지난 강좌에서는 ASP.NET에서 코멧을 활용하는 방법들에 대해서 살펴보았다. 이번 시간에는 코멧 기술을 활용하여 본격적으로 웹 채팅 프로그램을 만들어 볼 것인데 서버 프로그램은 C#으로 제작할 것이다. 기초 지식으로는 네트워크 통신에 대한 이해가 필요하다는 것을 알아두자.

지난 2회 강좌에서는 C#으로 직접 웹 서버를 만들어 보면서 HTTP통신에 대한 개념을 조금 언급 했듯이 채팅 서버를 만들기 위해서는 HTTP통신을 완벽히 이해하고 있어야 한다. 하지만 크게 어려울 것이 없는 것이 TCP통신에 대한 개념만 있다면 피들러(Fiddler)와 같은 HTTP 분석 툴을 이용해서 메시지가 어떻게 오고 가는지 손쉽게 확인이 가능하다. 물론, 가장 좋은 것은 W3C 홈페이지에서 정의한 스펙문서를 보고 이해하는 것이고 그것이 이해가 되지 않는다면 다음 [화면1]에서처럼 어떻게 요청을 보내고 어떻게 응답을 받는지 확인할 수 있을 것이다. 


[화면1] 피들러 이용하기

이 툴 외에도 많은 툴들을 이용할 수 있지만 필자는 프로그램을 제작하면서 피들러를 활용하였다. 이런 툴이 없이는 실제로 디버깅 자체가 굉장히 힘들기 때문에 채팅과 같은 프로그램을 개발할 때는 반드시 이런 분석 툴이 필요하다. 왜냐하면 잘못된 응답을 서버에서 보내게 될 경우 브라우저에서 어떻게 동작을 처리하게 될지 알 방법이 없기 때문이다. 필자가 채팅 데모 프로그램을 만들면서 가장 힘들었던 부분은 브라우저가 원하는 대로 동작을 하지 않는 상황이 많았기 때문이었다. 예를 들어 정해진 스펙에 맞추어 전달하지 않았을 경우에 브라우저는 어떤 동작도 처리하지 않게 되고, 심지어 서버에서 내려 보낼 컨텐츠의 크기도 잘못 지정하게 될 경우에도 브라우저가 잘 응답하지 않게 되는 경우도 많았다. 그렇기 때문에 웹 채팅을 만들겠다고 한다면 HTTP 프로토콜 스펙을 확실히 이해한 뒤에 프로그램을 시작할 것을 추천하고 싶다.


채팅 프로그램의 아키텍처

본격적으로 프로그램을 시작하기 전에 채팅 프로그램을 어떻게 설계하고 어떻게 동작시킬 수 있을지에 대한 프로그램 구조에 대해서 먼저 살펴보도록 하겠다. 지난 1회 강좌에서는 페이스북의 채팅 구조에 대해서 아주 간단하게 언급한 적이 있었다. 페이스북은 폴링과 롱폴링을 적절히 조합하여 채팅을 구현하였다. 구조는 다음과 같다. 


[그림1] 페이스북의 채팅 구조

채팅에서 필요한 기능은 크게 3가지라 할 수 있다. 먼저 서버에 접속해 있는 친구리스트를 전달해 주는 것이고, 두 번째 기능은 친구가 말을 걸었을 때 최대한 빨리 클라이언트로 전달해 주어야 하는 것이다. 그리고 마지막으로는 내가 원하는 말을 친구에게 전달해 주는 것이다. 필자는 처음 이 구조를 이용해서 웹 채팅을 만들 수 있다는 것을 지인에게 설명해 주었을 때 의심의 눈초리가 많았다. 하지만 이미 수억 명의 사용자를 가지고 있는 페이스 북이 이러한 구조를 도입하여 이용하고 있다는 것을 알려주었을 때 비로소 의심 없이 믿기 시작했다. 

어찌됐든 오늘 다룰 채팅 프로그램에서 필자는 위의 구조와 조금 다르게 설계를 해봤다. 바로 롱폴링이 아니라 청크 인코딩(Chunk Encoding)을 활용해서 스트리밍 서비스를 구현해보고 싶었던 것이다. 물론, 단점이 있다. IE에서만 동작이 가능하다는 것이다. 왜냐하면 청크 인코딩을 이용하게 될 경우 커넥션을 연결하고 유지하고 있기 때문에 브라우저는 로딩 바가 계속 표시되기 때문이다. 하지만 IE의 htmlfile 객체를 이용하면 이 문제를 해결할 수 있다.

비록 IE에서만 지원된다 하더라도 필자는 롱폴링 보다는 서버에서 실시간으로 연결을 관리할 수 있고, 가장 빠르게 데이터를 전달해 줄 수 있는 스트리밍을 구현해 보도록 하겠다. 기능은 크게 2가지가 있겠다. 친구한테 말을 보내는 기능과 친구로부터 전달된 메시지를 보여주는 기능이다. 구조는 다음 [그림2]처럼 정의할 수 있다.


[그림2] 채팅 프로그램의 구조

기능은 크게 두 개로 정의되며 먼저 서버에서 클라이언트로 메시지를 전달하기 위해서는 스트리밍 형식으로 브라우저에게 메시지를 전달해줄 것이다. 여기서 스트리밍이라는 것은 서버에서 일방적으로 브라우저에게 메시지를 전달하기만 하는 형태이지만 커넥션을 끊지 않고 연결하고 있게 되기 때문에 롱 폴링과는 다르게 표현할 수 있다. 그리고 메시지를 전달할 때는 단순히 Request, Response를 이용하게 된다.


청크 인코딩

본격적으로 프로그램 제작에 들어가기에 앞서 청크 인코딩 프로토콜에 대해서만 간단히 살펴보고 가도록 하겠다. 왜냐하면 청크 인코딩을 프로토콜이 어떻게 설계되어 있는지 이해하지 못하면 서버에서 클라이언트로 메시지를 전달해 줄 수 없기 때문이다. 일단, 2회차 기사에서 청크 인코딩을 웹에서 어떻게 구동시키는지에 대해서는 간단히 다루었기 때문에 프로토콜에 대해서만 간단히 정리해 보도록 하겠다. 청크 인코딩은 다음과 같은 특징을 가지게 된다. 

- 본문을 분할하여 전달하는 인코딩 
- 헤더에 다음과 같은 설정을 추가하여 클라이언트로 전달
: Transfer-Encoding: chunked
- 분할된 메시지를 전달하기 전에 반드시 메시지의 크기를 16진수로 메시지 전에 전달해야 함
- 문서의 끝을 알리기 위해서는 0을 이용함
- 브라우저에 따라서 1~2KB 이상의 본문이 전달되어야 브라우저는 파싱을 시작함


요약해보자면 청크 인코딩은 헤더에 먼저 Transfer-Encoding: chunked 라는 속성을 내려주게 된다. 그리고 메시지의 크기 즉, 얼만큼의 덩어리를 보낼 건지를 결정한 뒤에 그 크기를 16진수로 변환하여 먼저 내려주어야 한다. 그리고 나서 메시지를 전달하면 된다. 여기서 16진수로 변환한 값이 잘못되거나 할 경우 브라우저는 동작하지 않으므로 조심히 처리해야 된다. 그리고 모든 메시지를 전달하게 될 경우 0을 내려주게 되는데 우리는 여기서 0을 내려주지 않을 것이다. 왜냐하면 연결을 계속 유지할 것이기 때문이다. 

그렇다면 이렇게 연결을 계속 유지하고 있는 것이 실제 서비스에서 맞는 것일까? 절대 그렇지 않다. 이번 예제는 프로그램 자체의 이해를 돕기 위해서 가장 심플하게 구현하였다. 실제로 서비스를 하게 된다면 1분이든 2분이든 주기를 두고 커넥션을 닫았다가 다시 요청하게 하는 것이 가장 좋은 방법이다. 왜냐하면 브라우저가 비정상적으로 종료 되었을 경우에 서버는 커넥션이 끊어졌는지 메시지를 보내보기 전에는 알 수 없기 때문이다. 그렇다고 매번 핑을 날려서 클라이언트가 붙어있는지 혹, 비정상으로 종료가 되지 않았는지 확인하기에는 너무 많은 서버 리소스를 낭비하게 된다. 그렇기 대문에 페이스북의 롱폴링처럼 주기적으로 연결을 끊고 다시 맺는 방법을 추천하는 바이다. 

다음 [그림3]은 실제로 서버에서 클라이언트로 전달되는 메시지의 예를 보여주고 있다.


[그림3] 청크 인코딩의 처리 방식


서버 프로그램 구현하기

그럼 이제 본격적으로 채팅 프로그램을 구현해 보도록 하겠다. 서버는 앞에서 설명했듯이 C#으로 작성할 예정이고, 지면상 모든 코드를 담을 수 없기 때문에 핵심적인 코드만 작성하도록 하겠다. 네트워크 프로그래밍에 대해서 학습한 경험이 있는 개발자라면 분명 핵심은 서버에 있는 것이 아니라 클라이언트에 있다는 것을 알 수 있을 것이다. 서버를 구현하기 전에 먼저 동작 화면을 살펴보도록 하자. 다음 [화면2]는 클라이언트의 채팅 화면을 보여주고 있다.


[화면2] 클라이언트 구현 화면

그리고 서버는 다음 [화면3]과 같이 간단한 콘솔 프로그램으로 만들었다. 



[화면3] 서버 프로그램

그럼 서버 코드를 살펴보도록 하자. 먼저 사용자가 요청을 보낼 경우에 다음 메서드에서 그 요청을 처리하게 된다. 



private void LoginThread()
{
    TcpListener listener = new TcpListener(IPAddress.Any, ServerPort);
    listener.Start();
    Console.WriteLine("사용자 접속 대기.");
    while (true)
    {
        Socket socket = listener.AcceptSocket();
        try
        {
            lock (dnUser)
            {
                if (socket.Connected)
                {
                    Console.WriteLine("사용자 한명 접속");
                            
                    //클라이언트 생성
                    Client ct = new Client(socket,this);
                    // GET HTTP/1.1 인지 확인한다.
                    string urls=ct.ReadLine();
                    string[] tempValue = urls.Split(' ');
                    string strHttpVersion=tempValue[2];
                    string url=tempValue[1];
                    Console.WriteLine(url);
                    //HTTP 1.1이 아닌경우 연결끊기
                    if (strHttpVersion.IndexOf("HTTP/1.1") == -1)
                    {
                        ct.Dispose();
                    }
                        string UserID = "";
                    //////////////////////////////////////////////////////////
                    //1. 서버->클라이언트 채널일 경우
                    //////////////////////////////////////////////////////////
                        if (url.IndexOf("join.js") != -1)
                        {
                            try
                            {
                                UserID = (url.Split('='))[1];
                            }
                            catch
                            {
                                ct.Dispose();
                            }
                            //나머지 HTTP 속성들은 저장하지 않고 날린다.
                            ct.Flush();
                            //모두 읽어왔다면
                            ct.SendMessage("HTTP/1.1 200 OK");
                            ct.SendMessage("Transfer-Encoding: chunked");
                            ct.SendMessage("Content-Type: text/html");
                         
                            ct.SendMessage("");
                            string str = ct.MakeTo2kb("");
                            ct.SendMessage(ct.MakeToSize16(str));
                            ct.SendMessage(str);
                            //사용자 접속완료
                            SaveLog(UserID + "접속");
                            //사용자에게 접속했다고 알려주기 있을 경우 튕겨내기
                            if (dnUser.ContainsKey(UserID))
                            {
                                if (dnUser.ContainsKey(UserID))
                                    dnUser.Remove(UserID);
                            }
                            //그룹 추가하기
                            dnUser.Add(UserID, ct);
                            BroadcastMessage("[" + UserID + "] Enter the room");
                        }
                        //////////////////////////////////////////////////////////
                        //2. 클라이언트->서버 채널일 경우
                        //////////////////////////////////////////////////////////
                        else if (url.IndexOf("chat.js") != -1)
                        {
                            string strMsg = "";
                            try
                            {
                                strMsg = (url.Split('='))[1];
                            }
                            catch
                            {
                                ct.Dispose();
                            }
                            //메시지 전달하기
                            BroadcastMessage(strMsg);
                            ct.SendMessage("HTTP/1.1 200 OK");
                            ct.SendMessage("Content-Type: text/html");
                            ct.SendMessage("Content-Length: 0");
                            ct.SendMessage("");
                            ct.Dispose();
                        }
                        else
                        {
                            //연결 끊기
                            ct.Dispose();
                        }
                }
            }
        }
        catch (Exception e)
        {
            Console.WriteLine(e.ToString());
            SaveLog(e.ToString());
        }
        System.Threading.Thread.Sleep(200);
    }
}

[코드1] 서버의 LoginThread 메서드

만약 요청이 join.js로 들어오게 되면 처음 접속하게 된 것으로 알고 처리하게 되며 chat.js로 들어오게 되면 메시지를 보낸 것으로 간주하여 처리하게 된다. 앞 부분에는 일단 HTTP 버전이 1.1인지 확인하게 된다. 왜냐하면 청크 인코딩이 1.1에서만 지원이 되기 때문이다. 그럼 청크 인코딩 메시지를 어떻게 보내는지 확인해보도록 하자.

public void SendChunkingMessage(string message)
{
    try
    {
        if (ns == null) return;
        lock (this)
        { 
            sw.Flush();
            ns.Flush();
            message = "<script>parent.callback(\""+message+"\");</script>";
            sw.WriteLine(MakeToSize16(message));
            sw.WriteLine(message);
        }
    }
    catch (Exception e)
    {
    }
}
public string MakeToSize16(string txt)
{
return System.Convert.ToString(txt.Length, 16);
}

[코드3] 서버의 LoginThread 메서드

메시지를 보내기 전에 먼저 메시지의 길이를 16진수로 변환해서 내려주게 되고 그 뒤에 메시지를 보내게 된다. 하지만 커넥션은 끊지 않고 있다는 것을 알아두자. 모든 코드를 설명해주고 싶지만 지면상 이 채팅 프로그램의 서버 코드는 소스를 참고하도록 하자. 소스는 HOONS닷넷 공지사항에 올라온 23회 정기세미나 자료에서 다운 받을 수 있다. 


클라이언트 프로그램 구현하기

코멧의 핵심은 서버보다는 클라이언트에 있다고 할 수 있다. 물론, 양방향 통신을 위해서는 서버를 구현할 수 있어야 하는 것은 사실이지만 코멧이라는 스킬을 살펴볼 수 있는 것은 코드는 클라이언트에 담겨있기 때문이다. 먼저 이번 클라이언트는 정말 간단하게 다음과 같은 2개의 파일을 추가하였다. 

Comet.js
Chat.html


그럼 Chat.html이라는 파일부터 살펴보도록 하자. 

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
    <style type="text/css">
        #msg {
            width: 905px;
        }
        #btnEnter
        {
            width: 183px;
        }
        #userid
        {
            width: 202px;
        }
    </style>
</head>
<!--<body onload="ServerChannel('FrameFlushData.aspx')">-->
<body >
    <h2>SIMPLE CHAT BASED COMET</h2>
    <br /><br />이름: <input id="userid" name="userid" value="HOONS_1" name="userid" type="text" /><input id="btnEnter" onclick="foreverFrame('/join.js.html?userid=')" type="button" value="채팅방입장" />
    <br /><br />
    
    <div id="Content" style="border-color: #000000; border-style: dashed; border-width: thin; width: 100%; height: 400px">
    </div>
    <input id="msg" value="input ur message" name="msg" onkeypress="MessageChk()"  type="text" style="width: 100%;" />
    <script src="Scripts/Comet.js" type="text/javascript"></script>
    
</body>
</html>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body >
    <h2>SIMPLE CHAT BASED COMET</h2>
    <br /><br />이름: <input id="userid" name="userid" value="HOONS_1" name="userid" type="text" /><input id="btnEnter" onclick="foreverFrame('/join.js.html?userid=')" type="button" value="채팅방입장" />
    <br /><br /> 
    <div id="Content" style="border-color: #000000; border-style: dashed; border-width: thin; width: 100%; height: 400px">
    </div>    <input id="msg" value="input ur message" name="msg" onkeypress="MessageChk()"  type="text" style="width: 100%;" />
    <script src="Scripts/Comet.js" type="text/javascript"></script>
    
</body>
</html>

[코드4] Chat.html 

코드에서 특별한 부분이라 할 수 있는 것은 버튼을 눌렀을 경우에 서버로 접속을 시도하는 것이고 메시지를 입력하는 창에서 엔터를 누르게 될 경우 서버로 그 메시지를 전달하게 되는 것이다. 그럼 자바스크립트 코드를 살펴보자.


//전역적으로 사용할 변수들
var ServerUrl = "http://localhost:4501";
var clientDoc;
var UserID;
var ifrClientDiv; //클라이언트
// 서버채널 서버->클라이언트
function foreverFrame(url) {
    //1. URL에 사용자 아이디 넣기
    UserId = document.getElementById("userid").value;
    url += document.getElementById("userid").value;
    url = ServerUrl + url;
    //3. htmlfile 만들기
    var transferDoc = new ActiveXObject("htmlfile");
    transferDoc.open();
    transferDoc.write(
        "<html><script>" +
    //"document.domain='" + document.domain + "';" +
        "</script></html>");
    transferDoc.close();
    //4. IFRAME 생성
    var ifrDiv = transferDoc.createElement("div");
    transferDoc.body.appendChild(ifrDiv);
    //5. 콜백함수 작성하기
    transferDoc.parentWindow.callback = function (msg) {
        var body = document.getElementById("Content");
        body.innerHTML +=unescape(msg) + "<br/>";
    }
    //6. 서버 접속
    ifrDiv.innerHTML = "<iframe id='ifr' src='" + url + "'></iframe>";
}
// 클라이언트 채널 클라이언트->서버(R-R)
function SendMessage(url) {
    
    //1. URL에 사용자 아이디 넣기
    url += "[" + UserId + "]" + document.getElementById("msg").value;
    if (ifrClientDiv == null) {
        
        //2. htmlfile 만들기
        clientDoc = new ActiveXObject("htmlfile");
        clientDoc.open();
        clientDoc.write(
        "<html></html>");
        clientDoc.close();
        //3. IFRAME 생성
        ifrClientDiv = clientDoc.createElement("div");
        clientDoc.body.appendChild(ifrClientDiv);
        
        //5. 서버 접속
        ifrClientDiv.innerHTML = "<iframe id='ifrClient' src='" +  escape(url) + "'></iframe>";
    }
    else {
        
        var ifrClient = clientDoc.getElementById("ifrClient");
        ifrClient.src = url;
    }
}
function MessageChk() {
    if (event.keyCode == 13) {
        var msg = document.getElementById("msg");
        
        var url = ServerUrl+"/chat.js.html?text=";
        SendMessage(url);
        document.getElementById("msg").value = "";
        return false;
    }
    return false;
}

[코드5]comet.js의 코드

지면상 함수 역할들에 대해서만 foreverFrame 과 같은 경우는 서버에서 클라이언트로 메시지를 전달하는 채널을 만드는 함수로 입장 버튼을 만들 때 생성되게 된다. 그리고 SendMessage의 경우 서버로 특정 메시지를 전달하는 함수로 메시지를 보낸 후에 커넥션이 끊어지게 된다. 그리고 MessageChk 함수는 앞에서 설명했듯이 클라이언트가 엔터를 눌렀을 경우에 그 키를 후킹해서 서버로 메시지를 전달해주는 역할을 하게 된다.