Spring Boot WebSocket을 Shared Worker로 운영하는 방법: 여러 탭 연결 최적화 실무 정리

Spring Boot WebSocket을 Shared Worker에서 운영해 브라우저 여러 탭의 중복 WebSocket 연결을 줄이는 방법을 정리합니다. 서버 설정, 프론트엔드 Shared Worker 코드, 재연결, 인증, 운영 주의사항까지 실무 기준으로 설명합니다.

Spring Boot WebSocket을 Shared Worker로 운영하는 방법: 여러 탭 연결 최적화 실무 정리

Spring Boot WebSocket을 운영하다 보면 사용자가 같은 서비스를 여러 탭으로 열었을 때 탭마다 WebSocket 연결이 생성되는 문제가 자주 발생합니다. 이 글에서는 Shared Worker를 사용해 브라우저 여러 탭에서 하나의 WebSocket 연결을 공유하는 구조를 정리하고, Spring Boot WebSocket 서버 설정부터 프론트엔드 Shared Worker 구현, 재연결 처리, 인증 처리, 운영 시 주의사항까지 실무 기준으로 설명합니다.

Spring Framework는 WebSocket 위에서 STOMP 같은 서브 프로토콜을 사용할 수 있으며, STOMP는 메시지의 형식과 목적지를 명확히 정의하는 데 자주 사용됩니다. SharedWorker는 여러 탭, 창, iframe 같은 동일 출처의 브라우징 컨텍스트에서 접근할 수 있는 Worker입니다.

왜 Shared Worker에서 WebSocket을 운영해야 할까?

일반적으로 WebSocket은 페이지 단위로 연결됩니다.

예를 들어 사용자가 다음처럼 같은 서비스를 여러 탭으로 열었다고 가정합니다.

탭 1: /dashboard
탭 2: /monitoring
탭 3: /chat
탭 4: /notification
 

각 탭에서 WebSocket을 직접 연결하면 서버 입장에서는 같은 사용자에게 4개의 연결이 생깁니다.

userA - socket connection 1
userA - socket connection 2
userA - socket connection 3
userA - socket connection 4
 

서비스 규모가 작을 때는 큰 문제가 없어 보일 수 있지만, 운영 환경에서는 다음 문제가 생깁니다.

문제설명
서버 연결 수 증가 같은 사용자가 여러 탭을 열면 연결 수가 불필요하게 증가합니다.
인증 처리 중복 탭마다 토큰 검증, 세션 확인, 구독 처리가 반복됩니다.
메시지 중복 수신 같은 알림이 여러 탭에서 동시에 처리될 수 있습니다.
브라우저 리소스 증가 각 탭마다 WebSocket 객체와 이벤트 핸들러가 생성됩니다.
장애 대응 복잡도 증가 재연결, heartbeat, close 처리를 탭마다 구현해야 합니다.

Shared Worker를 사용하면 같은 출처에서 열린 여러 탭이 하나의 Worker를 공유하고, Worker 내부에서 WebSocket 연결을 한 번만 생성할 수 있습니다.

탭 1 ┐
탭 2 ├── Shared Worker ── WebSocket ── Spring Boot
탭 3 ┘
 

전체 구조

Shared Worker 기반 WebSocket 운영 구조는 다음과 같습니다.

Browser Tab
  └─ SharedWorker 연결
       └─ MessagePort로 메시지 송수신
            └─ SharedWorker 내부 WebSocket 관리
                 └─ Spring Boot WebSocket 서버
 

역할을 나누면 다음과 같습니다.

구성 요소역할
Spring Boot WebSocket endpoint 제공, 메시지 수신 및 브로드캐스트
Shared Worker 실제 WebSocket 연결 생성 및 유지
각 브라우저 탭 Worker와 MessagePort로 통신
MessagePort 탭과 Shared Worker 사이의 메시지 전달 채널

Spring Boot WebSocket 서버 설정

가장 단순한 WebSocket 서버 예제입니다. STOMP 없이 순수 WebSocket을 사용하는 방식입니다.

Gradle 의존성

 
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    implementation 'org.springframework.boot:spring-boot-starter-web'
}
 

WebSocket 설정

 
package com.example.websocket.config;

import com.example.websocket.handler.AppWebSocketHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final AppWebSocketHandler appWebSocketHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(appWebSocketHandler, "/ws")
                .setAllowedOriginPatterns("*");
    }
}
 

