로직의 네트워크 동기화 처리

실시간 게임이라 하더라도, 내부적으로는 모든 게임의 구성은 턴으로 구성하는 것이 좋다. (실제 초당 n프레임 같은 개념에서 각 프레임은 턴과 같은 개념이기 때문이다.)

이 턴의 동작 주기를 입력, 렌더, 로직 등을 잘 구분지어 처리하는 것이 좋다.   예를 들면, 입력은 1초에 5번, 렌더는 1초에 30번, 로직은 1초에 5번 등의 턴 기준을 명확히 정해 쪼개 놓는 것이 좋다.

그래야 네트워크 동기화, AI 구현 등의 처리 규정을 명확히 세울 수 있다.

로직을 1초에 5번으로 규정을 짓는다면, 한턴은 200ms이고, 200ms동안 이루어진 로직의 변화는 묶어서 다른 클라이언트로 전송하고, 내 변화도 묶어두었다가, 다른 유저의 정보를 수신한 다음턴이 돌아올 때 처리하는 식으로 구성하면 된다.

LOL의 경우에도 4단계를 두고 지연 시간에 따라 턴이 어떻게 밀리는가를 표시한다.

위에 언급한 방식으로 구현을 하면, 처리 규정이 명확하므로 지연시간 발생시에 얼마만큼의 늦은 반응성을 보여줄 것인지가 표현 가능하고, 이를 본 사용자가 자신이 겪고 있는 상황에 따라 판단이 가능하다는 장점이 있다.

로직 처리 방식 분류

  • 액션 베이스
    • 현재 값 수신.
    • 이전 -> 현재 보간.
  • 이벤트 베이스
    • 명령에 대해 전달. 새 명령을 수신하거나, 기존 명령을 완수할 때 까지, 기존 명령을 수행함.   어떠한 방식을 취하던 크게 상관없으나, 기본적으로 이벤트 베이스를 두는 것이 지연에 대처하기 조금 더 유연한 방식이라고 할 수 있다.

액션 베이스는 보간을 위한 기준값이 있으며, 이를 넘어서서 늦게 도달시에는 그만큼 어색할 수 있고, 지연이 조금이라도 발생한다면 지연 시간 동안 어떤 액션을 취해야 하는지 알 수 없기 때문이다.

이벤트 베이스라고 해도, 지연시간이 일정 시간이 넘어서면 제대로된 반응성을 보장하지 못한다. 하지만, 사용자의 입력이 적용 완료될 때까지의 시간동안 보여줄 이벤트가 존재하기에 상대적으로 여유가 있다.

물론 이벤트 베이스에서도 지속적으로 지연시간이 발생한다면 제대로 된 플레이는 어렵다고 볼 수 있다. (롤 400핑 이상에서의 플레이에서의 반응성은 정상적인 게임 진행이라 보기 어렵다)

그렇지만 이벤트 베이스로 구성되어야 이벤트를 묶거나, 이벤트 적용 과정도 조금 더 매끄럽기 때문에 이벤트 베이스 방식으로 구현하길 권장한다. (상대방의 이벤트 수신 코드와, 내 이벤트 처리 코드 부분을 일체시켜 나의 플레이와 상대방의 플레이를 구분 짓지 않고, 모든 것을 이벤트로 처리하기 쉽게 코드가 작성될 수 있기 때문이다.)

Thread design 01 - 우리는 이미 알고 있었다

제가 프로그래밍을 처음 배울 때의 CLI 프로그래밍과 WIN32 프로그래밍으로 넘어왔을 때 큰 괴리를 느꼈습니다.

그 이유는 바로 EVENT-DRIVEN(message based)프로그래밍 때문이었죠.

현재는 reactor라는 패턴이란 이름으로 더 알려진 이 메시지 기반 프로그래밍은, DOS 시절의 동기 프로그래밍에 익숙한 많은 프로그래머를 괴롭게 했습니다.

message라는걸 왜 굳이 만들어 처리하는가….에 대해 저는 그 당시 이해하기 어려웠습니다. 당시만해도, 윈도우 메시지를 굳이 처리하지 않고도 여러 작업이 가능했기 때문이죠.

