기존에 오프라인 유저를 찾기 위하여 socket.io 에서 isOnline 과 isInRoom 을 관리하는 로직을 Map 과 Set 을 활용하여 구현하였다. 이는 메모리에 저장해서 관리되어지는데 이 때 서버가 재시작되면 모든 데이터가 사라진다는 것과 현재는 단일 서버 이지만 만약 멀티 서버 환경에서 사용되어진다면 데이터가 동기화되지 않는 오류가 발생할 것이다. 따라서 서버가 여러개여도 상태가 공유 가능하고 서버가 재시작 되어도 데이터를 유지할 수 있는 Redis 를 활용하여 조금 더 안정적인 서비스 운영이 될 수 있게끔 변경하고자 한다.
1. ioredis vs redis
Node.js 에서 Redis 와 연결하기 위해 사용하는 두 가지 주요 라이브러리는 ioredis 와 redis 가 있다. 둘 다 Redis 클라이언트이지만 기능과 사용성에 차이가 있다. 쉽게 보면 redis 의 업그레이드 버전이 ioredis 라 생각하면 된다. 주요 차이점은 다음과 같다.

필자의 프로젝트는 MSA 환경에서 진행되었으며, 대규모 서비스를 가정하에 개발중이기 때문에 ioredis 가 적합하다고 판단하였다.
2. Redis 에서는 Set 만 사용한 이유
기존 필자가 구현한 코드에서는 Map 과 Set 을 함께 사용했지만, Redis 에서는 Set 만 사용할 예정이다. Redis 에서는 Set 만 사용해도 같은 기능을 구현할 수 있기 때문이다. 기존 코드에서 Map 은 유저 ID를 찾기 위해, Set 은 중복 없는 소켓 ID 저장을 위해 사용되었다. 즉, Map 은 "해당 유저가 방에 있는가 ?" 를 찾기 위해 필요했고, Set 은 한 유저가 여러 개의 소켓을 가질 수 있기 때문에 사용하였다.
Redis 의 Set 은 다양한 메소드를 통해 이미 Map 과 같은 역할을 포함하고 있다. 따라서 Redis 의 Set 메소드를 알아야 할 필요가 있다. 해당 메소드들을 정리한 글은 따로 작성하도록 하겠다.
3. 기존 코드 및 변경된 코드의 차이점 비교
3.1 UserID 와 Socket 간 맵핑 관리
// 기존 Map 을 사용한 방식
const userSocketMap = new Map(); // userId를 key로 하고, Set(socketId)을 value로 저장
// 특정 유저(userId)의 소켓을 등록
if (!userSocketMap.has(userId)) {
userSocketMap.set(userId, new Set()); // 유저 ID가 없으면 새 Set을 생성
}
userSocketMap.get(userId).add(socket.id); // Set에 socket.id 추가
-------------------------------------------------------------------------------------
// Redis Set 을 사용한 방식
const socketId = socket.id;
redis.sadd(`user:${userId}:sockets`, socketId);
기존 코드에서는 Map 객체를 생성 후 userId 가 Map 에 존재하지 않을 경우 Set 을 새로 생성해서 추가한다.
Redis 를 이용한 코드는 sadd 명령어를 사용해 userId 를 키로, socket.id 를 값으로 추가한다. 한줄이면 끝난다. (;;)

3.2 방 입장
// 기존 Map 을 사용한 방식
if (!roomUserMap.has(roomId)) {
roomUserMap.set(roomId, new Map()); // 방이 없으면 새로운 Map 생성
}
const roomUsers = roomUserMap.get(roomId); // 해당 방의 유저 목록 가져오기
if (!roomUsers.has(userId)) {
roomUsers.set(userId, new Set()); // 유저 ID가 없으면 새로운 Set 생성
}
roomUsers.get(userId).add(socket.id); // Set에 socket.id 추가
--------------------------------------------------------------------------------------
// Redis 에서 사용한 방식
await redis.sadd(`room:${roomId}:users`, userId); // 방에 속한 유저 관리
await redis.sadd(`user:${userId}:rooms`, roomId); // 유저가 속한 방 관리
await redis.sadd(`socket:${socketId}:rooms`, roomId); // 소켓이 속한 방 관리
기존 코드에서는 roomId 가 Map 에 존재하는지 확인한다. 존재하지 않으면 roomId 를 추가한다. 만약 roomId 가 있다면 해당 방의 유저 목록을 가져오고 없다면 Set 을 추가하고 해당 userId 의 socket.id 를 추가한다.
Redis 에서는 방에 속한 유저, 유저가 속한 방 그리고 방 나가기 기능에서 필요한 소켓이 속한 방 관리 Set 을 추가하였다.

