이 글에서 얻는 것

  • HTTP Polling vs WebSocket의 본질적 차이를 이해합니다
  • STOMP 프로토콜로 메시지 기반 통신을 구현합니다
  • 스케일 아웃 환경에서의 WebSocket 처리 전략을 알아봅니다

HTTP Polling vs WebSocket

통신 방식 비교

sequenceDiagram
    participant C as Client
    participant S as Server

    Note over C,S: HTTP Polling
    loop Every 1 second
        C->>S: GET /messages
        S-->>C: No new messages
        C->>S: GET /messages
        S-->>C: No new messages
        C->>S: GET /messages
        S-->>C: New message!
    end

    Note over C,S: WebSocket
    C->>S: Upgrade to WebSocket
    S-->>C: Connection Established
    S-->>C: New message (push)
    S-->>C: New message (push)
    C->>S: Send message
방식연결지연시간서버 부하적합한 경우
Short Polling매번 새 연결높음 (1~30초)높음간헐적 업데이트
Long Polling대기 후 응답중간중간실시간 대안
SSE단방향 스트림낮음낮음서버→클라이언트
WebSocket양방향 상시 연결매우 낮음연결당 리소스채팅, 게임, 거래

WebSocket Handshake

sequenceDiagram
    participant Client
    participant Server

    Client->>Server: HTTP GET /chat<br/>Upgrade: websocket<br/>Connection: Upgrade<br/>Sec-WebSocket-Key: xxx
    
    Server-->>Client: HTTP 101 Switching Protocols<br/>Upgrade: websocket<br/>Connection: Upgrade<br/>Sec-WebSocket-Accept: yyy
    
    Note over Client,Server: WebSocket Connection Established
    
    Client->>Server: WebSocket Frame (text/binary)
    Server-->>Client: WebSocket Frame (text/binary)
    
    Client->>Server: Close Frame
    Server-->>Client: Close Frame

Spring WebSocket 구현

기본 WebSocket 핸들러

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(chatHandler(), "/chat")
                .setAllowedOrigins("*");
    }
    
    @Bean
    public WebSocketHandler chatHandler() {
        return new ChatWebSocketHandler();
    }
}

@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {
    
    private final Set<WebSocketSession> sessions = ConcurrentHashMap.newKeySet();
    
    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        sessions.add(session);
        log.info("Connected: {}", session.getId());
    }
    
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        // 모든 클라이언트에게 브로드캐스트
        for (WebSocketSession s : sessions) {
            if (s.isOpen()) {
                s.sendMessage(new TextMessage(
                    "User " + session.getId() + ": " + message.getPayload()
                ));
            }
        }
    }
    
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        sessions.remove(session);
        log.info("Disconnected: {}", session.getId());
    }
}

STOMP 프로토콜

STOMP (Simple Text Oriented Messaging Protocol)

flowchart TB
    subgraph Clients
        C1[Client 1]
        C2[Client 2]
        C3[Client 3]
    end
    
    subgraph Server["Spring STOMP"]
        MB[Message Broker]
        
        subgraph Topics
            T1["/topic/chat"]
            T2["/topic/notifications"]
        end
        
        subgraph Queues
            Q1["/user/queue/messages"]
        end
    end
    
    C1 -->|SUBSCRIBE /topic/chat| MB
    C2 -->|SUBSCRIBE /topic/chat| MB
    C3 -->|SEND /app/chat| MB
    
    MB --> T1
    T1 -->|Broadcast| C1
    T1 -->|Broadcast| C2
    
    style MB fill:#e3f2fd,stroke:#1565c0

Spring STOMP 설정

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
    
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // 클라이언트가 구독할 prefix
        config.enableSimpleBroker("/topic", "/queue");
        
        // 서버로 메시지 보낼 때 prefix
        config.setApplicationDestinationPrefixes("/app");
        
        // 특정 사용자에게 메시지 보낼 때 prefix
        config.setUserDestinationPrefix("/user");
    }
    
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
                .setAllowedOrigins("*")
                .withSockJS();  // SockJS fallback
    }
}

메시지 컨트롤러

@Controller
public class ChatController {
    
    @Autowired
    private SimpMessagingTemplate messagingTemplate;
    
    // 클라이언트 → 서버 → 모든 구독자
    @MessageMapping("/chat.send")
    @SendTo("/topic/chat")
    public ChatMessage sendMessage(ChatMessage message) {
        message.setTimestamp(LocalDateTime.now());
        return message;
    }
    
    // 특정 사용자에게만 전송
    @MessageMapping("/chat.private")
    public void sendPrivateMessage(PrivateMessage message) {
        messagingTemplate.convertAndSendToUser(
            message.getRecipient(),
            "/queue/messages",
            message
        );
    }
    