예를 들어 GetAsyncKeyState 같은 함수로도 얼마든지 키 입력 처리가 가능했고, 마우스 입력도 마찬가지였죠.

굳이 왜 메시지 핸들러 코딩을 왜 해줘야 하는지 이해가 잘 안 갔어요. 특히나 윈도우 기본 컨트롤들을 사용하지 않았기 때문이기도 할 겁니다.

이 때가 98년 경이었는데, COM 기반의 메소드를 통해 초기화하던 Direct-X가 이해 안갔던… (그 당시엔 COM이 뭔지도, 왜 저렇게 복잡하게 포인터 캐스팅하는지도 이해가 어려웠습니다) 시기였습니다.

코어도 한 개이던 시절이라, multithread  프로그래밍은 n개의 CPU를 꽂아 쓰는 서버나 슈퍼 컴퓨터 사용자들에게나 필요한 걸로 치부 되기도 했습니다.

헌데…그 어렵디 어렵다던 multithread 프로그래밍이 사실은 WIN32 프로그래밍에선 각종 API를 통해 이루어지고 있었고, 메시지 핸들링 코드는 다른 thread (다른 프로세스에서 오는 메시지도 있지만)에서의 처리 결과를 전달하는 코드이기도 했다는 걸 한참이 지나서야 알게 됐습니다.

더 깊게 알아내지 못한 제 잘못도 있지만, 책과 PC통신을 제외하곤 정보를 얻기 힘든 당시 상황을 감안하면, 좀 더 친절하게 설명해주지 못한 당시 WIN32 프로그래밍 입문서들의 문제도 있습니다.

자! 여기서 핵심은, WIN32 어플리케이션을 만들어온 과정이 사실은 multithread 프로그래밍을 해왔다라는 점이에요. 우리가 몰랐을 뿐이죠.

모를 수 있었다는 것은, 사실 WIN32 메시지 핸들러를 통한 thread  모델이 안정적이고, 잘 디자인된 thread design중 하나라고 볼 수 있습니다. WIN32 메시지 핸들링 방식은 메시지 루프를 통해 비동기 작업들을 처리해야 될 때에만 양보하고 다시 주 thread 루프로 돌아오게 해주었던 안정적인 thread design과 API를 구성했죠.

이로써, multithread에 대한 이해 없이, 그리고 thread 동기화에 신경 쓰지 않고도 비동기 프로그래밍을 할 수 있었습니다.

그렇습니다! 우리는 WIN32 메시지 기반 프로그래밍을 통해 Reactor thread design을 경험한 것입니다!

정리하자면, thread design이란 다음 요구사항을 충족해야 한다고 보시면 됩니다.

객체를 lock을 신경 써야 될 부분, 그렇지 않은 부분을 명확히 규정

  • 프레임워크단에서만 신경쓰도록 구현하는 것을 권장하지만, 개체 단위 lock이 정책인 경우도 있습니다.
  • 개체 단위 lock으로 구현한 경우에는, 코딩 난이도 상승, 병목 지점 감지도 어려워질 뿐더러, 데드락 위험성도 높아져 좋지 않은 디자인이라고 봅니다.

API 사용법만 준수하면 최대한 많은 코딩 영역에 thread 동기화를 고민하지 않도록 하는 것

  • 이 부분이 사실 핵심인데, thread design이 잘 되어 있을 수록, 성능도 만족하면서 유지 보수 난이도가 급격하게 낮아진다고 보시면 됩니다.

Blocking 동작이나, 반복작업, 병렬 수행이 필요한 작업등을 지원하기 위해 비동기 작업을 위임하거나, 분산할 수 있는 기능

  • thread pool을 통한 작업 분배, open MP등으로 지원된 병렬 코드 수행등을 떠올리시면 좋습니다.   WIN32 메시지 기반 모델에서는 병렬 작업을 위한 분산 기능은 미흡했으나, (시기적인 부분을 감안해 줘야 된다고 생각합니다만) 그 이외의 요구 사항은 충족합니다.

이렇듯 thread design이란 먼 곳에 있지 않았습니다!

웹 로그 서버 구축기 with rails V2

