📚 /42seoul 시리즈

1. 소개


Internet Relay Chat

본과정에서 열세 번째로 진행한 과제로, C++을 활용하여 IRC(Internet Relay Chat) 서버를 구현하는 팀 과제이다. 이번 프로젝트는 IRC 프로토콜을 기반으로 한 채팅 서버를 구현하는 것이 목적이었다. IRC는 오래된 프로토콜로, 기본적인 RFC 문서를 바탕으로 하되 실제 상용 클라이언트와의 호환성을 확보하기 위해서는 추가적인 분석과 적용이 필요했다. 따라서 RFC 공식 문서를 기본으로 삼고, 실제로 사용되는 상용 서버 및 클라이언트 간의 통신 과정을 분석하여 구현하였다.

과제는 3명의 팀원이 함께 진행하였다. 나는 소켓 프로그래밍, 서버 구현 및 버퍼 관리를 담당했다. 나머지 두 팀원이 각각 명령어 구현과 irc 프로토콜과 레퍼런스에 따라 기본 사항 구현을 담당했다. 각 분야 개발이 끝나고, 디버깅을 하며 상세한 프로토콜 요구사항을 맞춰 완성했다. 이 과정에서 팀원 모두가 동일한 환경에서 테스트할 수 있도록 docker 컨테이너를 하나 만들어 사용했다. docker 컨테이너를 통해 환경 구축을 표준화하여, 팀원들이 빠르게 환경을 구축하고 동일한 조건에서 테스트를 수행할 수 있었다. docker의 강력함을 몸소 느낄 수 있었다…

‼️ 소켓 프로그래밍에 대한 이해가 있고, 바로 과제에 대한 정보를 얻고 싶다면? ‼️
FT_IRC서버전송중너만오면고 (« click!!!)



2. ft_irc 명세서



2-1. Overview



3. 개념 정리


  1. irc가 뭔지 정체를 우선 알아보자.
  2. 소켓 프로그래밍의 작동 방식을 공부하고
  3. 작동 중 나타나는 동기, 비동기 방식을 알아보고
  4. 어떤 방식으로 처리해야하는지 공부해보자.

3-1. IRC 프로토콜

IRC(Internet Relay Chat) 프로토콜은 RFC 1459, RFC 2810 등 여러 공식 문서에 걸쳐 정의되어 있으며, 설계 원칙부터 명령 포맷, 동작 방식까지 매우 상세하게 기술되어 있다. 하지만 IRC는 역사가 오래된 만큼 다양한 RFC 버전이 존재하고, 현재도 서버/클라이언트마다 프로토콜 해석과 구현이 조금씩 다르기 때문에, 초기에는 하나의 구현을 기준으로 삼는 것이 중요 하다.

3-1-1. 간단한 역사

IRC는 1988년 핀란드 대학생 Jarkko Oikarinen 이 기존 채팅 시스템의 한계를 극복하기 위해 개발을 시작했다. 1989년부터 유럽과 북미로 빠르게 퍼지며, 전 세계적으로 가장 널리 쓰이는 실시간 채팅 수단 중 하나가 되었다.

1993년 5월, RFC 1459를 통해 IRC 프로토콜이 공식 표준으로 정의되었으며, 이후 RFC 2810~2813 등이 보완사항을 제시하면서 확장되었다.

IRC는 당대 최초로 채널 개념을 도입했고, 서버 간 네트워크 구성, 실시간 사용자 리스트, 닉네임 변경, 프라이빗 메시지 기능 등 현대 채팅의 기반을 다진 시스템이다. 오늘날에는 Slack, Discord 같은 신세대 서비스에 밀려나긴 했지만, 여전히 상용 IRC 서버와 클라이언트는 존재하며, 각자 프로토콜을 조금씩 개량해 사용 중이다.

이번 프로젝트에서는 서버는 InspIRCd, 클라이언트는 irssi를 기준으로 테스트했다.

3-1-2. 프로토콜 개요

IRC 프로토콜은 텍스트 기반이며, 클라이언트와 서버 간에 주고받는 메시지는 특정한 포맷을 따른다. 특히 처음 접속 시 클라이언트가 전송해야 하는 메시지 순서, 그리고 서버로부터 수신하는 코드 기반 응답 메시지들이 RFC 문서에 명시되어 있다.

RFC를 참고하면 누군가의 블로그를 뒤지는 것보다 훨씬 정확하게 구조와 의도를 파악할 수 있다. 우리 팀은 RFC 내용을 바탕으로 문서 하나를 만들어 각 명령어와 응답 메시지를 정리하고, 체크리스트 형식으로 구현 여부를 관리했다. 이 방식이 협업에도 매우 유용했다.


3-2. 소켓 프로그래밍