    // 서버에서 직접 브로드캐스트
    public void broadcastNotification(String notification) {
        messagingTemplate.convertAndSend("/topic/notifications", notification);
    }
}

@Getter @Setter
public class ChatMessage {
    private String sender;
    private String content;
    private LocalDateTime timestamp;
}

클라이언트 (JavaScript)

// STOMP.js 사용
const socket = new SockJS('/ws');
const stompClient = Stomp.over(socket);

stompClient.connect({}, function(frame) {
    console.log('Connected: ' + frame);
    
    // 채팅방 구독
    stompClient.subscribe('/topic/chat', function(message) {
        const chatMessage = JSON.parse(message.body);
        showMessage(chatMessage);
    });
    
    // 개인 메시지 구독
    stompClient.subscribe('/user/queue/messages', function(message) {
        showPrivateMessage(JSON.parse(message.body));
    });
});

// 메시지 전송
function sendMessage(content) {
    stompClient.send('/app/chat.send', {}, JSON.stringify({
        sender: username,
        content: content
    }));
}

스케일 아웃 전략

문제: 다중 서버 환경

flowchart TB
    subgraph Clients
        C1[Client A]
        C2[Client B]
        C3[Client C]
    end
    
    LB[Load Balancer]
    
    subgraph Servers
        S1[Server 1]
        S2[Server 2]
    end
    
    C1 --> LB
    C2 --> LB
    C3 --> LB
    
    LB --> S1
    LB --> S2
    
    C1 -.->|Connected| S1
    C2 -.->|Connected| S1
    C3 -.->|Connected| S2
    
    S1 -.-x|"❌ S1의 메시지가\nS2 클라이언트에게\n전달 안됨"| S2
    
    style S1 fill:#e8f5e9,stroke:#2e7d32
    style S2 fill:#fff3e0,stroke:#ef6c00

해결: External Message Broker

flowchart TB
    subgraph Clients
        C1[Client A]
        C2[Client B]
        C3[Client C]
    end
    
    LB[Load Balancer]
    
    subgraph Servers
        S1[Server 1]
        S2[Server 2]
    end
    
    Redis[(Redis Pub/Sub)]
    
    C1 --> LB
    C2 --> LB
    C3 --> LB
    
    LB --> S1
    LB --> S2
    
    S1 <--> Redis
    S2 <--> Redis
    
    style Redis fill:#ffebee,stroke:#c62828

Redis Pub/Sub 설정

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // Redis를 외부 브로커로 사용
        config.enableStompBrokerRelay("/topic", "/queue")
              .setRelayHost("redis-host")
              .setRelayPort(6379);
        
        config.setApplicationDestinationPrefixes("/app");
    }
}

RabbitMQ STOMP 설정

@Configuration
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // RabbitMQ STOMP 플러그인 사용
        config.enableStompBrokerRelay("/topic", "/queue")
              .setRelayHost("rabbitmq-host")
              .setRelayPort(61613)  // STOMP port
              .setClientLogin("guest")
              .setClientPasscode("guest");
    }
}

인증 및 보안

WebSocket 인증

@Configuration
public class WebSocketSecurityConfig {
    
    @Bean
    public ChannelInterceptor authInterceptor() {
        return new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor = 
                    StompHeaderAccessor.wrap(message);
                
                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    String token = accessor.getFirstNativeHeader("Authorization");
                    
                    if (token != null && token.startsWith("Bearer ")) {
                        String jwt = token.substring(7);
                        Authentication auth = jwtTokenProvider.getAuthentication(jwt);
                        accessor.setUser(auth);
                    }
                }
                return message;
            }
        };
    }
}

메시지 레벨 보안

@Configuration
@EnableWebSocketSecurity
public class WebSocketSecurityConfig {
    
    @Bean
    AuthorizationManager<Message<?>> messageAuthorizationManager() {
        return messages -> {
            // /topic/admin/* 은 ADMIN 역할만
            // /user/** 는 인증된 사용자만
            // 나머지는 모두 허용
        };
    }
}

요약

WebSocket 사용 시점

적합부적합
실시간 채팅단순 정보 조회
실시간 알림간헐적 업데이트
온라인 게임RESTful API 대체
주식/거래 앱파일 전송
협업 도구SEO 필요

핵심 포인트

  • STOMP: 메시지 기반 프로토콜로 구독/발행 모델 지원
  • SockJS: WebSocket 미지원 브라우저 폴백
  • 스케일 아웃: Redis/RabbitMQ로 서버 간 메시지 동기화
  • 인증: CONNECT 시점 토큰 검증