IRCv3: 하위 호환을 지키며 프로토콜 진화시키기
1988년 프로토콜 IRC를 깨뜨리지 않고 현대화한 IRCv3를 살펴봅니다. 기능 협상, 메시지 태그, 하위 호환 프로토콜 설계의 교훈을 정리했습니다.

1988년, 핀란드의 한 대학생이 몇 페이지 분량밖에 안 되는 채팅 프로토콜을 만들었습니다. 거의 40년이 지난 지금도 그 프로토콜인 IRC는 하루에 수백만 건의 메시지를 실어 나릅니다. 그리고 IRCv3 워킹 그룹은 기존 설치 기반을 망가뜨리지 않고 프로토콜을 진화시키는 방법을 조용히 보여주고 있습니다. 낯선 사람들이 의존하는 API의 버전을 관리해 본 적이 있다면, IRCv3는 그대로 가져다 써야 할 사례 연구입니다.
저는 여러 해 동안 IRC를 대상으로 봇과 실험용 클라이언트를 만들어 왔는데, 양쪽이 최신 확장을 지원할 때 경험이 얼마나 현대적으로 느껴지는지가 계속 놀랍습니다. 서버가 재전송하는 히스토리, 진짜 인증, bouncer를 거쳐도 살아남는 메시지 타임스탬프가 있습니다. 그런데도 1995년에 만들어진 클라이언트가 같은 서버에 접속해 여전히 대화할 수 있습니다. 이런 조합은 우연히 생긴 게 아닙니다. 의도된 설계 결정들과 커뮤니티가 돌아가야 했던 몇 가지 뼈아픈 실수의 결과입니다.
원죄: 물어볼 수 없는 기능 목록
RFC 1459와 이후의 RFC 2812로 정리된 핵심 IRC 프로토콜에는 클라이언트가 서버에게 무엇을 지원하는지 묻는 메커니즘이 없었습니다. 전혀요. 접속한 뒤 NICK과 USER를 보내고, 나머지는 추측해야 했습니다. 서버가 특정 모드나 확장을 지원하는지 알고 싶다면 보내 보고 에러가 오는지 확인하는 수밖에 없었죠. 다섯 가지 서버 구현마다 동작을 다르게 특수 처리해야 하는 클라이언트 라이브러리를 유지보수해 본 사람이라면 이게 얼마나 골치 아픈 일인지 알 겁니다. 구현체마다 사양의 모호한 부분을 조금씩 다르게 해석하거든요. 지금 들으면 옛날 얘기 같지만요.
이 실패 모드는 이론적인 문제가 아니었습니다. 개발자들은 명령을 날리고 에러 숫자를 파싱해서 기능을 탐지하는 클라이언트를 만들었는데, 서버가 에러 문자열 하나만 바꿔도 깨지는 취약한 구조였습니다. 봇은 잘못된 가정을 가지고 채널에 들어가 실패 메시지를 쏟아냈죠. 이것이 바로 자연 서식지에서 드러난 명세와 구현의 간극입니다. 사양이 탐지 메커니즘을 제공하지 않으면 모든 구현자가 제각각 엉성한 방법을 새로 만들어 냅니다.
IRCv3의 해법은 기능 협상(capability negotiation)이었습니다. 너무 단순해서 조금 민망할 정도예요. 연결 등록 과정에서 클라이언트가 CAP LS를 보내면, 서버는 지원하는 기능의 이름 목록으로 응답합니다. sasl, server-time, message-tags 같은 것들이죠. 그러면 클라이언트는 원하는 부분집합만 담아 CAP REQ를 보내고, 서버가 승인하면 양측은 세션이 무엇을 지원하는지 명시적으로 합의한 상태로 진행합니다. CAP을 보내지 않는 레거시 클라이언트는 그냥 예전 동작을 받습니다. CAP을 이해하지 못하는 레거시 서버는 클라이언트가 안전하게 무시하고 폴백할 수 있는 에러를 돌려줍니다.
-> CAP LS 302
<- CAP * LS :sasl=PLAIN,EXTERNAL server-time message-tags echo-message
-> CAP REQ :sasl server-time
<- CAP * ACK :sasl server-time
-> AUTHENTICATE PLAIN
<- AUTHENTICATE +
-> AUTHENTICATE dXNlcgB1c2VyAHBhc3N3b3Jk
<- 900 jane jane!jane@host jane :You are now logged in
이 교훈은 IRC를 넘어 널리 적용됩니다. 오늘 어떤 프로토콜이나 API를 설계하든, 협상할 것이 아직 없더라도 첫 버전에 협상 메커니즘을 넣으세요. 비용은 메시지 한 번 주고받는 것뿐이고, 얻는 것은 앞으로 추가되는 모든 기능이 호환성을 깨는 변경이 아니라 옵트인이 된다는 점입니다.
교훈: 확장 가능성이 새 명령보다 낫다
IRCv3의 두 번째 핵심 아이디어는 메시지 태그인데, 실제 실수에서 나왔습니다. 사람들은 계속 새 명령을 정의하거나 기존 메시지 파라미터에 의미를 욱여넣는 방식으로 기능을 추가하려 했습니다. IRC 와이어 포맷은 악명 높게 간결합니다. 프리픽스, 명령, 그리고 뒤따르는 파라미터 목록이 전부입니다. 메타데이터를 걸어둘 곳이 없었죠. 그래서 재전송 히스토리에 타임스탬프를 붙이고 싶거나 어떤 계정이 메시지를 보냈는지 표시하고 싶으면, 옛날 방식은 새 숫자 응답을 만들거나 트레일링 텍스트를 망가뜨리는 것이었습니다. 두 방법 모두 구버전 클라이언트를 깨거나, 모든 파서에 특수 처리가 덧붙게 만듭니다.
해법은 메시지 맨 앞, 프리픽스 앞에 @key=value;key2=value2 형태의 새롭고 선택적인 섹션을 추가하는 것이었습니다. 핵심은 서버가 message-tags 기능을 협상한 클라이언트에게만 태그를 보낸다는 점입니다. 그래서 구버전 클라이언트는 파싱할 수 없는 바이트를 절대 보지 않습니다. 타임스탬프가 붙은 메시지가 실제로 와이어에서 어떻게 생겼는지는 다음과 같습니다:
@time=2024-03-14T09:26:00.000Z;account=jane :jane!jane@host PRIVMSG #rust :borrow checker finally clicked for me
이것은 HTTP 헤더, gRPC 메타데이터, protobuf의 알 수 없는 필드 허용과 같은 아키텍처적 접근입니다. 일반적인 확장 지점을 남겨 두면 기능마다 프로토콜을 수술할 필요가 없어집니다. server-time 확장은 재전송된 메시지가 모두 방금 도착한 것처럼 보이던 오래된 bouncer 문제를 해결하는데, 실제로는 태그 하나입니다. 클라이언트가 특정 메시지에 답글을 스레드로 묶을 수 있게 하는 +reply 초안도 또 하나의 태그입니다. 새 명령도, 새 숫자 응답도, 플래그 데이(flag day)를 통한 버전 전환도 없습니다.
저도 반대의 실수를 한 적이 있습니다. 몇 년 전 사내 pub/sub 시스템에서 「봉투 포맷을 바꾸는 건 너무 일이 많다」는 이유로 JSON 필드에 매직 프리픽스를 덧씌워 기능을 추가했습니다. 여덟 달 뒤 우리는 그 매직을 벗겨내기 위한 정규식을 세 가지 언어로 유지하고 있었고, 한 팀의 파서가 매직 문자열을 그대로 데이터로 취급하면서 잊을 수 없는 장애가 났습니다. 명시적이고 단순한 메타데이터 필드 하나면 하루면 됐을 일이었습니다. 매직 문자열은 몇 분기를 잡아먹었죠. IRCv3의 메시지 태그가 그 시스템에 처음부터 있었어야 했던 것입니다.

