Knowledge/Trouble Shooting

웹소켓 STOMP 에러 트러블 슈팅

똑똑한망치 2024. 5. 27. 16:00
728x90
반응형

😓 문제 상황

  • Client 측에서 웹소켓 연결 요청을 보낼 때 EC2에 올라간 서버 로그에서 문제 발생
  • 주요 에러 내용 : Handshake failed due to invalid Upgrade header:                       null
2024-05-25T08:47:36.638Z ERROR 32370 --- [nio-8080-exec-6] o.s.w.s.s.s.DefaultHandshakeHandler      : "Handshake failed due to invalid Upgrade header:                       null"
2024-05-25T08:47:36.702Z  INFO 32370 --- [nio-8080-exec-5] o.springdoc.api.AbstractOpenApiResource  : Init duration for springdoc-openapi is: 1940 ms
2024-05-25T08:47:36.809Z  WARN 32370 --- [nio-8080-exec-5] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.context.reques                      t.async.AsyncRequestNotUsableException: ServletOutputStream failed to flush: java.io.IOException: Broken pipe]

 

 

⚡ 고민 발생 이유

 

Application Load Balancers - Elastic Load Balancing

Application Load Balancers A load balancer serves as the single point of contact for clients. Clients send requests to the load balancer, and the load balancer sends them to targets, such as EC2 instances. To configure your load balancer, you create target

docs.aws.amazon.com

 

 

 

Application Load Balancers - Elastic Load Balancing

Application Load Balancers A load balancer serves as the single point of contact for clients. Clients send requests to the load balancer, and the load balancer sends them to targets, such as EC2 instances. To configure your load balancer, you create target

docs.aws.amazon.com

 

 

 

 

 

🔴 서버에 적용된 코드 

🍀 StompChatController.java

@Controller
@RequiredArgsConstructor
@Slf4j
public class StompChatController {

  private final ChatService chatService;
  private final SimpMessagingTemplate messagingTemplate;

  @MessageMapping("/enter/{gameId}")
  public void enter(@DestinationVariable Long gameId
      , SimpMessageHeaderAccessor accessor
  ) {

    String senderId = (String) accessor.getSessionAttributes()
        .get("senderId");

    UserEntity user = chatService.findUser(senderId);

    chatService.checkAcceptUser(gameId, user.getUserId());

    messagingTemplate.convertAndSend("/sub/" + gameId,
        user.getNickName() + "님이 입장했습니다");
  }

  @MessageMapping("/send/{gameId}")
  public void chat(@DestinationVariable Long gameId
      , @Payload Content content
      , SimpMessageHeaderAccessor accessor
  ) {
    String userLoginId = (String) accessor.getSessionAttributes()
        .get("senderId");

    MessageDTO messageDTO = chatService.createMessage(gameId, content,
        userLoginId);

    messagingTemplate.convertAndSend("/sub/" + gameId, messageDTO);
  }

  @PostMapping("/api/chat/create")
  public ResponseEntity<ChatRoomDTO> createChatRoom(
      @RequestBody CreateRoomDTO createRoomDTO) {
    ChatRoomDTO roomDTO = chatService.createChatRoom(createRoomDTO);

    return ResponseEntity.ok(roomDTO);
  }


}

 

 

🍀 ChatService.java

@Service
@RequiredArgsConstructor
public class ChatService {

  private final ChatRoomRepository chatRoomRepository;
  private final MessageRepository messageRepository;
  private final UserRepository userRepository;
  private final GameRepository gameRepository;
  private final ParticipantGameRepository participantGameRepository;

  public ChatRoomDTO createChatRoom(CreateRoomDTO createRoomDTO) {
    GameEntity gameEntity = gameRepository.findById(createRoomDTO.getGameId())
        .orElseThrow(() -> new CustomException(ErrorCode.GAME_NOT_FOUND));

    ChatRoomEntity chattingRoom = ChatRoomEntity.builder()
        .gameEntity(gameEntity)
        .build();

    return ChatRoomDTO.entityToDto(
        chatRoomRepository.save(chattingRoom));
  }