소켓이란 네트워크 상에서 데이터를 주고받는 통신의 끝점이다. 마치 콘센트 플러그와 같은. 양쪽이 소켓을 연결해야만 데이터를 주고받을 수 있다. 우리는 커널을 통해 소켓을 생성하고 관리할 수 있다. 코드로 한번 알아보자!

순서: socket() -> bind() -> listen() -> fcntl -> accept()

3-2-1. socket(): 소켓 생성

3-2-2. bind(): 소켓 바인딩

3-2-3.listen(): 소켓을 대기 상태로 설정

3-2-4. fcntl: 논블로킹 소켓 설정

3-2-5. accpet(): 서버에서 클라이언트의 연결 요청을 수락

요약하면, 소켓은 IP·포트·프로토콜 정보가 담긴 커널 내부 구조체이며, 일반 파일처럼 fd로 관리한다. 서버는 listen_fd를 통해 연결 요청을 기다리고, accept() 가 새로운 FD를 돌려주면 이를 내부 자료구조에 추가해 클라이언트마다 별도의 소켓으로 통신을 유지한다.


3-3. 동기, 비동기, 블로킹, 논블로킹

이 과제에서는 비동기/논블로킹 방식을 구현해야 한다. 비슷한듯 다른듯 애매한 이 개념들을 정리해야, 실제로 구현할때 헷갈리지 않을 듯 하다. 렛츠고 🥲

정리하면, 동기 vs 비동기는 누가 처리하느냐? 의 문제이다. 내가 직접 처리하면 동기, OS나 다른 시스템이 처리하면 비동기인 것이다.


블로킹 vs 논블로킹은 기다리느냐? 의 문제이다. 결과가 나올 때까지 멈추면 블로킹, 결과를 기다리지 않고 바로 다음 일을 하면 논블로킹인 것이다.

동기와 블로킹, 비동기와 논블로킹은 그 작동 행태가 비슷하여 많이 헷갈리지만, 누가 처리하느냐-기다리느냐의 구분을 잘 기억하고 다음으로 이어나가보자.


과제 지침에 따르면 우리는 비동기+논블로킹을 구현해야한다.


3-4. poll()

그렇다면 우리 서버는 클라이언트가 뭔가 우리에게 전송했을때 그것을 어떻게 비동기적이고 논블로킹스럽게 처리할 수 있을까? 이것을 도와주는 함수가 존재한다. poll(), epoll(), kqueue 등… 우리는 클러스터 개발 환경을 고려해서 poll() 함수를 사용했다.

3-4-1. poll()

3-4-2. epoll(), kqueue()

poll() 과 비슷한 역할을 하는 함수가 몇가지 더 있다. epoll(), kqueue() 을 간단히 알아보자.



4. Mandatory


4-1. 비동기, 논블로킹 구현

우리는 비동기 + 논블로킹 방식의 서버를 구현해야한다.

4-2. 서버 내부 자원 관리

서버 구현 시 기본적인 통신 처리뿐만 아니라 RFC에 정의된 다양한 데이터와 기능을 구현해야 한다. 클라이언트는 닉네임, 상태 메시지 등 고유한 속성을 가지며 하나의 클라이언트가 여러 채팅방에 동시에 참여할 수 있는 모델이다.

개발 초기에는 채팅방이 생성될 때마다 해당 방에 속한 클라이언트 정보를 복사하여 방 내부에 저장하는 방식을 사용하였다. 그러나 이 방식은 한 채팅방에서 닉네임이 변경되었을 때 다른 방에는 반영되지 않아 데이터 불일치 문제가 발생하는 단점이 있었다.

이를 해결하기 위해 클라이언트 데이터를 중앙에서 관리하는 싱글톤 패턴을 도입하였다. 모든 채팅방이 하나의 객체를 참조하도록 구성함으로써 클라이언트 정보가 변경되면 즉시 모든 방에 반영되도록 하였다. 이러한 구조는 채팅방과 클라이언트 수가 늘어날 때 발생하는 메모리 복사 부담을 크게 줄여주며 정보 동기화 문제를 효과적으로 해소해준다.

4-3. 코드

세부 명령어와 프로토콜은 과제를 수행하며 RFC 문서를 질리도록 읽게될 것이므로…
서버 기본 작동 구현을 소개하겠다.

main

int main(int ac, char** av) {
	if (ac != 3) {
		std::cerr << C_ERR << "Error: " << ERR_ARG_COUNT << C_RESET << std::endl;
		return 1;
	}

	try {
		IrcServer server(av[1], av[2]);
		signal(SIGINT, signalHandler);
		signal(SIGQUIT, signalHandler);
		server.init();  // 서버 자원 초기화
		server.run();   // 서버 실제 작동
	} catch (const IrcServer::ServerException &e) {
		std::cerr << C_ERR << "Error: " << e.what() << C_RESET << std::endl;
		return 1;
	}
	return 0;
}

