채팅, 실시간 알림, 주식 가격 업데이트처럼 서버에서 클라이언트에게 즉시 데이터를 보내야 한다면, HTTP 폴링 말고 더 나은 방법이 있을까요?

WebSocket이란

WebSocket은 HTTP 업그레이드를 통해 서버와 클라이언트 간 지속적인 양방향 연결 을 제공하는 프로토콜입니다. HTTP의 요청-응답 패턴과 달리 연결이 유지된 상태에서 양쪽이 자유롭게 메시지를 보낼 수 있습니다.

PLAINTEXT
HTTP:
클라이언트 → [요청] → 서버
클라이언트 ← [응답] ← 서버
(연결 종료)

WebSocket:
클라이언트 ↔ [핸드셰이크(HTTP 업그레이드)] ↔ 서버
클라이언트 ↔ [양방향 메시지] ↔ 서버
(연결 유지)

기본 설정

JAVA
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-websocket'

STOMP + SimpleBroker 설정

JAVA
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig
        implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 서버 → 클라이언트 메시지 prefix (구독용)
        registry.enableSimpleBroker("/topic", "/queue");

        // 클라이언트 → 서버 메시지 prefix (발행용)
        registry.setApplicationDestinationPrefixes("/app");

이어서 나머지 구현 부분입니다.

JAVA
        // 특정 사용자에게 메시지 보낼 때의 prefix
        registry.setUserDestinationPrefix("/user");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")      // WebSocket 연결 엔드포인트
            .setAllowedOrigins("http://localhost:3000")
            .withSockJS();               // SockJS 폴백 활성화
    }
}

메시지 흐름

PLAINTEXT
클라이언트 → /app/chat.send → @MessageMapping → 처리 → /topic/chat → 구독자들

채팅 애플리케이션 예제

메시지 컨트롤러

JAVA
@Controller
@RequiredArgsConstructor
public class ChatController {

    private final SimpMessagingTemplate messagingTemplate;

    // 채팅 메시지 수신 → 브로드캐스트
    @MessageMapping("/chat.send")
    @SendTo("/topic/chat")
    public ChatMessage sendMessage(ChatMessage message) {
        message.setTimestamp(LocalDateTime.now());
        return message;  // /topic/chat 구독자 전원에게 전달
    }

이어서 나머지 구현 부분입니다.

JAVA
    // 특정 사용자에게 메시지 전송
    @MessageMapping("/chat.private")
    public void sendPrivateMessage(
            PrivateMessage message, Principal principal) {
        messagingTemplate.convertAndSendToUser(
            message.getRecipient(),      // 대상 사용자
            "/queue/private",            // destination
            message
        );
    }

이어서 나머지 구현 부분입니다.

JAVA
    // 사용자 입장 알림
    @MessageMapping("/chat.join")
    @SendTo("/topic/chat")
    public ChatMessage joinChat(
            @Payload ChatMessage message,
            SimpMessageHeaderAccessor headerAccessor) {
        // WebSocket 세션에 사용자 정보 저장
        headerAccessor.getSessionAttributes()
            .put("username", message.getSender());
        message.setType(MessageType.JOIN);
        return message;
    }
}

메시지 DTO

JAVA
@Getter
@Setter
@NoArgsConstructor
public class ChatMessage {
    private MessageType type;  // CHAT, JOIN, LEAVE
    private String sender;
    private String content;
    private LocalDateTime timestamp;
}

SimpMessagingTemplate — 서버에서 능동적 메시지 전송

컨트롤러 밖에서도 메시지를 보낼 수 있습니다.

JAVA
@Service
@RequiredArgsConstructor
public class NotificationService {
    private final SimpMessagingTemplate messagingTemplate;

    // 전체 브로드캐스트
    public void broadcastNotification(String message) {
        messagingTemplate.convertAndSend(
            "/topic/notifications", message);
    }

    // 특정 사용자에게 전송
    public void sendToUser(String userId, Object payload) {
        messagingTemplate.convertAndSendToUser(
            userId, "/queue/alerts", payload);
    }
}

SockJS 폴백

WebSocket이 차단될 수 있는 환경(기업 프록시, 특정 방화벽)에서 SockJS는 자동으로 대체 전송 방식을 사용합니다.

PLAINTEXT
1. WebSocket       ← 최우선
2. XHR Streaming   ← WebSocket 불가 시
3. XHR Polling     ← Streaming 불가 시

클라이언트 설정:

JAVASCRIPT
const socket = new SockJS('/ws');
const stompClient = Stomp.over(socket);

stompClient.connect({}, (frame) => {
    // 토픽 구독
    stompClient.subscribe('/topic/chat', (message) => {
        const chatMessage = JSON.parse(message.body);
        displayMessage(chatMessage);
    });

이어서 이벤트를 구독하는 리스너를 정의합니다.

JAVASCRIPT
    // 개인 메시지 구독
    stompClient.subscribe('/user/queue/private', (message) => {
        const privateMsg = JSON.parse(message.body);
        displayPrivateMessage(privateMsg);
    });
});

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

인증 연동

핸드셰이크 인증

JAVA
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig
        implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureClientInboundChannel(
            ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(
                    Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor =
                    MessageHeaderAccessor.getAccessor(
                        message, StompHeaderAccessor.class);

이어서 보안 및 인증 관련 설정을 추가합니다.

JAVA
                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    String token = accessor.getFirstNativeHeader(
                        "Authorization");
                    // JWT 토큰 검증
                    Authentication auth = validateToken(token);
                    accessor.setUser(auth);
                }
                return message;
            }
        });
    }
}

