Server 구현 방식
2023년 7월 12일
서버 입출력모델 종류 #
동기(synchronous) #
요청한 결과가 바로 리턴돼서 사용할 수 있는 방식.
결과를 리턴해줄때까지 기다린 후 결과를 받아서 다음 작업을 수행할 수 있다.
- 장점 : 직관적이고, 코드 실행순서가 명확해서 디버깅이 편하다.
- 단점 : I/O 또는 네트워크 작업같이 시간이 오래걸리는 작업이 포함되면 작업이 완료되길 기다려야 한다
비동기(asynchronous) #
요청한 결과를 기다리지 않고 원할때 따로 요청해서 받아보는 방법.
다른 작업을 수행하다가 결과가 필요할때 요청하고 대기한다.
- 장점 : 다른 쓰레드나 입출력장치에게 맡기고 작업을 계속 수행할 수 있어서 메인스레드는 블로킹되지 않고 동시성(동시에 처리되는것 처럼 보이는것)이나 병렬성(실제로 동시에 처리)이 증가한다.
- 단점 : 로직이 복잡하고 실행순서를 제어하기 위해 콜백, 프로미스, async/await등 비동기 처리패턴을 이용해야한다.
CPU는 인터럽트 방식을 사용해서 대기하기 때문에 입출력이라면 병렬성이 증가할 수 있고, CPU가 사용된다 하더라도 남는 스레드에게 일을 시킬 수 있어서 병렬성이 증가할 수 있다. 그 외엔 동시성 증가의 장점밖에 없을것이다.
블로킹 #
작업이 완료될때까지 프로그램의 실행이 중단된다.
넌블로킹 #
작업을 기다리지 않고 다른작업을 계속할 수 있다.
동기식 블로킹 I/O (= Blocking I/O) #

Application이 소켓 입출력을 system call로 kernel에 요청하고, 데이터가 준비될때까지 대기하며 커널에서 리턴 받을때까지 Application의 동작이 차단된다.
한번에 하나의 클라이언트 요청만 처리할 수 있고, 클라이언트에서 입력이 안되고 대기한다면 서버가 계속해서 블로킹된다.
이 방식은 스레드 하나로는 말도 안되는 방식이고, 리소스를 제대로 활용하기 위해 스레드를 생성해서 각각의 클라이언트를 맡도록 처리하게 되는데 클라이언트 수가 많아질수록 스레드의 컨텍스트 스위칭이 늘어나 성능이 저하될 수있다.
실제 구현에서는 스레드풀을 사용해서 미리 생성한 스레드를 할당해주는 방식으로 오버헤드를 줄여 구현해야할것이다.
동기식 넌블로킹 I/O (= Non-Blocking I/O) #

동기식이라 하면 블로킹만 있는건 아니다. 소켓 생성 시 O_NONBLOCK 옵션으로 생성하고 커널이 완료 됐는지 계속해서 체크하면서 다른 작업을 수행할 수 있도록 한다. (Polling 방식)
반복적인 시스템 콜로 인해 오버헤드가 발생하는 단점이 있다.
이벤트루프를 만들어서 입출력이벤트가 발생했는지 계속해서 확인하고, 입출력이 발생된 경우 스레드를 할당해서 이벤트를 처리하는 방식을 사용할 수 있을것이다.
I/O Multiplexing #

여러개의 입출력 채널로부터 데이터를 전송받을 수 있게 하는 방법이다.
입출력 채널에 입출력 데이터가 존재하는지 감시할때 감시함수(select, poll)에서 블로킹이 걸릴 순 있지만,
소켓에서는 확인하고 들어왔기 때문에 무조건 블로킹되지 않아서 Non-Blocking I/O 처럼 동작할 수 있다.
Non-Blocking I/O와 동일하지만 계속 소켓을 확인하지 않는게 장점이고, 감시함수 또한 옵션에 따라 Blocking, Non-Blocking 을 선택할 수 있다.
감시할 모든 파일디스크립터를 순회하며 입출력 준비여부를 찾아야해서 성능이 저하될 수 있다. 이를 보완하기 위해 epoll(리눅스) 또는 kqueue(BSD) 가 있지만, OS에 종속적이다.
비동기식 블로킹 I/O #
비동기식이라 콜백을 사용하는데 블로킹이라 프로그램 실행이 멈추게된다고 한다. 비동기와 블로킹은 서로 상충하는 개념이기 때문에 실제로 사용되지 않는다.
I/O Multiplexing을 비동기식 블로킹 I/O라고 부르는 사람들도 있는데, 동작이 다르기때문에 의견이 분분하다.
비동기식 넌블로킹 I/O (= Asynchronous I/O) #

1void print_hello() {
2 std::cout << "Hello, World!\n";
3}
4
5int main() {
6 std::future<void> result(std::async(std::launch::async, print_hello));
7 result.get(); // Wait for the function to finish
8
9 return 0;
10}
처음 요청이 들어오면 새로운 스레드를 생성해 실행시킨다.
스레드내에서 처리되게 할수도 있고, 데이터가 필요하다면 .get()메서드를 호출해서 결과를 가져오거나 응답될때까지 대기(블록)한다.
async 함수는 로직에 따라 메인쓰레드가 호출하고, 이후 동작은 알아서 실행되도록 하는것이다.
서버 구현 방식 #
I/O multiplexing 방식 선택 #
- 여러 클라이언트가 연결하기 때문에 동시성이 중요해서 Blocking I/O 모델 불가
- 입출력 외에는 처리할 작업이 없기 때문에 Non-Blocking I/O 모델의 오버헤드를 감당할 필요가 없다.
- 이후 게임 프로젝트때 윈도우 Asynchronous I/O 모델인 IOCP를 적용할것이기 때문에 이번 프로젝트는 I/O Multiplexing을 선택
입출력 함수 선택 #
I/O Multiplexing을 지원하는 select, poll 함수가 있지만, select는 윈도우 리눅스 모두 사용가능해서 select로 결정했다.
서버 컴파일 환경 #
네트워크 소켓통신 함수는 c++ 표준이 없기 때문에 운영체제마다 다른 함수를 사용해야한다.
현재 주로 사용하는 환경이 윈도우라서 윈도우에서 컴파일 할 수 있는 WinSock을 사용하겠다.
나중에 구현이 완료되고나서 M1 Mac에서도 동작 가능하도록 수정할 예정이다.
클래스 구조 #
Server #
클라이언트에서 오는 요청을 받고, 클라이언트 정보와 메시지를 저장해두는 클래스.
- IRCServer class를 구현할때 상속해줌
- IRCServer와 NetworkLayer를 나눠서 Server의 멤버로 갖고 Server에서 두개를 연결해줌
- IRCManager라는 글로벌객체(싱글톤)를 이용해서 관리한다.
첫번째 방식으로 구현할 예정
Client #
Client의 정보를 담은 클래스. 명령어 입장에서는 서버가 accept한 클라이언트와 IRCManager가 알고있는 클라이언트는 다를 수 있다.
클라이언트의 상태
- 접속만 한 상태
- 서버에 로그인 : 이때부터 명령어 처리기에서 관리. 만약 여기서 종료하는 경우엔 두가지 상태가 있을 수 있다. 강제종료, 나가기명령 강제종료의 경우 명령어 처리기에서까지 클라이언트를 처리해줘야한다.
- 채널에 참여