그냥 글을 써 봅니다/서버 개발 수기

EP.02 — 전체 아키텍처: 3계층 구조와 설계 철학

Tiboong 2026. 3. 1. 23:36
728x90

이 글은 『서버 개발 수기』 시리즈의 두 번째 글이다.

지난 회에서는 20년 만에 코드를 다시 열어본 이야기를 했다.

오늘은 본격적으로 코드를 들여다보기 전에, 이 서버의 전체 구조부터 살펴본다.

※ 이 시리즈에서 다루는 서버 코드는 Project-AO(Ancient Origin)라는 가명으로 부른다.


SI 서버와 게임서버는 다르다

사실 이것부터 Claude한테 물어봤다. "내가 SI에서 하던 서버랑 게임서버는 뭐가 다른 거야?"

 

SI에서 내가 알던 서버는 단순했다. 사실 단순한지도 몰랐다. 프레임워크가 다 해주니까 스레드가 어떻게 돌아가는지 신경 쓸 일이 없었다. 요청 들어오면 처리하고 응답하면 끝. 그 안에서 스레드가 어떻게 생기고 어떻게 관리되는지는 내 영역이 아니었다.

동시 접속 200명? 스레드 200개. 들고 나는 패킷 양도 크지 않고, 한 건 한 건 처리하면 되니까 별로 문제없다. 서버 사양이 빵빵한데 리소스는 10%도 안 쓰고 있으니까.

 

그런데 MMORPG는 동시 접속이 수만 명이다.

 

스레드 1만 개를 만들면? 스레드 하나당 스택 메모리만 1MB니까 1만 개면 10GB. 거기다 OS가 스레드 1만 개를 번갈아 실행하는 것 자체가 엄청난 낭비다. 컨텍스트 스위칭이라고 하는데, 스레드를 바꿀 때마다 CPU 캐시가 날아가고 상태를 저장하고 복원하는 비용이 든다.

 

‘아... 그래서 이 서버가 이렇게 생겼던 거야?’

 

게임서버는 스레드를 사용자에게 붙이지 않는다. 스레드는 CPU 코어 수만큼만 만들고, 소켓은 수만 개를 열어두고, "일이 생긴 소켓"을 놀고 있는 스레드가 가져가서 처리하는 구조다.

 

SI가 "손님마다 전담 직원"이라면, 게임서버는 "상담원 8명이 대기표 시스템으로 1만 명을 돌려막는" 방식인 셈이다.

그 "대기표 시스템"의 이름이 Windows에서는 IOCP다. Linux에서는 epoll이 오랫동안 그 역할을 했고, 최근에는 더 빠른 io_uring이 나왔다. 이 서버를 포팅하면서 io_uring을 선택한 이유는 나중에 다룬다.

 

여기까지 듣고 나니까, 20년 전에 "SI랑 차원이 다르다"고 느꼈던 게 왜였는지 이제야 이해가 된다.


Project-AO 서버의 3계층 구조

그러면 이 서버는 어떻게 생겼나. Claude한테 전체 파일 구조를 보여주고 "큰 그림으로 설명해줘" 했더니 이렇게 정리해줬다.

아래에서 위로, 각 층이 다른 문제를 담당한다.

20년 전 코드치고는 계층이 깔끔하게 나뉘어 있어서 놀랐다고 했더니, "이게 2006년 코드예요? 꼼꼼하게 잘 나뉘어져 있네요"라고 하더라.


Layer 1 — "소켓에 무슨 일이 생겼는지 알려줘"

제일 아래층은 뭐 하는 건지 물어봤다.

 

하는 일은 하나다. 소켓 1만 개를 감시하고 있다가, 데이터가 온 소켓을 스레드에게 알려주는 것.

 