개발 목표

  • 자동으로 파일로 남겨져 있는 로그를 분석해, 시스템에 영향을 주는 작업을 알려주기 위해 개발 됐다.
  • 파일로 남겨져 있는 log를 db으로 밀어 넣는 작업은 log_shipper가 담당한다.
  • db에 있는 데이터를 조건에 맞게 검색해서 보여주는 역할을 하는 web_server를 만들고자 의도했다.
    • 실제 쿼리 작성 비용과 web_server의 API 개발 비용이 크게 차이 나지 않음을 느낄 수 있다.
  • 서비스 중에도 볼 수 있도록, REST API 서버로서의 기능도 수행한다.
    • 관련된 기능은 개발용으로 put method를 만들어둔 것을 이용하면 된다.

최종 목표

  • 통지 기능
    • 서비스에 문제가 있었는지를, 유저 보고가 오지 않더라도 알 수 있게끔 한다.
  • base line 설정 기능.
    • major patch시나, 무언가 의심될 때는 익일 보고 모드가 아닌 observer mode로 동작 시키자.

동작 방식

  • 각 서버는 파일로 로그를 떨군다.
    • local에 남겨진 로그를 log shipper가 db로 운송한다.
  • option에 따라 web으로 직접 전송하기도 한다. (observer mode)
    • 원하는 목표를 산출하기 위한 몇 가지 기능을 Ruby on rails를 통해 이용한다.

구현 issue

  • db 병목에 대한 대처. (기본적으로 rdb에 대한 접근이 실시간으로 이루어져, 이에 대한 비용 문제가 있다.)
    • partioning 할까?
      • raw data를 직접 쌓는 지금 방식에서 개선이 필요하다.
      • raw data를 합산해서 http call을 하고, 그 데이터를 바로 쌓지 않고 find and update 하는 구조로 가야 할 듯 싶다.
        • mvc를 1:1 대응으로만 구현하면 성능의 문제에 휘말림.
          • db cahching을 model을 통해 구현해야 함.
            • text file을 line별 parsing을 하고 split 하려던 중, 오류 발생.
    • ruby invalid byte sequence in utf 8
      • ruby도 encoding 문제에서 자유롭지 못함.
        • 사실 이건 모든 프로그래밍 언어의 문제…정확히는 윈도우의 문자열 코드 페이지 처리에서 생기는 문제.
          • 해당 문자열에 force_encoding(“iso-8859-1”).encoding(“UTF-8”)을 하니 처리 되기 시작.
        • 헌데, 읽혀진 문자열에 유효하지 않은 공백이 포함됨.
          • text 파일이 ascii, utf-8은 정상적으로 읽힘. 헌데, unicode option의 text file만 안 읽힘.
            • 각종 encoding option으로 해결 안됨.
        • line 별 encoding 을 시도.
          • 첫 번째 라인만 encoding 됨. 두 번째 라인부터 꼬임.
            • line별 encoding을 확인해보았더니, 두 번째 라인부터는 CP949 (ascii code page 949)로 인식되는 것을 확인.
              • 파일 전체를 열고 버퍼 전체를 encoding 해보았더니 정상적으로 읽힘.
                • 해결!

production 이슈

mysql2의 사용법을 따라 해도 gem install부터 안됨. 첫 번째 문제는 x64용 mysqlconnector를 이용한 것이 원인. x86으로 바꾸고 시도. Gem install에는 성공함.

Install은 됐으나 정상 동작하지 않음. 구글링 해보니 루비 설치폴더에 libmysql.dll을 수동으로 넣어줘야 된다고 함. 해당 파일을 C:\mysql-connector-c-6.1.5-win32\lib 에서 해당 파일을 C:\RailsInstaller\Ruby2.0.0\bin 로 옮겨주니,

gem mysql2가 수동 설치 되기 시작.

gem install mysql2 --platform=ruby -- '--with-mysql-dir="C:\mysql-connector-c-6.1.5-win32"'

gemfile에 gem ‘activerecord-mysql2-adapter’ 를 포함하고 시도.

rake db:migrate

에서 문제

gemfile에 gem ‘mysql2’ 를 포함하고 시도하니 해결

