이미지 로딩 중...

Redis 캐싱과 Socket.io 클러스터링 완벽 가이드 - 슬라이드 1/8
A

AI Generated

2025. 11. 22. · 6 Views

Redis 캐싱과 Socket.io 클러스터링 완벽 가이드

실시간 채팅 서비스의 성능을 획기적으로 향상시키는 Redis 캐싱 전략과 Socket.io 클러스터링 방법을 배워봅니다. 다중 서버 환경에서도 안정적으로 작동하는 실시간 애플리케이션을 구축하는 방법을 단계별로 알아봅니다.


목차

  1. Redis 캐시 전략 설계
  2. 채팅방 목록 캐싱
  3. 온라인 상태 Redis 관리
  4. Redis Adapter 설정
  5. 다중 서버 확장 전략
  6. 로드밸런싱 구성
  7. 세션 동기화

1. Redis 캐시 전략 설계

시작하며

여러분이 채팅 애플리케이션을 운영할 때 사용자가 늘어날수록 데이터베이스 응답이 점점 느려지는 경험을 해보셨나요? 사용자가 100명일 때는 괜찮았는데, 1000명이 되니 채팅방 목록을 불러오는데 5초나 걸리는 상황 말이죠.

이런 문제는 모든 요청이 데이터베이스로 직접 가기 때문에 발생합니다. 데이터베이스는 디스크에서 데이터를 읽어야 하므로 느릴 수밖에 없고, 동시 접속자가 많아지면 부하가 급격히 증가합니다.

결국 서비스 전체가 느려지고 사용자 경험이 나빠지죠. 바로 이럴 때 필요한 것이 Redis 캐시 전략입니다.

Redis는 메모리에 데이터를 저장하여 데이터베이스보다 100배 이상 빠른 응답을 제공하며, 올바른 캐시 전략을 세우면 서버 부하를 90% 이상 줄일 수 있습니다.

개요

간단히 말해서, Redis 캐시 전략은 자주 사용되는 데이터를 메모리에 임시로 저장해두었다가 빠르게 꺼내 쓰는 방법입니다. 마치 책상 서랍에 자주 쓰는 물건을 넣어두는 것처럼, 자주 조회하는 데이터를 가까운 곳에 보관하는 거죠.

실시간 채팅 서비스에서는 채팅방 목록, 사용자 온라인 상태, 최근 메시지 같은 데이터가 초 단위로 조회됩니다. 이런 데이터를 매번 데이터베이스에서 가져오면 서버가 감당할 수 없게 되고, 사용자는 답답함을 느끼게 됩니다.

기존에는 데이터베이스 쿼리 최적화나 인덱스 추가로 성능을 개선했다면, 이제는 Redis 캐시를 활용하여 아예 데이터베이스 접근 자체를 줄일 수 있습니다. 캐시 히트율이 80%만 되어도 데이터베이스 부하가 5분의 1로 줄어듭니다.

Redis 캐시 전략의 핵심 특징은 첫째, TTL(Time To Live)을 설정하여 데이터의 유효 기간을 관리할 수 있고, 둘째, 다양한 데이터 구조(String, Hash, Set, Sorted Set)를 활용할 수 있으며, 셋째, 원자적 연산으로 동시성 문제를 해결할 수 있다는 점입니다. 이러한 특징들이 실시간 서비스의 성능과 안정성을 크게 향상시킵니다.

코드 예제

// Redis 캐시 전략을 구현한 예제
const redis = require('redis');
const client = redis.createClient();

// 캐시 조회 함수 - Cache-Aside 패턴
async function getChatRooms(userId) {
  const cacheKey = `chatrooms:${userId}`;

  // 1. 먼저 캐시에서 조회
  const cached = await client.get(cacheKey);
  if (cached) {
    console.log('캐시 히트!');
    return JSON.parse(cached);
  }

  // 2. 캐시 미스 - 데이터베이스에서 조회
  console.log('캐시 미스 - DB 조회');
  const rooms = await db.query('SELECT * FROM chat_rooms WHERE user_id = ?', [userId]);

  // 3. 캐시에 저장 (5분 TTL)
  await client.setEx(cacheKey, 300, JSON.stringify(rooms));

  return rooms;
}

설명

이것이 하는 일: 위 코드는 Cache-Aside 패턴을 구현하여 채팅방 목록을 효율적으로 조회합니다. 먼저 캐시를 확인하고, 없으면 데이터베이스에서 가져온 후 캐시에 저장하는 방식입니다.

첫 번째로, cacheKey를 사용자별로 구분하여 생성합니다. 이렇게 하면 각 사용자의 데이터가 독립적으로 캐싱되어 다른 사용자의 데이터와 섞이지 않습니다.

chatrooms:1234 같은 형식으로 키를 만들어 Redis에서 빠르게 찾을 수 있죠. 그 다음으로, client.get(cacheKey)로 캐시를 먼저 확인합니다.

캐시에 데이터가 있으면 "캐시 히트"라고 하며, 이 경우 데이터베이스 접근 없이 즉시 결과를 반환합니다. 이것이 바로 성능 향상의 핵심입니다.

캐시에 데이터가 없는 "캐시 미스" 상황에서는 데이터베이스를 조회하고, setEx 메서드로 300초(5분) 동안 유효한 캐시를 저장합니다. TTL을 설정하면 오래된 데이터가 자동으로 삭제되어 메모리를 효율적으로 관리할 수 있습니다.

여러분이 이 코드를 사용하면 동일한 채팅방 목록 조회가 반복될 때 첫 번째 요청만 데이터베이스를 거치고, 이후 5분간은 모든 요청이 Redis에서 처리됩니다. 응답 시간이 200ms에서 5ms로 줄어들고, 데이터베이스 부하가 대폭 감소하며, 더 많은 동시 사용자를 처리할 수 있게 됩니다.

실무에서는 캐시 무효화 전략도 중요합니다. 채팅방이 새로 생성되거나 수정되면 해당 사용자의 캐시를 즉시 삭제하여 항상 최신 데이터를 보여줄 수 있습니다.

실전 팁

💡 TTL은 데이터의 변경 빈도에 따라 설정하세요. 자주 바뀌는 데이터는 짧게(1-5분), 거의 안 바뀌는 데이터는 길게(30분-1시간) 설정하면 효율적입니다.

💡 캐시 키 네이밍 규칙을 일관되게 유지하세요. 도메인:ID:속성 형식(예: user:1234:profile)을 사용하면 나중에 관리하기 쉽고 디버깅할 때도 편합니다.

💡 캐시 워밍업을 고려하세요. 서버 시작 시 인기 있는 데이터를 미리 캐시에 로드하면 초기 사용자들도 빠른 응답을 경험할 수 있습니다.

💡 캐시 모니터링을 반드시 설정하세요. 캐시 히트율이 70% 이하면 TTL이나 캐시 전략을 재검토해야 하며, Redis 메모리 사용률도 주기적으로 확인해야 합니다.

💡 데이터 타입에 맞는 Redis 자료구조를 선택하세요. 단순 값은 String, 객체는 Hash, 순위가 필요하면 Sorted Set을 사용하면 성능과 메모리 효율이 모두 좋아집니다.