3.3 방 나가기
// 기존 Map 을 사용한 방식
if (roomUserMap.has(roomId)) {
const roomUsers = roomUserMap.get(roomId);
if (!(roomUsers instanceof Map)) {
console.error(`roomUsers for ${roomId} is not a Map!`);
return;
}
// userId 가 아닌 socket.id 를 제거해야 함.
if (roomUsers.has(userId)) {
const userSockets = roomUsers.get(userId);
// 특정 socket.id만 제거
userSockets.delete(socket.id);
console.log(`💡Removed socket ${socket.id} from user ${userId} in room ${roomId}`);
// 모든 socket.id 가 삭제되면 userId 삭제
if (userSockets.size === 0) {
roomUsers.delete(userId);
console.log(`💡User ${userId} completely removed from room ${roomId}`);
}
// 방에 아무도 없으면 room 자체 삭제
if (roomUsers.size === 0) {
roomUserMap.delete(roomId);
console.log(`💡Room ${roomId} deleted as no users are left.`);
}
}
}
-------------------------------------------------------------------------------------------
// Redis 방식
await redis.srem(`socket:${socketId}:rooms`, roomId);
const remainingSockets = await redis.smembers(`user:${userId}:sockets`);
let isUserStillInRoom = false;
for (const otherSocketId of remainingSockets) {
if (otherSocketId !== socketId) {
const roomsForOtherSocket = await redis.smembers(`socket:${otherSocketId}:rooms`);
if (roomsForOtherSocket.includes(roomId)) {
isUserStillInRoom = true;
break;
}
}
}
if (!isUserStillInRoom) {
await redis.srem(`user:${userId}:rooms`, roomId);
await redis.srem(`room:${roomId}:users`, userId);
}
const remainingUsers = await redis.smembers(`room:${roomId}:users`);
if (remainingUsers.length === 0) {
await redis.del(`room:${roomId}:users`);
console.log(`💡 Room ${roomId} completely removed`);
}
const remainingRoomsForSocket = await redis.smembers(`socket:${socketId}:rooms`);
if (remainingRoomsForSocket.length === 0) {
await redis.del(`socket:${socketId}:rooms`);
console.log(`💡 socket:${socketId}:rooms deleted`);
}
기존 코드와 로직은 동일하다. Redis 에서 먼저 serm 을 통해 해당 socket.id 가 속한 방 목록에서 roomId 를 삭제한다. 그 다음 같은 userId 를 가진 다른 socket.id 가 아직 해당 방에 남아 있는지 확인한다. 같은 유자의 다른 socket.id 가 해당 방에 남아 있지 않다면 유저의 방 목록에서 해당 방을 제거하고, 방의 유저 목록 에서도 해당 userId 를 삭제한다. 만약 방에 아무도 남아있지 않다면 키를 삭제하여 메모리를 확보한다. 해당 소켓 또한 어떤 방에도 속해 있지 않다면, 해당 키도 삭제한다.

3.4 메시지 보내기
// 기존 Map 을 사용한 방식
const offlineUsers = usersInRoom.filter(userId => {
const isOnline = userSocketMap.has(userId);
const isInRoom = roomUserMap.has(roomId) && roomUserMap.get(roomId).has(userId);
console.log(`💡 Checking user ${userId}: isOnline=${isOnline}, isInRoom=${isInRoom}`);
return !isOnline || !isInRoom; // 방에 없거나 완전히 오프라인이면 offlineUsers 로 간주
});
------------------------------------------------------------------------------------------
// Redis 방식
const offlineUsers = await Promise.all(usersInRoom.map(async (userId) => {
const isOnline = await redis.exists(`user:${userId}:sockets`);
const isInRoom = await redis.sismember(`room:${roomId}:users`, userId);
console.log(`💡 Checking user ${userId}: isOnline=${isOnline}, isInRoom=${isInRoom}`);
return (!isOnline || !isInRoom) ? userId : null;
})).then(results => results.filter(user => user !== null));
}
기존 코드의 로직과 동일하지만 비동기 방식을 활용하였다. 먼저 해당 방에 속한 유저 목록안에 값을 가지고 와 해당 userId가 있다면 isOnline 에 저장, 그리고 해당 userId 가 방에 있다면 isInRoom 에 저장한 뒤 둘 중 하나라도 false 인 유저들을 반환한다.