run

void IrcServer::run() {
	bool exitFlag = false;
	while (true) {
		try {
			checkPingTimeOut();

			if (poll(&_fds[0], _fds.size(), 0) < 0) {
				if (errno != EINTR) {
					exitFlag = true;
				}
				throw ServerException(ERR_POLL);
			}

			for (int i = _fds.size() - 1; i>= 0; --i) {
				// POLLIN event
				if (_fds[i].revents & POLLIN) {
					if (_fds[i].fd == this->_fd) {
						// Another(client) fd
						acceptClient();
					} else {
						// Another(client) fd
						handleSocketRead(_fds[i].fd); 

						// Add to remove list if Client Shutdown
						if (getClient(_fds[i].fd) == NULL) { 
							_fdsToRemove.push_back(_fds[i].fd);
						}
					}
				}

				// POLLOUT event
				if (_fds[i].revents & POLLOUT) {
					handleSocketWrite(_fds[i].fd);
					
					// Add to remove list if Client Shutdown
					if (getClient(_fds[i].fd) == NULL) { 
						_fdsToRemove.push_back(_fds[i].fd);
					}
				}
			}

			// Remove Client fds
			for (size_t j = 0; j < _fdsToRemove.size(); ++j) {
				removeClientFd(_fdsToRemove[j]);
			}
			_fdsToRemove.clear();

		} catch (const ServerException& e) {
			serverLog(this->_fd, LOG_ERR, C_ERR, e.what());
			if (exitFlag) {
				exit(EXIT_FAILURE);
			}
		}
	}
}
  1. poll을 통해 여러 소켓을 감시하며, 새 데이터가 올 때마다 처리
  2. 데이터가 들어오는 소켓은 handleSocketRead로 처리하고, 데이터를 보낼 준비가 된 소켓은 handleSocketWrite로 처리
  3. 클라이언트 연결을 종료하면 해당 소켓을 제거
  4. 예외가 발생하면 이를 처리하고, 필요시 서버를 종료

무한 루프 (while true)

while (true) { ...

ping 타임아웃 검사

checkPingTimeOut();

논블로킹 poll()

if (poll(&_fds[0], _fds.size(), 0) < 0) {
    if (errno != EINTR) {
        exitFlag = true;
    }
    throw ServerException(ERR_POLL);
}

이벤트 루프 (역순 순회)

for (int i = _fds.size() - 1; i >= 0; --i) {
    // POLLIN
    if (_fds[i].revents & POLLIN) {  }

    // POLLOUT
    if (_fds[i].revents & POLLOUT) {  }
}

POLLIN 처리

if (_fds[i].revents & POLLIN) {
    if (_fds[i].fd == this->_fd) {
        // 서버 소켓 → 새로운 클라이언트 수락
        acceptClient();
    } else {
        // 클라이언트 데이터 수신
        handleSocketRead(_fds[i].fd);
        // 클라이언트 정상 종료 시 리스트에 추가
        if (getClient(_fds[i].fd) == NULL)
            _fdsToRemove.push_back(_fds[i].fd);
    }
}

POLLOUT 처리

if (_fds[i].revents & POLLOUT) {
    handleSocketWrite(_fds[i].fd);
    if (getClient(_fds[i].fd) == NULL)
        _fdsToRemove.push_back(_fds[i].fd);
}

연결 종료된 FD 정리

for (size_t j = 0; j < _fdsToRemove.size(); ++j) {
    removeClientFd(_fdsToRemove[j]);
}
_fdsToRemove.clear();

예외 처리 및 종료

} catch (const ServerException& e) {
    serverLog(this->_fd, LOG_ERR, C_ERR, e.what());
    if (exitFlag) {
        exit(EXIT_FAILURE);
    }
}


5. Evaluation


2025.05 코드 리뷰 추가 삽입

try1 - review 1

try1 - reivew 2

try1 - review 3

우선 전체적으로 어떤 과제인지 설명을 하고, 각자 맡은 부분에 대해서 따로 설명했다. 서버 구현 방법이 사람들 마다 조금씩 달랐고, 특히 서버에서 로그를 출력하는 것이 과제 지침 상 맞는 것인지 토의했던 기억이 난다. 또한 비동기-논블로킹 방식에 대해서 설명하고 어떻게 구현했는지 코드 리뷰 했다.

아쉽게 quit 명령어 동작에서 약간의 실수가 있어 점수가 조금 깎였다. 채팅방 이탈 시 문제였는데, 나중에 확인해보니 간단하게 고칠 수 있는 부분이라 좀 아쉬웠다.



6. Reference