현재 미구현 사항

  • 통지 기능
    • ruby script와 연동해 메일 발송 기능을 지원?
    • ror엔 trigger 기능이 없음. 정해진 시간에 http call을 해줄 애는 누구로..?
      • log_shipper를 call 할 시점과 주체는?
      • 로그 남기는 것을 float이나 double같은 실수 형으로 남기도록 수정. (count말고 수행 시간 같은 것)
      • 테이블 인덱스 설정
        • IP, Message를 pair key로 잡으면 될 듯?

해결 목록

  • partioning table 처리 (DB 병목 대비)
    • partioning 처리 자체를 update or insert로 처리 했으므로 DB 병목의 가능성은 줄였고, 대신 데이터 수신 시 비용이 증가.
    • Log_shipping 병렬화
      • 1차 thread 사용
      • 2차 thread pool 사용 (concurrent-ruby gem의 기능 사용)

non-blocking multithread programming

클럭도 물론 중요하지만, 코어가 몇갠지 부터 보는 일이 자연스러워진지도 몇년.

다들 병렬 프로그래밍 잘 하고 계시나요?

서버 프로그래밍을 시작한 2006년부터 지금까지… 멀티 스레드를 다뤄오며, 느낀 것에 대해 이야기해보고자 한다.

lock

말그대로 잠금. 이 데이터 unlock 될때 까지 쓰지말라는 거다.

  • non-blocking non-blocking이 뭐냐고? 대기하는 상황(blocking) 없이 코드가 수행되는 것을 말한다.

  • lock is blocking. lock이라는 것 자체가 blocking을 위한 녀석이다. 멀티스레드에 적합한 녀석 일리 없다.

  • lock-free container나 데이터에 접근할 때에 lock 객체에 대한 고민없이 사용해도 되는 자료구조나 스레드 모델을 lock-free라고 통칭한다. 좀 더 디테일하게 설명하자면 lock-free container, lock-free design이라 칭해도 되겠다.