3.5 연결 해제
// 기존 Map 을 사용한 방식
for (const [roomId, roomUsers] of roomUserMap.entries()) {
if (roomUsers.has(userId)) {
const userSockets = roomUsers.get(userId);
userSockets.delete(socket.id);
if (userSockets.size === 0) {
roomUsers.delete(userId);
console.log(`User ${userId} fully removed from room ${roomId}`);
}
if (roomUsers.size === 0) {
roomUserMap.delete(roomId);
console.log(`Room ${roomId} deleted as no users are left.`);
}
}
}
if (userSocketMap.has(userId)) {
const userSockets = userSocketMap.get(userId);
userSockets.delete(socket.id);
console.log(`💡 Removed socket ${socket.id} from userSocketMap for user ${userId}`);
if (userSockets.size === 0) {
userSocketMap.delete(userId);
console.log(`💡 User ${userId} completely removed from userSocketMap`);
}
}
----------------------------------------------------------------------------------------
// Redis 방식
await redis.srem(`user:${userId}:sockets`, socket.id);
const remainingSockets = await redis.smembers(`user:${userId}:sockets`);
if (remainingSockets.length === 0) {
await redis.del(`user:${userId}:sockets`);
console.log(`💡 user:${userId}:sockets deleted`);
const rooms = await redis.smembers(`user:${userId}:rooms`);
for (const roomId of rooms) {
await redis.srem(`room:${roomId}:users`, userId);
console.log(`💡 user ${userId} removed from room:${roomId}:users`);
const roomSize = await redis.scard(`room:${roomId}:users`);
if (roomSize === 0) {
await redis.del(`room:${roomId}:users`);
}
}
await redis.del(`user:${userId}:rooms`);
}
기존 코드에서는 모든 방을 순회하면 userId 가 속한 방을 찾고, 각 방에서 socket.id 를 제거하였다. 만약 해당 유저의 모든 소켓이 사라지면 userId 도 삭제했으며, 모든 유저가 없어진 방이면 방 자체를 삭제하는 로직이었다.
Redis 에서는 srem 을 사용하여 특정 socket.id 를 제거하였고 smembers 로 유저가 가진 남은 socket.id 를 조회한다. 만약 해당 유저가 가진 socket.id 가 없다면 Key 자체를 삭제한다. 다음에 방에서 나가지 않고 연결이 끊긴 유저가 있을 수 있다. 예를 들어 대화방에 있는 도중에 앱을 껐을 경우에도 방에서 나갔다고 처리해야 하기 때문에 유저가 속한 방 리스트를 가지고 와서 해당 방에서 유저를 제거하는 작업을 수행한다. scard 명령어를 통해 방에 유저의 수를 찾는 다음에 유저가 없을 경우 방을 삭제한다.