  public MessageDTO createMessage(Long gameId, Content content,
      String senderId) {
    ChatRoomEntity chatRoomEntity = chatRoomRepository.findById(gameId)
        .orElseThrow(() -> new CustomException(ErrorCode.GAME_NOT_FOUND));

    UserEntity user = this.findUser(senderId);

    MessageEntity messageEntity = MessageEntity.builder()
        .content(String.valueOf(content))
        .sendDateTime(LocalDateTime.now())
        .user(user)
        .chatRoomEntity(chatRoomEntity)
        .build();

    return MessageDTO.entityToDto(messageRepository.save(messageEntity));

  }

  public UserEntity findUser(String senderId) {
    return userRepository.findByIdAndDeletedDateTimeNull(senderId)
        .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
  }

  public void checkAcceptUser(Long gameId, Long userId) {
    if (!participantGameRepository
        .existsByStatusAndGameEntityGameIdAndUserEntityUserId(
            ParticipantGameStatus.ACCEPT, gameId, userId
        )) {
      throw new CustomException(ErrorCode.CANNOT_ENTER_CHAT);
    }
  }
}

 

 

🍀 ChatPreHandler.java

@RequiredArgsConstructor
@Component
@Slf4j
public class ChatPreHandler implements ChannelInterceptor {

  private final TokenProvider tokenProvider;
  private static final String TOKEN_PREFIX = "Bearer";

  @Override
  public Message<?> preSend(Message<?> message, MessageChannel channel) {
    log.info("preSend 실행됨");
    StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

    // websocket 연결 요청
    if (StompCommand.CONNECT == accessor.getCommand()) {
      String jwtToken = String.valueOf(
          accessor.getFirstNativeHeader("Authorization"));

      // TODO -> 프론트와 연동 후 정상작동하면 log 삭제 예정
      log.info("Header에서 Authorization 추출 성공");

      if (!this.validateAccessToken(jwtToken)) {
        throw new CustomException(INVALID_TOKEN);
      }

      String senderId = this.getSenderId(jwtToken);
      log.info(" === senderId : {} === ", senderId);

      // TODO -> 추후 header에 추가된 senderID값 사용하지 않으면 삭제
      // header에 senderId 값 추가
      accessor.addNativeHeader("senderId", senderId);

      // session에 senderId값 추가
      accessor.getSessionAttributes().put("senderId", senderId);

    }
    return message;
  }


  private boolean validateAccessToken(String accessToken) {
    if (accessToken == null) {
      return false;
    }

    String bearerToken = accessToken.trim();

    if (!bearerToken.trim().isEmpty() && bearerToken.startsWith(TOKEN_PREFIX)) {
      accessToken = bearerToken.substring(7);

      try {
        Claims claims = tokenProvider.parseClaims(accessToken);
        return true;
      } catch (ExpiredJwtException | MalformedJwtException e) {
        return false;
      }
    }

    return false;
  }

  private String getSenderId(String accessToken) {
    String bearerToken = accessToken.trim();

    if (!bearerToken.trim().isEmpty() && bearerToken.startsWith(TOKEN_PREFIX)) {
      accessToken = bearerToken.substring(7);

      try {
        Claims claims = tokenProvider.parseClaims(accessToken);
        return claims.get("id", String.class);
      } catch (ExpiredJwtException | MalformedJwtException e) {
        throw new CustomException(EXPIRED_TOKEN);
      }
    }

    return null;
  }


  @EventListener(SessionConnectEvent.class)
  public void handleWebSocketConnectListener(SessionConnectEvent event) {

    StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
    String sessionId = accessor.getSessionId();
    log.info("new connected sessionId : " + sessionId);

  }

  @EventListener
  public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
    StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage());
    String sessionId = accessor.getSessionId();

    log.info("sessionId Disconnected : " + sessionId);
  }
}

 

 

🍀 StompWebSocketConfig.java

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
@Slf4j
public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer {

  private final ChatPreHandler chatPreHandler;

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {

    registry.addEndpoint("/chat")
        .setAllowedOrigins("*")
        .withSockJS();

    registry
        .addEndpoint("/chat")
        .addInterceptors()
        .setAllowedOrigins("*");

  }

  @Override
  public void configureMessageBroker(MessageBrokerRegistry registry) {

    // @MessageMapping으로 라우팅
    registry.setApplicationDestinationPrefixes("/pub");

    // 해당 주소를 구독하고 있는 클라이언트들에게 메시지 전달
    registry.enableSimpleBroker("/sub");
  }


  @Override
  public void configureClientInboundChannel(ChannelRegistration registration) {
    registration.interceptors(chatPreHandler);
  }

}

 

 