운영 환경에서는 setAllowedOriginPatterns("*")를 그대로 사용하지 않는 것이 좋습니다. 실제 서비스 도메인을 명시하는 방식이 안전합니다.

 
.setAllowedOriginPatterns(
    "https://example.com",
    "https://www.example.com"
);
 

WebSocket Handler 구현

 
package com.example.websocket.handler;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Component
public class AppWebSocketHandler extends TextWebSocketHandler {

    private final Set<WebSocketSession> sessions = ConcurrentHashMap.newKeySet();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        sessions.add(session);
        log.info("WebSocket connected. sessionId={}, total={}", session.getId(), sessions.size());
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        log.info("message received. sessionId={}, payload={}", session.getId(), message.getPayload());

        String response = """
                {
                  "type": "ECHO",
                  "payload": %s
                }
                """.formatted(message.getPayload());

        session.sendMessage(new TextMessage(response));
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        sessions.remove(session);
        log.info("WebSocket closed. sessionId={}, code={}, reason={}, total={}",
                session.getId(),
                status.getCode(),
                status.getReason(),
                sessions.size());
    }

    public void broadcast(String message) {
        sessions.forEach(session -> {
            try {
                if (session.isOpen()) {
                    session.sendMessage(new TextMessage(message));
                }
            } catch (Exception e) {
                log.warn("broadcast failed. sessionId={}", session.getId(), e);
            }
        });
    }
}
 

Shared Worker에서 WebSocket 연결 관리하기

Shared Worker 파일은 일반 JS 파일로 분리해야 합니다.

예를 들어 다음 경로에 둡니다.

/public/ws-shared-worker.js
 

Nuxt, Vite, React, Vue 같은 환경에서는 보통 public 디렉터리에 두면 빌드 결과에서 루트 경로로 접근할 수 있습니다.

/ws-shared-worker.js
 

Shared Worker 코드 예제

 
// /public/ws-shared-worker.js

let socket = null;
let ports = [];
let reconnectTimer = null;
let reconnectCount = 0;

const WS_URL = 'wss://example.com/ws';

function broadcastToTabs(message) {
  ports.forEach((port) => {
    port.postMessage(message);
  });
}

function connectWebSocket() {
  if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
    return;
  }

  socket = new WebSocket(WS_URL);

  socket.onopen = () => {
    reconnectCount = 0;

    broadcastToTabs({
      type: 'WS_OPEN'
    });
  };

  socket.onmessage = (event) => {
    broadcastToTabs({
      type: 'WS_MESSAGE',
      data: event.data
    });
  };

  socket.onerror = () => {
    broadcastToTabs({
      type: 'WS_ERROR'
    });
  };

  socket.onclose = (event) => {
    broadcastToTabs({
      type: 'WS_CLOSE',
      code: event.code,
      reason: event.reason
    });

    scheduleReconnect();
  };
}

function scheduleReconnect() {
  if (reconnectTimer) {
    return;
  }

  const delay = Math.min(1000 * Math.pow(2, reconnectCount), 30000);
  reconnectCount += 1;

  reconnectTimer = setTimeout(() => {
    reconnectTimer = null;
    connectWebSocket();
  }, delay);
}

function sendToServer(data) {
  if (!socket || socket.readyState !== WebSocket.OPEN) {
    broadcastToTabs({
      type: 'WS_NOT_READY'
    });
    return;
  }

  socket.send(JSON.stringify(data));
}

self.onconnect = (event) => {
  const port = event.ports[0];

  ports.push(port);
  port.start();

  port.postMessage({
    type: 'WORKER_CONNECTED'
  });

  connectWebSocket();

  port.onmessage = (messageEvent) => {
    const message = messageEvent.data;

    if (message.type === 'SEND') {
      sendToServer(message.data);
    }

    if (message.type === 'CLOSE_PORT') {
      ports = ports.filter((p) => p !== port);
      port.close();

      if (ports.length === 0 && socket) {
        socket.close(1000, 'All tabs closed');
        socket = null;
      }
    }
  };
};
 

이 코드의 핵심은 탭마다 WebSocket을 직접 만들지 않고, Shared Worker 내부에서만 WebSocket을 생성한다는 점입니다. 각 탭은 MessagePort를 통해 Worker에게 메시지를 보내고, Worker는 서버에서 받은 WebSocket 메시지를 다시 모든 탭에 전달합니다.