4. 결과
이로써 userID 가 가지는 각 socket.id 의 값을 가지고 어떤 기기에서 연결되든 상관 없이 알림을 전송할 수 있는 로직을 구현하였다.
'Project > ST00CK' 카테고리의 다른 글
EXPO 에서 Push Token 발급 및 Redis 에서 관리하기 (0) | 2025.03.05 |
---|---|
gRPC 와 REST API 서버 동시에 사용하기 (0) | 2025.03.04 |
KafkaConsumer 중복 실행 오류 수정 (0) | 2025.02.26 |
socket.io 방 나가기 기능 및 연결 해제 처리 (0) | 2025.02.25 |
gRPC로 오프라인 유저를 찾기 위한 로직 구현 (0) | 2025.02.24 |
기존에 오프라인 유저를 찾기 위하여 socket.io 에서 isOnline 과 isInRoom 을 관리하는 로직을 Map 과 Set 을 활용하여 구현하였다. 이는 메모리에 저장해서 관리되어지는데 이 때 서버가 재시작되면 모든 데이터가 사라진다는 것과 현재는 단일 서버 이지만 만약 멀티 서버 환경에서 사용되어진다면 데이터가 동기화되지 않는 오류가 발생할 것이다. 따라서 서버가 여러개여도 상태가 공유 가능하고 서버가 재시작 되어도 데이터를 유지할 수 있는 Redis 를 활용하여 조금 더 안정적인 서비스 운영이 될 수 있게끔 변경하고자 한다.
1. ioredis vs redis
Node.js 에서 Redis 와 연결하기 위해 사용하는 두 가지 주요 라이브러리는 ioredis 와 redis 가 있다. 둘 다 Redis 클라이언트이지만 기능과 사용성에 차이가 있다. 쉽게 보면 redis 의 업그레이드 버전이 ioredis 라 생각하면 된다. 주요 차이점은 다음과 같다.

필자의 프로젝트는 MSA 환경에서 진행되었으며, 대규모 서비스를 가정하에 개발중이기 때문에 ioredis 가 적합하다고 판단하였다.
2. Redis 에서는 Set 만 사용한 이유
기존 필자가 구현한 코드에서는 Map 과 Set 을 함께 사용했지만, Redis 에서는 Set 만 사용할 예정이다. Redis 에서는 Set 만 사용해도 같은 기능을 구현할 수 있기 때문이다. 기존 코드에서 Map 은 유저 ID를 찾기 위해, Set 은 중복 없는 소켓 ID 저장을 위해 사용되었다. 즉, Map 은 "해당 유저가 방에 있는가 ?" 를 찾기 위해 필요했고, Set 은 한 유저가 여러 개의 소켓을 가질 수 있기 때문에 사용하였다.
Redis 의 Set 은 다양한 메소드를 통해 이미 Map 과 같은 역할을 포함하고 있다. 따라서 Redis 의 Set 메소드를 알아야 할 필요가 있다. 해당 메소드들을 정리한 글은 따로 작성하도록 하겠다.
3. 기존 코드 및 변경된 코드의 차이점 비교
3.1 UserID 와 Socket 간 맵핑 관리
// 기존 Map 을 사용한 방식
const userSocketMap = new Map(); // userId를 key로 하고, Set(socketId)을 value로 저장
// 특정 유저(userId)의 소켓을 등록
if (!userSocketMap.has(userId)) {
userSocketMap.set(userId, new Set()); // 유저 ID가 없으면 새 Set을 생성
}
userSocketMap.get(userId).add(socket.id); // Set에 socket.id 추가
-------------------------------------------------------------------------------------
// Redis Set 을 사용한 방식
const socketId = socket.id;
redis.sadd(`user:${userId}:sockets`, socketId);
기존 코드에서는 Map 객체를 생성 후 userId 가 Map 에 존재하지 않을 경우 Set 을 새로 생성해서 추가한다.
Redis 를 이용한 코드는 sadd 명령어를 사용해 userId 를 키로, socket.id 를 값으로 추가한다. 한줄이면 끝난다. (;;)

3.2 방 입장
// 기존 Map 을 사용한 방식
if (!roomUserMap.has(roomId)) {
roomUserMap.set(roomId, new Map()); // 방이 없으면 새로운 Map 생성
}
const roomUsers = roomUserMap.get(roomId); // 해당 방의 유저 목록 가져오기
if (!roomUsers.has(userId)) {
roomUsers.set(userId, new Set()); // 유저 ID가 없으면 새로운 Set 생성
}
roomUsers.get(userId).add(socket.id); // Set에 socket.id 추가
--------------------------------------------------------------------------------------
// Redis 에서 사용한 방식
await redis.sadd(`room:${roomId}:users`, userId); // 방에 속한 유저 관리
await redis.sadd(`user:${userId}:rooms`, roomId); // 유저가 속한 방 관리
await redis.sadd(`socket:${socketId}:rooms`, roomId); // 소켓이 속한 방 관리
기존 코드에서는 roomId 가 Map 에 존재하는지 확인한다. 존재하지 않으면 roomId 를 추가한다. 만약 roomId 가 있다면 해당 방의 유저 목록을 가져오고 없다면 Set 을 추가하고 해당 userId 의 socket.id 를 추가한다.
Redis 에서는 방에 속한 유저, 유저가 속한 방 그리고 방 나가기 기능에서 필요한 소켓이 속한 방 관리 Set 을 추가하였다.

