T_era

Chapter 1: WebSocket과 STOMP 프로토콜 본문

Programing/Spring

Chapter 1: WebSocket과 STOMP 프로토콜

블스뜸 2025. 7. 11. 13:14

핵심 내용:

  • 웹소켓 프로토콜의 동작 원리 이해
  • STOMP의 역할과 필요성 인지

양방향 통신 vs 지속적 연결

양방향 통신 (Full-Duplex)

정의

  • 동시에 양쪽 방향으로 데이터 전송 가능
  • 서버 ↔ 클라이언트가 동시에 일어날 수 있음

Java WebSocket 예시

// 서버 측 WebSocket 핸들러
@Component
public class ChatWebSocketHandler extends TextWebSocketHandler {

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

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        sessions.put(session.getId(), session);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        // 클라이언트 → 서버 메시지 수신 (동시에 가능)
        String clientMessage = message.getPayload();
        log.info("클라이언트 메시지 수신: {}", clientMessage);

        // 서버 → 클라이언트 메시지 전송 (동시에 가능)
        try {
            session.sendMessage(new TextMessage("서버 응답: " + clientMessage));
        } catch (IOException e) {
            log.error("메시지 전송 실패", e);
        }
    }
}
// 클라이언트 측 (JavaScript)
const socket = new WebSocket('ws://localhost:8080/chat');

// 클라이언트 → 서버 (동시에 가능)
socket.send('안녕하세요!');

// 서버 → 클라이언트 (동시에 가능)
socket.onmessage = function(event) {
    console.log('서버 응답:', event.data);
};

장점

  • 실시간 양방향 대화 가능
  • 채팅, 게임, 협업 도구 등에 적합
  • 즉시 응답 가능

단점

  • 복잡한 상태 관리 필요
  • 동시성 문제 발생 가능

🔗 지속적 연결 (Persistent Connection)

정의

  • 연결을 유지하여 재연결 오버헤드 제거
  • 연결이 끊어지지 않고 계속 유지됨

Java HTTP Keep-Alive 예시

// 일반 HTTP (연결 매번 생성/해제)
@Service
public class HttpService {

    private final RestTemplate restTemplate;

    public HttpService() {
        // 연결 풀 설정 없음 - 매번 새로운 연결
        this.restTemplate = new RestTemplate();
    }

    public String fetchData(String url) {
        // 매번 새로운 HTTP 연결 생성
        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
        return response.getBody();
    }
}
// HTTP Keep-Alive (연결 유지)
@Configuration
public class HttpConfig {

    @Bean
    public RestTemplate restTemplate() {
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();

        // 연결 유지 설정
        factory.setConnectionRequestTimeout(5000);
        factory.setConnectTimeout(5000);
        factory.setReadTimeout(5000);

        // Keep-Alive 설정
        HttpClient httpClient = HttpClients.custom()
            .setConnectionManager(createConnectionManager())
            .build();

        factory.setHttpClient(httpClient);
        return new RestTemplate(factory);
    }

    private PoolingHttpClientConnectionManager createConnectionManager() {
        PoolingHttpClientConnectionManager manager = new PoolingHttpClientConnectionManager();
        manager.setMaxTotal(100); // 최대 연결 수
        manager.setDefaultMaxPerRoute(20); // 라우트당 최대 연결 수
        return manager;
    }
}

장점

  • 연결 설정 오버헤드 감소
  • 빠른 응답 시간
  • 리소스 효율성

단점

  • 서버 리소스 사용 (연결 유지)
  • 연결 수 제한 (메모리, 파일 디스크립터)

비교표

구분 양방향 통신 지속적 연결
목적 동시 양방향 데이터 전송 연결 오버헤드 제거
방향성 양방향 동시 전송 단방향도 가능
사용 사례 채팅, 게임, 실시간 협업 API 호출, 파일 전송
복잡도 높음 낮음
리소스 높음 중간

WebSocket의 경우 (Java)

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 Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) {
        // 1. 지속적 연결 (Persistent) - 연결 유지
        sessions.put(session.getId(), session);
        log.info("연결 유지 중... 세션 ID: {}", session.getId());
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        // 2. 양방향 통신 (Full-Duplex) - 동시 양방향 전송
        String clientMessage = message.getPayload();

        // 클라이언트 → 서버
        log.info("클라이언트 메시지: {}", clientMessage);

        // 서버 → 클라이언트 (동시에 가능)
        try {
            session.sendMessage(new TextMessage("서버 응답: " + clientMessage));
        } catch (IOException e) {
            log.error("메시지 전송 실패", e);
        }
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
        // 연결 끊김 처리
        sessions.remove(session.getId());
        log.info("연결 종료: {}", session.getId());
    }
}

실제 사용 예시

양방향 통신이 필요한 경우

// 채팅 애플리케이션
@Component
public class ChatService {

    private final SimpMessagingTemplate messagingTemplate;