브라우저 탭에서 Shared Worker 연결하기

각 페이지에서는 다음처럼 Shared Worker에 연결합니다.

 
// websocket-client.js

const worker = new SharedWorker('/ws-shared-worker.js');

worker.port.start();

worker.port.onmessage = (event) => {
  const message = event.data;

  switch (message.type) {
    case 'WORKER_CONNECTED':
      console.log('Shared Worker connected');
      break;

    case 'WS_OPEN':
      console.log('WebSocket opened');
      break;

    case 'WS_MESSAGE':
      console.log('WebSocket message:', message.data);
      break;

    case 'WS_CLOSE':
      console.log('WebSocket closed:', message.code, message.reason);
      break;

    case 'WS_ERROR':
      console.log('WebSocket error');
      break;

    case 'WS_NOT_READY':
      console.log('WebSocket is not ready');
      break;

    default:
      console.log('Unknown message:', message);
  }
};

export function sendMessage(data) {
  worker.port.postMessage({
    type: 'SEND',
    data
  });
}

export function closeWorkerPort() {
  worker.port.postMessage({
    type: 'CLOSE_PORT'
  });
}
 

페이지에서는 다음처럼 사용할 수 있습니다.

 
import { sendMessage, closeWorkerPort } from './websocket-client';

sendMessage({
  type: 'PING',
  payload: {
    message: 'hello'
  }
});

window.addEventListener('beforeunload', () => {
  closeWorkerPort();
});
 

인증 토큰은 어떻게 처리할까?

운영 환경에서는 WebSocket 연결 시 인증이 거의 필수입니다. 다만 브라우저 기본 WebSocket API는 임의의 HTTP Header를 직접 추가할 수 없습니다. 그래서 보통 다음 방식 중 하나를 사용합니다.

방식설명주의사항
Cookie 인증 기존 로그인 세션 쿠키를 WebSocket 연결에 사용 SameSite, Secure 설정 확인 필요
Query String 토큰 wss://example.com/ws?token=... 형태 URL 로그에 토큰이 남을 수 있어 주의
최초 메시지 인증 연결 후 첫 메시지로 인증 정보 전송 인증 전 메시지 차단 로직 필요
STOMP Header STOMP CONNECT 프레임에 토큰 전달 STOMP 클라이언트 사용 시 적합

실무에서는 쿠키 기반 인증이 가장 단순한 경우가 많습니다. JWT를 써야 한다면 짧은 만료 시간의 WebSocket 전용 토큰을 발급해서 사용하는 방식이 안전합니다.

예를 들어 Shared Worker에 토큰을 전달하려면 다음처럼 처리할 수 있습니다.

 
const worker = new SharedWorker('/ws-shared-worker.js');

worker.port.start();

worker.port.postMessage({
  type: 'INIT',
  token: accessToken
});
 

Worker에서는 INIT 메시지를 받은 뒤 WebSocket URL을 구성합니다.

 
let authToken = null;

self.onconnect = (event) => {
  const port = event.ports[0];

  ports.push(port);
  port.start();

  port.onmessage = (messageEvent) => {
    const message = messageEvent.data;

    if (message.type === 'INIT') {
      authToken = message.token;
      connectWebSocket();
    }

    if (message.type === 'SEND') {
      sendToServer(message.data);
    }
  };
};

function connectWebSocket() {
  if (!authToken) {
    return;
  }

  const wsUrl = `wss://example.com/ws?token=${encodeURIComponent(authToken)}`;

  socket = new WebSocket(wsUrl);

  // 이하 동일
}
 

Spring Boot에서 토큰 검증하기

HandshakeInterceptor를 사용하면 WebSocket 연결 시점에 토큰을 검증할 수 있습니다.

 
package com.example.websocket.config;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;

import java.util.Map;

@Slf4j
@Component
public class WebSocketAuthInterceptor implements HandshakeInterceptor {

    @Override
    public boolean beforeHandshake(
            ServerHttpRequest request,
            ServerHttpResponse response,
            WebSocketHandler wsHandler,
            Map<String, Object> attributes
    ) {
        if (!(request instanceof ServletServerHttpRequest servletRequest)) {
            return false;
        }

        HttpServletRequest httpRequest = servletRequest.getServletRequest();
        String token = httpRequest.getParameter("token");

        if (token == null || token.isBlank()) {
            log.warn("WebSocket token is missing");
            return false;
        }

        // 실제 운영에서는 JWT 검증 로직으로 교체
        if (!isValidToken(token)) {
            log.warn("Invalid WebSocket token");
            return false;
        }

        attributes.put("userId", extractUserId(token));

        return true;
    }