3.3 방 나가기
// 기존 Map 을 사용한 방식
if (roomUserMap.has(roomId)) {
const roomUsers = roomUserMap.get(roomId);
if (!(roomUsers instanceof Map)) {
console.error(`roomUsers for ${roomId} is not a Map!`);
return;
}
// userId 가 아닌 socket.id 를 제거해야 함.
if (roomUsers.has(userId)) {
const userSockets = roomUsers.get(userId);
// 특정 socket.id만 제거
userSockets.delete(socket.id);
console.log(`💡Removed socket ${socket.id} from user ${userId} in room ${roomId}`);
// 모든 socket.id 가 삭제되면 userId 삭제
if (userSockets.size === 0) {
roomUsers.delete(userId);
console.log(`💡User ${userId} completely removed from room ${roomId}`);
}
// 방에 아무도 없으면 room 자체 삭제
if (roomUsers.size === 0) {
roomUserMap.delete(roomId);
console.log(`💡Room ${roomId} deleted as no users are left.`);
}
}
}
-------------------------------------------------------------------------------------------
// Redis 방식
await redis.srem(`socket:${socketId}:rooms`, roomId);
const remainingSockets = await redis.smembers(`user:${userId}:sockets`);
let isUserStillInRoom = false;
for (const otherSocketId of remainingSockets) {
if (otherSocketId !== socketId) {
const roomsForOtherSocket = await redis.smembers(`socket:${otherSocketId}:rooms`);
if (roomsForOtherSocket.includes(roomId)) {
isUserStillInRoom = true;
break;
}
}
}
if (!isUserStillInRoom) {
await redis.srem(`user:${userId}:rooms`, roomId);
await redis.srem(`room:${roomId}:users`, userId);
}
const remainingUsers = await redis.smembers(`room:${roomId}:users`);
if (remainingUsers.length === 0) {
await redis.del(`room:${roomId}:users`);
console.log(`💡 Room ${roomId} completely removed`);
}
const remainingRoomsForSocket = await redis.smembers(`socket:${socketId}:rooms`);
if (remainingRoomsForSocket.length === 0) {
await redis.del(`socket:${socketId}:rooms`);
console.log(`💡 socket:${socketId}:rooms deleted`);
}
기존 코드와 로직은 동일하다. Redis 에서 먼저 serm 을 통해 해당 socket.id 가 속한 방 목록에서 roomId 를 삭제한다. 그 다음 같은 userId 를 가진 다른 socket.id 가 아직 해당 방에 남아 있는지 확인한다. 같은 유자의 다른 socket.id 가 해당 방에 남아 있지 않다면 유저의 방 목록에서 해당 방을 제거하고, 방의 유저 목록 에서도 해당 userId 를 삭제한다. 만약 방에 아무도 남아있지 않다면 키를 삭제하여 메모리를 확보한다. 해당 소켓 또한 어떤 방에도 속해 있지 않다면, 해당 키도 삭제한다.

3.4 메시지 보내기
// 기존 Map 을 사용한 방식
const offlineUsers = usersInRoom.filter(userId => {
const isOnline = userSocketMap.has(userId);
const isInRoom = roomUserMap.has(roomId) && roomUserMap.get(roomId).has(userId);
console.log(`💡 Checking user ${userId}: isOnline=${isOnline}, isInRoom=${isInRoom}`);
return !isOnline || !isInRoom; // 방에 없거나 완전히 오프라인이면 offlineUsers 로 간주
});
------------------------------------------------------------------------------------------
// Redis 방식
const offlineUsers = await Promise.all(usersInRoom.map(async (userId) => {
const isOnline = await redis.exists(`user:${userId}:sockets`);
const isInRoom = await redis.sismember(`room:${roomId}:users`, userId);
console.log(`💡 Checking user ${userId}: isOnline=${isOnline}, isInRoom=${isInRoom}`);
return (!isOnline || !isInRoom) ? userId : null;
})).then(results => results.filter(user => user !== null));
}
기존 코드의 로직과 동일하지만 비동기 방식을 활용하였다. 먼저 해당 방에 속한 유저 목록안에 값을 가지고 와 해당 userId가 있다면 isOnline 에 저장, 그리고 해당 userId 가 방에 있다면 isInRoom 에 저장한 뒤 둘 중 하나라도 false 인 유저들을 반환한다.