2. 채팅방 목록 캐싱

시작하며

여러분의 채팅 앱에서 사용자가 앱을 열 때마다 채팅방 목록을 로딩하는데 시간이 오래 걸린다면, 사용자들은 금방 떠나갈 것입니다. 특히 100개 이상의 채팅방에 참여한 사용자라면 매번 복잡한 JOIN 쿼리를 실행해야 하죠.

이런 문제는 채팅방 정보가 여러 테이블에 분산되어 있고, 각 채팅방의 최신 메시지, 읽지 않은 메시지 수, 참여자 정보를 모두 가져와야 하기 때문입니다. 사용자 수가 증가하면 이런 복잡한 쿼리가 동시에 수백 개씩 실행되어 데이터베이스가 병목이 됩니다.

바로 이럴 때 필요한 것이 채팅방 목록 캐싱입니다. Redis Hash 자료구조를 활용하면 각 채팅방의 메타데이터를 효율적으로 저장하고, 밀리초 단위로 전체 목록을 조회할 수 있습니다.

개요

간단히 말해서, 채팅방 목록 캐싱은 사용자가 참여한 모든 채팅방의 정보를 Redis에 미리 저장해두고, 앱을 열 때 즉시 보여주는 방법입니다. 마치 연락처를 즐겨찾기에 등록해두면 빠르게 찾을 수 있는 것과 같은 원리죠.

실시간 채팅 서비스에서 채팅방 목록은 가장 자주 조회되는 데이터입니다. 사용자가 앱을 열 때마다, 새로운 메시지 알림을 받을 때마다, 백그라운드 동기화를 할 때마다 조회됩니다.

이런 빈번한 조회를 데이터베이스로 처리하면 서버가 금방 과부하에 걸립니다. 기존에는 데이터베이스 쿼리 결과를 애플리케이션 메모리에 잠깐 캐싱했다면, 이제는 Redis를 중앙 캐시 저장소로 사용하여 여러 서버 인스턴스가 동일한 캐시를 공유할 수 있습니다.

이렇게 하면 서버를 여러 대로 늘려도 캐시 효율이 유지됩니다. 채팅방 목록 캐싱의 핵심은 첫째, Redis Hash로 각 채팅방의 속성을 구조적으로 저장하고, 둘째, 정렬된 목록을 위해 Sorted Set을 활용하며, 셋째, 실시간 업데이트가 발생하면 즉시 캐시를 갱신한다는 점입니다.

이러한 접근 방식이 사용자에게 빠르고 일관된 경험을 제공합니다.

코드 예제

// 채팅방 목록을 Redis Hash와 Sorted Set으로 캐싱
const redis = require('redis');
const client = redis.createClient();

// 채팅방 목록 캐싱 함수
async function cacheUserChatRooms(userId, chatRooms) {
  const pipeline = client.multi();

  chatRooms.forEach(room => {
    // Hash로 각 채팅방 정보 저장
    const roomKey = `chatroom:${room.id}`;
    pipeline.hSet(roomKey, {
      name: room.name,
      lastMessage: room.lastMessage,
      unreadCount: room.unreadCount.toString(),
      participantCount: room.participantCount.toString()
    });

    // Sorted Set으로 사용자별 채팅방 목록 관리 (최신순)
    pipeline.zAdd(`user:${userId}:chatrooms`, {
      score: room.lastMessageTime,
      value: room.id.toString()
    });
  });

  await pipeline.exec();
}

// 캐시에서 채팅방 목록 조회
async function getCachedChatRooms(userId, limit = 20) {
  // 최신 메시지 순으로 채팅방 ID 가져오기
  const roomIds = await client.zRange(`user:${userId}:chatrooms`, 0, limit - 1, { REV: true });

  // 각 채팅방 정보 조회
  const rooms = await Promise.all(
    roomIds.map(id => client.hGetAll(`chatroom:${id}`))
  );

  return rooms;
}

설명

이것이 하는 일: 위 코드는 채팅방 목록을 두 가지 Redis 자료구조를 조합하여 효율적으로 캐싱합니다. Hash는 각 채팅방의 상세 정보를, Sorted Set은 사용자별 채팅방 순서를 관리합니다.

첫 번째로, pipeline = client.multi()로 파이프라인을 시작합니다. 파이프라인은 여러 Redis 명령을 묶어서 한 번의 네트워크 왕복으로 실행하는 기술로, 100개의 채팅방을 저장할 때 100번의 왕복 대신 1번만 하면 됩니다.

이렇게 하면 네트워크 지연을 99% 줄일 수 있죠. 그 다음으로, 각 채팅방마다 hSet으로 Hash에 정보를 저장합니다.

Hash는 객체처럼 키-값 쌍으로 데이터를 저장하므로, 나중에 특정 필드만 업데이트하거나 조회할 수 있습니다. 예를 들어 읽지 않은 메시지 수만 증가시킬 때 전체 객체를 다시 저장할 필요가 없어 효율적입니다.

동시에 zAdd로 Sorted Set에 채팅방을 추가합니다. Score에 마지막 메시지 시간을 넣으면 Redis가 자동으로 시간순으로 정렬해주므로, 조회할 때 별도의 정렬 작업이 필요 없습니다.

zRange로 최신 20개만 가져올 때도 O(log N) 시간에 처리됩니다. 조회 함수에서는 먼저 Sorted Set에서 채팅방 ID 목록을 가져오고, Promise.all로 모든 채팅방 정보를 병렬로 조회합니다.

순차적으로 조회하면 20개를 가져오는데 20배의 시간이 걸리지만, 병렬로 하면 1배의 시간만 걸립니다. 여러분이 이 코드를 사용하면 사용자가 채팅방 목록을 조회할 때 데이터베이스를 전혀 거치지 않고 Redis에서 모든 데이터를 가져옵니다.

응답 시간이 500ms에서 10ms 이하로 줄어들고, 서버가 초당 수천 명의 동시 접속자를 처리할 수 있게 되며, 데이터베이스 비용도 크게 절감됩니다. 실무에서는 새 메시지가 도착하면 해당 채팅방의 Hash와 Sorted Set을 즉시 업데이트하여 실시간성을 유지합니다.

또한 사용자가 채팅방을 나가면 Sorted Set에서 제거하는 등 캐시 일관성 관리가 중요합니다.

실전 팁

💡 Pipeline을 적극 활용하세요. 10개 이상의 명령을 실행할 때는 항상 multi()로 묶으면 네트워크 오버헤드가 크게 줄어듭니다. 특히 초기 데이터 로딩 시 수백 개의 명령을 파이프라인으로 처리하면 10배 이상 빨라집니다.

💡 Sorted Set의 score를 활용하여 다양한 정렬을 구현하세요. 마지막 메시지 시간 대신 읽지 않은 메시지 수를 score로 사용하면 읽지 않은 메시지가 많은 채팅방을 우선 표시할 수 있습니다.

💡 Hash의 필드별 업데이트 기능을 활용하세요. hIncrBy로 읽지 않은 메시지 수를 증가시키거나 hSet으로 특정 필드만 업데이트하면 전체 객체를 다시 저장하는 것보다 훨씬 효율적입니다.

