51.1 프레즌스가 대체 뭐예요?

채팅방에 들어가면 상대 이름 옆에 초록 점이 반짝이죠. "지금 접속 중"이라는 표시요. 대화를 치다 보면 "OO님이 입력 중..."이라는 글자도 슬쩍 떴다 사라지고요. 이렇게 지금 누가 있고 뭘 하는지를 실시간으로 보여주는 걸 프레즌스(presence, 접속 상태)라고 해요.


별거 아닌 것 같지만 이거 하나로 서비스 느낌이 확 달라져요. 초록 점이 있으면 "말 걸면 답 오겠구나" 싶고, 아무도 없으면 조용한 걸 알죠. 그래서 채팅, 협업 문서, 게임 로비 같은 실시간 서비스엔 거의 다 들어가요.


이번 편은 웹소켓(WebSocket, 서버와 연결을 계속 열어두는 통신 방식) 위에서 이걸 직접 만들어요. 서버는 Node의 ws 라이브러리로 예를 들게요. 저는 예전에 프레즌스를 대충 짰다가, 이미 나간 사람이 계속 초록 점으로 남아 "유령 접속자" 신고를 받은 적이 있어요. 그 삽질을 피하는 게 목표예요.

51.2 지금 누가 접속해 있는지 어떻게 세요?

원리는 단순해요. 누가 연결되면 명단에 넣고 끊기면 빼요. 그리고 명단이 바뀔 때마다 모두에게 새 명단을 브로드캐스트(broadcast, 전체 전송)하면 끝이에요. 명단은 서버 메모리에 Map(맵, 키와 값을 짝지어 담는 자료구조)으로 들고 있으면 편해요. 연결을 키로, 그 사람 아이디를 값으로요.


상황: 접속자 명단을 만들고, 바뀔 때마다 모두에게 알려요.
const wss = new WebSocketServer({ port: 3000 });
const online = new Map(); // 연결 -> 사용자 아이디

function broadcast(data) {
const msg = JSON.stringify(data);
for (const c of wss.clients)
if (c.readyState === 1) c.send(msg);
}

wss.on('connection', (ws) => {
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'join') {
online.set(ws, msg.userId);
broadcast({ type: 'presence', users: [...online.values()] });
}
});
ws.on('close', () => {
online.delete(ws);
broadcast({ type: 'presence', users: [...online.values()] });
});
});


wss.clients는 지금 연결된 모두를 담은 목록이고, readyState가 1이면 "정상으로 열려 있음"이라 그 사람들에게만 보내요. 접속자가 join을 보내면 명단에 넣고 close되면 빼요. 그때마다 현재 명단을 통째로 뿌리면, 클라이언트는 이 presence 메시지를 받아 접속자 목록을 다시 그리면 돼요.

51.3 브라우저를 그냥 닫으면 이탈을 어떻게 알죠?

방금 코드엔 함정이 있어요. close 이벤트를 믿으면 안 된다는 거예요. "나가기" 버튼을 곱게 누르면 close가 잘 와요. 그런데 와이파이가 뚝 끊기거나, 지하철에서 신호가 사라지거나, 브라우저가 갑자기 죽으면요? 연결은 끊겼는데 서버는 그걸 한참 몰라요. 그럼 그 사람이 명단에 유령처럼 남죠. 제가 신고받은 버그가 이거였어요.


그래서 서버가 주기적으로 "너 살아 있니?"를 물어야 해요. 이걸 하트비트(heartbeat, 심장박동 신호)라고 해요. 웹소켓엔 (ping)과 (pong)이 있어서, 서버가 핑을 던지면 살아 있는 브라우저는 자동으로 퐁을 돌려줘요. 답이 없으면 죽은 걸로 보고 끊는 거죠.


상황: 30초마다 살아 있는지 확인하고, 답 없으면 끊어요.
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});

setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
online.delete(ws);
ws.terminate(); // 답 없으면 강제로 끊어요
continue;
}
ws.isAlive = false; // 일단 죽었다 찍고
ws.ping(); // 핑을 던져요
}
}, 30000);


30초마다 모든 연결에 isAlive를 false로 찍고 핑을 던져요. 살아 있으면 퐁이 와서 다시 true가 되고요. 다음 30초에도 false로 남아 있다면 퐁을 못 보낸 거니 죽은 걸로 보고 terminate(즉시 종료)해요. 이러면 유령이 길어야 1분 안에 정리돼요. 참고로 close는 상대 응답을 기다리는 정중한 종료라 이미 죽은 연결엔 안 끝나요. 그래서 죽은 연결엔 terminate가 맞아요.

