Microsoft .NET/ASP.Net & ASP.Net Core

[ASP.NET Core] SignalR 분산 환경 구축: Redis 백플레인(Backplane)과 웹소켓 스케일아웃

전자기린 2026. 11. 11. 12:13

 

SignalR은 연결이 인스턴스에 묶여 있어, 서버를 여러 대로 늘리면 다른 인스턴스의 클라이언트에게 메시지가 전달되지 않습니다. Redis 백플레인을 붙여 인스턴스 간 메시지를 중계하고, 로드 밸런서에서 웹소켓이 끊기지 않게 구성하는 절차입니다.

 

절차

 

  1. 증상 확인
  2. Redis 기동
  3. 백플레인 등록
  4. 허브와 클라이언트 연결
  5. 로드 밸런서 설정
  6. 운영에서 조정할 값
  7. 점검 항목

 

1. 증상 확인

 

  1. 인스턴스 2대를 띄우고 같은 그룹에 두 클라이언트를 붙입니다.
  2. A 인스턴스에 붙은 클라이언트가 보낸 메시지가 B 인스턴스의 클라이언트에 도착하지 않습니다.
  3. 로그에는 오류가 없습니다. 브로드캐스트가 프로세스 안에서만 처리되기 때문입니다.

 

백플레인 없이 스케일아웃하면 이 상태가 정상 동작으로 보입니다. 연결 수는 늘고 실시간 기능만 조용히 깨집니다.

 

2. Redis 기동

 

docker run -d --name signalr-redis \
  -p 6379:6379 \
  -v $(pwd)/redis-data:/data \
  redis:7-alpine redis-server --appendonly yes

 

백플레인은 pub/sub만 사용하므로 영속성은 필수가 아닙니다. 다만 재연결 폭주 시 부하를 견디도록 maxmemory 정책과 타임아웃을 함께 봅니다.

 

docker exec -it signalr-redis redis-cli ping
docker exec -it signalr-redis redis-cli config get maxmemory

 

3. 백플레인 등록

 

dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis

 

using StackExchange.Redis;

var redisConnection = builder.Configuration.GetConnectionString("Redis")
                      ?? "localhost:6379";

builder.Services
    .AddSignalR(options =>
    {
        options.EnableDetailedErrors = builder.Environment.IsDevelopment();
        options.MaximumReceiveMessageSize = 64 * 1024;
        options.KeepAliveInterval = TimeSpan.FromSeconds(15);
        options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
    })
    .AddStackExchangeRedis(redisConnection, options =>
    {
        options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp");
        options.Configuration.AbortOnConnectFail = false;
    });

 

  1. ChannelPrefix는 같은 Redis를 여러 애플리케이션이 공유할 때 메시지가 섞이지 않게 합니다. 서비스마다 다르게 지정합니다.
  2. AbortOnConnectFail = false로 두면 Redis가 잠깐 끊겨도 애플리케이션 시작이 실패하지 않습니다. 백플레인 연결이 복구되면 자동으로 다시 붙습니다.
  3. MaximumReceiveMessageSize는 기본값이 작습니다. 큰 payload를 주고받으면 조용히 연결이 끊기므로 업무 메시지 크기에 맞춰 올립니다.

 

4. 허브와 클라이언트

 

public class ChatHub : Hub
{
    public Task JoinRoom(string room) =>
        Groups.AddToGroupAsync(Context.ConnectionId, room);

    public Task SendToRoom(string room, string message) =>
        Clients.Group(room).SendAsync("message", message);
}

app.MapHub<ChatHub>("/hubs/chat");

 

const connection = new signalR.HubConnectionBuilder()
  .withUrl("/hubs/chat", { accessTokenFactory: () => token })
  .withAutomaticReconnect()
  .build();

await connection.start();
await connection.invoke("JoinRoom", "notice");
connection.on("message", (msg) => console.log(msg));

 

백플레인이 연결되면 Clients.Group, Clients.User, Clients.All 메시지가 Redis를 거쳐 모든 인스턴스로 전달되고, 각 인스턴스는 자기에게 붙은 연결에만 실제로 씁니다.

 

5. 로드 밸런서 설정

 

SignalR은 협상(negotiate) 후 연결을 유지합니다. 연결이 끝난 뒤 다른 인스턴스로 보내면 세션이 깨지므로, 웹소켓 연결에 대한 고정(sticky)이 필요합니다.

 

upstream signalr_backend {
    ip_hash;
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

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

    location /hubs/ {
        proxy_pass http://signalr_backend;
        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-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
        proxy_buffering off;
    }
}

 

  1. ip_hash(또는 쿠키 기반 고정)가 없으면 협상과 연결이 다른 인스턴스로 흩어집니다.
  2. proxy_read_timeout이 짧으면 유휴 연결이 끊깁니다. KeepAliveInterval보다 넉넉하게 잡습니다.
  3. proxy_buffering off로 두어야 메시지가 즉시 전달됩니다.
  4. 웹소켓을 쓰지 않는 폴백(SSE, Long Polling)에서도 같은 고정이 필요합니다.

 

쿠키 기반 고정을 쓰는 로드 밸런서라면 애플리케이션에서 쿠키를 발급하지 않아도, LB가 붙인 세션 쿠키로 같은 인스턴스에 고정됩니다.

 

6. 운영에서 조정할 값

 

  1. KeepAliveInterval과 ClientTimeoutInterval은 2배 이상 차이가 나야 합니다. 클라이언트가 ping을 놓쳐도 즉시 끊기지 않게 하기 위해서입니다.
  2. 연결 수가 많은 서비스는 인스턴스당 연결 수와 메모리를 함께 봅니다. 연결 하나가 차지하는 메모리는 메시지 크기와 버퍼 설정에 좌우됩니다.
  3. Redis 단일 인스턴스는 처리량 한계가 있습니다. 초당 메시지가 많은 서비스는 Redis Cluster나 Azure SignalR Service 같은 관리형 백플레인을 검토합니다.
  4. 재연결 폭주를 막기 위해 클라이언트 withAutomaticReconnect([0, 2000, 5000, 10000])처럼 지연을 단계적으로 둡니다.

 

7. 점검 항목

 

  1. 인스턴스별 연결 수: 로그에 OnConnectedAsync/OnDisconnectedAsync 카운트를 남깁니다.
  2. Redis pub/sub 채널 구독 여부: redis-cli pubsub channels로 채널이 보이는지 확인합니다.
  3. 로드 밸런서의 업그레이드 헤더 누락: 400이나 502가 반복되면 Upgrade/Connection 헤더를 먼저 확인합니다.
  4. 프록시 뒤 클라이언트 IP: 인증·레이트 리밋이 IP 기반이면 X-Forwarded-For 처리(UseForwardedHeaders)를 함께 설정합니다.

 

정리

 

AddStackExchangeRedis로 백플레인을 붙이면 인스턴스 간 브로드캐스트가 Redis를 거쳐 전달됩니다. 로드 밸런서에서는 웹소켓 고정과 업그레이드 헤더, 넉넉한 read timeout을 함께 맞추고, 채널 프리픽스와 연결 파라미터를 서비스 단위로 고정하면 스케일아웃 후에도 실시간 기능이 유지됩니다.