💡 대용량 채팅방 목록은 페이지네이션을 적용하세요. zRange의 start와 end 파라미터로 0-19, 20-39 같은 범위를 지정하면 무한 스크롤을 구현할 수 있고, 메모리와 네트워크 대역폭을 절약할 수 있습니다.

💡 캐시 데이터의 TTL을 설정하되 너무 짧지 않게 하세요. 채팅방 목록은 자주 변경되므로 업데이트 기반으로 관리하고, TTL은 만약을 대비한 안전장치로 1시간 정도 설정하면 적절합니다.


3. 온라인 상태 Redis 관리

시작하며

여러분이 채팅 앱에서 친구가 온라인인지 오프라인인지 실시간으로 보여주려고 할 때, 수천 명의 사용자 상태를 어떻게 추적하시나요? 매초마다 데이터베이스를 업데이트하고 조회한다면 서버가 곧 다운될 것입니다.

이런 문제는 온라인 상태가 매우 빠르게 변하는 휘발성 데이터이기 때문입니다. 사용자가 접속하고, 잠시 자리를 비우고, 다시 활동하고, 종료하는 모든 상태 변화를 데이터베이스에 기록하면 초당 수만 건의 업데이트가 발생합니다.

게다가 다른 사용자들이 이 정보를 실시간으로 조회하려고 하면 부하가 배가됩니다. 바로 이럴 때 필요한 것이 Redis를 활용한 온라인 상태 관리입니다.

Redis의 빠른 속도와 TTL 기능, 그리고 Set 자료구조를 활용하면 수십만 명의 온라인 상태를 밀리초 단위로 추적하고 조회할 수 있습니다.

개요

간단히 말해서, 온라인 상태 관리는 사용자의 접속 여부를 Redis에 저장하고 일정 시간마다 갱신하여, 다른 사용자들이 실시간으로 확인할 수 있게 하는 방법입니다. 마치 사무실 출퇴근 카드처럼, 정기적으로 체크인하지 않으면 자동으로 퇴근 처리되는 시스템이죠.

실시간 채팅 서비스에서 온라인 상태는 사용자 경험에 큰 영향을 미칩니다. 친구가 지금 온라인이면 바로 메시지를 보낼 의욕이 생기고, 오프라인이면 나중에 보내려고 합니다.

정확한 온라인 상태 표시는 사용자 참여도를 20-30% 향상시킬 수 있습니다. 기존에는 데이터베이스의 last_seen 컬럼을 주기적으로 업데이트하고 5분 이내 활동을 온라인으로 판단했다면, 이제는 Redis를 사용하여 훨씬 정확하고 빠르게 실시간 상태를 추적할 수 있습니다.

데이터베이스는 영구 저장용으로만 사용하고, 실시간 상태는 모두 Redis에서 처리합니다. Redis 온라인 상태 관리의 핵심은 첫째, String 키에 TTL을 설정하여 자동 만료를 활용하고, 둘째, Set으로 특정 채팅방의 온라인 사용자 목록을 관리하며, 셋째, Pub/Sub으로 상태 변화를 즉시 브로드캐스트한다는 점입니다.

이러한 기능들이 결합되어 완벽한 실시간 상태 시스템을 만듭니다.

코드 예제

// Redis로 온라인 상태 관리하기
const redis = require('redis');
const client = redis.createClient();

// 사용자 온라인 상태 설정 (하트비트)
async function setUserOnline(userId) {
  const onlineKey = `user:${userId}:online`;

  // 30초 TTL로 온라인 상태 저장
  await client.setEx(onlineKey, 30, 'true');

  // 전체 온라인 사용자 Set에 추가 (30초 TTL)
  await client.sAdd('online_users', userId.toString());

  // 상태 변화 브로드캐스트
  await client.publish('user_status', JSON.stringify({
    userId,
    status: 'online',
    timestamp: Date.now()
  }));
}

// 사용자 온라인 상태 확인
async function isUserOnline(userId) {
  const exists = await client.exists(`user:${userId}:online`);
  return exists === 1;
}

// 여러 사용자의 온라인 상태 일괄 조회
async function checkMultipleUsersOnline(userIds) {
  const pipeline = client.multi();

  userIds.forEach(id => {
    pipeline.exists(`user:${id}:online`);
  });

  const results = await pipeline.exec();

  return userIds.reduce((acc, id, index) => {
    acc[id] = results[index] === 1;
    return acc;
  }, {});
}

// 채팅방의 온라인 사용자 목록
async function getRoomOnlineUsers(roomId) {
  return await client.sMembers(`room:${roomId}:online`);
}

설명

이것이 하는 일: 위 코드는 사용자의 온라인 상태를 Redis에서 관리하는 완전한 시스템을 구현합니다. 하트비트 방식으로 주기적인 체크인을 통해 자동으로 오프라인 처리가 되도록 설계되었습니다.

첫 번째로, setUserOnline 함수는 30초 TTL로 사용자 상태를 저장합니다. 클라이언트는 10-15초마다 이 함수를 호출하여 "나 여기 있어요"라고 알립니다.

만약 네트워크가 끊기거나 앱이 종료되면 하트비트가 멈추고, 30초 후 Redis가 자동으로 키를 삭제하여 오프라인 처리됩니다. 별도의 정리 작업이 필요 없죠.

그 다음으로, sAdd로 전체 온라인 사용자 Set에 추가합니다. Set은 중복을 자동으로 제거하므로 여러 번 추가해도 한 번만 저장되고, sMembers로 현재 온라인인 모든 사용자를 O(N) 시간에 가져올 수 있습니다.

관리자 대시보드에서 실시간 접속자 수를 표시할 때 유용합니다. publish로 상태 변화를 브로드캐스트하면 다른 서버 인스턴스나 클라이언트가 즉시 알림을 받을 수 있습니다.

Socket.io와 연동하면 친구 목록에서 친구가 온라인이 되는 순간 초록색 불이 켜지는 실시간 UI를 구현할 수 있습니다. checkMultipleUsersOnline 함수는 채팅방 참여자 목록을 표시할 때 각 사용자의 온라인 상태를 한 번에 조회합니다.

Pipeline을 사용하여 50명의 상태를 확인할 때 50번의 왕복이 아닌 1번만 하므로 매우 빠릅니다. 여러분이 이 코드를 사용하면 클라이언트가 15초마다 하트비트를 보내고, 서버는 밀리초 단위로 온라인 상태를 조회할 수 있으며, 네트워크 장애나 앱 종료 시 자동으로 오프라인 처리되어 항상 정확한 상태가 유지됩니다.

수십만 명의 동시 접속자도 문제없이 처리할 수 있습니다. 실무에서는 하트비트 간격과 TTL을 신중하게 설정해야 합니다.

간격이 너무 짧으면 서버 부하가 증가하고, 너무 길면 오프라인 감지가 늦어집니다. 일반적으로 15초 간격에 30초 TTL이 적절합니다.

실전 팁

💡 하트비트 간격은 TTL의 절반으로 설정하세요. TTL이 30초면 15초마다 하트비트를 보내면 네트워크 지연이 있어도 안전하게 온라인 상태를 유지할 수 있습니다. 2배의 여유를 두면 일시적인 네트워크 장애에도 강건합니다.