    @Override
    public void afterHandshake(
            ServerHttpRequest request,
            ServerHttpResponse response,
            WebSocketHandler wsHandler,
            Exception exception
    ) {
    }

    private boolean isValidToken(String token) {
        return token.startsWith("valid");
    }

    private String extractUserId(String token) {
        return "user-1";
    }
}
 

설정에 Interceptor를 추가합니다.

 
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {

    private final AppWebSocketHandler appWebSocketHandler;
    private final WebSocketAuthInterceptor webSocketAuthInterceptor;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(appWebSocketHandler, "/ws")
                .addInterceptors(webSocketAuthInterceptor)
                .setAllowedOriginPatterns("https://example.com");
    }
}
 

Handler에서는 attributes에서 사용자 정보를 꺼낼 수 있습니다.

 
@Override
public void afterConnectionEstablished(WebSocketSession session) {
    String userId = (String) session.getAttributes().get("userId");

    sessions.add(session);

    log.info("WebSocket connected. userId={}, sessionId={}", userId, session.getId());
}
 

STOMP를 사용할 때도 Shared Worker를 쓸 수 있을까?

가능합니다. 다만 구조가 조금 달라집니다.

STOMP는 WebSocket 위에서 동작하는 메시징 프로토콜입니다. Spring 공식 문서에서도 WebSocket 자체는 텍스트와 바이너리 메시지만 정의하고, 메시지의 의미나 형식은 애플리케이션 또는 서브 프로토콜이 정해야 한다고 설명합니다. Spring 가이드에서도 STOMP를 WebSocket 위에서 사용하는 예제를 제공합니다.

STOMP를 Shared Worker에서 운영하려면 다음 중 하나를 선택합니다.

방식설명
Worker 내부에서 STOMP client 사용 STOMP 연결, subscribe, publish를 모두 Worker에서 관리
Worker 내부는 순수 WebSocket, 탭에서 메시지 라우팅 직접 프로토콜을 설계해야 하므로 복잡도 증가
탭마다 STOMP, Worker는 사용하지 않음 구현은 단순하지만 중복 연결 문제가 남음

실무에서는 STOMP를 쓴다면 Worker 내부에 STOMP client를 넣는 방식이 가장 일관적입니다.

운영 시 반드시 고려해야 할 점

1. Shared Worker 브라우저 지원 확인

SharedWorker는 여러 브라우저 컨텍스트에서 접근할 수 있는 Worker이지만, 모든 환경에서 동일하게 사용할 수 있는 것은 아닙니다. MDN 문서에서도 SharedWorker가 여러 창, 탭, iframe에서 접근 가능한 Worker라고 설명합니다.

따라서 운영에서는 fallback 전략이 필요합니다.

 
if (window.SharedWorker) {
  // Shared Worker 방식
} else {
  // 일반 WebSocket 방식
}
 

2. 재연결은 반드시 Worker에서 중앙 관리한다

탭마다 재연결 로직을 두면 Shared Worker를 쓰는 의미가 줄어듭니다. 재연결은 Worker 내부에서만 처리하고, 탭은 상태만 전달받는 구조가 좋습니다.

Worker: reconnect 처리
Tab: WS_OPEN, WS_CLOSE, WS_MESSAGE 상태 수신
 

3. 모든 탭에 같은 메시지를 뿌릴지 결정해야 한다

알림 메시지는 모든 탭에 전달해도 괜찮지만, 특정 화면에서만 필요한 메시지는 탭별 필터링이 필요합니다.

예를 들어 탭에서 구독 정보를 Worker에 전달합니다.

 
worker.port.postMessage({
  type: 'SUBSCRIBE',
  topic: 'notification'
});
 

Worker에서는 port별 관심 topic을 저장합니다.

 
const subscriptions = new Map();

function subscribe(port, topic) {
  const topics = subscriptions.get(port) || new Set();
  topics.add(topic);
  subscriptions.set(port, topics);
}
 

4. 서버는 여전히 사용자 단위 세션 관리를 해야 한다