3.5 연결 해제
// 기존 Map 을 사용한 방식
for (const [roomId, roomUsers] of roomUserMap.entries()) {
if (roomUsers.has(userId)) {
const userSockets = roomUsers.get(userId);
userSockets.delete(socket.id);
if (userSockets.size === 0) {
roomUsers.delete(userId);
console.log(`User ${userId} fully removed from room ${roomId}`);
}
if (roomUsers.size === 0) {
roomUserMap.delete(roomId);
console.log(`Room ${roomId} deleted as no users are left.`);
}
}
}
if (userSocketMap.has(userId)) {
const userSockets = userSocketMap.get(userId);
userSockets.delete(socket.id);
console.log(`💡 Removed socket ${socket.id} from userSocketMap for user ${userId}`);
if (userSockets.size === 0) {
userSocketMap.delete(userId);
console.log(`💡 User ${userId} completely removed from userSocketMap`);
}
}
----------------------------------------------------------------------------------------
// Redis 방식
await redis.srem(`user:${userId}:sockets`, socket.id);
const remainingSockets = await redis.smembers(`user:${userId}:sockets`);
if (remainingSockets.length === 0) {
await redis.del(`user:${userId}:sockets`);
console.log(`💡 user:${userId}:sockets deleted`);
const rooms = await redis.smembers(`user:${userId}:rooms`);
for (const roomId of rooms) {
await redis.srem(`room:${roomId}:users`, userId);
console.log(`💡 user ${userId} removed from room:${roomId}:users`);
const roomSize = await redis.scard(`room:${roomId}:users`);
if (roomSize === 0) {
await redis.del(`room:${roomId}:users`);
}
}
await redis.del(`user:${userId}:rooms`);
}
기존 코드에서는 모든 방을 순회하면 userId 가 속한 방을 찾고, 각 방에서 socket.id 를 제거하였다. 만약 해당 유저의 모든 소켓이 사라지면 userId 도 삭제했으며, 모든 유저가 없어진 방이면 방 자체를 삭제하는 로직이었다.
Redis 에서는 srem 을 사용하여 특정 socket.id 를 제거하였고 smembers 로 유저가 가진 남은 socket.id 를 조회한다. 만약 해당 유저가 가진 socket.id 가 없다면 Key 자체를 삭제한다. 다음에 방에서 나가지 않고 연결이 끊긴 유저가 있을 수 있다. 예를 들어 대화방에 있는 도중에 앱을 껐을 경우에도 방에서 나갔다고 처리해야 하기 때문에 유저가 속한 방 리스트를 가지고 와서 해당 방에서 유저를 제거하는 작업을 수행한다. scard 명령어를 통해 방에 유저의 수를 찾는 다음에 유저가 없을 경우 방을 삭제한다.

4. 결과
이로써 userID 가 가지는 각 socket.id 의 값을 가지고 어떤 기기에서 연결되든 상관 없이 알림을 전송할 수 있는 로직을 구현하였다.
'Project > ST00CK' 카테고리의 다른 글
EXPO 에서 Push Token 발급 및 Redis 에서 관리하기 (0) | 2025.03.05 |
---|---|
gRPC 와 REST API 서버 동시에 사용하기 (0) | 2025.03.04 |
KafkaConsumer 중복 실행 오류 수정 (0) | 2025.02.26 |
socket.io 방 나가기 기능 및 연결 해제 처리 (0) | 2025.02.25 |
gRPC로 오프라인 유저를 찾기 위한 로직 구현 (0) | 2025.02.24 |