atomic operation

  • atomic operation (or atomic instruction) CAS (http://en.wikipedia.org/wiki/Compare-and-swap) 기반의 명령어 집합을 의미한다. windows 에선 Interlocked 계열 메소드(http://msdn.microsoft.com/en-us/library/windows/desktop/ms684122(v=vs.85).aspx)를 통해서 지원한다. software적으로 직접 구현도 가능하다. (Interlocked 계열 메소드도 software적 구현)

  • limit of atomic operation atomic operation이 해결해 주는 것도 꽤나 많다. 객체단위 lock보다, CAS는 잠금 시간이 짧다. 다만 그로 인해 성능은 좋아질 지언정 논리상 오류가 없을까? 기본적인 전치검사 후치보장 룰을 깨뜨릴 가능성이 높다. 결국엔 논리적 오류까지 해소하기 위해선 스레드 디자인이 필요하다는 얘기다.

parelell

  • lock and paralell 애초에 lock이란 pararell과 어울리지 않는 단어. 허나 locking을 minimal 하는 목표를 갖고 pararell 하게 구현해보자.

  • reactor, proactor

  • reactor : dispatching based.
  • proactor : callback based.

조금 더 자세히 보자면 proactor는 이 작업을 비동기로 수행하고 그 결과를 알려주세요라는 개념이고, reactor는 어떠한 작업이 언제 완료될지 모르니, 작업에 대한 통지가 오길 기다리는 개념이라고 보면 된다.

thread-design

  • entry point 진입점이 명확하면 좋다. 데이터를 발생 시키는 지점부터 고민해야 한다. 내 경험상 수 많은 서버가, entry point의 종류로 network receive, cyclic action (internal looping), system thread (api internal thread) callback, packet process thread 정도가 주를 이룬다. 이 위치를 명확히 파악해두어야, 부하에 대한 tracking이 쉬워진다.

  • contact point 각 entry point에서 발생된 event들이 겹치는 지점을 말한다. entry point가 종류별로 여러 스레드로 구성 되어 있을 경우 특히나 contact point가 겹치곤 하는데, 이 위치에 lock을 놓는 전략이 가장 쉽지만, 성능 저하를 일으키는 지점이 되곤한다.

  • thread count 스레드 갯수는 경합 확률을 높인다. 하지만, 잘 되어있는 스레드 디자인이 이를 우회할 수 있다. blocking 되는 상황이 있는 thread와, 할 일이 있다면 쉬지 않고 일하는 non-blocking thread를 파악하고, 그 갯수를 비례해서 조절하는 것이 좋다.

sleep 함수도 마찬가지다. 현재 context를 이관하는 선택은, blocking이 되는 상황이 되고 있다는 것을 전제한다.

  • tip contact point는 필연적으로 lock을 강제당한다.   contact point를 제거할 수 없다면, contact point를 갈라주거나, locking을 minimize 하는 노력을 기울여야 한다.

  • lock minimize -> contact point가 줄어들면 자연스레 lock을 최소화 해서 사용할 수 있게 된다. -> contact point를 줄이는 쉬운 팁은 thread마다 다른 instance에 접근하도록 하는 것이다.  -> 예를 들어 MMORPG에서 지역별로 다른 message worker를 이용하도록 하면, 잠금없이 스레드가 동작할 수 있다. -> 다른 지역간의 interaction이 일어나는 동작을 개체단위 lock이 아닌 message로 수신해서 처리하는 방식으로 한다면, message queue에 밀어넣는 잠깐의 지연만 걸릴 뿐이다. -> 조금 더 쉽게 정리하자면, 개체의 소유권을 각 스레드에 두고, 입력과 출력은 message로써 다루면 lock을 줄일 수 있다.

warning

  • event explosion 어떻게 구현하건간에 처리량은 한계가 있다. 이벤트량 자체가 폭발하지 않게끔제어해야 한다.

예를들면 패킷을 수신하는데, 비정상 적인 수의 패킷을 발생 시킨다면, flooding 처리를 해야 한다. 또한 로직에서 아이템 처리를 위해 여러개 티어를 거친다고 생각해보자. 아이템 획득 처리, 아이템 로그, 퀘스트 달성 여부, 이벤트 진행인지 검사 이벤트 등… 모든 컨텐츠의 티어가 잘게 나누어져 있고, 처리 티어들을 한번씩만 거친다고 해도 이벤트량 자체는 많을 수 밖에 없다.

티어를 너무 잘게 쪼갠다면, 데이터를 완성 시키기 위한 과정에서의 이벤트량 자체가 많아지고, 오류 발생의 여지도 늘어난다.

hot spot

latency는 두 종류가 있다.

  1. 절대적인 처리량이 많아서. (event explosion으로 인한 처리량이 많아진 상황도 포함한다)
  2. 병목 지점에서 대기가 이루어져, 전체 대기 시간이 길어지는 상황.

이 중 2번의 주 원인은 hot spot에 있다.  hot spot이 발생했다는 것 자체가, contact point에서의 지연이 발생하거나, contact point에 동시 접근하는 스레드가 많다는 의미다. 다시 한번 강조하자면, thread design이 그래서 중요하다.

maintain

  • profiling 측정하라. entry point부터 processing이 끝난 시점까지 측정하는 것은 너무나도 당연하지만 생각보다 이에 대한 모니터링이 이루어지지 않는 경우도 많다. 이벤트 갯수, 이벤트 처리시간, 오류 발생, 경고 등 지표화 할 수 있는 데이터들을 잘 남겨 두는 것이 중요하다.

프로파일링은 기능의 일부처럼 다뤄져야한다. 

번거롭고, 거추장스러워 문제가 생기고 나서야 확인하는 범행 현장 조사 같은 일이 되어서는 안된다.

  • base line 처리 속도와 처리 시간의 목표 지점을 설정하라. 한 패킷이 5ms이하로 동작하리라 목표로 삼는다면, 1스레드당 200개 패킷을 처리할 수 있다는 전제를 세울 수 있다. 처리량을 기준으로, 한 서버당 가용 동접이 설정 가능해진다.

정상, 경고, 임계, 장애등의 수위를 정해놓고 각 수위별로 다른 알람을 받으면 좋다. 또한 runtime에 해당 수치를 낮출 수 있는 대안 (컨텐츠별 block)을 마련해두어 대응할 수 있어야 한다.

스레드 디자인에 대한 내용은 다음 글에서 따로 다뤄보고자 합니다. 꽤나 길어질거 같군요.

제 생각에 대한 이의나 다른 의견, 질문 뭐든지 환영합니다.

이상으로 멀티 스레드 프로그래밍의 퍼포먼스 해결을 위한 팁을 정리해보았습니다.

유닛 테스트의 진짜 효과와 역할

유닛 테스트를 내가 접한 지도 어언 10년이 다 되간다. 그간 내가 거쳐온 많은 회사에서 사용되기도, 무시되기도, 우선 순위에 밀리기도 하더라.   이 과정에서 제안도 여러 번 해보고, 설득 과정에서 자주 나왔던 질문이 있었다.   유닛 테스트하면 뭐가 좋은가요?   처음 이 질문을 받았을 당시, 내 답변은   테스트야 하면 당연히 좋은 거다   라고 답변했었다.

사실 누구나 테스트의 중요성은 배운다. 그래서 아주 작은 팀이고 여력이 부족하다면, 개발팀 내 테스트라도 소화하려고들 하는 것은 사실이긴 하다.

하지만 많은 사람들에게 테스트란 재미없고, 지루하지만 해야만 하는 교리 같은 것에 불과하다는 것이 문제다. 듣는 사람도 고개는 끄덕였지만, 막상 실천으로 이끌어 들이는 데에는 실패했다. 결과적으로 동기부여에 실패했다.

이어진 내 고민은, 과연 유닛 테스트의 장점이라고 주장해야, 테스트 검증 개발 TDD is dead - 이후, 나는 이 생각을 더 굳게 굳혔다. Test Verify Development. 즉 테스트를 통해 검증을 하며 이뤄지는 개발을 말한다)에 동조 할 수 있게 만들 수 있을지 였다.