여기서 헷갈릴 수 있는 게, "소켓을 감시한다"면서 왜 소켓이 Layer 2에도 나오느냐는 건데. 알고 보니 소켓 자체는 OS가 관리하는 자원이었다. Layer 1도 2도 아니고 그 아래에 있는 것이다. Layer 1은 OS한테 "소켓에 뭐 왔어?" 하고 물어보는 창구이고, Layer 2는 알림 받은 다음 실제로 버퍼 할당하고 데이터 읽고 콜백 호출하는 쪽이다. Layer 1이 CCTV라면, Layer 2는 관제실인 셈이다. 그리고 Layer 2에 있는 XIOSocket은 OS 소켓을 감싸서 버퍼 관리나 참조 카운팅 같은 기능을 얹은 래퍼 클래스다.

 

원래 Windows에서는 IOCP가 이 역할을 했다. 포팅하면서 이 층을 io_uring으로 교체했는데, 핵심은 이거였다.

 

위층에는 똑같이 보이게 만들었다는 것.

 

Layer 2 입장에서는 GetQueuedCompletionStatus() — "일 생긴 소켓 줘" — 이 함수를 부르면 된다. 밑이 IOCP든 io_uring이든.

이게 뭘 뜻하냐면, Layer 2와 Layer 3의 코드를 하나도 안 고치고 OS만 바꿀 수 있다는 뜻이다. 실제로 포팅에서 이 층을 교체하는데 게임 로직은 거의 손대지 않았다.

 

아. 그래서 이렇게 나눈 거구나.


Layer 2 — "네트워크 I/O의 복잡함을 숨겨줘"

여기가 핵심이다. 6개 기법의 대부분이 이 층에 들어있다.

XIOSpinLock    → 3단계 적응형 스핀락
XIOBuffer      → 16슬롯 버퍼 풀
XIOObject      → 7종 참조 카운팅
XIOSocket      → OVERLAPPED 분리 + Write 병합
XIOAutoPtr     → 락-프리 스마트 포인터

 

하나씩 물어보니까 이 이름들이 다 역할이 있더라. 그런데 여기서 깨달은 게 있다.

 

Layer 3(게임 로직)은 이런 걸 신경 쓸 필요가 없었다. "패킷 오면 콜백 호출, 응답 보낼 때 Write 함수 호출". 그러면 버퍼 풀이든 락이든 병합이든 Layer 2가 알아서 처리한다.

 

여기서 소름이 돋았다. 내가 길드워 로직 짜면서 이 층을 몰랐던 이유가 여기 있었다. 너무 잘 숨겼으니까 몰랐던 거였다. "그냥 쓰는 것"이었던 건, 사실 "그냥 쓸 수 있게 만들어둔 것"이었다.

 

추상화가 잘 되어 있는 코드가 왜 좋은 코드인지 새삼 느꼈다.


Layer 3 — "게임 로직에만 집중해"

내가 일했던 곳이 여기다.

class CSocket : public XIOSocket {
    void OnRead() {
        // 패킷 분석
        // 게임 로직 처리
        // 응답 전송
    }
};

 

Layer 2의 XIOSocket을 상속받아서 OnRead()만 구현하면 된다. 패킷이 도착하면 이 함수가 호출되고, 나는 여기서 길드 생성이든 길드 가입이든 길드전이든 짜면 됐다.

 

소켓이 어떻게 관리되는지, 버퍼를 어디서 가져오는지, 응답이 어떻게 병합되는지 — 전부 몰라도 됐다.

 

알고 보니 이 구조가 스프링의 Controller 패턴이나 Node.js의 Express 미들웨어랑 같은 발상이었다. 2006년에 C++로. "인프라 걱정 말고 비즈니스 로직만 짜" — 지금은 당연한 철학이지만, 당시 C++ 게임서버에서는 전혀 당연하지 않았다.

 

아 그러니까 내가 그때 아무것도 몰라도 어찌됐든 돌아가는 코드를 짤 수 있었던 거구나.


패킷 하나의 여행

그러면 이 3계층을 패킷 하나가 어떻게 통과하는 걸까. 이걸 보니까 구조가 완전히 보인다.