💡 모바일 앱에서는 백그라운드 상태를 고려하세요. 앱이 백그라운드로 가면 하트비트를 멈추고 "자리 비움" 상태로 전환하여 배터리를 절약하고, 사용자가 실제로 활동 중인지 정확히 표시할 수 있습니다.

💡 채팅방별 온라인 사용자를 별도로 관리하세요. room:123:online Set에 해당 채팅방을 보고 있는 사용자만 추가하면 "김철수님이 입력 중..." 같은 기능을 구현할 수 있습니다. 전체 온라인과 채팅방별 온라인을 구분하세요.

💡 Pub/Sub 메시지는 가볍게 유지하세요. userId와 status만 보내고, 상세 정보가 필요하면 받는 쪽에서 캐시나 DB를 조회하도록 하세요. Pub/Sub은 메시지를 저장하지 않으므로 중요한 데이터는 별도로 저장해야 합니다.

💡 Redis의 EXPIRE 이벤트를 활용하여 오프라인 처리를 감지하세요. Keyspace notifications를 활성화하면 키가 만료될 때 이벤트를 받아서 "김철수님이 오프라인이 되었습니다" 같은 알림을 보낼 수 있습니다.


4. Redis Adapter 설정

시작하며

여러분이 Socket.io 서버를 두 대로 늘렸는데, A 서버에 접속한 사용자가 B 서버에 접속한 사용자에게 메시지를 보낼 수 없는 문제를 겪어본 적 있나요? 각 서버가 독립적으로 동작하여 서로의 클라이언트를 알지 못하기 때문입니다.

이런 문제는 Socket.io의 기본 동작이 단일 서버를 전제로 하기 때문에 발생합니다. 메모리에 연결된 클라이언트 정보를 저장하므로, 다른 서버의 메모리에는 접근할 수 없죠.

로드밸런서로 트래픽을 분산하면 일부 메시지가 전달되지 않는 심각한 문제가 생깁니다. 바로 이럴 때 필요한 것이 Redis Adapter입니다.

Redis를 중앙 메시지 브로커로 사용하여 모든 서버 인스턴스가 메시지를 공유하고, 어느 서버에 접속했든 모든 사용자에게 메시지가 전달되도록 만듭니다.

개요

간단히 말해서, Redis Adapter는 Socket.io 서버들 사이에 메시지를 중계하는 다리 역할을 합니다. 마치 우체국이 편지를 다른 도시로 전달하듯이, Redis가 한 서버의 메시지를 다른 모든 서버로 전달하는 거죠.

실시간 채팅 서비스를 확장할 때 가장 먼저 부딪히는 문제가 바로 서버 간 통신입니다. 사용자가 늘어나면 서버를 여러 대로 늘려야 하는데, 각 서버가 고립되어 있으면 서비스가 제대로 작동하지 않습니다.

친구와 다른 서버에 접속했다는 이유로 메시지를 주고받을 수 없다면 서비스로서 의미가 없죠. 기존에는 Sticky Session으로 같은 사용자를 항상 같은 서버로 연결했다면, 이제는 Redis Adapter로 모든 서버를 연결하여 어느 서버에 접속하든 상관없게 만들 수 있습니다.

이렇게 하면 로드밸런싱의 효율이 크게 향상되고, 한 서버가 다운되어도 다른 서버로 자동 전환이 가능합니다. Redis Adapter의 핵심은 첫째, Pub/Sub으로 서버 간 메시지를 즉시 전파하고, 둘째, 룸(채팅방) 정보를 모든 서버가 공유하며, 셋째, 클라이언트 연결 정보를 동기화한다는 점입니다.

이러한 기능들이 완벽한 수평 확장을 가능하게 합니다.

코드 예제

// Socket.io에 Redis Adapter 설정하기
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

// Socket.io 서버 초기화
const io = new Server(3000);

// Redis 클라이언트 생성 (Pub용과 Sub용 2개 필요)
const pubClient = createClient({ host: 'localhost', port: 6379 });
const subClient = pubClient.duplicate();

// Redis 연결
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
  // Redis Adapter 설정
  io.adapter(createAdapter(pubClient, subClient));

  console.log('Redis Adapter 연결 완료');
});

// 이제 모든 서버 인스턴스가 메시지를 공유합니다
io.on('connection', (socket) => {
  console.log('클라이언트 연결:', socket.id);

  // 채팅방 입장 - 어느 서버에서든 같은 룸 공유
  socket.on('join_room', (roomId) => {
    socket.join(roomId);

    // 같은 룸의 모든 사용자에게 알림 (다른 서버 포함)
    io.to(roomId).emit('user_joined', {
      userId: socket.userId,
      timestamp: Date.now()
    });
  });

  // 메시지 전송 - 다른 서버의 사용자에게도 전달됨
  socket.on('send_message', ({ roomId, message }) => {
    io.to(roomId).emit('new_message', {
      userId: socket.userId,
      message,
      timestamp: Date.now()
    });
  });
});

설명

이것이 하는 일: 위 코드는 Socket.io에 Redis Adapter를 설정하여 여러 서버 인스턴스가 하나의 통합된 시스템처럼 동작하도록 만듭니다. 사용자는 어느 서버에 접속했는지 전혀 알 수 없고 알 필요도 없습니다.

첫 번째로, Redis 클라이언트를 2개 생성합니다. pubClient는 메시지를 발행하고, subClient는 메시지를 구독하는 역할을 합니다.

Redis Pub/Sub의 특성상 구독 중인 클라이언트는 다른 명령을 실행할 수 없으므로 반드시 별도의 클라이언트가 필요합니다. duplicate()로 설정을 복사하여 두 번째 클라이언트를 만듭니다.

그 다음으로, io.adapter(createAdapter(pubClient, subClient))로 Adapter를 연결합니다. 이 한 줄의 코드가 마법처럼 모든 것을 해결합니다.

이제 io.to(roomId).emit()을 호출하면 현재 서버뿐만 아니라 다른 모든 서버의 해당 룸 사용자에게도 메시지가 전달됩니다. 내부적으로는 이렇게 동작합니다: 서버A에서 io.to('room1').emit('message')를 호출하면, Adapter가 Redis Pub/Sub으로 메시지를 발행합니다.

서버B, C, D가 모두 이 메시지를 구독하고 있으므로 즉시 받아서 자신에게 연결된 'room1' 사용자들에게 전달합니다. 이 모든 과정이 밀리초 단위로 완료됩니다.

socket.join(roomId)도 모든 서버에서 동기화됩니다. 서버A에서 사용자가 룸에 입장하면 Redis에 기록되고, 다른 서버들도 이 정보를 알게 됩니다.

따라서 서버B에서 io.to(roomId).emit()을 호출해도 서버A의 사용자에게 메시지가 전달됩니다. 여러분이 이 코드를 사용하면 서버를 10대로 늘려도 코드 변경 없이 완벽하게 작동하고, 로드밸런서가 사용자를 아무 서버나 보내도 문제없으며, 한 서버가 재시작되어도 다른 서버들이 계속 서비스를 제공합니다.

