이미지 로딩 중...
AI Generated
2025. 11. 19. · 2 Views
Socket.io 인증 및 온라인 상태 관리 완벽 가이드
실시간 채팅이나 협업 도구를 만들 때 꼭 필요한 Socket.io 인증 방법과 사용자 온라인 상태 관리를 배워봅니다. JWT를 활용한 안전한 WebSocket 연결부터 Redis를 활용한 확장 가능한 상태 관리까지, 실무에 바로 적용할 수 있는 모든 것을 다룹니다.
목차
1. WebSocket JWT 인증 미들웨어
시작하며
여러분이 실시간 채팅 애플리케이션을 만들 때 이런 고민을 해본 적 있나요? "누가 연결했는지 어떻게 확인하지?
HTTP처럼 헤더에 토큰을 넣을 수 있나?" WebSocket은 일반 HTTP 요청과 다르게 작동하기 때문에, 처음에는 사용자 인증을 어떻게 처리해야 할지 막막할 수 있습니다. 이런 문제는 실제 개발 현장에서 정말 자주 발생합니다.
인증 없이 WebSocket을 열어두면 누구나 접속할 수 있어서 보안에 큰 구멍이 생깁니다. 악의적인 사용자가 다른 사람인 척 메시지를 보내거나, 시스템에 과부하를 줄 수도 있죠.
바로 이럴 때 필요한 것이 WebSocket JWT 인증 미들웨어입니다. Socket.io의 미들웨어 기능을 활용하면, 연결이 성립되기 전에 사용자의 토큰을 검증하여 안전하게 신원을 확인할 수 있습니다.
개요
간단히 말해서, 이 개념은 Socket.io 서버에 연결을 시도하는 순간, 클라이언트가 보낸 JWT 토큰을 검사해서 "이 사람이 정말 우리 서비스의 사용자가 맞는지" 확인하는 관문입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 모든 실시간 서비스는 "누가" 연결했는지 정확히 알아야 합니다.
예를 들어, 슬랙 같은 협업 도구에서는 특정 워크스페이스의 멤버만 채팅방에 접속할 수 있어야 하고, 게임 서버에서는 유료 사용자와 무료 사용자를 구분해야 할 수 있습니다. 기존 HTTP API에서는 매 요청마다 Authorization 헤더로 토큰을 보냈다면, Socket.io에서는 연결 시 한 번만 인증하면 그 연결이 유지되는 동안 계속 신원이 보장됩니다.
이 미들웨어의 핵심 특징은 첫째, 연결 전에 실행되어 잘못된 접근을 원천 차단한다는 점입니다. 둘째, 한 번 인증되면 socket 객체에 사용자 정보를 저장해두어 이후 모든 이벤트에서 쉽게 사용할 수 있습니다.
셋째, 토큰이 만료되거나 변조된 경우 즉시 연결을 거부하여 보안을 강화합니다. 이러한 특징들이 실시간 애플리케이션의 보안과 사용자 경험을 동시에 향상시킵니다.
코드 예제
const jwt = require('jsonwebtoken');
// Socket.io 인증 미들웨어 설정
io.use((socket, next) => {
// 클라이언트가 보낸 토큰 추출 (쿼리 파라미터 또는 헤더에서)
const token = socket.handshake.auth.token || socket.handshake.headers.authorization;
if (!token) {
return next(new Error('인증 토큰이 없습니다'));
}
try {
// JWT 토큰 검증
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// 검증된 사용자 정보를 socket 객체에 저장
socket.userId = decoded.userId;
socket.username = decoded.username;
next(); // 인증 성공, 연결 허용
} catch (err) {
next(new Error('유효하지 않은 토큰입니다'));
}
});
설명
이것이 하는 일: 이 미들웨어는 마치 건물 입구의 경비원처럼, Socket.io 서버로 들어오려는 모든 연결 시도를 먼저 검사합니다. 클라이언트가 연결할 때 제시한 JWT 토큰이 우리가 발급한 정상적인 토큰인지 확인하고, 맞으면 통과시키고 아니면 거부합니다.
첫 번째로, io.use() 함수로 미들웨어를 등록하는 부분을 살펴보겠습니다. 이 함수는 모든 새로운 소켓 연결마다 자동으로 실행됩니다.
socket.handshake.auth.token에서 토큰을 가져오는데, 이는 클라이언트가 연결할 때 io.connect(url, { auth: { token: 'your-jwt-token' } })처럼 보낸 값입니다. 만약 auth 객체에 토큰이 없다면, Authorization 헤더에서도 찾아봅니다.
왜 이렇게 하냐면, 프론트엔드 구현 방식에 따라 토큰을 보내는 위치가 다를 수 있기 때문입니다. 두 번째 단계로, jwt.verify() 함수가 실행되면서 실제 토큰 검증이 일어납니다.
내부에서는 토큰의 서명이 우리 서버의 비밀키(JWT_SECRET)로 만들어진 것인지 확인하고, 유효기간이 지나지 않았는지 검사합니다. 검증에 성공하면 토큰 안에 담겨있던 원래 데이터(페이로드)가 decoded 변수에 담깁니다.
여기에는 사용자 ID, 이름, 권한 등 로그인할 때 토큰에 넣어둔 정보들이 들어있습니다. 세 번째 단계로, 검증된 사용자 정보를 socket.userId와 socket.username에 저장합니다.
이렇게 저장해두면 나중에 메시지를 보내거나 받을 때 "지금 누가 이 작업을 하는 거지?"라고 다시 물어볼 필요가 없습니다. socket 객체만 보면 바로 알 수 있죠.
마지막으로 next()를 호출하면 "이 사람은 정상 사용자니까 연결을 진행하세요"라고 다음 단계로 넘어갑니다. 만약 토큰이 없거나 잘못됐으면 next(new Error('에러메시지'))로 에러를 전달하고, Socket.io는 자동으로 연결을 거부합니다.
여러분이 이 코드를 사용하면 인증되지 않은 사용자의 접근을 원천 차단할 수 있고, 매번 인증을 확인할 필요 없이 한 번의 검증으로 연결 유지 기간 동안 안전하게 통신할 수 있습니다. 또한 토큰에서 추출한 사용자 정보를 활용해 개인화된 서비스를 제공하거나, 로그를 기록하거나, 권한별로 다른 기능을 제공할 수 있습니다.
실전 팁
💡 클라이언트에서는 socket = io(url, { auth: { token: yourToken } })처럼 연결하고, 토큰이 만료되면 socket.auth.token = newToken; socket.disconnect().connect()로 재연결하여 새 토큰으로 인증하세요.
💡 토큰 검증 실패 시 클라이언트는 socket.on('connect_error', (err) => console.log(err.message))로 에러를 받을 수 있으니, 이를 활용해 자동 로그인 페이지로 리다이렉트하거나 토큰 갱신을 시도하세요.
💡 JWT_SECRET은 절대 코드에 하드코딩하지 말고 환경변수로 관리하며, 운영 환경에서는 최소 256비트 이상의 강력한 랜덤 문자열을 사용하세요.
💡 사용자 권한이나 역할도 토큰에 포함시켜 socket.role = decoded.role처럼 저장하면, 이후 관리자 전용 이벤트나 특정 방 접근 권한을 쉽게 제어할 수 있습니다.
💡 토큰 검증 과정에서 데이터베이스 조회가 필요하다면(예: 사용자 차단 여부 확인), async/await를 사용하되 너무 무거운 작업은 피하세요. 연결 속도가 느려질 수 있습니다.
2. Socket 연결 이벤트 처리
시작하며
여러분이 실시간 채팅방을 만들 때 이런 상황을 겪어본 적 있나요? 사용자가 들어왔는데 "누가 입장했는지" 파악이 안 되거나, 같은 사용자가 여러 번 연결했을 때 어떻게 처리해야 할지 막막한 경우 말이죠.
또는 새로고침할 때마다 새로운 연결이 생겨서 중복 메시지가 발생하는 문제도 있습니다. 이런 문제는 실제 개발 현장에서 정말 흔합니다.
Socket.io는 연결이 성립되면 'connection' 이벤트를 발생시키는데, 이 이벤트를 제대로 처리하지 않으면 사용자를 추적할 수 없고, 메시지를 누구에게 보내야 할지도 알 수 없습니다. 또한 한 사용자가 여러 기기(PC, 모바일)에서 접속하는 경우를 고려하지 않으면 상태 관리가 꼬이게 됩니다.
바로 이럴 때 필요한 것이 체계적인 Socket 연결 이벤트 처리입니다. connection 이벤트에서 사용자 정보를 등록하고, 필요한 초기 데이터를 전송하며, 연결 상태를 추적하는 로직을 구현하면 안정적인 실시간 서비스를 만들 수 있습니다.
개요
간단히 말해서, 이 개념은 새로운 WebSocket 연결이 성공했을 때 서버가 "이 사람을 위해 무엇을 준비해야 하는가"를 정의하는 진입점입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 사용자가 앱에 들어온 순간부터 제대로 된 경험을 제공하려면 그들의 초기 상태를 설정해야 합니다.
예를 들어, 디스코드 같은 채팅 앱에서는 접속 즉시 읽지 않은 메시지 개수를 보여주고, 친구 목록의 온라인 상태를 업데이트하고, 현재 참여 중인 채널에 자동으로 입장시킵니다. 이 모든 것이 connection 이벤트에서 시작됩니다.
기존 HTTP 방식에서는 사용자가 페이지를 요청할 때마다 서버가 응답했다면, Socket.io에서는 연결이 유지되는 동안 서버가 먼저 데이터를 보낼 수 있습니다. 따라서 connection 이벤트에서 사용자를 "활성 사용자 목록"에 추가하고, 그들이 관심 있어 하는 데이터를 구독시키는 작업이 필수적입니다.
이 이벤트 처리의 핵심 특징은 첫째, 미들웨어에서 인증한 사용자 정보를 바로 활용할 수 있다는 점입니다. 둘째, 여기서 설정한 이벤트 리스너들이 연결이 끊어질 때까지 계속 작동합니다.
셋째, 같은 사용자의 여러 연결을 추적하여 멀티 디바이스 환경을 지원할 수 있습니다. 이러한 특징들이 복잡한 실시간 애플리케이션의 기반이 됩니다.
코드 예제
// 사용자별 소켓 연결 추적을 위한 Map
const userSockets = new Map(); // userId -> Set of socketIds
io.on('connection', (socket) => {
const userId = socket.userId; // 미들웨어에서 설정한 사용자 ID
console.log(`사용자 ${socket.username} 연결됨 (소켓 ID: ${socket.id})`);
// 사용자의 소켓 목록에 현재 소켓 추가 (멀티 디바이스 지원)
if (!userSockets.has(userId)) {
userSockets.set(userId, new Set());
}
userSockets.get(userId).add(socket.id);
// 사용자에게 초기 데이터 전송
socket.emit('init', {
userId: userId,
connectedDevices: userSockets.get(userId).size,
serverTime: new Date()
});
// 다른 이벤트 리스너 등록
socket.on('message', (data) => handleMessage(socket, data));
socket.on('disconnect', () => handleDisconnect(socket, userId));
});
설명
이것이 하는 일: 이 코드는 마치 호텔 체크인 데스크처럼, 새로운 손님(소켓 연결)이 도착하면 그들을 시스템에 등록하고, 방 번호(소켓 ID)를 부여하고, 필요한 서비스(이벤트 리스너)를 연결해줍니다. 동시에 같은 손님이 여러 방에 묵는 경우(멀티 디바이스)도 추적합니다.
첫 번째로, userSockets라는 Map 자료구조를 사용하는 부분을 살펴보겠습니다. 이것은 "사용자 ID"를 키로, "그 사용자의 모든 소켓 ID들의 집합(Set)"을 값으로 저장합니다.
왜 Set을 사용하냐면, 한 사용자가 PC로 접속하고 동시에 스마트폰으로도 접속할 수 있기 때문입니다. 각 디바이스마다 다른 소켓 ID가 생기지만, 실제로는 같은 사용자이므로 이를 묶어서 관리해야 합니다.
Set은 중복을 자동으로 제거해주고, 추가/삭제가 빠르기 때문에 이런 용도에 완벽합니다. 두 번째 단계로, io.on('connection') 내부에서 실행되는 로직을 보겠습니다.
socket.userId는 이전 단계의 인증 미들웨어에서 이미 설정해둔 값이므로, 우리는 "누가" 연결했는지 정확히 알고 있습니다. 먼저 로그를 남겨서 모니터링할 수 있게 하고, 그 다음 userSockets Map에 이 사용자가 없으면 새로운 Set을 만들어서 등록합니다.
이미 있으면 기존 Set에 현재 소켓 ID를 추가합니다. 이렇게 하면 나중에 "이 사용자의 모든 디바이스에 알림 보내기" 같은 기능을 쉽게 구현할 수 있습니다.
세 번째 단계로, socket.emit('init', {...})을 통해 클라이언트에게 초기 데이터를 보냅니다. 이것은 마치 "어서오세요, 당신의 계정 정보는 이렇고, 현재 3개 디바이스가 접속 중이며, 서버 시간은 이렇습니다"라고 안내하는 것과 같습니다.
클라이언트는 이 데이터를 받아서 UI를 초기화하거나, 동기화 작업을 시작할 수 있습니다. connectedDevices는 특히 유용한데, 사용자에게 "다른 기기에서 로그인되었습니다"라고 알려줄 수 있기 때문입니다.
마지막으로, 다양한 이벤트 리스너를 등록합니다. socket.on('message', ...)는 사용자가 메시지를 보낼 때, socket.on('disconnect', ...)는 연결이 끊어질 때 실행될 함수들을 지정합니다.
이렇게 connection 이벤트 안에서 모든 이벤트 리스너를 등록하는 이유는, 각 소켓마다 독립적인 컨텍스트를 유지하기 위함입니다. 즉, 소켓 A에서 발생한 이벤트는 소켓 A의 리스너만 처리하게 됩니다.
여러분이 이 코드를 사용하면 사용자가 언제 들어왔는지 정확히 추적할 수 있고, 같은 사용자의 여러 연결을 체계적으로 관리할 수 있으며, 연결 즉시 필요한 데이터를 제공하여 사용자 경험을 향상시킬 수 있습니다. 또한 추후 "사용자에게 메시지 보내기" 기능을 구현할 때 이 Map을 활용하면 매우 간단해집니다.
실전 팁
💡 userSockets Map은 메모리에만 저장되므로, 서버가 재시작되면 사라집니다. 프로덕션 환경에서는 Redis 같은 외부 저장소를 사용해 여러 서버 인스턴스 간에 연결 정보를 공유하세요.
💡 socket.emit('init')에서 보내는 데이터가 너무 크면 연결 속도가 느려지니, 필수 정보만 보내고 나머지는 클라이언트가 별도 요청하도록 설계하세요.
💡 사용자가 동시에 연결할 수 있는 디바이스 수를 제한하려면 userSockets.get(userId).size를 확인해서 일정 개수 이상이면 가장 오래된 연결을 강제로 끊으세요.
💡 개발 중에는 console.log로 충분하지만, 운영 환경에서는 Winston이나 Pino 같은 로거 라이브러리를 사용해 연결/해제 로그를 구조화된 형태로 남기세요. 나중에 문제 분석에 큰 도움이 됩니다.
💡 socket.on() 리스너 안에서 에러가 발생하면 연결이 끊어질 수 있으니, 각 리스너를 try-catch로 감싸고, 에러를 로깅한 뒤 클라이언트에게 적절한 에러 메시지를 보내세요.
3. 접속 종료 이벤트 관리
시작하며
여러분이 실시간 채팅 서비스를 운영할 때 이런 문제를 겪어본 적 있나요? 사용자가 앱을 종료했는데도 온라인 상태로 계속 표시되거나, 네트워크가 끊어졌다가 다시 연결될 때 중복 데이터가 쌓이는 경우 말이죠.
심지어 사용자가 나갔는데도 메모리에 계속 남아있어서 서버 메모리가 점점 부족해지는 상황도 발생합니다. 이런 문제는 실제 개발 현장에서 매우 치명적입니다.
disconnect 이벤트를 제대로 처리하지 않으면 "유령 사용자"가 쌓여서 실제로는 10명이 접속했는데 시스템은 100명이라고 생각하게 됩니다. 이는 잘못된 통계로 이어지고, 메모리 누수를 일으키며, 다른 사용자들에게 잘못된 온라인 상태 정보를 보여주게 됩니다.
바로 이럴 때 필요한 것이 체계적인 접속 종료 이벤트 관리입니다. disconnect 이벤트에서 사용자 정보를 정리하고, 리소스를 해제하며, 다른 사용자들에게 상태 변경을 알리는 로직을 구현해야 안정적인 서비스를 만들 수 있습니다.
개요
간단히 말해서, 이 개념은 WebSocket 연결이 끊어질 때 "이 사용자가 쓰던 자원을 어떻게 정리할 것인가"를 정의하는 청소 작업입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 모든 연결은 언젠가는 끊어지기 마련입니다.
사용자가 의도적으로 종료할 수도 있고, 네트워크 문제로 갑자기 끊어질 수도 있고, 서버를 재시작해야 할 수도 있습니다. 예를 들어, 온라인 게임에서는 플레이어가 나가면 즉시 게임에서 퇴장시키고 자리를 다른 사람에게 양보해야 하며, 협업 문서 편집 툴에서는 다른 사용자들에게 "누가 편집을 중단했다"고 알려줘야 합니다.
기존에는 연결이 끊어지면 그냥 끝이었다면, Socket.io에서는 disconnect 이벤트를 통해 우아하게 정리 작업을 할 수 있습니다. 특히 중요한 것은, 같은 사용자가 여러 디바이스로 접속한 경우 하나의 연결이 끊어졌다고 해서 사용자 전체를 오프라인으로 처리하면 안 된다는 점입니다.
이 이벤트 처리의 핵심 특징은 첫째, 특정 소켓 연결만 제거하고 같은 사용자의 다른 연결은 유지할 수 있다는 점입니다. 둘째, 이 사용자가 속한 모든 방(room)에서 자동으로 퇴장 처리됩니다.
셋째, 마지막 연결이 끊어진 경우에만 "완전히 오프라인"으로 표시할 수 있습니다. 이러한 특징들이 멀티 디바이스 환경에서도 정확한 상태 관리를 가능하게 합니다.
코드 예제
function handleDisconnect(socket, userId) {
console.log(`사용자 ${socket.username} 연결 해제 (소켓 ID: ${socket.id})`);
// 해당 사용자의 소켓 목록에서 현재 소켓 제거
const userSocketSet = userSockets.get(userId);
if (userSocketSet) {
userSocketSet.delete(socket.id);
// 마지막 연결이 끊어진 경우
if (userSocketSet.size === 0) {
userSockets.delete(userId);
console.log(`사용자 ${userId} 완전히 오프라인됨`);
// 다른 사용자들에게 오프라인 상태 알림
socket.broadcast.emit('user_offline', { userId, username: socket.username });
} else {
console.log(`사용자 ${userId}의 다른 디바이스 ${userSocketSet.size}개 아직 연결 중`);
}
}
}
설명
이것이 하는 일: 이 코드는 마치 호텔 체크아웃 시스템처럼, 손님이 한 방에서 나갈 때 그 방만 정리하고, 만약 그 손님이 예약한 모든 방에서 나갔다면 완전히 체크아웃 처리하며, 다른 손님들에게도 알립니다. 첫 번째로, 이 함수가 호출되는 시점을 이해해야 합니다.
connection 이벤트에서 socket.on('disconnect', () => handleDisconnect(socket, userId))처럼 등록했기 때문에, 어떤 이유로든 연결이 끊어지면 자동으로 실행됩니다. 네트워크 문제든, 사용자가 브라우저를 닫았든, 서버가 재시작되든 상관없이 작동합니다.
로그를 먼저 남기는 이유는 추후 문제 분석 시 "누가 언제 나갔는지" 추적하기 위함입니다. 두 번째 단계로, userSockets.get(userId)로 이 사용자의 모든 소켓 목록을 가져옵니다.
이것은 Set 자료구조이므로 delete(socket.id)로 현재 끊어진 소켓만 제거합니다. 예를 들어, 철수가 PC와 스마트폰으로 동시 접속했는데 PC 브라우저만 닫았다면, PC의 소켓 ID만 Set에서 사라지고 스마트폰의 소켓 ID는 여전히 남아있습니다.
이것이 멀티 디바이스 지원의 핵심입니다. 세 번째 단계로, userSocketSet.size === 0을 확인합니다.
이것은 "이 사용자의 마지막 연결마저 끊어졌는가?"를 체크하는 것입니다. 만약 그렇다면 userSockets.delete(userId)로 Map에서 사용자를 완전히 제거합니다.
메모리 관리를 위해 이 단계가 매우 중요합니다. 그렇지 않으면 빈 Set 객체들이 계속 쌓여서 메모리 누수가 발생합니다.
또한 로그에 "완전히 오프라인됨"을 남겨서 디버깅을 쉽게 합니다. 네 번째 단계로, socket.broadcast.emit('user_offline', ...)을 통해 다른 모든 연결된 클라이언트들에게 "이 사람이 오프라인 되었어요"라고 알립니다.
broadcast는 "나를 제외한 모든 사람"이라는 의미인데, 어차피 이 소켓은 이미 끊어졌으므로 본인에게 보낼 필요가 없습니다. 이 이벤트를 받은 다른 사용자들은 UI에서 해당 사용자의 아이콘을 회색으로 바꾸거나, "철수님이 나갔습니다" 메시지를 표시할 수 있습니다.
만약 아직 다른 디바이스가 연결되어 있다면 else 블록으로 가서 로그만 남기고, 다른 사용자들에게는 알리지 않습니다. 왜냐하면 여전히 온라인 상태이기 때문입니다.
여러분이 이 코드를 사용하면 메모리 누수를 방지하고, 정확한 온라인/오프라인 상태를 관리하며, 사용자들에게 실시간으로 상태 변경을 알려줄 수 있습니다. 또한 멀티 디바이스 환경에서도 혼란 없이 작동하여, 사용자가 스마트폰으로 접속 중인데 PC를 껐다고 해서 오프라인으로 표시되는 실수를 방지합니다.
실전 팁
💡 disconnect 이벤트에는 이유가 담긴 인자가 전달됩니다. socket.on('disconnect', (reason) => {...})처럼 받아서 로그에 남기면 "transport close"(네트워크 문제), "client namespace disconnect"(사용자가 직접 끊음) 등을 구분할 수 있어 디버깅에 도움됩니다.
💡 네트워크가 불안정한 환경에서는 잠깐 끊어졌다가 바로 재연결되는 경우가 많습니다. 즉시 오프라인 처리하지 말고, setTimeout으로 5-10초 정도 유예 기간을 준 뒤에도 재연결이 없으면 그때 오프라인 처리하는 것이 좋습니다.
💡 사용자가 나갈 때 저장하지 않은 데이터가 있다면, disconnect 이벤트에서 자동 저장하거나 최소한 로그를 남겨서 복구할 수 있게 하세요. 특히 협업 도구에서는 마지막 편집 내용을 잃어버리면 큰 문제가 됩니다.
💡 프로덕션 환경에서는 disconnect 로그를 별도 파일에 저장하거나 모니터링 시스템으로 전송하여, 비정상적으로 많은 연결 해제가 발생하는지 감시하세요. 서버 문제의 조기 신호일 수 있습니다.
💡 사용자가 속한 채팅방이나 게임 방이 있다면, disconnect 이벤트에서 명시적으로 socket.leave(roomId)를 호출하여 정리하고, 해당 방의 다른 사용자들에게도 알리세요.
4. 온라인 상태 저장 Redis
시작하며
여러분이 실시간 서비스를 확장할 때 이런 벽에 부딪힌 적 있나요? 서버를 여러 대로 늘렸더니 A 서버에 접속한 사용자와 B 서버에 접속한 사용자가 서로를 "오프라인"으로 보는 문제가 발생했습니다.
또는 서버를 재시작할 때마다 모든 온라인 상태 정보가 날아가서 사용자들이 다시 로그인해야 하는 불편함을 겪었을 수도 있습니다. 이런 문제는 실제 개발 현장에서 서비스가 성장하면 필연적으로 마주치게 됩니다.
앞서 우리가 사용한 userSockets Map은 단일 서버의 메모리에만 저장되기 때문에, 여러 서버로 확장하면 각 서버가 자기에게 연결된 사용자만 알고 있습니다. 이는 틀린 통계, 누락된 메시지 전송, 혼란스러운 온라인 상태 표시로 이어집니다.
바로 이럴 때 필요한 것이 Redis를 활용한 중앙 집중식 온라인 상태 관리입니다. Redis는 모든 서버가 공유하는 빠른 메모리 저장소로, 여기에 온라인 상태를 저장하면 어느 서버에 접속하든 일관된 정보를 볼 수 있습니다.
개요
간단히 말해서, 이 개념은 "누가 지금 온라인인가"라는 정보를 모든 서버가 함께 볼 수 있는 공용 게시판(Redis)에 붙여두는 것입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 현대의 모든 대규모 서비스는 로드 밸런서 뒤에 여러 서버를 두고 운영합니다.
예를 들어, 슬랙 같은 서비스는 수백만 명이 동시 접속하므로 서버 한 대로는 불가능합니다. 철수가 서버 1번에, 영희가 서버 2번에 접속했을 때, 서버 1번이 영희의 온라인 상태를 알려면 서버 2번과 통신하거나 공통 저장소를 봐야 합니다.
기존 메모리 기반 Map에서는 각 서버가 독립적으로 상태를 관리했다면, Redis를 사용하면 모든 서버가 같은 "진실의 원천(Single Source of Truth)"을 공유합니다. 또한 Redis는 디스크에도 저장할 수 있어서 서버 재시작 후에도 상태가 유지됩니다.
Redis 사용의 핵심 특징은 첫째, 마이크로초 단위의 빠른 속도로 온라인 상태를 조회할 수 있다는 점입니다. 둘째, SET 자료구조를 제공하여 한 사용자의 여러 소켓 ID를 효율적으로 저장할 수 있습니다.
셋째, TTL(Time To Live)을 설정하여 일정 시간 후 자동으로 데이터를 삭제할 수 있어 메모리 누수를 방지합니다. 이러한 특징들이 확장 가능한 실시간 서비스의 기반이 됩니다.
코드 예제
const redis = require('redis');
const client = redis.createClient({ url: 'redis://localhost:6379' });
await client.connect();
// 사용자 온라인 상태 저장 (소켓 ID를 Set에 추가)
async function setUserOnline(userId, socketId) {
// Redis의 Set 자료구조에 소켓 ID 추가
await client.sAdd(`online:${userId}`, socketId);
// 24시간 후 자동 삭제 (만약을 위한 안전장치)
await client.expire(`online:${userId}`, 86400);
console.log(`사용자 ${userId} 온라인 상태로 저장됨`);
}
// 사용자의 모든 연결 가져오기
async function getUserSockets(userId) {
return await client.sMembers(`online:${userId}`);
}
// 특정 사용자가 온라인인지 확인
async function isUserOnline(userId) {
const socketCount = await client.sCard(`online:${userId}`);
return socketCount > 0;
}
설명
이것이 하는 일: 이 코드는 마치 회사의 중앙 출입 관리 시스템처럼, 어느 입구로 들어왔든 모든 직원의 출입 기록을 한곳에 모아서 누가 지금 사무실에 있는지 정확히 파악할 수 있게 해줍니다. 첫 번째로, Redis 클라이언트를 생성하고 연결하는 부분을 살펴보겠습니다.
redis.createClient()는 Redis 서버와의 연결 객체를 만드는데, 여기서는 로컬 개발 환경의 주소를 사용했지만 운영 환경에서는 Redis 클러스터의 주소를 넣으면 됩니다. await client.connect()는 비동기로 실제 연결을 수립합니다.
이 연결은 서버 시작 시 한 번만 하면 되고, 이후 계속 재사용됩니다. 중요한 점은 Redis 작업은 모두 비동기이므로 async/await를 사용해야 한다는 것입니다.
두 번째 단계로, setUserOnline() 함수에서 client.sAdd()를 사용합니다. 's'는 Set을 의미하며, online:${userId}라는 키에 소켓 ID를 추가합니다.
예를 들어 사용자 ID가 '123'이고 소켓 ID가 'abc'라면, Redis에는 online:123 = {'abc'}처럼 저장됩니다. 만약 같은 사용자가 스마트폰으로도 접속하면 online:123 = {'abc', 'def'}가 됩니다.
Set이라서 중복은 자동으로 제거됩니다. client.expire()는 이 키를 24시간 뒤 자동 삭제하는데, 이는 혹시 disconnect 이벤트에서 정리를 못 했을 경우를 대비한 안전장치입니다.
네트워크 문제로 disconnect가 안 불릴 수도 있거든요. 세 번째 단계로, getUserSockets() 함수는 client.sMembers()로 특정 사용자의 모든 소켓 ID를 배열로 가져옵니다.
이것은 "이 사용자의 모든 디바이스에 알림 보내기" 같은 기능을 구현할 때 필요합니다. 반환된 배열의 각 소켓 ID를 사용해 io.to(socketId).emit()으로 메시지를 보낼 수 있습니다.
네 번째 단계로, isUserOnline() 함수는 client.sCard()로 Set의 크기(원소 개수)를 반환합니다. 만약 0보다 크면 최소 하나의 소켓이 연결되어 있다는 뜻이므로 온라인입니다.
이 함수는 매우 빠르므로(O(1) 복잡도), 친구 목록의 온라인 상태를 표시하거나, 메시지를 보내기 전에 상대방이 접속 중인지 확인하는 용도로 활용할 수 있습니다. 여러분이 이 코드를 사용하면 서버를 몇 대로 늘리든 항상 일관된 온라인 상태 정보를 얻을 수 있고, 서버 재시작 후에도 데이터가 유지되며, 수백만 명의 사용자 상태를 빠르게 관리할 수 있습니다.
또한 Redis Pub/Sub 기능과 결합하면 서버 간 실시간 메시지 전송도 가능합니다.
실전 팁
💡 Redis 연결은 서버 시작 시 한 번만 하고 재사용하세요. 매번 새로 연결하면 성능이 크게 떨어지고, Redis 서버에 부담을 줍니다. 연결 실패 시 재시도 로직도 구현하세요.
💡 운영 환경에서는 Redis를 클러스터 모드로 구성하여 고가용성을 확보하고, AWS ElastiCache나 Redis Cloud 같은 관리형 서비스를 사용하면 백업/복구가 자동으로 됩니다.
💡 expire를 너무 짧게 설정하면 정상 연결 중인데 데이터가 사라질 수 있고, 너무 길게 설정하면 메모리 낭비입니다. 24시간이 적당하며, 중요한 서비스라면 setUserOnline을 주기적으로 호출해서 TTL을 갱신하세요.
💡 Redis 명령어의 성능을 이해하세요. sCard, sIsMember는 O(1)로 매우 빠르지만, sMembers는 O(N)이라서 Set이 크면 느려집니다. 큰 Set은 sScan으로 페이징해서 읽으세요.
💡 개발 환경에서는 redis-commander 같은 GUI 도구를 사용하면 Redis 내용을 시각적으로 확인할 수 있어 디버깅이 훨씬 쉽습니다.
5. 오프라인 상태 업데이트
시작하며
여러분이 채팅 앱을 만들 때 이런 문제를 겪어본 적 있나요? 사용자가 앱을 종료했는데 한참 뒤까지 "온라인" 상태로 보이거나, 반대로 잠깐 네트워크가 끊어졌다가 바로 복구됐는데 "오프라인"으로 바뀌어서 친구들이 걱정하는 경우 말이죠.
특히 모바일 환경에서는 지하철이나 엘리베이터에서 신호가 끊어졌다가 연결되는 일이 흔해서 이런 문제가 더 자주 발생합니다. 이런 문제는 실제 개발 현장에서 사용자 경험을 크게 해치는 요소입니다.
온라인 상태가 불안정하게 표시되면 사용자들이 서비스를 신뢰하지 못하게 되고, "왜 내 친구가 온라인인데 메시지 응답이 없지?"라는 혼란을 겪게 됩니다. 또한 잘못된 오프라인 처리는 알림을 놓치게 만들거나, 불필요한 재연결 요청으로 서버 부하를 증가시킬 수 있습니다.
바로 이럴 때 필요한 것이 지능적인 오프라인 상태 업데이트 로직입니다. 단순히 연결이 끊어졌다고 즉시 오프라인 처리하지 않고, 재연결 가능성을 고려하며, 마지막 디바이스가 정말로 완전히 종료됐을 때만 오프라인으로 표시해야 합니다.
개요
간단히 말해서, 이 개념은 사용자가 연결을 끊었을 때 Redis에서 그 소켓 정보를 제거하고, 더 이상 연결된 디바이스가 없으면 완전히 오프라인으로 처리하는 정리 작업입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 온라인 상태 관리는 "추가"만큼이나 "제거"도 중요합니다.
예를 들어, 카카오톡에서 친구가 나갔을 때 프로필 옆 초록 점이 사라지는 것을 보셨을 겁니다. 이것이 바로 오프라인 상태 업데이트의 결과입니다.
만약 이것이 제대로 작동하지 않으면 실제로는 아무도 없는데 "100명 온라인"이라고 표시되는 우스운 상황이 벌어집니다. 기존 메모리 기반 관리에서는 Map에서 삭제하면 끝이었다면, Redis 기반에서는 네트워크 호출이 필요하므로 비동기 처리와 에러 핸들링이 필수적입니다.
또한 여러 서버가 동시에 같은 사용자의 연결을 제거하려 할 때 경쟁 조건(Race Condition)을 고려해야 합니다. 오프라인 업데이트의 핵심 특징은 첫째, 특정 소켓만 제거하고 다른 디바이스는 유지하는 정교한 제어가 가능하다는 점입니다.
둘째, Redis의 원자적 연산(Atomic Operation)을 사용하여 데이터 일관성을 보장합니다. 셋째, 마지막 연결 제거 여부를 확인하여 정확한 시점에만 오프라인 이벤트를 발생시킵니다.
이러한 특징들이 안정적이고 정확한 상태 관리를 가능하게 합니다.
코드 예제
// 사용자 오프라인 상태 업데이트 (소켓 ID 제거)
async function setUserOffline(userId, socketId) {
// Redis Set에서 해당 소켓 ID 제거
const removed = await client.sRem(`online:${userId}`, socketId);
if (removed === 0) {
console.warn(`소켓 ${socketId}가 이미 제거되었거나 존재하지 않습니다`);
return false;
}
// 남은 소켓 개수 확인
const remainingSockets = await client.sCard(`online:${userId}`);
if (remainingSockets === 0) {
// 마지막 연결이 끊어짐 - 완전히 오프라인
await client.del(`online:${userId}`);
console.log(`사용자 ${userId} 완전히 오프라인됨`);
return true; // 완전 오프라인 상태를 알림
}
console.log(`사용자 ${userId}의 ${remainingSockets}개 디바이스 아직 연결 중`);
return false; // 아직 다른 연결이 남아있음
}
설명
이것이 하는 일: 이 코드는 마치 주차장 출차 시스템처럼, 한 대의 차가 나갈 때 그 차량만 기록에서 삭제하고, 만약 그 사람의 모든 차량이 나갔다면 "이 사람은 더 이상 주차장에 없음"으로 상태를 바꿉니다. 첫 번째로, client.sRem() 함수의 동작을 이해해야 합니다.
이것은 Redis의 Set에서 특정 값을 제거하는 명령인데, 반환 값이 중요합니다. 성공적으로 제거했으면 1을, 제거할 원소가 없었으면 0을 반환합니다.
왜 0이 반환될 수 있냐면, 네트워크 문제로 같은 disconnect 이벤트가 여러 번 발생하거나, 다른 서버가 이미 제거했을 수 있기 때문입니다. 이런 경우 경고 로그를 남기고 false를 반환하여 "이미 처리됨"을 알립니다.
이렇게 함으로써 중복 처리로 인한 버그를 방지합니다. 두 번째 단계로, 제거가 성공했다면 client.sCard()로 남은 소켓 개수를 확인합니다.
이 두 작업(제거와 개수 확인)은 별도의 명령이지만, Redis는 싱글 스레드로 작동하므로 그 사이에 다른 명령이 끼어들 걱정이 적습니다. 더 엄격한 원자성이 필요하다면 Redis의 Lua 스크립트나 Transaction을 사용할 수 있지만, 대부분의 경우 이 정도면 충분합니다.
remainingSockets가 0이라는 것은 방금 제거한 소켓이 마지막 연결이었다는 의미입니다. 세 번째 단계로, 마지막 연결이 끊어진 경우 client.del()로 키 자체를 삭제합니다.
빈 Set을 남겨두면 메모리 낭비이므로 완전히 제거하는 것이 좋습니다. 그리고 true를 반환하여 호출자에게 "이 사용자는 이제 완전히 오프라인이니 다른 사용자들에게 알리세요"라는 신호를 보냅니다.
이 반환 값을 받은 disconnect 이벤트 핸들러는 socket.broadcast.emit('user_offline')을 실행할지 말지 결정할 수 있습니다. 네 번째 단계로, 아직 다른 연결이 남아있다면 로그만 남기고 false를 반환합니다.
이 경우 다른 사용자들에게는 아무 알림도 보내지 않습니다. 왜냐하면 이 사람은 여전히 온라인이기 때문입니다.
예를 들어 철수가 PC를 껐지만 스마트폰으로 여전히 접속 중이라면, 친구들은 계속 철수를 "온라인"으로 봐야 합니다. 여러분이 이 코드를 사용하면 정확한 오프라인 상태 관리가 가능하고, 멀티 디바이스 환경에서도 혼란 없이 작동하며, Redis의 원자적 연산 덕분에 여러 서버에서 동시에 실행되어도 데이터 일관성이 유지됩니다.
또한 반환 값을 활용하여 필요할 때만 브로드캐스트를 하므로 불필요한 네트워크 트래픽을 줄일 수 있습니다.
실전 팁
💡 sRem과 sCard를 하나의 Lua 스크립트로 묶으면 완전한 원자성을 보장할 수 있습니다. const script = 'local removed = redis.call("srem", KEYS[1], ARGV[1]); return {removed, redis.call("scard", KEYS[1])}' 형태로 작성하세요.
💡 disconnect 이벤트에서 이 함수를 호출할 때 try-catch로 감싸서 Redis 연결 실패 시에도 서버가 죽지 않도록 하세요. 로그를 남기고 다음 연결 시 자동으로 정리되도록 TTL에 의존하는 것도 방법입니다.
💡 오프라인 처리 전에 짧은 지연(예: 5초)을 주면 네트워크 순간 단절로 인한 불필요한 오프라인 표시를 막을 수 있습니다. setTimeout과 clearTimeout을 조합하여 재연결되면 취소하는 로직을 구현하세요.
💡 마지막 온라인 시간을 기록하려면 await client.set(lastSeen:${userId}, Date.now())을 추가하세요. 이를 활용해 "5분 전 접속" 같은 정보를 표시할 수 있습니다.
💡 대규모 서비스에서는 오프라인 이벤트를 즉시 브로드캐스트하지 말고 메시지 큐(RabbitMQ, Kafka)에 넣어서 비동기로 처리하면 서버 부하를 분산할 수 있습니다.
6. 상태 변경 브로드캐스트
시작하며
여러분이 실시간 협업 툴을 만들 때 이런 요구사항을 받아본 적 있나요? "누가 들어오거나 나가면 모든 사용자에게 알려줘야 해요", "하지만 본인에게는 알림을 보내지 말아주세요", "특정 그룹이나 방에 속한 사람들에게만 알려주세요".
이런 세밀한 메시지 전송 제어를 구현하려다가 복잡한 for 루프와 조건문에 빠져본 경험이 있을 겁니다. 이런 문제는 실제 개발 현장에서 실시간 기능의 핵심입니다.
상태 변경을 제대로 브로드캐스트하지 않으면 사용자들은 최신 정보를 볼 수 없고, 잘못 브로드캐스트하면 필요 없는 사람에게까지 메시지가 가서 프라이버시 문제나 성능 저하가 발생합니다. 특히 대규모 서비스에서는 수천 명에게 동시에 메시지를 보내야 하므로 효율적인 방법이 필수적입니다.
바로 이럴 때 필요한 것이 Socket.io의 강력한 브로드캐스팅 기능입니다. Room 개념과 다양한 emit 메서드를 활용하면 "누구에게, 무엇을, 어떻게" 보낼지 정교하게 제어할 수 있습니다.
개요
간단히 말해서, 이 개념은 한 사용자의 상태가 변경됐을 때 그 정보를 "관심 있는" 다른 사용자들에게만 선택적으로 알리는 메시지 전송 시스템입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 모든 실시간 서비스는 "변화를 알리는" 것이 핵심입니다.
예를 들어, 슬랙에서는 채널에 누가 입장했는지 알려주고, 디스코드에서는 음성 채널에 누가 들어왔는지 표시하며, 온라인 게임에서는 플레이어가 준비 완료했는지 모두에게 보여줍니다. 이 모든 것이 상태 변경 브로드캐스트로 이루어집니다.
기존 HTTP 방식에서는 클라이언트가 계속 폴링(주기적 요청)해야 했다면, Socket.io에서는 서버가 변경 즉시 능동적으로 알려줄 수 있습니다. 또한 "모든 사람"이 아닌 "특정 방의 사람들"이나 "나를 제외한 사람들"처럼 대상을 세밀하게 지정할 수 있습니다.
브로드캐스팅의 핵심 특징은 첫째, Room을 사용하여 논리적으로 사용자를 그룹화할 수 있다는 점입니다. 둘째, broadcast, to(), except() 같은 메서드 체이닝으로 대상을 유연하게 선택할 수 있습니다.
셋째, Redis Adapter를 사용하면 여러 서버에 분산된 사용자들에게도 브로드캐스트할 수 있습니다. 이러한 특징들이 효율적이고 정확한 실시간 통신을 가능하게 합니다.
코드 예제
// 사용자 온라인 상태 변경을 다른 사용자들에게 알림
async function broadcastUserStatus(io, userId, username, status) {
// status: 'online' 또는 'offline'
if (status === 'online') {
// 본인을 제외한 모든 연결된 클라이언트에게 알림
io.emit('user_online', {
userId,
username,
timestamp: new Date().toISOString()
});
} else {
// 오프라인 상태 브로드캐스트
io.emit('user_offline', {
userId,
username,
timestamp: new Date().toISOString()
});
}
}
// 특정 방(채팅방, 게임방 등)의 사용자들에게만 알림
function broadcastToRoom(io, roomId, eventName, data) {
io.to(roomId).emit(eventName, {
...data,
roomId,
timestamp: new Date().toISOString()
});
}
설명
이것이 하는 일: 이 코드는 마치 학교 방송실처럼, 중요한 소식을 "전교생에게", "특정 학년에게", 또는 "특정 반에게"만 선택적으로 방송할 수 있는 시스템입니다. 첫 번째로, broadcastUserStatus() 함수의 구조를 살펴보겠습니다.
이 함수는 io 객체(전체 Socket.io 인스턴스)를 받아서 모든 연결된 클라이언트에게 메시지를 보냅니다. io.emit()은 서버에 연결된 모든 소켓에게 이벤트를 발송하는데, 이것은 "전체 공지"와 같습니다.
온라인 상태일 때는 'user_online' 이벤트를, 오프라인 상태일 때는 'user_offline' 이벤트를 보냅니다. 클라이언트는 socket.on('user_online', (data) => {...})처럼 리스너를 등록해서 이 이벤트를 받을 수 있습니다.
두 번째 단계로, 데이터 구조를 보면 userId, username, timestamp를 함께 보냅니다. userId는 프로필 사진이나 추가 정보를 가져올 때 필요하고, username은 "철수님이 입장했습니다" 같은 메시지를 표시할 때 쓰이며, timestamp는 이벤트 발생 시간을 기록하거나 순서를 보장하는 데 활용됩니다.
ISO 형식 문자열을 사용하는 이유는 모든 프로그래밍 언어와 데이터베이스에서 표준으로 인식되기 때문입니다. 이렇게 구조화된 데이터를 보내면 클라이언트가 일관된 방식으로 처리할 수 있습니다.
세 번째 단계로, broadcastToRoom() 함수를 살펴보겠습니다. io.to(roomId)는 특정 Room에 속한 소켓들에게만 메시지를 보냅니다.
Room은 Socket.io의 핵심 개념으로, 마치 카카오톡의 채팅방처럼 논리적인 그룹입니다. 사용자는 socket.join('room-123')으로 방에 입장하고, socket.leave('room-123')으로 퇴장합니다.
한 소켓이 여러 방에 동시에 속할 수도 있습니다. 예를 들어 회사 전체 방, 팀 방, 프로젝트 방에 동시에 속해서 각각의 알림을 받을 수 있죠.
네 번째 단계로, ...data를 사용해 전달받은 데이터를 펼치고, roomId와 timestamp를 추가합니다. 이렇게 하면 클라이언트가 "이 메시지가 어느 방에서 온 건지" 알 수 있어서, 여러 방에 동시 참여 중일 때도 올바른 UI에 표시할 수 있습니다.
이 함수는 범용적이어서 채팅 메시지, 파일 업로드 알림, 게임 상태 변경 등 다양한 이벤트에 재사용할 수 있습니다. 여러분이 이 코드를 사용하면 상태 변경을 실시간으로 모든 관련 사용자에게 전달할 수 있고, Room 기능으로 메시지를 효율적으로 필터링하여 불필요한 트래픽을 줄일 수 있으며, 일관된 데이터 형식으로 클라이언트 구현을 단순화할 수 있습니다.
또한 나중에 Redis Adapter를 추가하면 여러 서버로 확장해도 같은 코드가 작동합니다. 추가 고급 기능을 살펴보겠습니다.
socket.broadcast.emit()을 사용하면 메시지를 보낸 본인을 제외한 모든 사람에게만 전달됩니다. 예를 들어 채팅 메시지를 보낼 때, 본인은 이미 UI에 표시했으므로 다시 받을 필요가 없을 때 유용합니다.
io.to('room1').to('room2').emit()처럼 체이닝하면 여러 방에 동시에 보낼 수 있고, io.except('socketId').emit()을 사용하면 특정 소켓만 제외할 수 있습니다. 또한 io.fetchSockets()를 사용하면 현재 연결된 모든 소켓의 목록을 가져올 수 있어서 "현재 온라인 사용자 목록" 같은 기능을 구현할 때 유용합니다.
단, 이것은 비동기 작업이며 여러 서버가 있을 때는 Redis Adapter가 필요합니다. io.in('roomId').fetchSockets()처럼 특정 방의 소켓만 가져올 수도 있습니다.
성능 측면에서, 수천 명에게 브로드캐스트할 때는 메시지 크기를 최소화하세요. 큰 데이터는 클라이언트가 별도로 요청하도록 하고, 브로드캐스트에는 "변경되었다"는 신호만 보내는 것이 좋습니다.
또한 브로드캐스트 빈도가 높으면 throttling(예: 100ms마다 최대 1회)을 적용하여 서버 부하를 줄이세요.
실전 팁
💡 프로덕션 환경에서는 Socket.io Redis Adapter(@socket.io/redis-adapter)를 설정하세요. io.adapter(createAdapter(pubClient, subClient))로 설정하면 여러 서버 인스턴스가 Redis Pub/Sub을 통해 브로드캐스트를 공유합니다.
💡 Room 이름을 체계적으로 지으세요. 예를 들어 chat:${chatId}, game:${gameId}, user:${userId} 형식으로 네임스페이스를 두면 나중에 관리하기 쉽고, 와일드카드 검색도 가능합니다.
💡 브로드캐스트 전에 인증/권한 검사를 하세요. 특히 민감한 정보는 모든 방 멤버가 볼 권한이 있는지 확인하고, 필요하면 각 사용자의 권한에 따라 다른 데이터를 보내세요.
💡 클라이언트에서는 이벤트 리스너를 등록할 때 중복 등록을 방지하세요. socket.off('user_online').on('user_online', handler) 패턴을 사용하거나, 컴포넌트 언마운트 시 socket.off('user_online')로 정리하세요.
💡 대규모 브로드캐스트는 모니터링하세요. io.emit() 호출 횟수, 전송된 바이트 수, 실패한 전송 등을 로깅하여 병목 지점을 찾고, 필요하면 메시지 큐로 비동기 처리하거나 배치 처리로 최적화하세요.