🍀 추가적인 코드

  • TokenFilter에 웹소켓 엔드포인트로 등록된 엔드포인트는 별도의 인증과정을 거치지 않고 permitAll()로 설정했다.

 

🟡 문제 해결 시도

1️⃣ 첫번째 시도

AWS에 배포된 EC2 서버에서 nginx 설정 문제라고 판단하여 EC2 서버에 nginx 설치하였다. 

nginx를 설치하자마자 기존에 프론트엔드와 연동이 되었던 페이지들이 502 Error 발생하는 문제가 발생함.

  👉 현재 프로젝트 due date가 얼마 남지 않았으므로 추후에 별도의 서버를 올려 다시 시도해보자.

 

2️⃣ 다른 팀원의 코드 적용 시 WebSocket 연결 성공

  • 팀원 코드 첨부

ChatController.java

@Slf4j
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {

  private final SimpMessagingTemplate messagingTemplate;
  private final JwtTokenExtract jwtTokenExtract;

  @MessageMapping("/sendMessage/{gameId}")
  public void sendMessage(
      @Payload ChatMessage chatMessage,
      @DestinationVariable String gameId
  ) {
    messagingTemplate.convertAndSend("/topic/" + gameId, chatMessage);
  }


  @MessageMapping("/addUser/{gameId}")
  public void addUser(
      @Payload ChatMessage chatMessage,
      StompHeaderAccessor headerAccessor,
      @DestinationVariable String gameId
  ) {
    String nickName = jwtTokenExtract.currentUser().getNickName();
    log.info(nickName);
    headerAccessor.getSessionAttributes()
        .put("username", nickName);
    headerAccessor.getSessionAttributes().put("gameId", gameId);
    messagingTemplate.convertAndSend("/topic/" + gameId, chatMessage);
  }
}

 

 

ConnectionConfig.java

@Slf4j
@Configuration
@EnableWebSocketMessageBroker
public class ConnectionConfig implements WebSocketMessageBrokerConfigurer {

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    log.info("Registering STOMP endpoints...");
    registry.addEndpoint("/ws")
        .setAllowedOrigins(
            "http://127.0.0.1:8080",
            "http://127.0.0.1:5001",
            "http://localhost:5173",
            "https://hoops-frontend-jet.vercel.app",
            "https://hoops.services")
        .withSockJS();
    log.info("STOMP endpoints registered.");
  }

  @Override
  public void configureMessageBroker(MessageBrokerRegistry registry) {
    registry.enableSimpleBroker("/topic");
    registry.setApplicationDestinationPrefixes("/app");
  }

 

 

ConnectionEventListener.java

@Component
@RequiredArgsConstructor
public class ConnectionEventListener {

  private final SimpMessageSendingOperations messageTemplate;

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

    if (username != null && gameId != null) {
      ChatMessage chatMessage = ChatMessage.builder()
          .type(MessageType.LEAVE)
          .sender(username)
          .build();
      messageTemplate.convertAndSend("/topic/" + gameId, chatMessage);
    }
  }
}

 

 

테스트를 위해 팀원분이 임시로 작성한 프론트 파일

login.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        * {
            font-size: 30px;
        }
    </style>
</head>
<body>
    <input type="text" id="input-id" placeholder="id">
    <input type="password" id="input-pw" placeholder="password">
    <button id="btn-login">login</button>
    <script>
        const mainUrl = 'https://hoops.services';

        let btn = document.getElementById('btn-login');

        btn.addEventListener('click', async () => {
            let id = document.getElementById("input-id").value;
            let pw = document.getElementById("input-pw").value;

            try {
                const res = await fetch(`${mainUrl}/api/auth/login`, {
                    headers: {
                        "Content-Type": "application/json"
                    },
                    body: JSON.stringify({
                        "id": id,
                        "password": pw
                    }),
                    method: "POST"
                });

                if (!res.ok) {
                    throw new Error('Network response was not ok');
                }

                let resJson = await res.json();
                console.log("res : ", resJson);

                if (res.status === 200) {
                    localStorage.setItem('access', resJson.refreshToken);
                    localStorage.setItem('nickname', resJson.nickName);
                    location.replace('index.html');
                }
            } catch (error) {
                console.error('There has been a problem with your fetch operation:', error);
            }
        });
    </script>
