안녕하세요! 2채널 호환 게시판 시스템 "츠우바이채널(つゔぁいちゃんねる, 2ch.biz)"의 개발 기록 제42회입니다.
지난번(제41회)에서는 시그널링 서버(Signaling Server)의 무조건 자동 생성을 막고, 존재하지 않는 방에 대한 접근을 오류로 차단하는 시스템을 구축했습니다.
하지만 이 오류 처리를 통합하자마자 개발팀 내에서 새로운 논쟁이 벌어졌습니다.
"'방을 찾을 수 없습니다'라고만 알려주면,"
"단순히 URL을 잘못 입력한 것인지, 아니면 어제까지 활발하게 사용되던 방이 모두 퇴실하여 종료된 것인지 알 수가 없잖아요!"
"하지만 과거 방 목록을 데이터베이스(DB)에 저장한다면, 우리가 자랑하는 '제로 스토리지(Zero Storage, 로그를 전혀 남기지 않는)' 설계 철학에 위배되지 않을까요...?"
방의 이력을 담는 테이블을 단 하나도 만들지 않고, 프라이버시를 완벽하게 지키면서 어떻게 '오타(Typo)'와 '종료된 방'을 완벽하게 구분할 수 있을까요?
본고에서는 '단 하나의 정수 카운터(integer counter)' 만으로 과거, 현재, 미래의 방을 순식간에 구별하는 매우 우아한 Stateless 식별 알고리즘의 전반적인 내용을 설명합니다!
사용자 친화적인 서비스를 목표로 한다면, 에러 메시지는 친절해야 합니다.
- URL을 잘못 입력한 사람에게는: "그러한 ID의 방은 한 번도 존재하지 않습니다. 입력하신 내용을 확인해 주세요"
- 어제 해산된 방 링크를 누른 사람에게는: "이 채팅방은 모든 사용자가 퇴실했기 때문에 정상적으로 종료되었습니다"
일반적인 경우라면, 데이터베이스에 rooms_history 테이블을 만들고 발행한 룸 ID와 종료 일시를 저장해 두면 쉽게 판별할 수 있습니다.
-- 【일반적인 웹 서비스의 접근 방식】
SELECT status FROM rooms_history WHERE room_id = 'xxx';
-- → 과거에 생성되었다면 'closed', 단 하나도 없으면 'not_found'
하지만, 츠우바이채널의 P2P 채팅 절대 원칙은 '서버에 과거 로그나 방의 흔적을 일절 남기지 않는 것' 입니다.
누가 언제 어떤 방을 개설했는지와 같은 메타데이터조차 DB에 축적하고 싶지 않았습니다.
"DB에 과거 방을 하나도 저장하지 않으면서도" + "오타 ID와 종료된 ID를 고정밀로 구분하는 것"이라는 상반되는 요구사항을 어떻게 양립시켰을까요?
저희가 고안한 것은, 서버 측에서 보관하는 '지금까지 발행된 방의 총 개수(p2p_room_counter)'라는 단 하나의 정수 값 을 활용한 판별 알고리즘입니다.
룸 ID 포맷을 다음과 같이 '연번 카운터 + 암호학적 난수' 로 하이브리드 구성합니다.
$$ ext{룸 ID 형식}: ext{room_} + ext{<발행 번호 } N ext{>} + ext{ extunderscore} + ext{<랜덤 문자열>}$$
(예: room_1_a8b9c0d1, room_42_k3m9p2x1, room_105_7f2a1b9c)
이 설계 덕분에, 입력된 룸 ID에서 정규 표현식(regular expression)으로 번호 $N$을 추출하는 것만으로, 서버는 어떠한 이력 테이블도 보지 않고 다음 3가지 상태를 순식간에 판별할 수 있습니다.
-
미래의 번호 ($N > ext{Max}$ 또는 형식 오류): 서버가 아직 100번밖에 방을 발행하지 않았는데,
room_9999_xyz
나 hoge123이 전송되었다면, 그것은 **'존재할 수 없는 오타 ID'**입니다. - ➔ 에러: INVALID_ROOM_ID
(「지정된 룸 ID는 존재하지 않습니다」)
-
서버가 아직 100번밖에 방을 발행하지 않았는데,
-
과거의 번호 ($1 ext{ } ext{≤} N ext{ } ext{≤} ext{Max}$ 이고 메모리에 비존재): 과거에 확실히 서버가 발행한 번호이지만, 현재 메모리 맵(rooms)에 존재하지 않는다는 것은, **'모두 퇴실하여 깨끗하게 사라진 과거의 방'**임을 논리적으로 확정합니다. - ➔ 에러: ROOM_CLOSED
(「모든 사용자가 퇴실했기 때문에 종료되었습니다」)
-
과거에 확실히 서버가 발행한 번호이지만, 현재 메모리 맵에 존재하는 경우: 누군가가 체류하고 있는 현역 방입니다.
-
➔ 정상 입장 (비밀번호 인증이나 승인 플로우로 진행)
서버 측에서는 방이 새로 생성될 때마다 원자적(Atomic)으로 카운터를 증가시키고, SQLite의 관리 설정(admin_settings)에 단일 수치로 저장합니다.
// server.js: 방 생성 로직
function generateNextRoomId() {
// 1. 카운터 +1 증가 (영속화)
...
// 참가 요청 수신 시
if (action === 'join') {
const { roomId, isCreate } = payload;
...
테스트 스크립트를 실행하여 상태 공간 판정의 정확도를 검증했습니다.
| 테스트 입력 | 서버 최신 번호: 10 | 기대되는 판정 | 실행 결과 |
|---|
hoge_invalid | - | | |
INVALID_ROOM_ID (형식 부적합) | ✅ PASS | | |
room_999_abcd1234 | 미래 번호 (999 > 10) | INVALID_ROOM_ID (미발행) | ✅ PASS |
room_5_abcd1234 | 과거 번호 (현재 인원 없음) | ROOM_CLOSED (종료됨) | ✅ PASS |
room_10_abcd1234 | 현재 가동 중 (참가자 있음) | 입실 성공 (비밀번호 확인 필요) | ✅ PASS |
데이터베이스에는 '현재 최대 발행 번호'라는 단 하나의 정수만 저장되어 있음에도 불구하고, 사용자에게는 '오타인지', '해산했는지'를 완벽하게 구분하여 친절하게 전달할 수 있게 되었습니다.
히스토리를 DB에 쌓아두지 않고, 정수 카운터 1개로 상태 공간을 과거・현재・미래로 나누어 식별하는 Stateless(무상태) 방식입니다.
프라이버시 보호와 친절한 UX라는 본래 상반되는 두 가지 목표를 모두 달성시킨 아름다운 엔지니어링의 성과입니다.
하지만 이 메커니즘을 도입하자마자 보안 담당자로부터 날카로운 지적이 들어왔습니다.
"잠깐만요. 룸 ID에 room_105_...처럼 연속 번호 카운터가 그대로 노출되어 있으면, 악의적인 공격자에게 '지금 이 사이트에서 누적 몇 개의 방이 생성되었는지' 이용 규모가 적나라하게 드러나지 않을까요?"
"게다가 직전 번호(104나 103)를 추측해서 무차별 대입 공격(Brute-force attack)을 시도할 위험이 있습니다!"
연속 번호의 편리함은 유지하면서, 외부에서는 완전히 랜덤한 암호 문자열처럼 보이게 하려면 어떻게 해야 할까요?
본 서비스: https://2ch.biz/