Spring Security 연동

JAVA
@Configuration
@EnableWebSocketSecurity
public class WebSocketSecurityConfig {

    @Bean
    AuthorizationManager<Message<?>> messageAuthorizationManager(
            MessageMatcherDelegatingAuthorizationManager.Builder messages) {
        return messages
            .simpDestMatchers("/app/**").authenticated()
            .simpSubscribeDestMatchers("/topic/**").authenticated()
            .simpSubscribeDestMatchers("/user/**").authenticated()
            .anyMessage().denyAll()
            .build();
    }
}

세션 관리와 이벤트

JAVA
@Component
@Slf4j
public class WebSocketEventListener {

    @EventListener
    public void handleConnect(SessionConnectedEvent event) {
        log.info("WebSocket 연결: {}",
            event.getMessage().getHeaders().get("simpSessionId"));
    }

    @EventListener
    public void handleDisconnect(SessionDisconnectEvent event) {
        StompHeaderAccessor accessor =
            StompHeaderAccessor.wrap(event.getMessage());
        String username = (String) accessor.getSessionAttributes()
            .get("username");

이어서 나머지 구현 부분입니다.

JAVA
        if (username != null) {
            log.info("사용자 퇴장: {}", username);
            // 퇴장 알림 브로드캐스트
            ChatMessage leaveMessage = new ChatMessage();
            leaveMessage.setType(MessageType.LEAVE);
            leaveMessage.setSender(username);
            messagingTemplate.convertAndSend("/topic/chat", leaveMessage);
        }
    }
}

외부 메시지 브로커 연동

다중 서버 인스턴스 환경에서는 SimpleBroker 대신 외부 브로커를 사용해야 합니다.

JAVA
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    // RabbitMQ를 외부 브로커로 사용
    registry.enableStompBrokerRelay("/topic", "/queue")
        .setRelayHost("rabbitmq-server")
        .setRelayPort(61613)
        .setClientLogin("guest")
        .setClientPasscode("guest");

    registry.setApplicationDestinationPrefixes("/app");
}

에러 처리

JAVA
@Controller
public class ChatController {

    @MessageExceptionHandler
    @SendToUser("/queue/errors")
    public ErrorMessage handleException(Exception e) {
        return new ErrorMessage(e.getMessage());
    }
}

성능 고려사항

  • ** 동시 연결 수 **: 기본 Tomcat은 WebSocket 연결도 스레드를 소비합니다. 대량 연결에는 Netty(WebFlux) 사용을 고려하세요.
  • ** 메시지 크기 **: 대용량 메시지는 분할하거나 파일 업로드는 별도 HTTP 엔드포인트를 사용합니다.
  • Heartbeat: STOMP heartbeat으로 끊어진 연결을 탐지합니다.
JAVA
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.enableSimpleBroker("/topic", "/queue")
        .setHeartbeatValue(new long[]{10000, 10000})  // 서버/클라이언트 heartbeat 간격
        .setTaskScheduler(heartbeatScheduler());
}

주의할 점

1. WebSocket 세션은 메모리에 유지되므로 서버 스케일아웃 시 메시지가 유실된다

WebSocket 세션은 연결된 서버 인스턴스의 메모리에 유지됩니다. 서버가 2대 이상이면 A 서버에 연결된 사용자에게 B 서버에서 발생한 메시지를 전달할 수 없습니다. Redis Pub/Sub이나 RabbitMQ를 STOMP 브로커로 사용하여 메시지를 서버 간에 공유해야 합니다.

2. WebSocket 연결 수 제한을 설정하지 않으면 서버 리소스가 고갈된다

WebSocket 연결 하나당 TCP 소켓과 메모리를 점유합니다. 연결 수에 제한이 없으면 수만 개의 연결이 유지되어 파일 디스크립터 한도 초과(Too many open files)나 메모리 부족이 발생합니다. 동시 연결 수를 제한하고, 비활성 세션을 주기적으로 정리하는 하트비트 메커니즘을 설정하세요.

3. SockJS 폴백을 활성화하지 않으면 프록시/방화벽 뒤에서 연결이 실패한다

기업 네트워크의 프록시나 방화벽이 WebSocket 업그레이드를 차단하는 경우가 많습니다. SockJS 폴백을 설정하지 않으면 이런 환경의 사용자가 실시간 기능을 전혀 사용할 수 없습니다. .withSockJS()를 추가하면 자동으로 long-polling 등 대체 전송 방식으로 폴백됩니다.

정리

  • WebSocket 은 서버-클라이언트 간 양방향 실시간 통신을 제공합니다.
  • STOMP 는 WebSocket 위의 메시지 프로토콜로 구독/발행 패턴을 지원합니다.
  • SockJS 는 WebSocket이 차단된 환경에서 자동 폴백을 제공합니다.
  • @MessageMapping으로 메시지를 수신하고, @SendToSimpMessagingTemplate으로 메시지를 전송합니다.
  • 다중 인스턴스 환경에서는 **외부 메시지 브로커 **(RabbitMQ 등)가 필요합니다.
  • Spring Security와 연동하여 WebSocket 연결과 메시지에 인증/인가를 적용할 수 있습니다.
댓글 로딩 중...