TCP 스트림을 IRC 프로토콜 메시지로 재조립하는 방법은 C++98에서 C++17로 오면서 많은 변화가 있었다. 파싱, 토큰화 그리고 의존성이 어떻게 발전했는지 알아본다.
TCP 스트림에서 하나의 메시지까지
IRC는 TCP 위에서 동작하는 텍스트 기반 프로토콜로, 클라이언트가 보낸 PRIVMSG #hello :Hello\r\n 같은 메시지는 TCP 스트림을 통해 바이트 덩어리로 도착한다. 서버의 첫 번째 임무는 이 연속된 바이트 흐름에서 \r\n으로 구분된 완전한 명령어를 조립해 내는 것이다.
// C++98 ft_irc
std::map<int, std::string> _message;
void Server::readMessage(int clientFd)
{
_message[clientFd].append(_buffer);
if (_message[clientFd] != CRLF)
{
Command command(_clients[clientFd], _message[clientFd]);
// (command execute, parse)
_message[clientFd].clear();
}
}
// Modern irc
std::string mRecvBuffer; // Client class
void Client::AppendRecvBuffer(const char* data, size_t len)
{
mRecvBuffer.append(data, len);
}
std::string Client::ExtractCompleteMessage()
{
size_t pos = mRecvBuffer.find("\r\n");
if (pos == std::string::npos)
return "";
std::string msg = mRecvBuffer.substr(0, pos + 2);
mRecvBuffer.erase(0, pos + 2);
return msg;
}
void Server::StartServer()
{
clientIter->second->AppendRecvBuffer(buf, n);
while (true)
{
std::string msg = clientIter->second->ExtractCompleteMessage();
if (msg.empty())
break;
Command cmd(*clientIter->second, msg, mChannels, mClients, mPassword, mClientsByNick, mStartTime);
// (command execute, parse)
}
}
예전 ft_irc에서는 서버가 직접 모든 클라이언트의 수신 버퍼(_message)를 관리했다. 이 방식은 서버가 TCP I/O뿐만 아니라 메시지 프레이밍(framing)까지 책임져야 하므로, 서버의 책임이 과도하게 커지는 문제가 있었다. 이번 C++17 프로젝트에서는 각 Client 객체가 버퍼(mRecvBuffer)를 소유하도록 했고, 서버는 AppendRecvBuffer와 ExtractCompleteMessage만 호출할 뿐, 내부 버퍼 구조를 알 필요가 없어졌다.
이는 TCP 스트림에서 완전한 메시지를 조립하는 책임을 Server에서 Client로 옮긴 것이며, 그 결과 서버의 코드는 간결해지고 각 구성 요소의 역할이 더 명확해졌다. 이제 서버는 원본 메시지를 통째로 넘기는 대신, 완성된 메시지만을 Command에 전달한다.
// C++98 ft_irc
char _msgBuffer[512]; // Client class
void Server::start()
{
// ...
std::string msg = iter->second->getMsgBuffer();
if (send(iter->first, msg.c_str(), msg.length(), 0) == -1)
throw std::runtime_error("Error sending message");
}
메시지를 수신하는 쪽의 구조를 개선하고 나서 응답을 송신하는 쪽도 개선하기로 했다. 예전 ft_irc의 송신 버퍼는 char _msgBuffer[512]로 크기가 고정되어 있었고, strcat으로 데이터를 추가해 버퍼 오버플로우 위험이 있었다. 게다가 send()를 실패하면 예외를 던져 서버가 중단될 위험도 있었다.
// Modern irc
std::deque<std::string> mSendQueue; // Client class
void Server::StartServer()
{
Client& client = *clientIter->second;
std::string msg = client.PopNextSend();
ssize_t sent = send(fd, msg.c_str(), msg.length(), 0);
if (sent < 0)
{
if (errno == EAGAIN || errno == EWOULDBLOCK)
{
client.AddSendMessage(msg); // retry
}
else
{
removeClient(fd);
ret--;
continue;
}
}
else if (static_cast<size_t>(sent) < msg.length())
{
std::string remainder = msg.substr(sent);
client.PushFrontSend(remainder);
}
}
이번 프로젝트에서는 이를 std::deque<std::string>으로 교체했다. std::deque는 메시지를 순서대로 저장하면서도, 앞쪽과 뒤쪽 모두에서 으로 삽입/삭제가 가능하다. send()가 한 번에 모든 데이터를 전송하지 못하는 부분 전송 상황에서도, 남은 데이터를 큐의 맨 앞에 안전하게 되돌려 놓을 수 있게 되었다.
Command로 가는 길
완성된 메시지가 만들어지면, 이제 이 메시지를 해석하고 실행할 Command 객체에 전달할 차례이다. 이 과정에서 예전 코드와 현재 코드는 근본적으로 다른 설계를 보여준다.
// C++98 ft_irc
Command(Client * client, const std::string message);
void Command::join(std::map<std::string,Channel*> & channelsInServer);
// Modern irc
using ChannelMap = std::unordered_map<std::string, std::unique_ptr<Channel>>;
using ClientMap = std::unordered_map<int, std::unique_ptr<Client>>;
Command(Client& client, const std::string& rawMessage, ChannelMap& channels, ClientMap& clients,
const std::string& serverPassword, NickIndexMap& nickIndex, const std::string& startTime);
void Command::handleJoin()
예전 ft_irc에서는 Command가 Client*와 메시지 문자열만 받고, 실행 시점에 join(_channels)처럼 서버의 내부 맵을 인자로 받아 처리했다. 이 방식은 Command가 서버의 거의 모든 내부 상태를 알고 있어야 했고, 명령어마다 전달받는 인자의 종류와 개수가 다 달라서 인터페이스가 통일되지 않았다.
이번 C++17 프로젝트에서는 이런 숨은 의존성을 제거하기 위해, Command 생성자에서 필요한 모든 참조를 미리 전달받도록 변경했다. 이제 Command는 자신이 살아있는 동안 이 맵들과 데이터에 자유롭게 접근할 수 있으며, 각 핸들러는 별도의 인자 없이 mChannels, mNickIndex같은 멤버 변수를 사용하면 된다. Command와 Server의 결합도를 낮추어 Command는 파싱 결과만 잠깐 들고 있다가 실행 후 폐기되는 가벼운 객체가 되었다.
parser 내부
Command에 전달된 메시지는 곧바로 파싱 과정을 거친다. 여기서도 C++17의 기능을 활용한 개선이 이루어졌다.
// C++98 ft_irc
std::string str = this->_message.substr(prev, crlf - prev);
if (str.length() != 0)
this->_tokens.push_back(str);
// Modern irc
std::string_view remaining = mRawMessage;
// ...
mTokens.push_back(remaining.substr(0, end));
예전 코드는 substr()을 호출할 때마다 새로운 std::string을 생성했다. 이는 동적 메모리 할당과 불필요한 복사를 발생시킨다. 이번에 적용한 std::string_view는 원본 문자열을 참조만 하고, substr()을 호출해도 범위를 조정해 새로운 메모리를 할당하지 않는다. 다만, std::string_view는 원본 데이터의 수명이 보장되어야만 안전하게 사용할 수 있어, 파싱이 완료될 때까지 원본 메시지가 유지되는 현재 구조에 잘 맞는다.
// C++98 ft_irc
int Command::getCommandType()
{
std::string cmd = _tokens[_messageIndex];
for (size_t i = 0; i < cmd.length(); ++i)
{
cmd[i] = std::toupper(cmd[i]);
}
}
// Modern irc
eCommandType Command::getCommandType() const
{
if (mTokens.empty())
return eCommandType::UNKNOWN;
std::string_view cmd = mTokens[0];
if (cmd == "PASS") return CommandType::PASS;
// ...
}
IRC 명령어는 대소문자를 구분하지 않기 때문에, 예전에는 비교 전에 무조건 대문자로 변환해야 했다. std::string을 복사해서 루프를 돌며 한 글자씩 변환했었는데 이번에는 이런 변환 과정을 제거했다. irssi를 비롯한 대부분의 IRC 클라이언트는 표준 관행으로 명령어를 대문자로 전송한다. 이 점을 이용해서 "명령어는 항상 대문자로 들어온다" 라는 가정을 세워 std::string_view::operator==을 그대로 사용해도 대소문자 문제가 발생하지 않게 되었다.
std::string 대신 std::string_view를 사용한 이유는
세 가지가 있다.
첫 번째는 string_view::operator==는
내부적으로
compare()를 호출해 효율적인 바이너리 비교만 하므로, 대문자 변환
루프에 비해 비교 비용이 거의 들지 않는다. 두 번째는 string_view는
원본 메시지를 참조만 하므로, 토큰을 추출하는 과정에서 메모리 할당이 전혀
발생하지 않는다. 세 번째는 명령어 타입을 확인하는
getCommandType() 함수는 토큰을 잠시 참조한 뒤 결과만 반환하므로,
원본 데이터 수명이 보장되는 한 string_view가 가리키는 값이
유효하지 않을 위험은 없다. 결과적으로 불필요한 문자열 변환과 복사를 제거할 수
있었다.
inline 함수와 constexpr
inline std::string RPL_CREATED(const std::string& servername, const std::string& nick, const std::string& date)
{
return ":" + servername + " 003 " + nick + " :This server was created " + date + "\r\n";
}
inline std::string ERR_NOTREGISTERED(const std::string& serverName, const std::string& nick)
{
return ":" + serverName + " 451 " + nick + " :You have not registered\r\n";
}
#define 매크로 대신 inline 함수로 응답 메시지를 관리했다. 매크로는 함수가 아니라서 콜 스택에서 볼 수 없고 브레이크 포인트에 걸리지 않는다. 그리고 네임스페이스를 존중하지 않아 전역 오염을 일으킬 수 있다. 반면, inline 함수를 사용하면 타입 검사가 가능하고 디버깅도 훨씬 수월해진다.
namespace irc
{
constexpr std::size_t MAX_CLIENTS = 128; // Maximum number of client connections
constexpr std::size_t BUF_SIZE = 1024; // Buffer size for receiving data
constexpr std::size_t MAX_TOKENS = 5; // Maximum expected token count in a single IRC message
constexpr std::size_t MAX_MSG_LEN = 512; // Maximum length of an IRC message (RFC 1459)
constexpr int BACKLOG = 128; // Backlog argument for listen()
}
상수 관리에 있어서도 #define을 버리고 constexpr을 선택했다. constexpr는 단순히 컴파일 타임 상수를 정의하는 것을 넘어, 명확한 타입과 네임스페이스를 부여해 준다. #define이 전역적으로 작동하는 것과 달리, namespace irc로 감싸진 constexpr 변수들은 irc::MAX_CLIENTS처럼 범위가 제한되어 이름 충돌의 위험을 없애준다.
최적화
mPollFds.reserve(irc::MAX_CLIENTS + 1);
mClients.reserve(irc::MAX_CLIENTS);
mTokens.reserve(irc::MAX_TOKENS);
std::vector는 동적 배열로, 요소 수 증가에 따라 자동으로 메모리를 관리해 준다는 점에서 편리하다. 하지만 용량(capacity)이 증가할 때마다 새로운 저장 공간을 재할당하고 기존 요소들을 모두 새 공간으로 복사해야 하는 비용이 발생한다. 이런 불필요한 재할당을 막기 위해, 이번 프로젝트에서는 사용되는 모든 vector에 대해 생성 직후 reserve를 호출하여 충분한 용량을 미리 확보해 두었다. 이를 통해 메모리 파편화를 방지할 수 있다.