진정한 무중단 실시간 서비스가 완성되는 거죠. 실무에서는 Redis 클러스터를 사용하여 Redis 자체도 고가용성을 확보해야 합니다.

또한 연결 에러 처리와 재연결 로직을 추가하여 일시적인 Redis 장애에도 강건하게 대응해야 합니다.

실전 팁

💡 Redis Sentinel이나 Cluster를 사용하여 Redis 자체의 고가용성을 확보하세요. Redis가 단일 장애점이 되면 전체 채팅 시스템이 멈추므로, 프로덕션 환경에서는 필수입니다.

💡 Adapter 옵션으로 메시지 키 접두사를 설정하세요. createAdapter(pubClient, subClient, { key: 'chat:' })처럼 하면 여러 애플리케이션이 같은 Redis를 공유해도 채널이 충돌하지 않습니다.

💡 네임스페이스별로 다른 Redis 인스턴스를 사용할 수 있습니다. 공개 채팅과 비공개 채팅을 분리하거나, 부하가 높은 채팅방을 별도 Redis로 격리하여 성능을 최적화할 수 있습니다.

💡 Redis 연결 에러를 모니터링하고 재연결 로직을 구현하세요. pubClient.on('error', ...)로 에러를 감지하고, 자동 재연결이 실패하면 알림을 보내 빠르게 대응해야 합니다.

💡 메시지 크기를 최소화하세요. Adapter를 통해 전파되는 메시지는 Redis를 거치므로 큰 데이터를 보내면 네트워크와 메모리를 많이 사용합니다. 메시지 ID만 보내고 실제 데이터는 캐시나 DB에서 조회하는 방식도 고려하세요.


5. 다중 서버 확장 전략

시작하며

여러분의 채팅 서비스가 성공하여 사용자가 급증하는데, 단일 서버가 CPU 100%로 버티지 못하는 상황을 상상해보세요. 서버를 더 강력한 것으로 교체하는 수직 확장은 한계가 있고 비용도 기하급수적으로 증가합니다.

이런 문제는 모든 성공적인 서비스가 겪는 성장통입니다. 단일 서버로는 아무리 성능이 좋아도 물리적 한계가 있고, 장애가 발생하면 전체 서비스가 중단됩니다.

사용자 경험이 나빠지고 매출에도 직접적인 타격을 입죠. 바로 이럴 때 필요한 것이 수평 확장 전략입니다.

서버를 여러 대로 늘려 부하를 분산하고, 각 서버가 협력하여 하나의 통합된 서비스를 제공하며, 일부 서버가 다운되어도 서비스가 계속되도록 만드는 체계적인 접근 방법입니다.

개요

간단히 말해서, 다중 서버 확장 전략은 하나의 큰 서버 대신 여러 개의 작은 서버를 협력시켜 더 큰 용량과 안정성을 확보하는 방법입니다. 마치 혼자 짐을 나르는 것보다 여러 명이 나누어 나르는 것이 더 효율적인 것처럼요.

실시간 채팅 서비스는 특히 수평 확장이 중요합니다. WebSocket 연결은 장시간 유지되므로 서버당 처리할 수 있는 동시 연결 수에 한계가 있습니다.

일반적으로 서버 한 대가 1만-5만 동시 연결을 처리할 수 있으므로, 10만 명의 동시 접속자를 처리하려면 최소 2-10대의 서버가 필요합니다. 기존에는 서버 성능을 높이는 수직 확장에 집중했다면, 이제는 서버 대수를 늘리는 수평 확장으로 패러다임을 전환해야 합니다.

클라우드 환경에서는 Auto Scaling으로 트래픽에 따라 서버를 자동으로 추가/제거할 수 있어 비용 효율도 높습니다. 다중 서버 확장의 핵심은 첫째, 상태를 서버 메모리가 아닌 Redis나 DB에 저장하는 무상태(stateless) 설계, 둘째, 서버 간 메시지 동기화를 위한 Redis Adapter, 셋째, 부하 분산을 위한 로드밸런서입니다.

이 세 요소가 조화를 이루면 무한히 확장 가능한 시스템이 됩니다.

코드 예제

// 무상태(stateless) Socket.io 서버 설정
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const express = require('express');

const app = express();
const httpServer = require('http').createServer(app);
const io = new Server(httpServer);

// Redis 클라이언트 설정
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

// 세션 스토어도 Redis 사용 (무상태 설계)
const sessionStore = createClient({ url: process.env.REDIS_URL });

Promise.all([
  pubClient.connect(),
  subClient.connect(),
  sessionStore.connect()
]).then(() => {
  io.adapter(createAdapter(pubClient, subClient));

  // Socket.io 미들웨어: 세션 복원
  io.use(async (socket, next) => {
    const sessionId = socket.handshake.auth.sessionId;

    if (sessionId) {
      // Redis에서 세션 복원
      const session = await sessionStore.get(`session:${sessionId}`);

      if (session) {
        socket.sessionId = sessionId;
        socket.userId = JSON.parse(session).userId;
        return next();
      }
    }

    // 새 세션 생성
    socket.sessionId = require('crypto').randomUUID();
    next();
  });

  io.on('connection', (socket) => {
    // 세션 저장 (모든 서버에서 접근 가능)
    sessionStore.setEx(
      `session:${socket.sessionId}`,
      86400, // 24시간
      JSON.stringify({
        userId: socket.userId,
        connectedAt: Date.now()
      })
    );

    // 연결 해제 시 정리
    socket.on('disconnect', async () => {
      // 다른 서버에서도 이 정보 확인 가능
      const sockets = await io.in(socket.userId).fetchSockets();

      // 해당 사용자의 모든 연결이 끊어졌는지 확인
      if (sockets.length === 0) {
        // 온라인 상태 제거
        await sessionStore.del(`user:${socket.userId}:online`);
      }
    });
  });

  // 헬스 체크 엔드포인트 (로드밸런서용)
  app.get('/health', (req, res) => {
    res.status(200).send('OK');
  });

  const PORT = process.env.PORT || 3000;
  httpServer.listen(PORT, () => {
    console.log(`서버 시작: ${PORT} 포트`);
  });
});

설명

이것이 하는 일: 위 코드는 완전한 무상태 Socket.io 서버를 구현하여 무한히 확장 가능하고 고가용성을 갖춘 시스템을 만듭니다. 서버의 메모리에 중요한 상태를 저장하지 않으므로 언제든 추가/제거할 수 있습니다.

첫 번째로, 환경 변수로 Redis URL을 받아 동일한 Redis를 모든 서버가 공유합니다. 로컬 개발에서는 localhost, 프로덕션에서는 Redis 클러스터 주소를 사용하면 됩니다.

이렇게 설정을 외부화하면 코드 변경 없이 환경을 전환할 수 있습니다. 그 다음으로, Socket.io 미들웨어에서 세션을 Redis로부터 복원합니다.

사용자가 서버A에 접속했다가 연결이 끊긴 후 서버B로 재접속해도, 동일한 세션 ID로 이전 상태를 복원할 수 있습니다. 사용자 입장에서는 끊김 없는 경험을 하게 되죠.