Shared Worker를 사용하면 브라우저 탭 중복 연결은 줄어들지만, 사용자가 여러 브라우저, 여러 기기에서 접속하는 것은 막을 수 없습니다.

Chrome PC: 1 connection
Safari Mobile: 1 connection
Office PC: 1 connection
 

따라서 서버에서는 사용자 ID 기준으로 session을 관리하는 구조가 필요합니다.

 
private final Map<String, Set<WebSocketSession>> userSessions = new ConcurrentHashMap<>();
 

5. Nginx, Apache 프록시 설정도 확인해야 한다

WebSocket은 HTTP Upgrade를 사용하므로 프록시 서버에서 Upgrade 헤더를 전달해야 합니다.

Nginx 예시는 다음과 같습니다.

 
location /ws {
    proxy_pass http://spring_boot_app;
    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_read_timeout 3600s;
    proxy_send_timeout 3600s;
}
 

Apache를 사용한다면 mod_proxy_wstunnel 설정이 필요합니다.

 
ProxyPass "/ws"  "ws://127.0.0.1:8080/ws"
ProxyPassReverse "/ws"  "ws://127.0.0.1:8080/ws"
 

Shared Worker WebSocket 운영 체크리스트

항목확인 내용
연결 수 같은 브라우저 여러 탭에서 WebSocket 연결이 1개만 생성되는지 확인
인증 Worker 초기화 시 토큰 전달 또는 쿠키 인증 구조 확인
재연결 서버 재시작 후 자동 재연결되는지 확인
탭 종료 모든 탭 종료 시 WebSocket이 닫히는지 확인
메시지 중복 같은 메시지가 여러 번 처리되지 않는지 확인
브라우저 지원 SharedWorker 미지원 환경 fallback 처리
프록시 Nginx, Apache에서 Upgrade 헤더 전달 확인
보안 운영 도메인 CORS, Origin 제한 적용

FAQ

Shared Worker를 쓰면 WebSocket 연결이 무조건 1개가 되나요?

같은 브라우저, 같은 출처, 같은 Shared Worker 파일을 사용하는 탭에서는 하나의 Worker를 공유할 수 있습니다. 다만 브라우저가 다르거나, 시크릿 모드이거나, 출처가 다르면 별도의 Worker와 WebSocket 연결이 생성될 수 있습니다.

Shared Worker와 Service Worker 중 무엇을 써야 하나요?

WebSocket 연결을 직접 유지하는 목적이라면 Shared Worker가 더 적합합니다. Service Worker는 네트워크 프록시, 캐싱, 푸시 알림 같은 목적에 더 가깝고, 페이지 생명주기와 별도로 항상 WebSocket을 유지하는 용도로 설계된 것은 아닙니다.

Spring Boot 서버는 Shared Worker를 따로 알아야 하나요?

아닙니다. 서버 입장에서는 일반 WebSocket 클라이언트가 하나 접속한 것처럼 보입니다. Shared Worker 적용은 주로 브라우저 클라이언트 구조의 변경입니다.

STOMP를 꼭 써야 하나요?

단순 알림, 상태 변경, 채팅 정도라면 순수 WebSocket으로도 충분합니다. 하지만 topic, queue, publish, subscribe 구조가 필요하거나 메시지 라우팅이 복잡하다면 STOMP를 사용하는 것이 유지보수에 유리할 수 있습니다.

정리

Spring Boot WebSocket을 운영할 때 사용자가 여러 탭을 열면 탭마다 WebSocket 연결이 생성되어 서버 리소스와 메시지 처리 복잡도가 증가할 수 있습니다. Shared Worker를 사용하면 같은 브라우저의 여러 탭에서 하나의 WebSocket 연결을 공유할 수 있어 연결 수를 줄이고, 재연결과 메시지 분배 로직을 중앙에서 관리할 수 있습니다.

실무에서는 단순히 WebSocket 코드를 Shared Worker로 옮기는 것에서 끝나지 않고, 인증 토큰 처리, 브라우저 fallback, 재연결 전략, 프록시 설정, 사용자별 세션 관리까지 함께 설계해야 안정적으로 운영할 수 있습니다.

5. 추천 태그

Spring Boot WebSocket, Shared Worker, WebSocket 운영, 스프링부트 웹소켓, 브라우저 탭 WebSocket, WebSocket 연결 최적화, STOMP WebSocket, 실시간 알림 구현, Spring Boot 실시간 통신, WebSocket 재연결