1편에서 소개한 설계원칙을 기반으로 Server, Client, Channel, Command 클래스를 설계했고 C++98에서 사용할 수 없었던 std::unique_ptr, final, noexcept, delete 등을 이용해 안정적인 프로그램을 만들고자 했다.
RAII와 Resource Ownership
// Server.h
std::unordered_map<int, std::unique_ptr<Client>> mClients;
std::unordered_map<std::string, std::unique_ptr<Channel>> mChannels;
리소스를 소유한 객체가 해당 리소스의 수명을 책임지기 위해 unique_ptr을 사용했다.
Server가 std::unordered_map을 통해 모든 Client와 Channel 객체를 unique_ptr로 소유했고 그 생명주기를 완전히 통제한다. 이제 개발자가 수동으로 리소스를 해제할 필요가 없고 메모리 누수나 이중 해제 같은 실수를 막을 수 있다.
안전한 타입 사용하기
// Server.h
class Server final
{
public:
Server(int portNum, std::string password);
Server(Server const&) = delete;
Server& operator=(Server const&) & = delete;
~Server() noexcept;
...
}
이번 IRC Server는 단일 프로젝트여서 상속하지 않을 것이고, 확장할 수 있는 구조로 만들고 싶지 않아서 final을 붙였다. Client, Channel, Command 클래스에도 final을 붙였다. final 키워드는 컴파일 도중에 확인하기 때문에 실수를 원천 봉쇄할 수 있다.
이번 IRC Server에 다형성이 필요할까? 다형성은 보통 플러그인, 이벤트 핸들러 인터페이스 등에 필요하므로 이번 프로젝트에는 적합하지 않다. 만약 다양한 종류의 클라이언트 프로토콜을 지원한다면 필요하다.
대신 각 클래스가 소유하는 리소스가 있어 delete 키워드를 사용했다. delete 키워드를 사용하면 컴파일러가 코드를 생성하는 암시적 방식에 기댈 필요가 없어진다.
| 클래스 | 소유 리소스 | 설명 |
|---|---|---|
| Server | 서버 소켓(mServerSocket), | 모든 핵심 자원의 최상위 소유자 |
| Client | 클라이언트 소켓 (mFd) | 소켓 자원을 직접 소유 |
| Channel | 멤버 목록, 운영자 목록, 초대목록 등 | 동적으로 할당된 데이터를 포함하는 컨테이너 |
| Command | (없음) | 오직 참조만 가지며, 아무것도 소유하지 않음 |
Server는 소켓과 unique_ptr 맵을 복사하면 이중 해제가 발생할 수 있으므로 반드시 복사를 금지해야 하고, Client도 마찬가지로 같은 fd에 대해 close가 두번 호출될 위험이 있으므로 복사를 금지해야한다. Channel은 직접 소유하는 OS 자원은 없지만, 멤버 목록 등 고유한 상태를 가지므로 복사로 인한 불일치를 막기 위해 금지해야 한다. Command는 복사할 이유가 전혀 없고, 참조 멤버를 포함하므로 금지한다.
소켓을 소유하는 객체(Server, Client)는 이동도 금지했다. 이동을 허용하면 이동 후의 객체가 여전히 유효하지 않은 fd를 들고 있을 수 있고, 소멸자에서 그 fd를 닫으려고 시도할 위험이 있다. delete 또한 final처럼 컴파일 타임에 확인하고, 위반 시 컴파일 오류를 발생시키므로 실수를 방지할 수 있다.
// Command.h
enum class eCommandType
{
PASS,
NICK,
USER,
JOIN,
PART,
TOPIC,
INVITE,
KICK,
MODE,
PRIVMSG,
PING,
QUIT,
UNKNOWN
};
기존 enum은 이름이 전역으로 새어나가고 이름 충돌로 인해 컴파일 에러가 날 수 있다. 또한 그냥 정수(int)로 암시적 변환되어 다른 enum 간의 비교가 컴파일될 수 있었다. 이제 enum class를 사용하면서 명시적 스코프와 개발자의 명시적 형변환을 강제하여 타입 안정성을 높여주었다. 메모리 크기도 명시가능하지만 이번 프로젝트에서는 필요가 없어 명시하지 않았다.
Single Responsibility Principle
▶클래스 다이어그램 보기

