성능 최적화
- 네트워크 I/O
비동기 I/O 사용: IOCP와 같은 비동기 I/O 모델을 사용하면 클라이언트 요청을 효율적으로 처리할 수 있다. 이는 블로킹을 최소화하고, 높은 동시성을 유지하는 데 도움이 된다.
데이터 패킷 크기 조절: 전송하는 데이터 패킷의 크기를 최적화하여 네트워크 오버헤드를 줄일 수 있다. 너무 큰 패킷은 분할 전송으로 인한 지연을 유발할 수 있다.
프로토콜 최적화: 필요한 정보만 전송하고, 불필요한 데이터는 제거하여 통신 효율성을 높인다. 예를 들어, HTTP 대신 WebSocket을 사용하여 지속적인 연결을 유지하는 것이 유리할 수 있다.
압축 및 캐싱: 데이터를 압축하여 전송하면 대역폭 사용을 줄일 수 있다. 또한 자주 요청되는 데이터는 캐시하여 네트워크 요청 수를 줄이는 것도 효과적이다.
- 데이터베이스 I/O
쿼리 최적화: 데이터베이스 쿼리를 분석하고 인덱스를 추가하거나 불필요한 JOIN을 제거하여 성능을 개선한다. 실행 계획을 확인하여 비효율적인 쿼리를 식별할 수 있다.
비동기 데이터베이스 호출: 비동기 방식으로 데이터베이스에 접근하면, 데이터베이스 요청과 다른 작업을 동시에 처리할 수 있어 성능이 향상된다.
데이터베이스 연결 관리: 연결 풀링을 사용하여 데이터베이스와의 연결을 효율적으로 관리하면, 연결 생성 및 종료에 소요되는 시간을 줄일 수 있다.
캐싱: 자주 조회되는 데이터는 메모리 캐시에 저장하여 데이터베이스 호출을 줄인다. Redis와 같은 인메모리 데이터베이스를 활용할 수 있다.
좋은 게임서버는 네트워크 레벨 및 데이터베이스 레벨에서 성능 최적화가 되어야 한다! 네트워크 I/O와 데이터베이스 I/O의 최적화는 성능 개선의 핵심. 비동기 처리, 쿼리 최적화, 데이터 패킷 조정, 캐싱 전략 등을 통해 시스템의 반응성과 처리량을 향상시킬 수 있다. 이를 통해 더 나은 사용자 경험을 제공하고, 서버의 확장성을 높일 수 있다.
IOCP를 알아보기 전에...
IOCP는 게임회사에서 가장 널리 쓰이는 I/O 모델로, 특히 C++로 작성된 게임서버라면 IOCP 모델을 활용하지 않고선 고성능의 게임서버를 구현하기가 어렵기 때문에 필수 지식이라고 할 수 있다. 게임 서버는 수천 또는 수만 명의 동시 사용자 요청을 처리해야 한다. IOCP는 효율적으로 많은 클라이언트의 연결을 관리할 수 있는 비동기 I/O 모델을 제공하므로, 성능과 확장성에서 큰 장점을 준다. 또, 스레드 관리와 작업 분배를 최적화하여 CPU와 메모리 사용을 줄여주는데, 이는 게임 서버의 반응성을 향상시키고, 서버가 더 많은 요청을 처리할 수 있게 만든다. 그 밖에도 빠른 데이터 송수신, 복잡한 서버 아키텍처의 이해 등에 도움이 된다는 장점을 가진다.
IOCP (I/O Completion Port)
기존에 존재하던 서비스 어플리케이션의 전통적인 모델을 한 번 살펴보자!
시리얼 모델은 하나의 스레드가 사용자의 요청을 대기하고 요청이 들어오면 처리까지 해줘야 한다. 이 상황에서 두 번째 요청이 들어오면 첫 번째 요청이 처리될 때까지 대기해야 하는 것이다. 컨커런트 모델은 하나의 스레드가 사용자의 요청을 대기하고 요청을 처리하는 별도의 워커 스레드들을 생성해놓는 모델로, 요청을 기다리는 스레드와 요청을 처리하는 스레드를 명백히 분리하게 되니 요청을 기다리는 스레드는 비교적 최소한의 작업만 수행을 하면 된다.
그런데 생각을 한 번 해보자! 게임에 1000명이 접속을 한다고 해서 스레드를 1000개를 생성하게 되면 어떻게 될까?
스레드 개수가 CPU 개수보다 많아지면 스레드간 Context Switch를 하기 위해서 CPU 자원이 낭비된다. 게다가 스레드를 생성하는 것도 역시나 무시할 수 없는 비용이다. 이러한 단점들을 극복하기 위해 Windows 운영체제에서 나온 모델이 IOCP (I/O Completion Port)라는 모델인 것이다!
IOCP는 Windows 운영 체제에서 비동기 입출력(I/O) 작업을 효율적으로 처리하기 위한 메커니즘으로, 특히 네트워크 서버와 같이 많은 동시 클라이언트 연결을 처리해야 하는 상황에서 유용하다.
- 주요 개념
비동기 I/O: 비동기 방식으로 I/O 작업을 수행한다. 즉, 요청을 보내고 결과를 기다리지 않고 다른 작업을 수행할 수 있다. I/O 작업이 완료되면 시스템이 이를 알려주게 된다.
완료 포트: 여러 I/O 요청을 하나의 완료 포트에 연결한다. 이 포트는 완료된 I/O 작업에 대한 알림을 수신하고, 클라이언트 요청이 완료되면 해당 작업의 결과를 처리할 수 있도록 애플리케이션에 신호를 보낸다.
스레드 풀(Thread Pool): 스레드 풀과 결합되어 사용된다. 스레드 풀은 필요한 만큼의 스레드를 미리 생성하여 대기시키고, I/O 작업이 완료되면 해당 스레드를 사용하여 작업을 처리한다. 이를 통해 스레드 생성 및 소멸에 대한 오버헤드(비용)를 줄일 수 있고, 스레드 개수가 필요한 만큼 유지가 되니 과도한 컨텍스트 스위칭도 방지가 되고 메모리 사용도 보다 더 효율적으로 할 수 있다.
효율성: 스레드와 I/O 작업을 효율적으로 관리하여 높은 동시성을 유지하고 CPU와 메모리 사용을 최적화한다. 따라서 대규모 서버 애플리케이션에서 성능이 뛰어나다.
CS 질문 대처
동접자가 많아졌을 때 어떻게 대처해야 할까요? → 스레드풀 이용 설명!
- 동작 원리
초기 설정: IOCP를 사용하기 위해 먼저 CreateIoCompletionPort(CICP) 함수를 사용하여 IOCP를 생성한다. 이 함수는 새로운 완료 포트를 생성하고, 비동기 I/O 작업의 완료를 통지받는 데 사용된다.
HANDLE WINAPI CreateIoCompletionPort(
_In_ HANDLE FileHandle,
_In_opt_ HANDLE ExistingCompletionPort,
_In_ ULONG_PTR CompletionKey,
_In_ DWORD NumberOfConcurrentThreads
);
- NumberOfConcurrentThreads 값은 아래에서 얘기할 워커 스레드의 최대 동시 실행 수를 제어하는데 사용한다. 예를 들어, 해당 값이 4라면 최대 4개의 워커 스레드만 동시에 실행될 수 있으며, 나머지 스레드들은 대기 상태로 유지된다.
- NumberOfConcurrentThreads 값은 0으로 쓰시면 논리적 코어 수 기반으로 자동으로 결정된다.
이렇게 했는데 성능 문제가 발생하면 그 때부터 해당 값을 조정하는 실험이 필요!!!
- I/O 집약적인 작업이 많은 경우 → 코어 수보다 높게 설정.
- CPU 집약적인 작업이 많은 경우 → 코어 수보다 낮게 설정.
이후 클라이언트의 연결 요청을 수신하고 이 요청을 비동기로 처리하기 위해 서버 소켓(리스너)을 생성(설정)한다.
서버 소켓 생성 과정
- 소켓 생성: socket() 함수를 호출하여 TCP 소켓을 생성한다. 이 소켓은 클라이언트 연결 요청을 수신하는 리스너 역할을 하게 된다.
- 소켓 바인딩: bind() 함수를 사용하여 소켓을 특정 IP 주소와 포트 번호에 바인딩한다. 이렇게 하면 해당 주소와 포트에서 들어오는 연결 요청을 수신할 수 있다.
- 리스닝 시작: listen() 함수를 호출하여 소켓을 리스닝 모드로 설정한다. 이 함수는 클라이언트 연결 요청을 대기할 수 있게 만든다.
- IOCP와 연결: 리스너 소켓을 IOCP에 연결합니다. CreateIoCompletionPort() 함수를 사용하여 리스너 소켓을 완료 포트에 추가함으로써, 이 소켓에 대한 비동기 I/O 작업을 관리할 수 있다.
- 클라이언트 연결 수락: AcceptEx()와 같은 비동기 함수 또는 accept()를 사용하여 클라이언트 연결을 수락한다. 이 과정에서 새로운 클라이언트 소켓이 생성되며, 해당 소켓도 IOCP에 연결하여 이후 비동기 I/O 작업을 처리할 수 있다.
그리고 IOCP와 함께 사용할 스레드를 미리 생성하여 대기 상태로 두는 스레드 풀을 설정한다. 이 스레드는 I/O 작업이 완료될 때 작업을 처리하기 위해 사용한다. 스레드 준비가 되면 워커 스레드는 I/O 작업 완료 요청을 확인하고, 대기 스레드 큐에 진입한다. 이 큐는 추가 작업이 있을 때까지 대기하는 스레드를 저장하는 역할을 한다. 워커 스레드가 대기 큐에 있는 동안, 새로운 작업이 할당되면 해당 스레드는 대기 상태에서 벗어나 그 작업을 수행하게 된다. 이 때 스레드는 CPU를 활용하여 비즈니스 로직을 실행한다. 이후 스레드 풀의 스레드는 완료 포트에서 대기하며, 완료된 작업이 있으면 이를 읽어와 처리하고, GetQueuedCompletionStatus(GQCS) 함수를 사용하여 완료된 작업을 가져온다.
- 네트워크 스레드: 클라이언트 연결만을 처리하는 스레드(Listener를 매개로).
스레드 풀 생성: 서버가 시작될 때, 클라이언트의 요청을 처리할 네트워크 스레드를 생성. 이는 일반적으로 스레드 풀로 구현되며, 여러 스레드가 동시에 대기할 수 있도록 설정한다.
스레드 대기: 각 스레드는 IOCP의 완료 포트에서 완료된 I/O 작업을 대기한다. 이 스레드는 GetQueuedCompletionStatus() 함수(GQCS함수)를 호출하여 완료된 작업이 있는지 확인하고, 작업이 완료되면 해당 작업을 처리한다.
- 워커 스레드: I/O 작업을 담당하는 스레드.
작업 처리 로직: 네트워크 스레드가 완료된 I/O 작업을 수신하면, 워커 스레드로 작업을 분배하거나 직접 작업을 처리한다. 워커 스레드는 실제 데이터 처리, 게임 로직 처리 등 다양한 비즈니스 로직을 수행할 수 있다.
스레드 관리: 워커 스레드는 비동기 처리의 결과를 바탕으로 후속 작업을 수행한다. 이 단계에서는 클라이언트에 응답을 보내거나 추가 작업을 요청하는 등의 작업을 처리하게 된다.
이제 게임 서버가 동작을 한다. 먼저 새로운 클라이언트가 접속하면, 이 클라이언트 소켓을 CreateIoCompletionPort() 함수(CICP 함수)를 사용하여 클라이언트 소켓을 완료 포트에 추가하고, 이후 해당 소켓에 대한 비동기 I/O 작업을 처리할 준비를 한다. 즉, IOCP 시스템과 연결한다. 이 때, 소켓 핸들이 Completion Key(I/O 작업 완료 시 어떤 작업인지 구분하기 위한 식별자)로 쓰인다.
IOCP 시스템과 연결: 해당 소켓을 통해 발생하는 모든 I/O 작업은 I/O Completion Queue에 자동으로 보고된다. (WSARecv 혹은 WSASend가 완료될 때마다 결과가 자동으로 Completion Queue에 적재된다.)
클라이언트가 요청 시, 워커 스레드는 WSARecv 함수로 클라이언트의 요청이 담긴 패킷을 수신하고 Windows 커널에서 I/P Completion Queue에 해당 패킷을 삽입한다. (네트워크 스레드는 오직 클라이언트와의 연결만 처리!)
이후 워커 스레드가 작업을 실시하는데, GQCS로부터 가져온 I/O작업을 해당 스레드가 Release Thread List로 가서 작업을 하게 된다. 이후 작업이 완료되면 WSASend 함수를 이용하여 클라이언트의 요청에 맞는 응답을 클라이언트에게 송신한다. 송신을 마치면 Windows 커널에서 I/O Completion Queue에 송신완료 패킷을 삽입한다.
이후 과정은 요청과 응답의 무한 반복이다!!!
마지막으로 서버를 종료할 때, PostQueuedCompletionStatus (PQCS) 함수 호출로 서버 종료 완료 패킷을 삽입하고 워커 스레드가 해당 패킷을 처리 후에 모든 워커 스레드가 작업을 마친 후 안전하게 서버의 종료를 처리한다.
PostQueuedCompletionStatus(PQCS)의 또다른 활용!
몬스터 리젠 이벤트 시, 몬스터 리젠 커멘드를 캐치하고 PQCS를 호출, 몬스터 리젠 이벤트를 삽입하면 워커 스레드가 해당 패킷을 처리함으로써 월드 내 지정된 지역마다 몬스터를 리젠할 수 있다.
'CS' 카테고리의 다른 글
TCP 서버를 공부하기 전에... (2) | 2024.10.17 |
---|---|
객체 지향? (0) | 2024.10.16 |
메모리? (9) | 2024.10.08 |
CPU? (0) | 2024.10.08 |
면접 질문 연습하기(1) (0) | 2024.09.30 |