    public ChatService(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    // 클라이언트에서 메시지 수신
    @MessageMapping("/chat")
    @SendTo("/topic/messages")
    public ChatMessage handleChatMessage(ChatMessage message) {
        log.info("클라이언트 메시지 수신: {}", message);

        // 서버에서 즉시 응답 (양방향 통신)
        return new ChatMessage("서버", "메시지 수신됨: " + message.getContent());
    }
}

지속적 연결이 중요한 경우

// 실시간 알림 시스템
@Service
public class NotificationService {

    private final SimpMessagingTemplate messagingTemplate;

    public NotificationService(SimpMessagingTemplate messagingTemplate) {
        this.messagingTemplate = messagingTemplate;
    }

    // 서버에서 알림 전송 (단방향이지만 연결 유지)
    public void sendNotification(String userId, String message) {
        messagingTemplate.convertAndSendToUser(
            userId,
            "/topic/notifications",
            new NotificationMessage(message)
        );
    }

    // 연결 상태 모니터링
    @EventListener
    public void handleSessionConnected(SessionConnectedEvent event) {
        log.info("사용자 연결됨: {}", event.getUser().getName());
    }

    @EventListener
    public void handleSessionDisconnected(SessionDisconnectEvent event) {
        log.info("사용자 연결 끊김: {}", event.getUser().getName());
    }
}

성능 관점

양방향 통신

// 동시성 처리가 중요
@Component
public class ConcurrentMessageHandler {

    private final ExecutorService executorService = Executors.newFixedThreadPool(10);

    public void handleConcurrentMessages(List<Message> messages) {
        // 동시에 여러 메시지 처리
        List<CompletableFuture<Void>> futures = messages.stream()
            .map(message -> CompletableFuture.runAsync(() -> {
                processMessage(message);
            }, executorService))
            .collect(Collectors.toList());

        // 모든 메시지 처리 완료 대기
        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
    }
}

지속적 연결

// 연결 풀 관리가 중요
@Configuration
public class WebSocketConnectionConfig {

    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();

        // 연결 유지를 위한 설정
        container.setMaxTextMessageBufferSize(8192);
        container.setMaxBinaryMessageBufferSize(8192);
        container.setMaxSessions(10000); // 최대 연결 수 제한
        container.setAsyncSendTimeout(5000);

        return container;
    }
}

결론

  • 양방향 통신: 동시성에 초점 (채팅, 게임)
  • 지속적 연결: 효율성에 초점 (API, 알림)

 

STOMP의 역할과 필요성

순수 WebSocket의 한계

WebSocket은 단순한 데이터 전송 통로

// 순수 WebSocket - 메시지 구조나 형식에 대한 규칙 없음
@Component
public class RawWebSocketHandler extends TextWebSocketHandler {

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        String rawMessage = message.getPayload();

        // 개발자가 직접 메시지 파싱 및 라우팅 로직 구현 필요
        if (rawMessage.startsWith("CHAT:")) {
            handleChatMessage(session, rawMessage);
        } else if (rawMessage.startsWith("NOTIFICATION:")) {
            handleNotificationMessage(session, rawMessage);
        } else if (rawMessage.startsWith("GAME:")) {
            handleGameMessage(session, rawMessage);
        }
        // ... 복잡한 메시지 라우팅 로직
    }

    private void handleChatMessage(WebSocketSession session, String message) {
        // 채팅 메시지 처리 로직
    }

    private void handleNotificationMessage(WebSocketSession session, String message) {
        // 알림 메시지 처리 로직
    }

    private void handleGameMessage(WebSocketSession session, String message) {
        // 게임 메시지 처리 로직
    }
}

문제점

  • 메시지 형식 규칙 없음: 개발자가 직접 정의해야 함
  • 복잡한 라우팅 로직: 메시지 타입별 분기 처리 필요
  • 구독/발행 모델 구현 어려움: 직접 구현해야 함
  • 비즈니스 로직과 인프라 로직 혼재

STOMP의 역할

STOMP = WebSocket 위의 하위 프로토콜

// STOMP 사용 시 - 구조화된 메시지와 명확한 라우팅
@Controller
public class StompController {

    // 채팅 메시지 처리
    @MessageMapping("/chat")  // 목적지: /app/chat
    @SendTo("/topic/chat")    // 구독자들에게 브로드캐스트
    public ChatMessage handleChatMessage(ChatMessage message) {
        log.info("채팅 메시지 수신: {}", message);
        return message;
    }

    // 개인 알림 처리
    @MessageMapping("/notification")
    public void handleNotification(NotificationMessage message) {
        // 특정 사용자에게 전송
        messagingTemplate.convertAndSendToUser(
            message.getUserId(),
            "/topic/notifications",
            message
        );
    }

    // 게임 이벤트 처리
    @MessageMapping("/game")
    @SendTo("/topic/game")
    public GameEvent handleGameEvent(GameEvent event) {
        log.info("게임 이벤트 수신: {}", event);
        return event;
    }
}

STOMP 메시지 구조