세션 정보를 sessionStore에 저장할 때 24시간 TTL을 설정합니다. 이렇게 하면 오래된 세션이 자동으로 정리되어 메모리 누수를 방지할 수 있습니다.

영구 저장이 필요한 데이터는 별도로 데이터베이스에 저장하고, Redis는 휘발성 세션만 관리합니다. 연결 해제 시 io.in(socket.userId).fetchSockets()로 모든 서버의 해당 사용자 연결을 확인합니다.

이 함수는 Redis Adapter를 통해 다른 서버에도 질의하여 완전한 정보를 제공합니다. 사용자가 여러 기기로 접속한 경우 모든 연결이 끊어졌을 때만 오프라인 처리합니다.

/health 엔드포인트는 로드밸런서가 서버 상태를 확인하는 용도입니다. 서버가 정상적으로 응답하면 200, 문제가 있으면 500을 반환하도록 구현하면 로드밸런서가 자동으로 문제 있는 서버를 제외합니다.

여러분이 이 코드를 사용하면 서버를 1대에서 100대로 늘려도 아무 문제없이 작동하고, 트래픽 급증 시 Auto Scaling으로 서버를 자동 추가하며, 일부 서버가 다운되어도 나머지 서버가 계속 서비스를 제공하여 99.9% 이상의 가용성을 달성할 수 있습니다. 실무에서는 배포 전략도 중요합니다.

블루-그린 배포나 카나리 배포로 새 버전을 점진적으로 롤아웃하고, 문제 발생 시 즉시 롤백할 수 있어야 합니다. 무상태 설계가 이런 유연한 배포를 가능하게 합니다.

실전 팁

💡 세션 ID를 클라이언트에게 돌려주세요. 연결이 끊겼을 때 클라이언트가 같은 세션 ID로 재접속하면 끊김 없는 경험을 제공할 수 있습니다. socket.emit('session', { sessionId: socket.sessionId })로 전달하세요.

💡 Sticky Session은 피하세요. 특정 사용자를 특정 서버에 고정하면 부하 분산이 고르지 않고, 그 서버가 다운되면 해당 사용자들이 모두 영향을 받습니다. 무상태 설계로 어느 서버에 접속해도 동일한 경험을 제공하는 것이 더 낫습니다.

💡 메트릭을 수집하여 확장 시점을 파악하세요. CPU 사용률, 메모리, 동시 연결 수, 평균 응답 시간 등을 모니터링하고, CPU 70% 이상 또는 연결 수가 서버 용량의 80%에 도달하면 자동으로 서버를 추가하도록 설정하세요.

💡 Redis도 수평 확장하세요. 서버가 10대 이상으로 늘어나면 Redis도 병목이 될 수 있습니다. Redis Cluster로 여러 노드에 데이터를 분산하고, 읽기 부하는 Replica로 분산하세요.

💡 지역별로 서버를 배치하세요. 글로벌 서비스라면 미국, 유럽, 아시아에 각각 서버를 두고 가장 가까운 서버로 연결하면 지연 시간이 크게 줄어듭니다. Redis도 지역별로 두되, 중요한 데이터는 지역 간 복제를 고려하세요.


6. 로드밸런싱 구성

시작하며

여러분이 서버를 3대로 늘렸는데, 1번 서버에만 모든 트래픽이 몰리고 2, 3번 서버는 놀고 있는 상황을 경험해본 적 있나요? 서버는 늘렸지만 부하 분산이 안 되면 아무 소용이 없습니다.

이런 문제는 클라이언트가 어느 서버로 연결해야 할지 모르기 때문에 발생합니다. 모든 클라이언트가 같은 주소로 접속하면 결국 한 서버로 집중되고, DNS 라운드 로빈은 클라이언트 캐싱 때문에 효과적이지 않습니다.

서버 장애 감지와 자동 제외도 어렵죠. 바로 이럴 때 필요한 것이 로드밸런서입니다.

클라이언트와 서버 사이에서 트래픽을 지능적으로 분산하고, 서버 상태를 모니터링하여 문제 있는 서버를 자동으로 제외하며, SSL 종료나 압축 같은 부가 기능도 제공합니다.

개요

간단히 말해서, 로드밸런서는 여러 서버 앞에 위치하여 들어오는 요청을 적절히 분배하는 교통 경찰 같은 역할을 합니다. 마치 은행 창구 앞에서 대기 줄을 관리하는 직원처럼, 가장 한가한 서버로 고객을 안내하는 거죠.

실시간 채팅 서비스에서 로드밸런싱은 단순 HTTP 로드밸런싱보다 복잡합니다. WebSocket은 장시간 연결이 유지되므로 연결 수 기반 분산이 필요하고, 일단 연결된 후에는 같은 서버를 유지해야 합니다.

Nginx나 HAProxy 같은 전문 로드밸런서가 이런 요구사항을 충족합니다. 기존에는 DNS 라운드 로빈으로 간단히 분산했다면, 이제는 L7 로드밸런서로 WebSocket 프로토콜을 이해하고 헬스 체크로 장애 서버를 자동 제외하며 SSL 오프로딩으로 서버 부하를 줄일 수 있습니다.

로드밸런서가 단일 진입점이 되어 서버 IP 변경도 자유롭죠. 로드밸런싱의 핵심은 첫째, 적절한 분산 알고리즘 선택(라운드 로빈, 최소 연결, IP 해시 등), 둘째, 헬스 체크로 정상 서버만 사용, 셋째, WebSocket 프로토콜 지원입니다.

이러한 설정이 안정적이고 확장 가능한 서비스의 기반이 됩니다.

코드 예제

# Nginx 로드밸런서 설정 (nginx.conf)

# 업스트림 서버 그룹 정의
upstream socketio_backend {
    # IP Hash: 같은 클라이언트는 같은 서버로 (선택사항)
    # ip_hash;

    # Least Connections: 연결 수가 가장 적은 서버로 분산
    least_conn;

    # Socket.io 서버들
    server 10.0.1.10:3000 max_fails=3 fail_timeout=30s;
    server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:3000 max_fails=3 fail_timeout=30s;

    # 헬스 체크 (Nginx Plus에서 지원)
    # health_check interval=5s fails=2 passes=2 uri=/health;
}

server {
    listen 80;
    server_name chat.example.com;

    # HTTPHTTPS로 리다이렉트
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name chat.example.com;

    # SSL 인증서
    ssl_certificate /etc/nginx/ssl/cert.pem;
    ssl_certificate_key /etc/nginx/ssl/key.pem;

    # WebSocket 프록시 설정
    location / {
        proxy_pass http://socketio_backend;

        # WebSocket 업그레이드 헤더
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        # 클라이언트 정보 전달
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 타임아웃 설정 (장시간 연결 유지)
        proxy_connect_timeout 7d;
        proxy_send_timeout 7d;
        proxy_read_timeout 7d;
    }

    # 정적 파일 캐싱
    location ~* \.(jpg|jpeg|png|gif|css|js)$ {
        proxy_pass http://socketio_backend;
        proxy_cache_valid 200 1d;
        add_header Cache-Control "public, immutable";
    }
}

설명