교훈: 평문 비밀번호 우회책을 없애라
2000년대에 IRC 클라이언트를 써 본 사람에게 로그인이 어떻게 됐는지 물어보면 모두 같은 답을 할 겁니다. /msg NickServ IDENTIFY password를 입력했죠. 자격 증명이 일반 채팅 메시지로 나가서 로그에 남고, 클라이언트가 명령을 잘못 다루면 공개 채널에 그대로 붙여넣어지기도 했습니다. 너무 흔해서 밈이 될 정도의 관문이었습니다. 프로토콜에 인증 개념이 없었기 때문에 인증은 애플리케이션 계층에 덧대어졌습니다.
IRCv3는 등록 과정의 SASL 인증을 표준화해서 이 문제를 제대로 해결했습니다. 접속한 뒤 봇과 대화하는 대신, 클라이언트는 sasl 기능을 협상하고 핸드셰이크의 일부로 인증합니다. 누구에게도 보이는 닉네임을 갖기 전에요. 서버는 첫 메시지부터 당신이 누구인지 알고, 이것이 다른 모든 것으로 이어집니다. account-tag 확장은 모든 메시지에 계정 이름을 붙일 수 있고, 봇은 자체 NickServ 파싱 휴리스틱 없이 권한 결정을 내릴 수 있으며, 클라이언트는 NOTICE 텍스트 벽을 읽을 필요가 없는 등록 흐름을 만들 수 있습니다.
더 넓은 교훈은 이렇습니다. 우회책이 보편화되면 더 이상 우회책이 아니라, 설계의 세심함 없이 사실상의 프로토콜이 됩니다. NickServ는 어떤 RFC에도 없었지만, 실패 모드까지 포함해 20년 동안 클라이언트 동작을 빚어냈습니다. 사람들이 이미 하던 일(연결 시 인증)을 진짜 메커니즘(PLAIN, 클라이언트 인증서용 EXTERNAL, 그리고 더 추가할 여지가 있는 SASL)으로 표준화하자, 민간 전승이 인프라가 되었습니다. 사용자 전부가 어떤 일을 끝내기 위해 어색한 춤을 추고 있다면, 그건 고객 지원 FAQ가 아니라 다음 명세 항목입니다.
교훈: 히스토리는 클라이언트 문제가 아니라 프로토콜 문제다
IRC의 대부분의 역사 동안 프로토콜은 엄격하게 실시간이었습니다. 메시지가 전송될 때 클라이언트가 연결되어 있지 않았다면, 그 메시지는 당신에게 존재하지 않은 것이었습니다. 커뮤니티의 해답은 bouncer였습니다. 영구 연결을 유지하는 프록시(ZNC가 대표적)를 VPS에 올려 두고 세션을 붙잡아 두었다가 놓친 내용을 재전송했죠. bouncer는 작동했지만 또 하나의 운영 인프라였고, 관리할 자격 증명이 늘어났으며, 재생 경험도 형편없었습니다. 모든 것이 재생 시각으로 찍혀 도착했고, 실시간으로 본 것들과 순서가 어긋났습니다.
IRCv3는 이를 여러 층위에서 공략했습니다. 먼저 server-time이 실제 수신 타임스탬프를 실어 재생된 히스토리를 쓸 만하게 만들었습니다. 그다음 chathistory 초안이 클라이언트에게 히스토리 구간을 표준 방식으로 요청하는 길을 주었고, Ergo 같은 서버는 채널 히스토리를 직접 저장하기 시작했습니다. 친구들을 위해 작은 Ergo 서버를 띄워 본 많은 사람들이 발견했듯 결과는 놀라울 정도입니다. 일주일간 오프라인이었다가 휴대폰으로 접속하면, 서버가 정확한 타임스탬프와 함께 모든 것을 재생해 줍니다. bouncer도, VPS 관리도 필요 없습니다. IRC가 이제껏 겪은 가장 큰 사용성 도약이며, 클라이언트 해킹이 아니라 프로토콜에서 나왔습니다.
진지하게 고려할 만한 반론이 있고, 매일 IRC를 쓰는 사람들이 이를 제기합니다. IRC의 체감 불편함 대부분은 클라이언트 쪽 문제이고, 괜찮은 클라이언트와 bouncer만 있으면 지금도 좋은 경험을 할 수 있다는 것이죠. 맞는 말입니다. 하지만 그 주장은 반대 방향에서 같은 결론을 증명합니다. 모든 클라이언트 개발자가 스크롤백 이어붙이기, 하이라이트 추적, 재생 중복 제거를 각자 다시 구현했고, 미묘하게 다른 버그를 남겼습니다. 어려운 부분을 프로토콜과 서버로 옮기면 한 번만 올바르게 구현되고 모든 클라이언트가 혜택을 봅니다. 웹 개발에서도 우리는 같은 결정을 끊임없이 내립니다. 불변 조건을 플랫폼에 밀어 넣는 것이죠. 브라우저에 네이티브 fetch가 들어오거나 TLS가 기본이 되면서 얼마나 많은 앱 코드가 사라졌는지 생각해 보세요. 수천 개의 코드베이스가 반쯤만 해결하도록 두지 마세요.
교훈: 선택적 기능은 갈라진 현실을 만든다
이제 IRCv3가 아직 완전히 해결하지 못한 실수를 살펴보겠습니다. 확장 가능한 시스템을 설계하는 사람에게 가장 관련이 깊은 문제입니다. 모든 확장이 선택적이고 독립적으로 협상되기 때문에, 같은 채널의 두 사용자가 말 그대로 서로 다른 대화를 볼 수 있습니다. 한 클라이언트의 메시지에는 +reply 태그가 붙어 스레드 답글로 렌더링되는데, 다른 클라이언트는 같은 메시지를 맥락 없는 평평한 한 줄로 보여줍니다. 한 사용자는 이모지 반응을 메시지에 붙은 깔끔한 글리프로 보고, 다른 사용자는 아무것도 보지 못하거나 알아보기 어려운 대체 텍스트를 봅니다. IRCv3 클라이언트 지원 매트릭스는 예와 아니오로 이루어진 표이며, 당신의 경험은 어느 열에 속해 있느냐에 달려 있습니다.
이것은 우아한 성능 저하(graceful degradation)의 어두운 면입니다. 하위 호환성은 아무것도 깨지지 않음을 보장했지만, 일관성까지 보장하지는 않습니다. Slack과 Discord는 그 밖에 뭐라고 생각하든 이 문제를 겪지 않습니다. 플랫폼이 양쪽 끝을 충분히 강하게 통제해서, 반응은 모두에게 같은 반응이기 때문입니다. IRCv3는 통제 대신 진화를 택했고, 그 대가는 영구적으로 고르지 않은 기능 지형입니다. 초안 명세가 몇 년씩 초안으로 머무는 일도 있습니다.
확장 가능한 무언가를 만들고 있다면 첫날부터 이 비용을 계획에 넣으세요. IRCv3와 비슷한 생태계들이 택한 완화책은 다음과 같습니다:
- 성능 저하를 의도적으로 설계하라. reply 태그는 사라지는 대신 읽을 수 있는 인용 줄로 폴백해야 합니다. 폴백이 쓸모없다면 그 기능은 선택 기능으로 출시할 준비가 되지 않은 것입니다.
- 지원 매트릭스를 공개하고 정직하게 유지하라. IRCv3의 클라이언트 및 서버 표는 사용자가 정보에 기반해 선택하게 해 줍니다. 침묵은 엉뚱한 소프트웨어를 향한 비난을 낳습니다.
- 기준선을 정의하고 승격시켜라. 번호가 붙은 릴리스(3.1, 3.2)는 기능을 묶어서, 클라이언트가 개별 플래그 열한 개 대신 "3.2 지원"을 목표로 삼을 수 있게 합니다. 생태계가 수렴할 바닥을 마련해 주세요.
- 초안에는 만료일을 두라. 서버에는 배포되지만 끝내 확정되지 않는 초안은, 채널 히스토리가 이런 운명에 가까이 갔던 것처럼, 아무것도 약속하지 않으면서 동작을 갈라놓기 때문에 초안이 없는 것보다 나쁩니다.
협업 편집이 왜 생각보다 어려운가에 관한 글에서도 이와 비슷한 모습을 봤습니다. 참여자들이 공유 상태의 모양에 대해 의견이 갈릴 수 있는 순간, 좋은 의도만으로는 부족하고 명시적인 수렴 규칙이 필요합니다. 기능에 대해 기본값을 선택적으로 두는 전략은 괜찮지만, 사용자에게 보이는 의미론에는 매트릭스 반대편에 갇힌 사람들을 위한 계획이 필요합니다.