유저가 스킬 사용 버튼을 눌렀다.

  1. 네트워크 카드에 패킷 도착

  2. [Layer 1] io_uring이 감지
     → "소켓 7823번에 데이터 왔다!"
     → 놀고 있는 스레드에게 넘김

  3. [Layer 2] 스레드가 버퍼 풀에서 버퍼 빌림
     → 16슬롯 중 하나에서 락 잡고 버퍼 획득 (3단계 스핀락)
     → 패킷 데이터를 버퍼에 복사
     → OnRead() 콜백 호출

  4. [Layer 3] 게임 로직 처리
     → "아 스킬 패킷이네"
     → 데미지 계산, 대상 HP 감소, 이펙트 생성
     → 주변 유저들에게 알려야 함

  5. [Layer 2] 응답 패킷들을 큐에 담음
     → "대상에게 HP 변경 패킷"
     → "주변 유저들에게 스킬 이펙트 패킷"
     → "시전자에게 스킬 쿨타임 패킷"
     → 여러 개를 모아서 한 번에 전송 (Write 버퍼 병합)

  6. [Layer 1] io_uring이 커널에 전송 요청
     → 읽기와 쓰기가 동시 진행 (OVERLAPPED 분리)

 

이걸 보는 순간 "아..." 하고 탄성이 나왔다. 6개 기법이 따로 노는 게 아니라, 패킷 하나가 들어와서 나가는 길 위에 전부 얹혀있었다.


"몰라도 되게" — 이 설계의 핵심

코드를 읽으면서 계속 드는 생각이 있다.

 

20년 전에 나는 이 구조를 몰랐다. Layer 3에서 길드워 로직만 짜면서, 밑에서 무슨 일이 일어나는지 전혀 신경 쓰지 않았다.

그리고 그건 의도된 거였다.

 

좋은 아키텍처는 복잡함을 숨긴다. 위층에서는 단순하게 보이지만, 아래층에서는 온갖 최적화가 돌아가고 있다.

OnRead()를 호출하는 그 순간, 버퍼 풀에서 락 경합을 피하며 버퍼를 받아오고, 3단계 스핀락으로 대기 시간을 최소화하고, 응답은 병합해서 보내고 있다.

 

그걸 몰라도 되게 만들어둔 사람. 대단하다고 생각한다.


요즘은 어떻게 다를까

이 기법들이 지금도 유효할까?

 

Project-AO가 직접 만들었던 것들 — 스핀락, 버퍼 풀, 참조 카운팅 — 을 이제는 OS나 라이브러리가 제공한다. pthread_mutex는 알아서 spin하다 sleep하고, jemalloc은 스레드별 메모리 풀을 관리하고, shared_ptr는 참조 카운팅을 자동으로 해준다.

 

서버 한 대를 극한까지 쥐어짜는 대신, 클라우드에서 서버를 여러 대 띄워서 분산시킨다. 게임 엔진이 네트워크 레이어를 제공하기도 한다.

 

그런데 구조는 그대로다. 스레드 소수 + 이벤트 루프 + 비동기 I/O. 도구가 바뀐 것이지, "소수의 스레드로 수만 개의 소켓을 처리한다"는 원칙은 20년이 지나도 변하지 않았다.

 

Project-AO 코드를 분석하는 가치가 여기 있다. 도구가 없던 시대에 원리를 직접 구현한 코드니까, **"왜 이렇게 해야 하는가"**가 코드 자체에 다 드러나 있다.

 

계속 느끼는 건데, 20년 전 코드에 모르는 것 투성이라는 것이 나는 20년간 제자리인가 하는 생각이 들어 씁쓸~하다.


다음 회에서는

전체 구조를 봤으니, 이제 각 층 안으로 들어간다. 먼저 Layer 2의 첫 번째 기법 — 3단계 적응형 스핀락.

"이 코드에서 while 루프가 왜 3개야?" 하고 물어봤더니, "락을 기다리는 방법"에도 전략이 있다는 걸 알게 된다.

 

→ EP.03: 3단계 적응형 스핀락 — 왜 spin하다 yield하다 sleep하는가

728x90