이것이 하는 일: 위 설정은 Nginx를 WebSocket 로드밸런서로 구성하여 트래픽을 세 대의 Socket.io 서버에 지능적으로 분산하고, SSL 종료와 장애 처리를 자동화합니다. 첫 번째로, upstream socketio_backend 블록에서 백엔드 서버 그룹을 정의합니다.

least_conn 지시어는 현재 연결 수가 가장 적은 서버로 새 연결을 보내므로, WebSocket처럼 장시간 연결에 이상적입니다. max_fails=3 fail_timeout=30s는 30초 내에 3번 실패하면 해당 서버를 30초간 제외하는 설정입니다.

그 다음으로, 포트 80에서 HTTPS로 리다이렉트하여 모든 트래픽이 암호화되도록 강제합니다. 이것은 보안 모범 사례이며, 최신 브라우저들이 권장하는 방식입니다.

SSL 인증서는 Let's Encrypt로 무료로 발급받을 수 있습니다. 포트 443의 location 블록에서 WebSocket 프록시를 설정합니다.

proxy_http_version 1.1Upgrade, Connection 헤더가 핵심입니다. 이 헤더들이 HTTP 연결을 WebSocket으로 업그레이드하는 핸드셰이크를 가능하게 합니다.

이 설정이 없으면 WebSocket 연결이 실패합니다. 타임아웃을 7일로 설정한 이유는 WebSocket 연결이 며칠씩 유지될 수 있기 때문입니다.

기본값은 60초로 너무 짧아서 정상 연결이 끊어질 수 있습니다. 실무에서는 클라이언트가 주기적으로 핑을 보내므로 무한정 유지되지는 않습니다.

X-Real-IPX-Forwarded-For 헤더로 실제 클라이언트 IP를 백엔드 서버에 전달합니다. 이 정보가 없으면 서버는 모든 연결이 로드밸런서에서 온 것으로 보이므로, IP 기반 제한이나 로깅이 제대로 작동하지 않습니다.

여러분이 이 설정을 사용하면 클라이언트는 단일 도메인(chat.example.com)으로 접속하지만 자동으로 가장 한가한 서버로 연결되고, 한 서버가 다운되면 자동으로 다른 서버로 분산되며, SSL 인증서 관리도 로드밸런서에서 중앙화되어 서버 관리가 간편해집니다. 실무에서는 AWS ALB, Google Cloud Load Balancer, Cloudflare 같은 관리형 로드밸런서를 사용하면 자동 확장, DDoS 방어, 글로벌 분산 등 더 많은 기능을 활용할 수 있습니다.

Nginx는 자체 운영할 때 훌륭한 선택입니다.

실전 팁

💡 Least Connections 알고리즘을 사용하세요. WebSocket은 연결 시간이 길어 라운드 로빈보다 연결 수 기반 분산이 훨씬 효과적입니다. 일부 사용자가 오래 접속해 있어도 부하가 고르게 분산됩니다.

💡 헬스 체크 간격은 5-10초가 적당합니다. 너무 짧으면 서버 부하를 증가시키고, 너무 길면 장애 감지가 늦어집니다. /health 엔드포인트는 가볍게 구현하여 데이터베이스 조회 없이 즉시 응답하도록 하세요.

💡 백업 서버를 설정할 수 있습니다. server 10.0.1.13:3000 backup;으로 지정하면 다른 서버가 모두 다운되었을 때만 사용됩니다. 긴급 상황에 최소한의 서비스를 유지하는 안전장치입니다.

💡 로드밸런서도 이중화하세요. Nginx 서버가 단일 장애점이 되지 않도록 최소 2대를 운영하고, Keepalived나 클라우드 로드밸런서의 HA 기능으로 자동 페일오버를 구성하세요.

💡 액세스 로그를 분석하여 트래픽 패턴을 파악하세요. 특정 시간대에 트래픽이 집중된다면 Auto Scaling 일정을 미리 설정하여 사전에 서버를 늘릴 수 있습니다. 로그를 ELK 스택으로 수집하면 실시간 대시보드를 만들 수 있습니다.


7. 세션 동기화

시작하며

여러분이 채팅 앱에서 로그인한 상태로 서버A에 접속했다가, 연결이 끊긴 후 서버B로 재접속했을 때 다시 로그인해야 한다면 사용자 경험이 최악이겠죠? 사용자는 자신이 어느 서버에 접속했는지 알 필요도 없고 알고 싶지도 않습니다.

이런 문제는 각 서버가 독립적으로 세션을 메모리에 저장하기 때문에 발생합니다. 서버A의 메모리에 있는 세션 정보를 서버B는 알 수 없으므로, 사용자 입장에서는 완전히 새로운 접속으로 취급됩니다.

채팅방 목록, 설정, 읽지 않은 메시지 표시 등 모든 것이 초기화되죠. 바로 이럴 때 필요한 것이 세션 동기화입니다.

Redis를 중앙 세션 저장소로 사용하여 모든 서버가 동일한 세션 정보를 공유하고, 사용자가 어느 서버에 접속하든 일관된 경험을 제공합니다.

개요

간단히 말해서, 세션 동기화는 사용자의 로그인 상태와 관련 정보를 중앙 저장소에 보관하여 모든 서버에서 접근할 수 있게 하는 방법입니다. 마치 은행의 중앙 데이터베이스처럼, 어느 지점에 가도 내 계좌 정보를 볼 수 있는 것과 같은 원리입니다.

실시간 채팅 서비스에서 세션은 단순한 로그인 상태를 넘어 사용자의 현재 컨텍스트를 모두 포함합니다. 어떤 채팅방을 보고 있는지, 마지막으로 메시지를 읽은 시간, 알림 설정, 온라인 상태 등이 모두 세션의 일부입니다.

이 정보가 동기화되지 않으면 서비스가 엉망이 됩니다. 기존에는 Express Session을 메모리 스토어로 사용했다면, 이제는 connect-redis나 ioredis로 Redis에 세션을 저장하여 모든 서버가 공유할 수 있습니다.

로드밸런서가 사용자를 다른 서버로 보내도 세션은 유지되고, 서버가 재시작되어도 세션이 사라지지 않습니다. 세션 동기화의 핵심은 첫째, Redis를 중앙 세션 저장소로 사용하고, 둘째, 세션 ID를 쿠키나 토큰으로 클라이언트에 저장하며, 셋째, 적절한 TTL로 오래된 세션을 자동 정리한다는 점입니다.

이러한 아키텍처가 무상태 서버와 일관된 사용자 경험을 동시에 제공합니다.

코드 예제

// Express와 Socket.io의 세션 동기화
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const { Server } = require('socket.io');

const app = express();
const httpServer = require('http').createServer(app);
const io = new Server(httpServer);

// Redis 클라이언트 생성
const redisClient = createClient({
  url: process.env.REDIS_URL,
  legacyMode: true
});

redisClient.connect().catch(console.error);

// 세션 미들웨어 설정
const sessionMiddleware = session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production', // HTTPS에서만
    httpOnly: true,
    maxAge: 1000 * 60 * 60 * 24 // 24시간
  }
});

// Express에 세션 적용
app.use(sessionMiddleware);

// Socket.io에도 같은 세션 미들웨어 적용
io.engine.use(sessionMiddleware);