</body>
</html>

 

 

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
    <title>Real-time Chat Application</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.1.3/css/bootstrap.min.css">
    <style>
        #username-page {
            height: 100vh;
        }
    </style>
</head>
<body class="bg-light">

<div id="username-page" class="container">
    <div class="row justify-content-center">
        <div class="col-md-6">
            <form id="username-form">
                <div class="form-group">
                    <label for="gameId">Enter Game ID</label>
                    <input type="text" id="gameId" class="form-control" required>
                </div>
                <button type="submit" class="btn btn-primary mt-2">Join Chat</button>
            </form>
        </div>
    </div>
</div>

<div id="chat-page" class="container d-none">
    <div class="row justify-content-center">
        <div class="col-md-6">
            <div class="connecting">Connecting...</div>
            <table id="message-area" class="table"></table>
            <form id="message-form">
                <div class="form-group">
                    <input type="text" id="message" class="form-control" placeholder="Type a message..." required>
                </div>
                <button type="submit" class="btn btn-primary mt-2">Send</button>
            </form>
        </div>
    </div>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.4/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script>
    "use strict";
    var stompClient = null;
    var gameId = null;
    const mainUrl = 'https://hoops.services';
    var accessToken = localStorage.getItem('access');
    var nickname = localStorage.getItem('nickname');

    function connect(event) {
        gameId = $("#gameId").val().trim();
        // Switch page and connect to WebSocket
        if (gameId) {
            $("#username-page").addClass("d-none");
            $("#chat-page").removeClass("d-none");

            // Get username from backend or other source

            var socket = new SockJS(mainUrl + "/ws");
            stompClient = Stomp.over(socket);

            stompClient.connect({
                'Authorization': 'Bearer ' + accessToken
            }, function() {
                onConnected(nickname);
            }, onError);
        }
        event.preventDefault();
    }

    function onConnected(nickname) {
        // Subscribe to the specific gameId topic
        stompClient.subscribe("/topic/" + gameId, onMessageReceived);
        stompClient.send("/app/addUser/" + gameId,
            {},
            JSON.stringify({sender: nickname, type: "JOIN"})
        );
        $(".connecting").addClass("d-none");
    }

    function onError(error) {
        $(".connecting").text("Could not connect to the WebSocket server. Please refresh this page to try again!").css("color", "red");
        console.error('WebSocket connection error:', error);
    }

    function sendMessage(event) {
        var messageContent = $("#message").val().trim();
        if (messageContent && stompClient) {
            var chatMessage = {
                sender: nickname,
                content: messageContent,
                type: "CHAT"
            };
            stompClient.send("/app/sendMessage/" + gameId, {}, JSON.stringify(chatMessage));
            $("#message").val("");
        }
        event.preventDefault();
    }

    function onMessageReceived(payload) {
        var message = JSON.parse(payload.body);
        if (message.type === "JOIN") {
            $("#message-area").prepend(`<tr><td class="text-secondary fs-6">${message.sender} joined!</td></tr>`);
        } else if (message.type === "LEAVE") {
            $("#message-area").prepend(`<tr><td class="text-secondary fs-6">${message.sender} left!</td></tr>`);
        } else {
            $("#message-area").prepend(`<tr><td class="fs-5"><b>${message.sender} :</b> ${message.content}</td></tr>`);
        }
    }

    $("#username-form").on("submit", connect);
    $("#message-form").on("submit", sendMessage);
</script>
</body>
</html>

 

 

🔵 후기

💢 깨달은 점

  • 프론트 엔드 측에서 어떻게 동작하는지 너무 무지했고 프론트엔드님과 소통을 하려고 하지 않고 너무 안일하게 생각했다.
  • 백엔드 코드만 공부하는 것이 아니라 개발자라면 다른 언어들도 공부가 필수적이라고 느꼈다.

❓ 궁금한 점 ❓

  • simpmessagingtemplate 와 simpmessagesendingoperations 차이점
public class SimpMessagingTemplate extends AbstractMessageSendingTemplate<String> implements SimpMessageSendingOperations

 

👉 SimpMessagingTemplate 는 SimpMessageSendgingOperations 인터페이스를 상속받은 클래스이다. 

반응형