이 과정에서 유닛 테스트 및 시스템 테스트 코드에 대해 여러 가지 대화를 나누곤 했다. 그 과정에서, 그리고 내가 직접 겪었던 테스트 코드의 가장 큰 장점은, 바로 작업자의 의도를 테스트로 강제한다는 점이다.

건즈2 팀에서 일 할 당시, 테스트가 깨지는 것은 build가 깨지는 것으로 간주됐다. 왜냐하면 테스트 프로젝트는 각 프로젝트와 대응되어 함께 build 되었으며, post build 이벤트로 테스트가 수행되는 것이 프로세스였는데, 이 과정에 strict한 검사가 많으면 많을 수록, 새로 작성한 코드나 수정된 코드가 기존 작업자의 의도를 벗어난다는 것을 알 수 있었다.

그렇다. 유닛 테스트를 통해, 동작하는 코드만으로 부족한 작업자의 의도를 전달하고, 그 의도가 지켜질 수 있었다.

이 이야기를 해보면, 유닛 테스트를 팀 프로젝트에 밀접해 적용해온 팀들의 개발자 분들은 비슷한 의견을 내비치셨다.

여기서 또 중요한 점이 있다.

바로 자신의 코드가, 자신이 의도한 대로 동작하는지를 더 먼저 확인해준다는 점이다.

나는 DHH가 위에서 언급한 TDD is dead라는 글을 쓰기 전에도 이미, TDD는 하지 않고 있었다. 난 Post Test Development를 해왔다고 볼 수 있다.

그렇게 해오다 보니, 테스트를 작성하는 과정에서 내가 작성한 코드가 의도대로 동작하는지 확인하는 과정을, 그리고 예외 상황에 대한 고민을 테스트 작성하는 과정에서 진행했다.

물론 이 과정이 너무 늦어, 설계를 변경해야 되면 어떻게 할거냐는 질문을 받기도 했는데, 사실 설계가 테스트를 통과하지 못해 무너지게끔 구성되어 있다면, 유연하지 못한 코드를 작성한 거라고 봐도 무방하다고 생각한다.

사실 아무리 commit message를 잘 작성해도, 문서를 잘 작성해도, 주석을 잘 달아놓아도, build가 깨지는 것만큼 강한 메시지는 없다.

그것도 논리적 모호함이 가장 적은 그 언어의 문법에 맞춰 작성된 코드라면 더 할 나위 없이 명확하다.

그래서 우리는 유닛 테스트를, 시스템 테스트를 진행 해야만 한다.