다음 API나 프로토콜을 위한 고찰
채팅 향수를 걷어내면, IRCv3는 와이어 포맷을 가진 무엇이든 진화시키는 간결한 플레이북입니다. 프로토콜, 이벤트 스키마, 심지어 공개 REST API를 시작하는 팀에게 건네고 싶은 체크리스트는 다음과 같습니다:
- 협상부터 출하하라. 기능 목록이 비어 있더라도 첫 버전에 기능 교환을 넣으세요. 나중에 탐지 기능을 뒤늦게 붙이는 건 열 배는 어렵습니다.
- 확장 지점을 남겨 두라. 태그, 헤더, 메타데이터 맵처럼 핵심 문법을 건드리지 않고도 미래 기능이 걸 수 있는 일반 슬롯 하나를 두세요.
- 새 동작은 연결별 옵트인으로 만들라. 구버전 클라이언트는 바이트 단위로 동일한 옛 동작을 받아야 합니다. IRCv3 서버는 요청하지 않은 클라이언트에게 태그를 절대 보내지 않습니다.
- 민간 전승을 표준화하라. 사용자들이 수렴한 어떤 우회책이든(NickServ 방식 인증, bouncer 방식 재생) 제대로 쓰여질 명세를 기다리고 있습니다.
- 지원 매트릭스를 계획하라. 선택 기능은 갈라집니다. 성능 저하된 경험이 어떤 모습일지 정하고, 누가 무엇을 지원하는지 공개하세요.
- 진척을 이름 붙은 마일스톤으로 묶어라. 개별 플래그는 구현자를 위한 것이고, 번호가 붙은 릴리스는 사람을 위한 것입니다.
제가 정말 인상 깊게 보는 것은 절제입니다. IRCv3는 종단 간 암호화, 연합 구조 재설계, 어떤 거대한 재작성도 쫓지 않았습니다. 탐지, 메타데이터, 인증, 시간처럼 작고 날카로운 문제들을 골라서, 1995년 클라이언트가 무시할 수 있는 방식으로 해결했습니다. 그래서 IRC는 그 세대의 인터넷 프로토콜 중 사실상 유일하게 대체로 설계대로 계속 돌아가는 생존자이고, 오늘날 주말 프로젝트 하나로 $5짜리 VPS에서 친구들에게 히스토리 동기화, TLS, 실제 계정을 제공할 수 있는 이유입니다.
하위 호환성은 변화가 없다는 뜻이 아닙니다. 모든 변화를 과거가 안전하게 무시할 수 있는 것으로 만드는 규율입니다.
API를 버전 관리하려고 경로를 /v1에서 /v2로 올리고 모든 소비자에게 마이그레이션을 강요하고 싶어질 때, 다이얼업 시대 클라이언트와 최신 모바일 앱이 같은 채널을 공유하게 해 준 프로토콜을 떠올리세요. 기능을 협상하세요. 새 명령을 만드는 대신 메타데이터를 붙이세요. 바닥은 단단히 유지하고, 천장은 자기 일정대로 올라가게 두세요. IRCv3는 선배를 존중하면서도 새로운 기술을 배울 수 있는 프로토콜이 가능하다는 것을 증명합니다. 1988년의 채팅 시스템도 해냈다면, 3년 된 마이크로서비스에는 변명의 여지가 없습니다.


