이 글은 월간 마이크로소프트웨어(일명 마소) 2010년 5월 5일자 뉴스캐스트에 기고한 글입니다. 물론 구성이나 내용 상의 차이가 있을 수 있습니다.
예전 직장 동료들과 오픈 소스 프로젝트로 C++ 기반의 서버 라이브러리를 조금씩 구축 중이다. 나름 취미이자 친목 도모 프로젝트인 Earlgrey는 아직 네트워크 쪽이 덜 구현된 데다 여러 가지로 미흡하지만 점차 그 형태를 갖춰나가는 중이다. 그리고 스레딩과 메모리 모델은 어느 정도 뼈와 살이 붙어 이야깃거리로는 충분하다고 생각한다.
이 글에서는 얼그레이를 설계하고 구현할 때 염두에 둔 철학에 대해 알아보려 한다. 거창하게 철학이라 했지만 설계시 고려했던 점 정도라 생각하면 될 듯 하다.
참고로 영문 발표 자료를 바탕으로 보충 설명하는 형식이다. 영문 자료를 슬쩍 훑어보는 것도 도움이 되리라 생각한다. 다만 한번에 모든 이야기를 풀기엔 벅차므로 발표 자료와 달리 여기선 스레딩 모델에만 집중하기로 한다.
플랫폼과 머신에 대해
본격적인 논의에 앞서 이 글에서 도모할 논의를 따라잡기 위해 알아둘 키워드가 몇 개 있다.
-
인텔 x64 멀티프로세서
-
넉넉한 메인 메모리
-
Windows Vista/2003 이상
-
단독 작동 (써드파티 라이브러리 없이 동작 가능)
64비트 프로세서를 채택한 것은 순전히 메인 메모리를 넉넉하게 쓰기 위해서이다. 32비트 프로세서일지라도 사용자 모드 가상 주소 공간을 3GB로 늘려주는 /3GB 옵션이라던가, 프로세스 하나가 사용 가능한 주소 공간을 확장해주는 /PAE 옵션이 있긴 하다(http://support.microsoft.com/kb/291988/ko). 그러나 64비트 운영체제가 있는 마당에 굳이 이런 부자연스런 방식으로 힘들게 제품을 개발할 필요는 없다고 생각한다.
멀티프로세서에 대해선 굳이 덧붙일 말이 없다. 이제 데스크톱에조차 멀티프로세서가 기본이 된 세상이니 말이다.
얼그레이는 기본적으로 Windows Vista(데스크톱 운영체제)나 Windows Server 2003(서버 운영체제)에서 동작하는 걸 목표로 한다. 최신 기술을 맘껏 쓰는 것이야 말로 백엔드 엔지니어의 특권이기 때문이다. 하지만 개발환경이 이보다 못한 경우가 많기 때문에 실제론 XP/2000 이상에서도 작동하게 구현한다.
마지막으로 boost나 log4cxx 같은 써드파티 라이브러리 없이도 제대로 작동하는 걸 목표로 삼는다. 간혹 라이선스 문제가 발생하는 경우가 있기 때문이다. 기본적으론 Visual Studio 2008에 포함된 Windows SDK와 서비스 팩 1에 든 tr1만 있으면 빌드가 가능하다. 그러나 써드파티 라이브러리를 배척하는 것은 아니다. 원한다면 빌드 옵션에서 내부 기능을 외부 라이브러리로 대체 가능하다. 이 프로젝트 참여자가 여러 회사에 흩어진 상황이라 각자에 구미에 맞게 커스터마이징이 가능하게 됐다.
스레딩 – 작업 분석
고성능 응용프로그램을 작성하려면 뭐니뭐니 해도 컴퓨터의 자원을 효율적으로 활용해야 한다. 그러려면 CPU가 놀지 않고 제 일을 하도록 일을 분배하는 게 중요하다. 멀티스레딩은 당연한 세상이 됐지만 이제부턴 멀티프로세서 머신을 고려해야 하기 때문에 문제의 양상이 다소 달라진 감은 있다.
어쨌거나 적절한 스레딩 모델을 정하려면 응용프로그램이 하는 일의 성격을 분석해볼 필요가 있다.
-
CPU 바운드 (CPU-bound)
대부분의 작업이 이 범주에 든다. 대기 시간 없이 CPU를 맘껏 돌리며 평균적으로 빠른 시간 내에 작업이 마무리된다. 플레이어가 몬스터를 공격했다는 패킷이 서버로 들어오면 주 메모리에 올린 플레이어와 몬스터의 데이터를 바탕으로 몬스터의 체력을 계산하는 일이 이러한 경우이다. -
IO 바운드 (IO-bound)
IO 바운드는 대기 시간이 길고 대체로 CPU 사용률이 적다. 데이터베이스 관련 작업이 이 범주에 속한다.
보다시피 CPU 바운드 작업과 IO 바운드 작업은 성격이 상이하다. 그래서 얼그레이는 작업 특성에 따라 스레드 풀을 따로 유지하는 전략을 채택한다. 클라이언트의 요청을 받으면 IOCP 스레드 풀이 작동한다. 기본적으로 IOCP 스레드 풀은 CPU 바운드 풀로 사용한다. 단순히 몬스터의 체력을 계산하는 작업일 땐 여기서 전부 처리해서 클라이언트에 결과를 전달한다. 만약 체력을 계산하다 데이터베이스를 조회할 일이 있다고 하자. 그러면 해당 요청은 별도로 마련한 IO 바운드 스레드 풀에서 처리해달라고 메시지를 보내고 IOCP 스레드 풀은 다음 요청을 처리할 준비를 한다. IO 바운드 스레드 풀에서 데이터베이스 요청을 마무리하면 그 결과를 IOCP 스레드 풀에 보내 처리를 마무리한다.
스레딩 – 성능 저하 요소
경쟁 상태 (Race condition)
여러 스레드가 하나의 자원을 공유할 때 발생한다. 자원 획득에 걸리는 시간만큼 컴퓨터 자원을 놀리게 되므로 성능 저하를 일으키는 주요 원인이 된다. 이 문제를 해결하는 가장 좋은 방법은 자원을 공유하지 않는 것이다.
캐시 무효화 (Cache invalidation)
멀티프로세서 머신이 대중화될수록 캐시 무효화 문제도 심각해진다. 이러한 문제는 원천적으로 봉쇄하려면 하나의 작업을 하나의 프로세서에서 온전히 마무리 짓는 게 우선이다. 작업을 요청 받은 스레드가 이 프로세서, 저 프로세서 왔다 갔다 하지 말아야 한다. 그러므로 각각의 작업 스레드를 하나의 프로세서에 고정 할당하는 방식을 취하기로 한다. 이러면 운영체제가 그 나름의 최적화된 스케줄링을 적용하지 못하고 스레딩 관리는 전적으로 응용프로그램(즉 개발자)의 몫이 된다.
운영체제의 스케줄링
Windows와 같은 운영체제는 캐시 무효화 같은 이슈를 고려해 설계되었기 때문에 특수한 상황이 아니면 특정 스레드를 갑자기 다른 프로세서로 보내지는 않는다. 다만 특정 프로세서가 수행하던 스레드가 모두 대기 상태에 빠지고 다른 프로세서가 바빠서 처리 중인 못한 스레드가 있는 경우라던가 특수한 상황에서는 프로세서에 스레드를 다시 분배한다.
스레딩 – 스레드 그룹
CPU 바운드 스레드 그룹
대기 금지!!!
CPU 바운드 작업 스레드 풀은 대기(Wait) 상태에 빠져선 안 된다. 프로세서 하나에 스레드 하나를 고정 할당했다고 해보자. 이때 프로세서 A가 처리 중이던 스레드 A가 대기 상태에 빠지면 프로세서 A는 놀게 된다. 운영체제의 최적화 기능을 포기한 만큼 이 원칙은 반드시 지켜야 한다. 대기가 필요한 작업은 IO 바운드 스레드 그룹에 메시지를 보내 요청한다.
성능 최적화
클라이언트의 요청을 받으면 IOCP 스레드가 활성화되고 스레드 루프를 한 바퀴 돈다. 이렇게 루프를 한 바퀴 돌 때 시간이 오래 걸리면 안 된다. 그만큼 사용자의 요청을 처리할 기회를 잃기 때문이다. 특이할 정도로 오래 걸리는 요청이 있다면 뭔가 잘못 구현했을 가능성이 높다. 그러므로 요청 별로 평균 처리 시간을 측정하여 개발자에게 특이사항을 보고하는 기능이 포함되면 성능 최적화에 도움이 될 것이다. 아직 이런 기능은 준비 중일 뿐이지만 말이다.
IO 바운드 스레드 그룹
대기 없이 모든 요청을 처리할 수 있다면 좋을 것이다. 그러나 불가피한 경우도 있다. 데이터베이스 관련 작업이 대표적이다. 이러한 작업은 대기가 불가피하므로 프로세서 하나에 스레드 한두 개만 할당해선 안 된다. 그러면 금새 할당된 스레드가 모두 대기 상태로 빠지고 해당 프로세서가 노는 사태가 벌어진다.
그렇다면 몇 개의 스레드를 할당해야 하는가? 어떤 IO 작업을 처리하느냐, IO 작업이 얼마나 많느냐에 따라 답은 달라진다. 그러므로 이는 개발자가 응용프로그램을 직접 테스트해보고 판단해야 한다. 물론 Windows가 제공하는 스레드 풀처럼 통계를 수집하고 상황에 따라 적절하게 스레드 풀의 크기를 조정할 수도 있겠다.
스레딩 – 성능 지침
위와 같이 분리된 스레딩 모델과 별도로 스레드마다 성능 향상을 위해 따를 지침이 몇 가지 있다. 얼그레이의 코드는 이러한 지침과 원칙에 따라 작성되지만 때에 따라선 최적화를 미루기도 한다.
스레드 별 데이터 보관
굳이 공유하지 않아도 되는 데이터나 자원이라면 스레드가 각기 복사본을 가지고 쓰는 게 좋다. 예를 들어 FromUnicode 같은 함수는 스레드마다 내부 버퍼는 따로 둔다. 이 버퍼 문제는 메모리 관리 전략과 관련이 있다. 그러므로 상세한 논의는 추후에 다시 할 기회가 있을 것이다.
공유해야 하는 데이터이지만 어느 정도 시간 내에선 스레드 별로 데이터 불일치가 발생해도 되는 경우가 있다. 예를 들어 OX 퀴즈를 생각해보자. 1번 문제에 hello라는 단어를 hell로 바꾼다고 하자. 퀴즈 문제의 오타가 1초, 2초, 심지어 1분이 더 늦게 반영된다고 해서 문제될 건 없어 보인다. 게다가 퀴즈 문제를 추가하거나 갱신하는 경우는 매우 드물다고 판단된다. 대체로 읽기 전용에 가까운 데이터이다. 이런 경우라면 스레드 별로 데이터를 따로 두고 각 스레드에 변경 요청을 메시지로 보내는 편이 성능에 좋다. 물론 그만큼 메모리 소모량은 심하지만 서버용 메모리일지라도 CPU나 기타 자원에 비하면 상당히 가격이 싼 요즘이다. 제일 처음 내세웠던 조건 중 하나가 넉넉한 주 메모리 공간이었다는 점을 기억하자.
Lock-free 컨테이너
스레드 별로 데이터를 보관하는 식으로 동기화의 필요성 자체를 없애는 게 우선이다. 하지만 메시지 포스팅 등 일부 작업에는 동기화가 필수적이다. 이런 경우에는 Lock-free 컨테이너 등을 사용해 대기 시간을 최소화하려 노력한다.
메시지 포스팅
메시지 포스팅은 한 스레드가 다른 스레드로 작업을 요청하고 그에 필요한 데이터를 보내는 과정이다. 이 과정에는 데이터를 복사하는 과정이 따르기도 한다. 데이터를 공유해야 하기 때문에 할당과 해제가 아주 빠른 스택을 쓰지 못하고 힙에서 메모리를 할당 받아야 한다. 그러므로 다소 희생이 필요하지만 공유 자원을 획득하느라 잃는 시간에 비하면 대체로 무시할 수준에 불과하다.
스레딩 – 로깅
지난 ‘스레딩 편’에서 멀티프로세서, 멀티스레드 환경에서 최적의 성능을 유지하기 위한 전략을 알아봤다. 한데 원고를 마무리하느라 비몽사몽하다 중요한 이슈 하나를 빼먹었다. 소제목에 나와 있듯 로깅 이슈가 남았다.
응용프로그램, 특히 서버라면 로깅이 빠져선 안 될 일이다. 개발 과정에선 버그를 잡는데 유용하고 출시 후, 또는 운영 중에는 문제 발생시 사후 검토할 자료를 남기는데 필수적이기 때문이다. 그만큼 당연하게 받아들이는 기능이지만 의외로 로깅이 성능의 발목을 잡는 주요 요소 중 하나라는 점을 모르거나 간과하는 사례가 많다.
로깅이 왜 문제되는지는 의외로 간단하다. 로깅이라 하면 결국 파일이나 콘솔 등 목적지로 메시지를 전달하는 행동이다. 오픈 소스 로깅 라이브러리인 log4cxx를 보면 이러한 목적지는 Appender라는 클래스를 상속받아 구현된다. FileAppender, ConsoleAppender 등이 그런 구현체들이다.
목적지가 되는 파일이나 이벤트 로그, 콘솔 등은 하나의 공유 자원이다. 공유 자원에 대한 쓰기 작업은 따라서 경쟁 조건을 수반한다. 때에 따라선 운영체제가 IOCP 같은 비동기 API를 제공한다. Appender가 이러한 API를 활용한다면 상황이 많이 나아진다. 그러나 비동기 연산을 하지 않는 구현체가 많은 데다 아예 비동기 연산이 불가능한 경우가 많다. 그러므로 로깅 작업을 이 스레드, 저 스레드에서 마구잡이로 했다간 공 들여 마련한 스레딩 모델이나 전략이 무용지물이 되기 싶다.
일반적으로 특별한 상황에서만 로그를 남기므로 개발 중에는 로깅으로 인한 성능 저하를 체험하기 어렵다. 그런 까닭에 이 점을 간과하는 일이 잦은 것이다.
마치는 글
이 정도면 얼그레이의 밑바탕이 되는 개념과 아키텍처는 거진 설명한 것 같다. 아마 글을 읽는 도중에 “이건 아닌 것 같은데?”라던가 “이봐, 그건 네가 잘못 생각한 거야!”라는 생각이 들었을 것이다. 그렇다면 여러분이 옳다. 얼그레이 프로젝트는 어느 정도는 취미이자 보여주기 위한 활동이기 때문에 극단으로 치우치거나 실험적인 시도를 할 때가 많다. 그러니 비판 정신을 갖춘 훌륭한 소프트웨어 엔지니어라면 반론이 나와야 당연하다.
최고의, 최적의 아키텍처를 제시하려고 쓴 글이 아니다. 한국 소프트웨어 산업의 한 축을 담당하는 온라인 게임 분야에서 공개적인 논의가 그다지 없는 것은 업계 종사자라면 누구나 동감하리라 생각한다. 이 글은 그러한 폐쇄성을 일부나마 극복하고 활발한 논의의 물꼬를 트고자 하는 시도일 뿐이다. 마이크로소프트웨어나 개인 블로그(https://andromedarabbit.net) 등을 통해 의견을 주고 받았으면 하는 바람이다.