하나의 클래스는 오직 하나의 변경 이유만을 가져야 한다 는 단일 책임 원칙(SRP)에 따라 클래스를 설계했다.
- Server
- 모든 객체의 수명을 관리하고 이벤트 루프를 통해 이들을 조율
- Client
- 연결된 사용자의 소켓과 인증 상태를 관리
- Channel
- 채널의 멤버 목록, 모드, 토픽을 관리
- Command
- IRC 메시지를 파싱하고 실행 (처리 후에는 바로 소멸)
// C++98 ver ft_irc
std::vector<Channel*> _joinedChannels; // 클라이언트가 직접 채널 목록 소유
Client는 자신의 상태만 알고 외부 관계(채널 멤버십)는 외부에서 관리하게 했다. 예전 프로젝트에서는 클라이언트가 직접 채널 목록을 소유하고 있었다. 예전 방식의 장점은 QUIT을 할 때 자기 채널만 바로 순회()가 가능하고, 단점은 클라이언트와 채널 사이의 양방향 참조로 인해 동기화 문제가 생기기 쉽고 dangling pointer 위험도 있다.
이번 설계에서는 클라이언트는 자신의 상태만 알기 때문에 QUIT을 할 때 소속을 체크하기 위해 Server가 가진 전체 채널 목록에서 멤버십을 확인()해야 한다. 하지만 단방향 의존성으로 포인터가 꼬일 위험이 전혀 없고 코드가 단순해졌다. 만약 채널 수가 수천 개가 넘어 전체 순회가 병목이 된다면, 그때 Client에 채널 목록을 추가하는 방식으로 최적화할 수 있다.
현재 프로젝트 규모에서는 안정성과 유지보수성을 우선시했다. '채널 멤버십 관리' 책임은 Server로 이동시켰고 각 객체가 더 작고 집중된 책임을 갖도록 분리했다. 결과적으로 Client는 극도로 단순해졌고, 외부와의 결합도가 사라졌다.
에러 처리: exception, 에러코드
// Server.cpp
if (bind(mServerSocket, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) < 0)
{
close(mServerSocket);
throw std::runtime_error("bind() failed");
}
if (listen(mServerSocket, irc::BACKLOG) < 0)
{
close(mServerSocket);
throw std::runtime_error("listen() failed");
}
이번 프로젝트에서 에러 처리는 명확한 기준을 통해 두 가지 방법으로 분리했다. 예외(exception)는 생성자에서만 사용하고, 에러 코드는 그 외 모든 부분에서 사용했다. 생성자는 리소스를 획득하는 유일한 시점이며, 반환 값이 없기 때문에 예외를 통해 실패를 알려야 한다. 만약 소켓 생성, bind, listen 등이 실패하면, 객체 자체가 유효하지 않은 상태가 되기 때문에 객체 생성을 중단하고, RAII를 통해 이미 획득한 자원을 자동으로 정리한 뒤, 호출자에게 실패를 알리는 것이 안전한 방법이다. 논블로킹 소켓에서 send, recv가 실패하는 것은 예외적인 상황이 아니라 일반적인 흐름이라서 예외를 던지지 않고 에러 코드로 에러 처리를 한다.
C++는 Java의 NullPointerException 같은 런타임 예외가 자동으로 발생하지 않는다. 예를 들어, nullptr 역참조는 C++ 예외가 아닌 정의되지 않은 동작(UB)이며, 보통 OS가 SIGSEGV 시그널을 보내는 방식으로 드러난다. 언어 차원에서 보호해 주는 것이 거의 없어서, 우리가 명시적으로 throw를 쓰지 않는 한 예외는 발생하지 않는다. 반면에 에러 코드는 실패가 예상되는 경로를 if문으로, 명시적으로 처리하므로, 코드 흐름이 직관적으로 드러난다. 예외와 달리 성능 오버헤드가 거의 없고, 논블로킹 환경과 잘 맞는다.
// Client. h
class Client final
{
public:
explicit Client(int fd);
Client(Client const&) = delete;
Client& operator=(Client const&) & = delete;
Client(Client&&) = delete;
Client& operator=(Client&&) & = delete;
~Client() noexcept;
int GetFd() const noexcept;
const std::string& GetNickname() const noexcept;
}
또한 C++11부터 도입된 noexcept 키워드를 적극 활용했다. 소멸자에는 기본적으로 noexcept를 붙여, 객체 파괴 도중 예외가 발생하지 않음을 명시하고 컴파일러가 최적화할 수 있게 했다. 단순 getter/setter 등 실패할 수 없는 함수에도 noexcept를 명시하여, 코드의 의도를 분명히 하고 컴파일러 최적화를 유도했다.