// Socket.io 연결 시 세션 접근
io.on('connection', (socket) => {
  const session = socket.request.session;

  console.log('세션 ID:', session.id);
  console.log('사용자 ID:', session.userId);

  // 세션에 Socket ID 저장
  session.socketId = socket.id;
  session.save(); // Redis에 즉시 저장

  socket.on('authenticate', async (credentials) => {
    // 로그인 검증 후 세션에 저장
    const user = await verifyCredentials(credentials);

    if (user) {
      session.userId = user.id;
      session.username = user.username;
      session.save(); // 변경사항 저장

      socket.emit('authenticated', { success: true });
    }
  });

  socket.on('disconnect', () => {
    // 연결 해제 시 세션 업데이트
    session.socketId = null;
    session.lastSeen = Date.now();
    session.save();
  });
});

// REST API에서도 같은 세션 사용
app.post('/api/messages', (req, res) => {
  const userId = req.session.userId;

  if (!userId) {
    return res.status(401).json({ error: '인증 필요' });
  }

  // 메시지 저장 로직...
  res.json({ success: true });
});

async function verifyCredentials(credentials) {
  // 실제 인증 로직
  return { id: 1, username: 'user' };
}

httpServer.listen(3000);

설명

이것이 하는 일: 위 코드는 Express HTTP 요청과 Socket.io WebSocket 연결이 동일한 Redis 세션을 공유하도록 설정하여, REST API와 실시간 통신이 완벽하게 통합된 시스템을 만듭니다. 첫 번째로, connect-redis로 RedisStore를 생성하여 Express Session이 Redis를 세션 저장소로 사용하도록 합니다.

기본 메모리 스토어 대신 Redis를 사용하면 서버를 재시작해도 세션이 유지되고, 여러 서버가 동일한 세션에 접근할 수 있습니다. resave: falsesaveUninitialized: false로 불필요한 세션 저장을 방지하여 Redis 부하를 줄입니다.

그 다음으로, 같은 sessionMiddleware를 Express와 Socket.io Engine에 모두 적용합니다. 이것이 핵심입니다!

이렇게 하면 HTTP 요청으로 로그인한 사용자가 WebSocket으로 연결할 때 자동으로 인증된 상태가 유지됩니다. 두 프로토콜이 완전히 통합되는 거죠.

Socket.io 연결 핸들러에서 socket.request.session으로 세션에 접근합니다. 이 세션은 Redis에 저장되어 있으므로, 사용자가 이전에 다른 서버에서 로그인했어도 현재 서버에서 즉시 인증 상태를 확인할 수 있습니다.

session.save()를 명시적으로 호출하여 변경사항을 즉시 Redis에 반영합니다. 쿠키 설정도 중요합니다.

httpOnly: true로 JavaScript에서 쿠키 접근을 차단하여 XSS 공격을 방지하고, secure: true로 HTTPS에서만 쿠키를 전송하여 중간자 공격을 방지합니다. maxAge로 24시간 후 자동 로그아웃되도록 설정하여 보안을 강화합니다.

REST API 엔드포인트에서도 req.session.userId로 동일한 세션에 접근합니다. 사용자가 Socket.io로 메시지를 보내든 HTTP POST로 보내든, 서버는 동일한 인증 정보를 확인하고 일관된 권한 검사를 수행합니다.

여러분이 이 코드를 사용하면 사용자가 로그인한 후 어느 서버에 접속하든 로그인 상태가 유지되고, WebSocket 연결과 HTTP 요청이 완벽하게 동기화되며, 서버 재시작이나 배포 중에도 세션이 유지되어 사용자 경험이 끊기지 않습니다. 실무에서는 세션 하이재킹 방지를 위해 IP 주소나 User-Agent 변경을 감지하고, 중요한 작업 전에 재인증을 요구하는 등 추가 보안 조치가 필요합니다.

또한 세션 데이터를 최소화하여 Redis 메모리를 효율적으로 사용해야 합니다.

실전 팁

💡 세션 데이터는 최소한으로 유지하세요. userId, username 정도만 저장하고, 상세 정보는 필요할 때 DB에서 조회하세요. 세션에 큰 객체를 저장하면 Redis 메모리를 낭비하고 네트워크 전송도 느려집니다.

💡 세션 갱신 전략을 신중히 선택하세요. 매 요청마다 TTL을 갱신하면 활동 중인 사용자는 로그아웃되지 않지만, Redis 부하가 증가합니다. 절대 만료 시간과 갱신 가능 시간을 조합하는 방법도 고려하세요.

💡 로그아웃 시 명시적으로 세션을 삭제하세요. req.session.destroy()로 Redis에서 세션을 즉시 제거하면 보안이 강화되고, 오래된 세션이 쌓이지 않아 메모리 효율도 좋아집니다.

💡 세션 ID를 주기적으로 재생성하세요. 로그인 성공 시 req.session.regenerate()로 새 세션 ID를 발급하면 세션 고정 공격을 방지할 수 있습니다. 특히 권한이 변경될 때 필수입니다.

💡 JWT와 세션을 조합할 수 있습니다. 모바일 앱은 JWT로 무상태 인증하고, 웹은 세션으로 관리하면 각 플랫폼의 장점을 살릴 수 있습니다. Socket.io는 연결 시 JWT를 검증하여 세션을 생성하는 방식도 효과적입니다.


#Redis#Caching#Socket.io#Clustering#Performance#Redis,Clustering,Performance

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

인덱스와 쿼리 성능 최적화 완벽 가이드

데이터베이스 성능의 핵심인 인덱스를 처음부터 끝까지 배워봅니다. B-Tree 구조부터 실행 계획 분석까지, 실무에서 바로 사용할 수 있는 인덱스 최적화 전략을 초급자도 이해할 수 있게 설명합니다.

Docker 배포와 CI/CD 완벽 가이드

Docker를 활용한 컨테이너 배포부터 GitHub Actions를 이용한 자동화 파이프라인까지, 초급 개발자도 쉽게 따라할 수 있는 실전 배포 가이드입니다. AWS EC2에 애플리케이션을 배포하고 SSL 인증서까지 적용하는 전 과정을 다룹니다.

보안 강화 및 테스트 완벽 가이드

웹 애플리케이션의 보안 취약점을 방어하고 안정적인 서비스를 제공하기 위한 실전 보안 기법과 테스트 전략을 다룹니다. XSS, CSRF부터 DDoS 방어, Rate Limiting까지 실무에서 바로 적용 가능한 보안 솔루션을 제공합니다.

반응형 디자인 및 UX 최적화 완벽 가이드

모바일부터 데스크톱까지 완벽하게 대응하는 반응형 웹 디자인과 사용자 경험을 개선하는 실전 기법을 학습합니다. Tailwind CSS를 활용한 빠른 개발부터 다크모드, 무한 스크롤, 스켈레톤 로딩까지 최신 UX 패턴을 실무에 바로 적용할 수 있습니다.

React 채팅 UI 구현 완벽 가이드

실시간 채팅 애플리케이션의 UI를 React로 구현하는 방법을 다룹니다. Socket.io 연동부터 컴포넌트 설계, 상태 관리까지 실무에 바로 적용할 수 있는 내용을 담았습니다.