
IRC 구현은 거의 완성된 상태이고 이제 필요한 커맨드와 그에 따른 응답 코드 및 예외 처리만 구현해 주면 된다.
백문이 불여일견이라고 먼저 지난 2주 동안 엉금엉금 구현한 IRC 서버의 플로우차트를 만들어 보았다. 이를 한번 살펴보자.

위 플로우차트는 IRC 서버의 소켓 생성부터 포트 바인딩 후 실제 무한 루프를 돌며 돌아가는 실행부를 추상화하였다.
이를 단계적으로 한번 살펴보자.
클라이언트 소켓을 관리하는 방법에는 멀티 스레드, 자식 프로세스 관리 등 여러 방법이 있다.
위 두 방식은 클라이언트 생성마다 스레드, 혹은 자식 프로세스를 생성시켜 입출력을 병렬적으로 처리할 수 있어 서버의 응답성과 처리량을 향상시킬 수 있다.
하지만 과도한 컨텍스트 스위칭으로 인한 오버헤드가 발생한다든지, 동기화 상태 관리에 대한 번거로움, 메모리 공간을 필요 이상으로 잡아먹는 등 여러 단점 또한 존재한다.
반면 우리는 멀티플렉싱으로 구현해야 한다는 제약이 있었기에 선택지를 고민할 필요 없이 이를 따랐다.

멀티플렉싱은 단일 프로세스 및 스레드에서 여러 I/O 작업을 동시에 처리할 수 있도록 하는 기술이다.
클라이언트 연결마다 새로운 프로세스 및 스레드를 생성하는 것이 아닌, accept로 연결된 클라이언트의 소켓 fd들을 비트 셋으로 관리하며 발생하는 이벤트(I/O 작업)를 감시한게 된다.
select 함수를 사용하면, 프로그램이 여러 소켓에서 동시에 데이터가 도착하는지, 데이터를 보낼 준비가 되었는지 등을 확인할 수 있다. 이를 통해, 하나의 스레드가 여러 네트워크 연결을 효율적으로 관리할 수 있게 된다.
select 함수는 2번째 인자로 주어진 fd_set(파일기술자 집합)에서 이벤트가 발생하기 전까지 블로킹 상태가 된다.
때문에 서버 푸쉬 방식으로 일정 주기마다 클라이언트에게 메시지를 보내는 걸 보장하기 위해서는 5번째 인자에 timeval 구조체를 넘겨 줘 실행 주기를 보장할 수 있다.
typedef struct timeval {
long tv_sec;
long tv_usec;
} TIMEVAL;
가령, tv_sec을 5로 설정하고 인자로 넘겨 준다면 프로그램은 '5초 동안 이벤트가 발생하지 않을 때까지' 블로킹 상태가 된다.

등록된 클라이언트 객체 set을 이터레이터로 순회하며 fd가 fd_set에 설정되어 있는지 확인한다.
FD_ISSET 함수를 통해 설정이 되어있는 것을 확인했다면 각 클라이언트의 I/O 작업을 수행하게 된다.
만약 예외가 발생한다면 클라이언트 연결을 해제해 준다. (예외 처리에 대한 자세한 부분까지 다루지 않겠다)

클라이언트 객체는 소켓 스트림이라는 객체를 지니게 된다.
소켓 스트림 객체는 클라이언트의 데이터 입출력 스트림을 관리해 주며, 읽기에 대한 버퍼와 쓰기에 대한 버퍼, 총 2개의 버퍼를 지닌다.
기본적으로 소켓을 생성하게 되면 소캣 내부에는 송신(write) 버퍼와 수신(read) 버퍼가 내장되어 있다.
그치만 우리는 입출력 처리를 위해 추가적인 송신 버퍼와 수신 버퍼를 구현해 주었다. 그 이유는 아래에서 설명하겠다.
사용자의 입력 장치(예: 키보드)로부터 서버에 명령어 전달까지의 입출력 스트림에 대한 파이프라인은 이러하다.
클라이언트로부터 서버