51.4 "OO님이 입력 중" 표시는 어떻게 만들어요?

이제 상대가 입력 중인 걸 보여줄게요. 원리는 똑같아요. 타이핑을 시작하면 typing 메시지를 서버에 보내고, 서버는 나 말고 나머지에게 뿌려요. 조심할 건, 글자마다 매번 보내면 "안녕하세요" 다섯 글자에 다섯 번이나 서버를 두드려요. 그래서 디바운스(debounce, 몰아서 처리)를 써요. 손을 멈추면 잠시 뒤 "멈춤"도 보내야 표시가 지워지고요.


상황: 입력창의 타이핑 상태를 알려요. (클라이언트)
let timer = null;
input.addEventListener('input', () => {
socket.send(JSON.stringify({ type: 'typing', on: true }));
clearTimeout(timer);
timer = setTimeout(() => {
socket.send(JSON.stringify({ type: 'typing', on: false }));
}, 2000); // 2초간 조용하면 '멈춤'
});


clearTimeout으로 매번 타이머를 초기화하니 계속 치는 동안엔 "멈춤"이 안 나가고 손을 뗀 뒤에만 딱 한 번 나가요. 서버는 이걸 보낸 사람만 빼고 전달해요.


// 서버: 나 말고 나머지에게
if (msg.type === 'typing') {
const me = online.get(ws);
for (const c of wss.clients)
if (c !== ws && c.readyState === 1)
c.send(JSON.stringify({ type: 'typing', user: me, on: msg.on }));
}


c !== ws 이 한 줄이 포인트예요. 내가 친 걸 나한테 돌려보내면 "내가 입력 중"이라고 내 화면에 뜨는 이상한 일이 생기거든요. 받는 쪽은 on이 true면 "OO님이 입력 중..."을 띄우고 false면 지우면 돼요.

51.5 새로 들어온 사람 화면은 왜 비어 있죠?

여기서 초보들이 꼭 한 번 데여요. 지금까지 우리는 "바뀔 때"만 명단을 뿌렸어요. 그런데 중간에 새로 들어온 사람은 그 바뀌는 순간을 못 봤잖아요. 그래서 이미 접속해 있던 사람들이 그 사람 화면엔 안 보여요. 초록 점이 텅 비죠.


해법은 간단해요. 누가 새로 join하면 지금 상태를 통째로 그 사람한테 한 번 보내줘요. 이걸 초기 동기화(initial sync, 처음 상태 맞추기)라고 해요.


if (msg.type === 'join') {
online.set(ws, msg.userId);
// 새로 온 사람에게 현재 명단을 통째로
ws.send(JSON.stringify({ type: 'presence', users: [...online.values()] }));
// 나머지에게는 '새 사람 들어옴'을
broadcast({ type: 'presence', users: [...online.values()] });
}


두 갈래가 핵심이에요. 새로 온 사람에겐 전체 스냅샷을, 원래 있던 사람에겐 변경 소식을 보내는 거죠. 프레즌스가 자꾸 어긋나는 버그는 열에 아홉이 이 초기 동기화를 빼먹어서 생겨요. "나한텐 다 보이는데 상대 화면엔 내가 안 뜬대요" 하는 문의가 오면 여기부터 보세요.

51.6 오늘 배운 걸 정리해볼게요

실시간 프레즌스를 뼈대부터 만들어봤어요. 핵심만 다시 짚을게요.


접속자 명단은 서버 메모리에 Map으로 들고, 연결·해제마다 브로드캐스트로 모두에게 뿌려요. close는 못 믿으니 하트비트(핑·퐁)로 죽은 연결을 걸러 유령 접속자를 막고요. "입력 중"은 typing 메시지를 디바운스로 보내되 보낸 사람은 빼고 전달해요. 마지막으로 새로 들어온 사람에겐 현재 상태를 통째로 한 번 보내주는 초기 동기화를 잊지 마세요.


한 가지 기억할 건, 이 방식은 접속자 명단이 서버 한 대의 메모리에 통째로 들어 있다는 전제 위에 서 있다는 점이에요. 그래서 세기도 뿌리기도 이렇게 쉬웠던 거고요. 빈 프로젝트 하나 만들어서 브라우저 두 개를 나란히 띄우고 접속·이탈을 눈으로 보세요. 한쪽을 닫으면 다른 쪽 초록 점이 꺼지고, 와이파이를 껐을 때 1분 안에 유령이 정리되는 걸 보면 훨씬 오래 남아요.