• 2006-05-02

    오블리비언 스토리#2

    Official Guide 가 20일만에 드디어 도착했다. 랄라~

    the Origin of Gray Prince

    수도 임페리얼 시티의 투기장에 가면, 별명이 Gray Prince인 그랜드 챔피언 Agronak gro-Malog이라는 오크를 만날 수 있다. 그는 하녀였던 자신의 엄마가 군주와 사랑에 빠져서 자신을 낳았다가 군주의 마누라가 죽이려고 해서 할 수 없이 도망쳤다고 한다. 곧 열쇠를 하나 주며 혈통의 증거를 찾아와 달라고 하는데, 실제로 그 성에 가보면 오크 군주가 아니라 뱀파이어만 득실거리고 있다. 뱀파이어인 Lord Lovidicus를 죽이고 일기장을 가져다주면, 챔피언은 자신은 아무것도 아닌 괴물의 자식이며 그럴바엔 투기장에서 명예롭게 죽겠다며 실의에 빠진다.

    실제로 투기장에서 그와 싸우게 되면, 공격하는 대신 귓속말로 빨리 자신을 죽이라고 하는데... 무시무시하게 달려오는 속도에 비해서 너무나도 슬퍼보였던 오크의 눈빛이 잊혀지지 않는다. (대신 이 퀘스트를 하지 않고 챔피언과 겨루게 되면 아마 피터지는 싸움이 되었으리라.)

    오블리비언의 모든 NPC들의 목소리가 다 녹음된 것처럼, 매 경기마다 투기장에 울려퍼지는 아나운서(?)의 목소리도 계속 달라진다. 또한 챔피언과의 대결에서는 아나운서가 불러주는 호칭도 지정할 수도 있다. (이번에 키우는 캐릭터는 남자 레드가드 전사라서 the Crimson Sword를 선택했다. 으하하) 그리고, 투기장 위로 올라가면 실제로 NPC들이 싸우는 장면을 보면서 돈도 걸 수 있다.

    산적(Bandit) 이야기

    bandits.gif코럴성에 가면 얼굴은 잘생겼지만 지능이 약간 모자라는 청년이 쌍동이 형을 찾아달라는 퀘스트를 준다. 대륙 반대편의 도시에 있는 형을 찾아가서 소식을 알려주면, 형은 즉시 동생을 찾아서 대륙을 횡단하기 시작한다. 과연 또 어디까지 뛰어가나 싶어서 따라가봤는데, 갑자기 매복중인 산적에게 두들겨 맞아 쓰러지는게 아닌가... 이런 퀘스트 NPC들은 죽지 않는 대신 그냥 실신했다가 다시 일어나기를 반복하기에, 귀찮아서 산적을 잡아 주었다. 다시 벌떡 일어나서 동생을 찾아 뛰어가는 걸 보고... 인사를 하고 뒤돌아섰다. ㅋ

    그 후, 또다른 퀘스트를 하기 위해서 들판을 달려가고 있는데 멀리서 말을 탄 NPC하나가 달려 와서 인사를 하고 지나갔다. 웃긴 건 그 뒤로 또다른 산적 하나가 도끼를 들고 열심히 쫓아가는 장면. 결국 그 산적은 옆에 있던 길드 NPC에게 죽음을 당하게 되는데.. 앞에 NPC는 그것도 모르고 열심히 달려가고 있었다. 으하하.

    보통 산적들은 길가에서 매복해 있는데 커다란 돌 뒤에 숨어 있다가 뛰어나와서 돈 100골드를 내놓을래 죽을래? 라고 묻곤 한다. 제일 특이한 넘들은 다리에서 통행세를 받는 넘들인데, 꼭 다리 건너 귀퉁이에 숨어 있다가 뛰어나온다. 언젠가는 다리 옆 으슥한 곳에서 자세를 잡고 있길래 숨어서 활로 죽여 버린 적도 있다. 레벨이 올라갈수록 장비가 화려해지기 때문에, 길을 가면서도 가끔은 산적이 나오기를 기대하곤 한다. :)

  • 2006-04-29

    CSS Tip

    padding & margin

    padding 은 box 영역의 안쪽, margin 은 바깥쪽을 의미하는데, 본문 바깥쪽의 여백을 나타낸다는 것을 연상하면 쉬울 것이다. 각 방향별 속성을 지정할 수도 있고, 한꺼번에 지정할 수도 있다. 이때, 위에서부터 시계방향이라는 것만 외우면 편하다.

    padding-top: 1px;
    padding-bottom: 2em;
    padding : 'top' 'right' 'bottom' 'left';
    

    padding & margin example을 참고할 것.

    div & span

    div는 해당 블럭에 특정 id 또는 class 속성을 적용한다. 반면 span은 inline 으로 특정 id 또는 class 속성을 적용한다.

    font size

    픽셀 등의 단위를 사용하게 되면, 화면 해상도에 관계없이 크기가 고정되게 된다. 이렇게 할 경우, 브라우저의 문자 크기 확대-축소를 해도 바뀌지 않는다. 눈이 나쁜 사람들을 위해서도 픽셀 고정 보다는 아래의 크기를 사용하는 것을 권장한다. (순수 디자인의 관점에서는 맞지 않을지도?)

    xx-small <x-small <small <medium <large <x-large <xx-large
    

    아니면, 상대 크기인 smaller - larger를 써서, 자신의 부모 객체의 폰트보다 크거나 작도록 지정할 수도 있다.

    호환성 있는 페이지 만들기

    우선, 해당 페이지를 순수하게 텍스트로 렌더링하는 페이지를 만든다. 이때, h1, h2, ..., p, hr, ul, ol, li, b 등의 텍스트형 태그만을 사용하도록 한다. (당연히 font 류의 데코레이션형 태그는 사용하지 않도록 한다.)

    텍스트 페이지가 완성되면, 이제 각 element 에 적당한 클래스 스타일을 지정해서, 아름답게 꾸미도록 한다. 중복되는 스타일은 DIV 등을 이용해서 그룹으로 묶는다. (가령 li 태그들)

    끝으로 CSS validate 및 HTML validate 를 통해서 호환성을 검증한다. 브라우저 테스트는 기본.

    float & clear

    박스 또는 이미지가 다른 레이어와 충돌하지 않는 상태에서, 좌측 또는 우측으로 정렬될 수 있게 해준다. 가끔은, float 박스가 2개 이상의 레이어에 걸치지 않았으면 할 때가 있다. 이럴 경우, float 를 포함한 상위 레이어에다가 clear:both 속성을 지정해주면 걸치기를 막을 수 있다.

    Visual Formatting Model을 참고할 것.

    see also:

  • 2006-04-28

    RakNet Tips

    RakPeer::Disconnect(msec,orderingChannel)

    서버에 접속 해제를 알리기 위해서는 RakPeer::Disconnet(msec>0) 를 사용해야 한다. Disconnect(0) 후에 바로 객체를 삭제하면 서버는 10-15초 후에나 연결 끊김을 감지할 수 있게 되며, 그 사이에 서버에 다시 접속할 경우 서버는 이미 연결되어 있는 것으로 간주해버린다. 단 내부적으로 blocking wait 를 하기 때문에 너무 큰 시간을 넘기면 곤란.

    PacketLogger

    RakNet에서 제공하는 플러그인 인터페이스를 이용한 패킷 로깅 클래스. peer.AttachPlugin(logger) 만 해주면 콜백에서 자동적으로 로그를 남겨준다. PacketFileLogger를 사용하면 CSV형식으로 로그를 남긴다. 엑셀을 잘 활용해서 트래픽 최적화라든지 그래프를 확인할 수 있다. 좀더 세밀한 로그를 원하면 상속을 받을 것.

    Unconnected Ping

    동일한 NAT아래에 있는 peer와 통신할 때에는 public보다는 private를 사용하는 것이 좋다. 이런 주소 선택 과정은 RakPeer::Connect()를 호출하기 전에 이루어져야 하는데, RakPeer::Ping(host,port,acceptOnly)을 이용해서 양쪽 주소 모두에게 Ping을 쏴서 확인할 수 있다. 기본적으로 ID_PING 을 보내고 ID_PONG을 받는데, 마지막 파라미터를 true로 할 경우 ID_PING_OPEN_CONNECTIONS을 보내며 상대방은 이미 연결되어 있을 경우에만 ID_PONG 으로 응답한다. FullyConnectedMesh처럼 플러그인 인터페이스를 이용해서 깔끔하게 구현할 수 있을 것 같은데, 플러그인에게 직접 뭔가를 보낼 수 있는지는 미확인.

    주의사항 : Unconnected Ping을 보낸 직후 Connect()를 하게 되면 3-way handshake 흐름이 꼬일 수 있다. 원인은 연결되기 전후의 프로토콜이 다르긴 하나 확률적으로 같게 될 수 있어서, 연결하는 동안 핑/퐁을 하다 보면 가끔 패킷을 잘못 처리하기 때문이다. 라크넷의 메인 개발자 Rak’kar는 가능하면 핑을 보낸 후 일정 시간 동안 기다린 다음 연결하는 것을 권장한다고 한다. 레이옷은 이 버그로 인해서 1개월 가량을 고생했다. -_-;;

    전송 보장의 구현

    전송 보장(packet reliability)은 말 그대로 어떤 패킷이 Peer 에 잘 도착했다는 것을 의미한다.

    • 전송시 RELIABLE_XXX 플래그를 지정한다.
    • 보내는 쪽에서는 재전송큐에 패킷을 넣어둔다.
    • 받는 쪽에서는 RELIABLE 패킷이 도착하면 ACK(num)를 전송한다.
    • 보내는 쪽에서는 ACK(num)를 받으면 재전송큐에서 삭제한다.
    • ACK 손실에 대비, 모든 ACK들은 ACK큐에 저장된다.
    • 주기적으로 실행되는 ReliabilityLayer::Update()에서 그동안 쌓인 ACK큐와 재전송큐를 한꺼번에 모아서 Peer에게 전송한다. (물론 손실이 가정되는 넘에 대해서만)
    • 모든 user defined message 앞에 위와 같은 InternalPacket 이 1개 이상 붙어 나갈 수 있다.

    결국, 모든 RELIABLE 메시지에 대해서 ACK 가 왔다갔다 하는 셈이다.

    순서 보장의 구현

    모든 데이터가 순서 보장이 필요하지 않으며, 오직 몇몇 종류의 패킷들만이 순서 보장이 필요하다. 따라서, 이런 류들을 위한 채널(ordering channel)을 구현해서 각각의 채널마다의 순서를 보장해줘야 한다.

    • 각 채널마다 waitingForOrderedPacketReadIndex 가 존재한다.
    • 순서 보장 패킷이 도착하면, 채널과 인덱스(orderingIndex)를 읽는다. 채널이 없으면 낭패
    • 만약 orderingIndex == waitingForOrderedPacketReadIndex 이면, 대기 인덱스를 증가시키고, 대기 리스트(orderingList)에 들어있는 넘들을 처리해준다.
    • 그렇지 않으면 중간에 누락된 것이므로 대기 리스트에 넣어둔다.
    • orderingIndex 는 BYTE 로 구현된다. 255 로 해서 wrap 시키면 충분한 듯.
    • 그렇지만 아주 오래전의 패킷이 도착했다면 순서가 꼬일 수 있을 법 하다. 이런 것들은 상대편의 전송 시간을 이용해서 체크한다.

    패킷 시퀀스의 구현

    순서 보장(packet ordering)과는 달리 패킷 시퀀스(packet sequence)는 최신의 패킷이 도착하면, 그 전에 도착한 동일한 패킷은 모두 무시하는 것을 의미한다. 가령, xyz 좌표가 10개가 쌓여 있다면 가장 최신의 좌표만을 보여주면 되므로 시퀀스 플래그를 사용하면 된다. 패킷 시퀀스 역시 ordering channel + ordering index 로 구현된다.

    • 각 채널마다 waitingForSequencedPacketReadIndex 가 존재한다.
    • 시퀀스 패킷이 도착하면, 채널과 인덱스(orderingIndex)를 읽는다. 채널이 없으면 낭패
    • 만약 orderingIndex >= waitingForSequencedPacketReadIndex 이면, 시퀀스 인덱스를 증가시킨다.
    • 그렇지 않으면 이미 최신 데이터가 먼저 와 있으므로, 무시해버린다.

    긴 메시지의 구현

    MTU 크기를 넘어선 긴 데이터는, 여러 개의 작은 패킷에 나눠져서(split) 전송될 수 밖에 없다.

    더 많은 팁들은 레이옷의 RakNet 링크모음을 참고할 것.

  • 2006-04-26

    SQL Tips

    MS-SQL SERVER + Transact SQL Tip

    IP 문자열에서 a.b.c 추출하기

    declare @ip varchar(20)
    SET @ip = '111.22.33.22'
    SELECT LEFT(@ip,len(@ip)-patindex('%.%',reverse(@ip)))
    

    리포팅 서비스

    IIS + ASP + VisualStudio.NET 을 이용한 리포트 서비스 제작

    FTP 파일 업로드

    DTS 에서 제공하는 파일 전송 프로토콜 작업(FTPTask)의 경우, 다운로드만 가능하고 업로드는 지원하지 않는다. 가령, 로컬 백업한 .bak, .trn 파일을 리모트 서버로 옮기려면 업로드 스크립트를 SQL 이나 VB 또는 Python으로 직접 개발해야 한다.

    복구 모델

    당연히, 트랜잭션 로그를 남기려면 FULL 복구 모델로 데이터베이스를 설정해야 한다. 백업 정책을 세울 때 꼭 모델을 체크하기 바란다. (기본적으로 FULL 모델로 만들어지는 것 같지만, 필수적으로 체크할 것)

    SQLServerAgent

    유지 관리 계획 등 주기적인 작업을 실행하려면 기본적으로 꺼져있는 SqlServerAgent 를 켜야 한다.

    테이블 스키마 복사

    스크립트로 추출해서 다른 DB 에 설치할 경우 디폴트값이 지정되지 않는 경우가 있다. 이때 DEFAULT 등 제약조건 스크립팅에 꼭 체크를 해야 한다. 아니면 그냥 데이터 내보내기를 사용하길.

    NULL 비교

    NULL 에 대한 비교는 is 로 하라!

    declare @a int
    SET @a = NULL
    
    IF @a = NULL
    print '@a=NULL'
    IF @a IS NULL
    print '@a is NULL'
    
    declare @b int
    SET @b = 1
    
    IF @b <> NULL
    print '@b <> NULL'
    IF @b IS NOT NULL
    print '@b is not NULL'
    
    -- 실행 결과
    @a IS NULL
    @b IS NOT NULL
    

    constraint 관리

    디폴트 constraint 를 삭제하고 다시 추가하는 법...

    -- 삭제
    ALTER TABLE [dbo].[테이블] DROP
    CONSTRAINT [DF_테이블_컬럼]
    
    -- 추가
    ALTER TABLE [dbo].[테이블] WITH NOCHECK ADD
    CONSTRAINT [DF_테이블_컬럼] DEFAULT (디폴트값) FOR [관련컬럼]
    

    CASE WHEN...

    다음은 승률을 나타내는 컬럼을 계산해오는 방식이다.

    SELECT
    ....
    CASE
    WHEN (WinCount + LoseCount + DrawCount) = 0 THEN 0.00
    ELSE round((WinCount * 1.0) / (WinCount + LoseCount + DrawCount) , 2)
    END AS WinRate,
    ....
    

    SELECT...INTO...

    INSERT INTO...SELECT...가 존재하는 테이블에 데이터를 넣을 때 사용한다면, SELECT...INTO...는 새로운 테이블을 만들어 넣을 때 사용한다. 특히 통계 테이블에 데이터를 넣을 때, TRUNCATE TABLE xxx; INSERT INTO...SELECT를 하게 되면, 문제가 생길 수 있으므로 아예 DROP 한 다음 SELECT...INTO... 를 사용하도록 한다. (우리 DBA의 말씀~)

    SELECT.. INTO는 MS-SQL의 기능! . 일반적인 ANSI-SQL에서는 CREATE TABLE ... SELECT 를 사용함.

    대용량 데이터 빨리 넣기

    • 관련 데이터베이스의 로그 타입 변경 : EM에서 데이터베이스 등록정보를 선택, 복구 모델을 단순 또는 대량 로그로 임시 변경해준다.
    • ALTER TABLE ... 로 관련 테이블의 PK 제거하기. 그러나, FK 관계에 있을 경우에도 제거를 해야 하므로 EM 또는 VS 의 DB 다이어그램에서 삭제하는게 제일 간편하다. 단. 복구는 수동 -_-;
    • DROP INDEX ...로 관련 테이블의 인덱스 제거하기. 그러나, 다시 복구해야 하므로... 수작업 필수
    • BULK INSERT : DB 스트레스 테스트를 위해 백만건 이상의 데이터를 넣어야 할 때 사용하면 간편하다. 단 샘플 데이터 파일을 제공해줘야 한다.
    • INSERT INTO xxx () SELECT ...로 자기 자신 혹은 관련 테이블을 활용할 것. while + insert 1 보다 훨씬 빠르다. 테스트해본 결과 한줄씩 insert 할 경우 10만건에 40초, 그러나 위의 방법을 쓰면 2초로 해결 가능. 단, 용량이 너무 커질 경우 실행이 안되므로, 범위를 나눠야 할 듯. 300만건에서는 대충 됨.
    • PK/FK/Index/Trigger 복구하기. 800만건이 들어있는 테이블의 PK 를 복구하는데 Xeon Dual 0.8G 에서 무려 '''5분'''이나 걸렸다.
    ALTER TABLE xxx ADD CONSTRAINT PK_xxx_yyy PRIMARY KEY(yyy)
    ALTER TABLE xxx ADD CONSTRAINT FK_xxx_yyy FORENGN KEY REFERENCES zzz(yyy) ON DELETE CASCADE
    CREATE [UNIQUE] INDEX IX_xxx_yyy ON xxx(yyy)
    

    데드락 해결하기

    sp_who2
    sp_lock pid
    KILL pid
    

    like 와 인덱스

    • col like 'userid%'
    • col like '%userid%'
    • col like '%userid'

    오직 1번만이 인덱스를 타게 된다. Index는 Binary Tree로 구성되어 있기 때문에 처음부터 찾아서 들어가는 것임. 당연히 첫자를 알 수 없다면 index를 경유하지 못함!!!

    Update Trigger

    특정 컬럼이 변경된 경우 로그 테이블에 row 를 추가해야할 경우 Update Trigger 를 사용하면 간편하다. 가령, 플레이어 레벨이 바뀌었을 때 레벨 테이블에 insert 를 해야 한다고 가정하면 트리거는 대략 다음과 같다.

    CREATE TRIGGER trigger_name ON src_tbl FOR UPDATE
    AS
    IF UPDATE(level)
    begin
    declare @old_level smallint, @new_level smallint
    SELECT @new_level = [Level] FROM inserted
    SELECT @old_level = [Level] FROM deleted
    IF @old_level <> @new_level
    begin
    INSERT INTO log_tbl (....) VALUES (...,@old_level,@new_level,...)
    end
    RETURN
    end
    

    @@IDENTITY vs. SCOPE_IDENTITY()

    전자의 경우, 현재 세션에서의 최종적인 IDENTITY 컬럼의 값을 리턴해준다. 단, 트리거에 의해서 다른 테이블에서 IDENTITY 가 바뀐 경우 그 값을 리턴해준다. 따라서, 최종적으로 직접 만진 테이블의 IDENTITY 값을 알아오려면 SCOPE_IDENTITY() 를 사용하는 것이 안전하다.

    UPDLOCK

    쿼리 분석기에서 좌측 쿼리를 먼저 실행한 후 우측 쿼리를 실행할 때, 첫번째 예제의 경우 그냥 실행이 되지만 두번째 예제는 block 된다. 오라클에는 select for update 라는 게 있다나...

    nonblocked query
    begin trans select * from users
    select * from users with (updlock)
    blocked query
    begin trans select * from users with (updlock)
    select * from users with (updlock)

    DB레벨에서 중복로그인을 처리할 경우, DB 컬럼에 LogOn flag 를 둬야 한다. LOGOFF - LOGON - GAME 이런 식의 상태 변화가 있다고 가정하면, 로그인시 LOGOFF -> LOGON 으로 바꿔주고 로그아웃시 LOGON -> LOGOFF 로 설정하게 될 것이다. 문제는 이걸 언제 어떻게 체크해서 바꾸느냐인데...

    -- simplified login procedure
    declare @logon
    SELECT @logon=LogOn FROM users WHERE ...
    IF ( @logon <> LOGOFF )
    begin
    handle_error...
    end
    UPDATE users SET @logon = LOGON WHERE ...
    

    이런 식으로 처리할 경우, abuser 에 의해서 동일 유저에 대한 로그온 프로시저가 DB에서 동시에 일어난다고 가정하면 중복로그인이 충분히 발생할 수 있게 된다. (아주 낮은 확률이지만...) 이를 손쉽게 막으려면 select with updlock를 사용하면 된다. (단, 이때 (Page,IU), (KEY,U), (Table,IX) 의 3가지의 락이 걸린다)

    웬지 불필요한 lock 을 피하기 위해서는, update 의 where 절에서 다시 한번 LogOn 필드를 체크(즉 select 해온 뒤 누가 바꿨는지 다시 확인) 해주면 될 거 같다.

    -- simplified login procedure
    declare @logon
    SELECT @logon=LogOn FROM users WHERE ...
    IF ( @logon <> LOGOFF )
    begin
    handle_error...
    end
    UPDATE users SET @logon = LOGON WHERE ... AND logon = LOGOFF
    IF ( @@rowcount <> 1 )
    begin
    handle_error...
    end
    

    테스트 해 본 결과 양 쪽에서 select 한 후 한쪽에서 update 를 하면, 다른 쪽의 update 는 트랜잭션에 의해 block 되며 상대 트랜잭션이 끝나면 실패를 리턴하게 된다. (만약 where 절의 체크가 없다면 둘 다 로그인 성공으로 간주되므로 주의할 것)

    이게 consistent non-blocking read을 지원하기 때문인데, 간혹 조금 이해하기 힘든 현상이 일어날 때도 있지. 하지만 Query 하나 들어갈때마다 table에 lock을 걸어버리는 방법은 너무 위험부담이 크기 때문에. consistent read에 대한 설명이 필요하다면 다음 기회에..

    sp_lock, sp_who2

    • sp_lock : 현재 시스템에 존재하는 lock 을 보여준다.
    • sp_who2 : 현재 시스템에서 실행되는 프로세스, 소유주, 접속지, 실행내용 등을 보여준다.

    이 내용은 Enterprise Manager 의 관리에서 찾아볼 수 있다.

    GOTO

    어릴 적(?)부터 항상 GOTO 는 쓰지 말라는 이야기를 들어 왔지만, 프로시저에서 또 쓰게 될 줄은... 그러나, 트랜잭션이 존재할 경우 GOTO 를 쓰지 않으면 자꾸 if-else-if-else 로 indent 해들어가는 바람에 읽기가 힘들어져서...

    begin transaction
    ....
    ....
    IF ( error_found )
    begin
    rollback transaction
    SET @errorcode = -1
    goto Label
    end
    ....
    ....
    commit transaction
    ....
    ....
    Label:
    log something...
    ....
    ....

    SET IDENTITY_INSERT

    identity 컬럼의 경우 DELETE FROM tbl 하더라도 시드값은 계속 증가하게 되어 있다. 또한, 특정 값을 명시적으로 insert 할 수 없다. 이를 가능하게 하기 위해서는 아래와 같이 하면 된다.

    SET identity_insert tbl ON
    INSERT INTO tbl (identity_col,...) VALUES (N,...)
    SET identity_insert tbl off

    @@ERROR / @@ROWCOUNT

    프로시저 중간에 insert 나 update 할 경우 혹시 모를 실패에 대비해서 아래와 같이 체크해야 한다.

    INSERT ...
    IF @@error <> 0
    begin
    rollback transaction
    RETURN
    end

    update 의 경우 1개의 업데이트만 기대했는데, 2개가 업데이트된 경우 @@ERROR는 정상이 된다. 이때에는 @@ROWCOUNT 를 체크해야 한다.

    시간 필드의 디폴트값

    insert 시점의 시간을 저장하는 필드의 경우, 테이블 선언시

    CREATE TABLE xxx
    ...
    yyy DATETIME DEFAULT GETDATE()
    ...

    라고 해주면 insert 할때 굳이 명시할 필요가 없다.

    현재 시간 + a

    dateadd( datepart, N, getdate() )

    스크립트 디버깅

    exec sp_sdidebug 'legacy_on'

    시간 변환

    convert(varchar(10),getdate(),121)

    프로시저 실행시 파라미터

    out 을 명시해주지 않으면 받아낼 수 없다

    execute proc @param1, @param2, @param3 out, @param4 out

  • 2006-04-26

    Spread

    개요

    Spread는 그룹 기반의 UDP 메시징 라이브러리로, unicast & multicast 및 scattered send/receive를 지원한다. 단 PeerToPeer 모델이 아니라, 독립적인 어플리케이션인 Spread 데몬이 가운데에서 중계를 해주는, 일종의 메시지 버스의 역할을 한다. RakNet 과 마찬가지로 다양한 UDP 전송 방식을 지원한다.

    전송방식 설명
    Unreliable least
    Reliable will get there, no ordering
    Fifo reliable and ordered fifo by source
    Causal reliable and all mesg from any source of this level are causally ordered
    Agreed reliable and all mesg from any source of this level are totally ordered
    Safe Agreed ordering and mesg will not be delivered to application until the mesg has reached ALL receipients' daemons

    성능

    문서에 의하면 1k 메시지 8000개를 1초에 전송 보장한다고 하며, 또한, The Spread Toolkit: Architecture and Performance에 따르면 만개의 그룹에서 Safe 메시지를 보낼 때의 지연 시간이 6ms 라고 나온다. 실제로, 파이썬 바인딩을 이용한 클라이언트와 (아무런 부하를 주지 않은) localhost 데몬과의 테스트 결과 0.25ms 정도의 반응 시간을 보여줬다.

    전송방식 평균 ping 시간
    UNRELIABLE_MESS 0.248ms
    RELIABLE_MESS 0.248ms
    FIFO_MESS 0.247ms
    CAUSAL_MESS 0.247ms
    AGREED_MESS 0.247ms
    SAFE_MESS 0.248ms

    로컬랜에 연결된 2개의 머신에서 한쪽(Windows XP, P4 2G + 512M)에 데몬을 real time priority 로 띄우고 다른 쪽에 송수신 전용 쓰레드를 가진 테스트 클라이언트를 붙인 다음, 초당 8000개의 4byte 메시지를 보내는 경우 CPU 사용량 100% 에 RTT 0.8s 가 나왔다. 즉 1초 안에 전송은 보장하지만 반응 속도는 영 좋지 않았다. 그리고 로컬랜의 방화벽 바깥에 있는 머신(Windows 2003 + P4 2.8G + RAM 2G)에 데몬을 띄우고 방화벽 안쪽에서 테스트한 결과, 초당 4000개를 보내면 연결이 끊기며 초당 1000개를 보내면 0.8s 의 반응 속도를 보여줬다.

    예상되는 용도

    컨텐트 중계 서버
    채팅 채널, 파티 채팅, 길드 채팅을 중계해주는 서버. 굳이 채팅 서버를 두지 않아도 무방할 듯. 그 역할을 Spread Daemon 이 해주니까.
    게이트웨이 서버
    M:N 관계에 있는 tier 에서의 중계 서버 역할. 가령 N개의 에이전트와 M개의 게임서버간의 연결을 TCP로 하게 되면 각 에이전트는 M개의 TCP연결을 하고 그것을 관리해야 한다. 이를 1개의 Spread 연결로 해결할 수 있다. (M개의 그룹에 대해 1개의 연결을 사용한다는 가정 하에서...) 마찬가지로 게임서버는 1개의 Spread 연결에 대해서만 처리하게 되므로 이벤트 드리븐이 간단하게 이루어진다. 가령 클라이언트가 에이전트에 로그인하고 이전 위치를 로딩하게 되면, 에이전트는 해당 서버 그룹에 join 하고, 클라이언트가 로그아웃하면 그룹에서 leave 하면 된다. (ref-counting을 사용하면 간단) 서버간 이동도 마찬가지로 처리할 수 있다. 단 이렇게 될 경우 데몬이 다운되면 게임 끝이다.
    브로드캐스트 서버
    (현실성이 없는 이야기지만) 게임 서버의 각 브로드캐스트 영역(일명 Area)을 그룹으로 간주하고, 그 안에 들어있는 플레이어들에게 멀티캐스팅할 수 있겠다. 단 이런 모델은 잦은 그룹 join/leave 가 이루어지므로 Area 개수가 늘어날수록 부하가 심해질 듯. 1000개의 그룹일 경우 35ms 정도니까... 10000개면...
    데이터 복제 서버
    게임 서버의 경계 영역에서의 데이터 변화를, 이를 공유하는 다른 서버로 복제한다. 예를 들면, NPC 길찾기를 위한 동적 객체 정보를 게임 서버에서 NPC 에이전트로 복제하는 등.

    참고사항

    경험자의 말에 의하면, windows xp 와의 궁합이 잘 맞지 않다고 한다. 대신 windows 2000 에서는 잘 된다고 하는데, 커널의 네트워크 모듈과의 궁합 문제일 가능성이 있다고 한다. 즉 windows 2003 에서 테스트를 더 해봐야 한다는 의미.

    see also:

    
    #format python
    import spread
    import unittest
    import time
    
    daemon = '3333@localhost'
    
    class SpreadTest(unittest.TestCase):
      def testConnect(self):
      mbox = spread.connect(daemon,'test1')
      self.failUnless( mbox.fileno > 0 )
      mbox.disconnect()
      def testJoin(self):
      mbox = spread.connect(daemon,'test2')
      mbox.join('test1')
      if mbox.poll() > 0:
      msg = mbox.receive()
      self.failUnlessEqual( str(type(msg)) , "" )
      self.failUnlessEqual( msg.group , "test1" )
      self.failUnlessEqual( msg.reason, spread.CAUSED_BY_JOIN )
      mbox.leave('test1')
      if mbox.poll() > 0:
      msg = mbox.receive()
      self.failUnlessEqual( str(type(msg)) , "" )
      self.failUnlessEqual( msg.group , "test1" )
      self.failUnlessEqual( msg.reason, spread.CAUSED_BY_LEAVE )
      mbox.disconnect()
      def testLeave(self):
      mbox = spread.connect(daemon,'test2')
      mbox.join('test1')
      mbox.leave('test2')
      mbox.disconnect()
      def testSend(self):
      mbox = spread.connect(daemon,'test3')
      group = 'StressTesters'
      message = 'Hello Spread!'
      mbox.join(group)
      if mbox.poll() > 0:
      msg = mbox.receive()
      self.failUnlessEqual( msg.reason, spread.CAUSED_BY_JOIN )
      mbox.multicast( spread.FIFO_MESS, group, message)
      if mbox.poll() > 0:
      msg = mbox.receive()
      self.failUnlessEqual( msg.groups[0], group )
      self.failUnlessEqual( msg.message, message )
      mbox.leave(group)
      mbox.disconnect()
    
    def testPing( trycount = 100 ):
      mbox = spread.connect(daemon,'StressTester')
      group = 'StressTesters'
      mbox.join(group)
      msg = mbox.receive()
      assert msg.reason == spread.CAUSED_BY_JOIN
    
      service_types = (
      spread.UNRELIABLE_MESS,
      spread.RELIABLE_MESS,
      spread.FIFO_MESS,
      spread.CAUSAL_MESS,
      spread.AGREED_MESS,
      spread.SAFE_MESS )
      service_type_str = (
      'UNRELIABLE_MESS',
      'RELIABLE_MESS',
      'FIFO_MESS',
      'CAUSAL_MESS',
      'AGREED_MESS',
      'SAFE_MESS' )
      ping_stat = []
    
      for service_type in service_types:
      ping_count = 0
      total_ping = 0
      for i in range(0,trycount):
      mbox.multicast( service_type, group, str(time.clock()) )
      msg = mbox.receive()
      total_ping = total_ping + time.clock() - float( msg.message )
      ping_count = ping_count + 1
    
      if ping_count > 0 :
      ping_stat.append( total_ping / ping_count )
      else:
      ping_stat.append(0)
    
      for i in range( 0, len(service_type_str) ):
      print "%15s : %8f" %( service_type_str[i],ping_stat[i])
    
      mbox.leave(group)
      msg = mbox.receive()
      assert msg.reason == spread.CAUSED_BY_LEAVE
    
      mbox.disconnect()
    if __name__ == '__main__':
      testPing(10000)
      #unittest.main()