클라이언트가 무언가를 입력을 하게 되었을 때, 해당 입력은 소켓 내부에 추상화되어 있는 송신 버퍼에 쓰이게 된다.
송신 버퍼에 있는 내용을 recv 함수로 읽어 직접 만든 소켓 스트림 객체 내부의 수신 버퍼에 저장한다.
소켓 스트림 객체의 수신 버퍼에 있는 내용을 다시 읽어와 유효한 커맨드인지 검증 및 파싱 후 커맨드를 수행한다.
송신 버퍼에서 바로 커맨드 수행을 하지 않고 소켓 스트림 수신 버퍼를 한번 거치는 이유는 인터럽트가 발생할 수 있기 때문이다.
"안녕하세요. 반갑습니다.\r\n"이라는 문자열을 입력했다고 가정해보자.
멀티플렉싱으로 무한 루프를 돌며 모든 소켓 입출력을 처리하다보면 인터럽트가 발생할 수 있다. 네트워크 지연, 시그널, 논블로킹 동작 등 이유는 다양할 수 있다.
가령, 문자열을 읽어오다 중간에 인터럽트가 발생한다면, "안녕하세요. 반갑습"까지 읽어오고 나머지 내용은 여전히 버퍼에 남아있을 수 있다.
때문에 별도의 중간 버퍼를 두지 않으면 사용자가 의도한 대로 동작하지 않는 경우가 생길 수 있다.
중간 버퍼인 소켓 스트림 수신 버퍼를 두게 되면, '\r\n'을 만나기 전까지 읽어온 데이터를 계속 보관해 놓을 수 있다.
만약 입력이 섞여도 문제가 되지 않는다. 가령 "안녕하세요. 반갑습니다.\r\n" 뒤에 새로운 입력이 버퍼에 추가되어도 '\r\n'까지만 파싱하고 뒤에 이어지는 데이터만 버퍼에 남길 수 있다.
서버로부터 클라이언트

원리는 클라이언트 입력부터 서버에서의 커맨드 수행까지 과정과 완전히 동일하다. 때문에 자세한 설명은 생략하겠다.

만약 I/O 이벤트 처리에 대한 동작부를 구현하였다면, 이제 클라이언트 입력에 대한 파싱을 해야 한다.
파싱되어 쪼개진 토큰들은 메시지라는 객체를 만들어 관리하도록 하였다. 메시지 객체 내부에서 파싱을 진행하여 해당 커맨드가 유효한지 검증하고, 만약 유효하지 않는다면 에러를 던지게 하여 그 즉시 동작은 중단된다.
만약 유효하다면 알맞는 커맨드를 수행하게만 해주면 된다. 이 부분은 복잡한 개념은 없고 구현도 어렵지 않아 최적화에만 좀 신경써 주면 될 거 같다.
IRC 커맨드 구현에 참고할 표준 스펙은 RFC1459와 Modern IRC Client Protocol를 참고해보자.

만약 IRC 서버의 소켓 fd가 fd_set에 설정되어 있다면 새로운 클라이언트의 연결 요청을 받게 된다.
accept 함수를 사용해 새로운 클라이언트(소켓)을 연결해 주고 fd_set에도 새로운 클라이언트의 fd를 추가해 주면 된다.
이 외에도 세부적으로 들어가면 신경써야 할 부분들은 더욱 많다.
커맨드 및 함수의 실행 실패 시 에러 핸들링을 해주어야 하며, 커맨드의 세부 구현 또한 표준 스펙에 따라 구현하면 신경써야 할 부분들이 많다.
이번 글에서는 2주 동안 진행했던 IRC 서버의 실행 흐름과 구조를 대략적으로 정리해보았다.
혹시라도 IRC 서버를 구현하려는 사람들에게 도움이 되었으면 좋겠다.