// STOMP 메시지 예시
{
  "command": "SEND",
  "destination": "/app/chat",
  "headers": {
    "content-type": "application/json",
    "user": "user123"
  },
  "body": "{\"content\":\"안녕하세요!\",\"sender\":\"user123\"}"
}

STOMP의 핵심 기능

1. Destination 기반 라우팅

// 명확한 목적지 정의
@MessageMapping("/chat")           // 클라이언트 → 서버: /app/chat
@SendTo("/topic/chat")            // 서버 → 클라이언트: /topic/chat
@SendToUser("/topic/notifications") // 특정 사용자에게: /user/topic/notifications

2. 구독/발행(Pub/Sub) 모델

// 클라이언트 측 구독
const stompClient = new StompJs.Client({
    webSocketFactory: () => new WebSocket('ws://localhost:8080/ws')
});

// 채팅 메시지 구독
stompClient.subscribe('/topic/chat', function(message) {
    const chatMessage = JSON.parse(message.body);
    displayChatMessage(chatMessage);
});

// 개인 알림 구독
stompClient.subscribe('/user/topic/notifications', function(message) {
    const notification = JSON.parse(message.body);
    showNotification(notification);
});

// 게임 이벤트 구독
stompClient.subscribe('/topic/game', function(message) {
    const gameEvent = JSON.parse(message.body);
    handleGameEvent(gameEvent);
});

3. 메시지 헤더 관리

// 헤더 정보 활용
@MessageMapping("/chat")
@SendTo("/topic/chat")
public ChatMessage handleChatMessage(
    @Payload ChatMessage message,
    @Header("user") String user,
    @Header("timestamp") String timestamp
) {
    log.info("사용자 {}의 메시지: {}", user, message);
    message.setTimestamp(timestamp);
    return message;
}

STOMP의 필요성

1. 개발 생산성 향상

// STOMP 없이 구현 시 (복잡함)
@Component
public class ComplexWebSocketHandler extends TextWebSocketHandler {

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) {
        String rawMessage = message.getPayload();
        JsonNode jsonNode = objectMapper.readTree(rawMessage);

        String type = jsonNode.get("type").asText();
        String destination = jsonNode.get("destination").asText();
        String payload = jsonNode.get("payload").asText();

        switch (type) {
            case "CHAT":
                handleChatMessage(session, destination, payload);
                break;
            case "NOTIFICATION":
                handleNotificationMessage(session, destination, payload);
                break;
            // ... 수많은 분기 처리
        }
    }
}

// STOMP 사용 시 (간단함)
@MessageMapping("/chat")
@SendTo("/topic/chat")
public ChatMessage handleChatMessage(ChatMessage message) {
    return message; // 비즈니스 로직에만 집중
}

2. 표준화된 메시지 구조

// STOMP 메시지 구조
public class StompMessage {
    private String command;        // SEND, SUBSCRIBE, MESSAGE 등
    private String destination;    // /app/chat, /topic/notifications 등
    private Map<String, String> headers; // 메타데이터
    private String body;           // 실제 데이터
}

3. 자동 라우팅

// STOMP가 자동으로 처리하는 것들
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        // /app으로 시작하는 메시지는 @MessageMapping으로 라우팅
        config.setApplicationDestinationPrefixes("/app");

        // /topic으로 시작하는 메시지는 구독자들에게 브로드캐스트
        config.enableSimpleBroker("/topic");

        // /user로 시작하는 메시지는 특정 사용자에게 전송
        config.setUserDestinationPrefix("/user");
    }
}

�� 비교표

항목 순수 WebSocket STOMP
메시지 구조 개발자 정의 표준화된 구조
라우팅 직접 구현 자동 라우팅
구독/발행 직접 구현 내장 지원
개발 복잡도 높음 낮음
유지보수 어려움 쉬움
확장성 제한적 높음

실제 사용 예시

채팅 시스템

@Controller
public class ChatController {

    @MessageMapping("/chat")
    @SendTo("/topic/chat")
    public ChatMessage handleChat(ChatMessage message) {
        // 비즈니스 로직에만 집중
        message.setTimestamp(LocalDateTime.now());
        return message;
    }

    @MessageMapping("/private-chat")
    public void handlePrivateChat(PrivateChatMessage message) {
        // 특정 사용자에게만 전송
        messagingTemplate.convertAndSendToUser(
            message.getRecipientId(),
            "/topic/private-chat",
            message
        );
    }
}

알림 시스템

@Controller
public class NotificationController {

    @MessageMapping("/subscribe")
    public void handleSubscription(SubscriptionRequest request) {
        // 구독 처리
        notificationService.addSubscription(request.getUserId(), request.getProductId());
    }

    // 서버에서 알림 전송
    @EventListener
    public void handleProductEvent(ProductDiscountEvent event) {
        messagingTemplate.convertAndSend("/topic/product-events", event);
    }
}

결론

STOMP는 WebSocket의 한계를 보완하여:

  • 구조화된 메시지 제공
  • 자동 라우팅 지원
  • 구독/발행 모델 내장
  • 개발 생산성 향상
  • 비즈니스 로직 집중 가능