tioon's Devlog

스프링 웹소켓 구현기 (feat. STOMP 먹통) 본문

자바/스프링

스프링 웹소켓 구현기 (feat. STOMP 먹통)

tioon 2024. 2. 25. 20:40

이번엔 제가 스프링으로 웹소켓을 구현하면서 겪었던 문제들을 해결하면서 고민했던것과 해결한 결과를 말씀 드리려 합니다.

 

예전에 웹소켓을 STOMP로 구현하면서 블로그 글을 올린 적이 있었는데, 그때엔 제대로 작동이 되었습니다.
하지만, 최근에 STOMP 테스트 하는 것이 어떤 이유에서인지 모르겠지만, 막혔더라구요...ㅜ

그래서 다양한 방법으로 해결을 하려 했지만, STOMP를 결국 활용을 못하고, 스프링 WebSocket으로 리팩토링하여 해결으 하였습니다.

 

우선 다음과 같은 단계로 설명을 드리려합니다.

 

  1. 현재 STOMP의 문제.
  2. STOMP 테스트 시스템 부재
  3. STOMP 대체제인 스프링 WebSocket 직접 구현
  4. 스프링 WebSocket 테스트

 

현재 STOMP의 문제.

 

과거에 제가 STOMP를 이용해 웹소켓을 구현한 적이 있습니다. STOMP의 자세한 설명은 다음 글에서 확인해 주세요.

 

https://tioon.tistory.com/133

 

스프링 웹소켓(Web Socket) (STOMP)

웹소켓(Web Socket)이란? -클라이언트와 서버간의 양방향 통신을 가능하게 하는 프로토콜입니다. HTTP와 같은 웹표준 프로토콜입니다. 이는 실시간, 이벤트 기반 통신이 필요한 애플리케이션을 개발

tioon.tistory.com

 

STOMP 같은 경우는 스프링 웹소켓 위에 구현된 프로토콜입니다.
즉, 스프링 웹소켓을 기반으로 구현된것이며, '메세지 브로커' 라는 개념을 활용하여 웹소켓을 구현합니다.

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements 
WebSocketMessageBrokerConfigurer
 {
//웹소켓 설정 클래스 (이 클래스로 웹소켓 설정을 다룰 수 있음)

    @Override
    public void 
configureMessageBroker
(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    } 
//메시지 브로커 설정 메서드(브로드 캐스트)

    @Override
    public void
 registerStompEndpoints
(StompEndpointRegistry registry) {
        registry.addEndpoint("/websocket-endpoint").withSockJS();
    } 
// 웹소켓 연결 설정 메서드 (메세지 전송)
}

 

위 코드처럼, registerStompEndpoints 메서드를 통해 웹소켓 연결을 시작할 수 있고,
configureMessageBroker를 활용해 메세지 브로커를 통해 연결된 사용자들에게 웹소켓 메세지를 보낼 수 있습니다.

 

이렇게 웹소켓을 좀 더 사용하기 쉽게 HTTP와 유사한 구조를 가지고 있어 개발자들이 쉽게 적용할 수 있다 라는 장점이 있지만, 이걸 테스트할 수 있는 프로그램이 거의 없다 라는 게 문제입니다.

 

테스트 프로그램이 적으면 무엇이 문제일까요?

문제는 다음과 같습니다.

  • 개발중에 혼란성 야기
    -웹소켓이 가뜩이나 어려운데, 테스트 까지 어려우면 개발중에 많은 혼란성을 야기합니다.
  • 프론트와 API 연동 문제 발생
    -저희가 웹소켓이 어려운만큼, 프론트 측에서 웹소켓 연동시키는게 많이 어렵습니다. 이러한 상황에서 테스트 API까지 주지 못한다면, 연동 과정에서 굉장히 많은 어려움이 생길 수 있습니다.

 

저는 그래서 과거에는 APIC이라는 테스트기를 이용해서 직접 웹소켓을 테스트 하기도 했고,
프론트 분들에게 API 명세서를 작성하기도 하였습니다.
따라서, 과거에는 STOMP의 큰 문제점은 없었습니다.

 

 

 

STOMP 테스트 시스템 부재

 

 

과거에는 이렇게 APIC을 사용하여 테스트 했지만, 제가 최근(2024년 1월 기준)에 다시 테스트 하려고 했는데, APIC이 막혔습니다.....

왜 이러는진 모르겠어요. APIC이 정상적으로 되시는 분은 알려주시면 감사하겠습니다 ㅜㅜ

 

암튼, 원래 APIC을 크롬 Extensions에서 다운받아서 활용을 했는데 이게 어떤 이유에서인지 막히고, 이거에 대한 대안으로, APIC 웹앱을 사용하려고 했으나, 이것도 막혔더라구요....

 

APIC 웹앱 링크 - https://apic.app/online/#/tester

 

따라서, 현재 APIC은 STOMP 테스트기로 활용을 아예 할 수 없는 상황입니다.
(추후에 다시 된다면 한번 다시 사용해볼 계획입니다!)

 

그래서 이걸 다른걸로 STOMP 테스트를 할 순 없을까....고민하며 구글링을 한 결과,

PostMan에서 웹소켓 테스트를 할 수 있는 기능이 있다는걸 알았습니다.

 

 

위 사진처럼 HTTP 대신 WebSocket을 테스트할 수 있는 기능이 있습니다.


"이걸 한번 활용해서 테스트를 해보자!" 싶었지만, 제 STOMP와 계속 연동 실패하는 것을 볼 수 있었습니다.
이게 아직 WebSocket은 정식 버전이 아니라 그런가 싶었지만, 다른 분들의 블로그를 보니 다른분들도 안된다는 걸 확인할 수 있었습니다.

 

그래서  STOMP의 문제인거 같다 라고 판단을 하였습니다.
이러한 판단의 이유는 STOMP는 결국 스프링의 웹소켓 기반으로 위에 구현된 프로토콜이기 때문에 현재 PostMan WebSocket 테스트기가 작동이 안된다라고 생각을 했습니다.
따라서, PostMan에서 제공하는것은 STOMP가 아닌 일반 웹소켓 테스트기 라는 생각이 들었습니다.


그래서 결국, 웹소켓을 STOMP로 구현하는 것이 아닌, 직접 스프링 WebSocket을 활용해서 웹소켓을 구현하자라는 생각으로 기존 코드를 전부 리팩토링을 하였습니다.

 

(STOMP 테스트 할 수 있는 다른방법 알고 계시는분은 알려주세요 ㅎㅎ)

 

 

스프링 WebSocket 직접 구현

 

이게 STOMP를 이용하지 않고 스프링 WebSocket을 직접 구현하겠습니다.

스프링 WebSocket의 방식은 간략하게 설명하면, 다음과 같습니다.

STOMP처럼 메세지브로커 같은 개념이 없어 원시적인(?) 방법으로 직접, 메세지 연결 후 메세지 전송하는 내용으로 구별을 해야합니다.

위의 그림을 보시면처음에 웹소켓 연결을 하고, 이후에 메세지 전송을 하고 있습니다.
이때, 원하는 대상에게만 메세지를 전송하게 하려면, id 같은 값을 추가해서 전달해야합니다. 
이런 id 값을 추가함으로써 웹소켓 서버에서 어떤 대상이 연결되어 있고, 메세지를 어떤 대상에게 전송해야하는지 알 수 있습니다.

 

 

우선 기존에 STOMP를 이용하기 위해 사용했던 인터페이스인 WebSocketMessageBrokerConfigurer 이거를 사용하는 것이 아닌, WebSocketConfigurer 인터페이스를 활용하여 Config 파일을 작성할것입니다.
이후에 TextWebSocketHandler 인터페이스를 활용하여 Handler 파일을 작성합니다.

 

 

config 파일은 어떻게 웹소켓 접속을 할 수 있는지를 정하는 파일이며, 다음과 같습니다.

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

    private final WebSocketHandler webSocketHandler;




    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        /*
         * 웹소켓 핸들러 추가 및 접속 경로 설정
         */
        registry.addHandler(webSocketHandler, "websocket")
                .setAllowedOrigins("*");//CORS 설정
    }

}

 

 

 

handler 파일은 웹소켓 요청이 들어왔을 때, 어떻게 처리할지 정하는 핸들러 파일이며, 다음과 같습니다.

@Slf4j
@RequiredArgsConstructor
@Component
public class WebSocketHandler extends TextWebSocketHandler {
    private final ObjectMapper objectMapper;
    private final ChatService chatService;
    private ChatRoom chatRoom;
    private String roomId;

    //웹소켓 연결되었을때 호출
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("웹소켓이 연결됨");
    }


	//클라이언트가 웹소켓 메세지를 전송했을 때 호출
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {

        String payload = message.getPayload();
        log.info("{}", payload);
       
        //해당 코드의 경우, 방입장, 방퇴장에 대한 코드가 구현되어 있음.
        ChatMessage chatMessage = objectMapper.readValue(payload, ChatMessage.class);
        chatRoom = chatService.findRoomById(chatMessage.getRoomNumber());
        chatRoom.handlerEnterExit(session, chatMessage, chatService);
    }

    //해당 메서드의 경우, 방 이벤트 발생시 클라이언트에게 전송
    public void handleRoomEvent(ChatMessage chatMessage) throws Exception{

        chatRoom = chatService.findRoomById(chatMessage.getRoomNumber());
        chatRoom.handlerRoomEvent(chatMessage,chatService);
    }


    //웹소켓 연결 끊겼을 때 호출
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        
        
        //해당 방을 제거
        chatRoom.getMembers().remove(session);
        chatService.deleteRoom(roomId);
        log.info("웹소켓이 닫힘");
    }

}

 

 

저 같은 경우는 간단한 게임 방을 구현했기때문에 chatRoom같은 추가 코드가 구현되어 있습니다. 해당 config 파일과 handler 파일을 바탕으로 이제 웹소켓이 구현됩니다.

 

이제 나머지 코드들은 제 프로젝트때 썻던 코드들이라, 코드를 보시고 프로젝트에 맞게 수정해서 사용하시면 될거같습니다!
(저는 데이터가 날라가도 크게 상관없었기때문에 Room 데이터들을 Map를 사용해서 메모리에 저장했습니다.
하지만 데이터가 필요하신 분들은 데이터베이스에 저장하는 추가 과정이 있어야하니, 참고 부탁드립니다.)

 

 

 

 

ChatMessage 코드

@Getter
@Setter
@Builder
public class ChatMessage {
    public enum MessageType{
        ENTER,// 방 입장
        EXIT, // 방 퇴장
        GAME_START, // 게임시작
        GET_QUESTION, // 질문 가져오기
        ANSWER_COMPLETE, //질문 답변 완료
        ROUND_CHANGE, // 라운드 변환
        CORRECT_COUNT, // 맞춘 정답 개수
        FINISH // 게임 종료

    }

    private MessageType type; // 메세지 타입
    private String roomNumber; // 방 Id값
    @Nullable
    private Long playerId; // 플레이어 Id값
    @Nullable
    private byte[] image; // 사진 이진데이터
    @Nullable
    private int currentRound; // 현재 라운드
    @Nullable
    private int correctCount; // 정답 개수
    @Nullable
    private String question; // 질문
}

 

 

 

 

ChatRoom 코드

@Getter
public class ChatRoom {
    private String roomNumber;
    private Set<WebSocketSession> members = new HashSet<>();

    private PlayerRepo playerRepo;

    @Builder
    public ChatRoom(String roomNumber,PlayerRepo playerRepo) {
        this.roomNumber = roomNumber;
        this.playerRepo = playerRepo;
    }

    //방입장, 퇴장시에 호출
    public void handlerEnterExit(WebSocketSession session, ChatMessage chatMessage, ChatService chatService) {

        switch (chatMessage.getType()) {
            // 방입장 (소켓 세션 연결)
            case ENTER:


                if(!playerRepo.findById(chatMessage.getPlayerId()).get().getRoom().getRoomId().equals(chatMessage.getRoomNumber()))
                    throw new RuntimeException(); // 사용자 정보가 일치하지 않으면 에러 처리.

                members.add(session);

                chatMessage.setType(chatMessage.getType());
                chatMessage.setRoomNumber(chatMessage.getRoomNumber());
                chatMessage.setPlayerId(chatMessage.getPlayerId());
                chatMessage.setImage(chatMessage.getImage());

                sendMessage(chatMessage, chatService);
                break;

            // 방퇴장 (소켓 세션 해제)
            case EXIT:
                if(playerRepo.findById(chatMessage.getPlayerId()).get().getRoom().equals(chatMessage.getRoomNumber()))
                    throw new RuntimeException(); // 사용자 정보가 일치하지 않으면 에러 처리.

                chatMessage.setType(chatMessage.getType());
                chatMessage.setRoomNumber(chatMessage.getRoomNumber());
                chatMessage.setPlayerId(chatMessage.getPlayerId());

                sendMessage(chatMessage, chatService);

                members.remove(session);
                break;

            // 필요한 경우 다른 case 블록 추가
        }
    }


    private <T> void sendMessage(T message, ChatService chatService) {
        // 연결되어있는 모든 세션에 메세지 전달.
        members.parallelStream()
                .forEach(session -> chatService.sendMessage(session, message));
    }

    public void handlerRoomEvent(ChatMessage chatMessage, ChatService chatService) {

        switch (chatMessage.getType()) {

            // 질문 답변 완료
            case ANSWER_COMPLETE:
                chatMessage.setType(chatMessage.getType());
                chatMessage.setRoomNumber(chatMessage.getRoomNumber());
                chatMessage.setPlayerId(chatMessage.getPlayerId());

                sendMessage(chatMessage, chatService);
                break;

            // 질문 가져오기
            case GET_QUESTION:
                chatMessage.setType(chatMessage.getType());
                chatMessage.setRoomNumber(chatMessage.getRoomNumber());
                chatMessage.setQuestion(chatMessage.getQuestion());

                sendMessage(chatMessage, chatService);
                break;

            //게임시작, 라운드 변환 (술래 체인지)
            case GAME_START:
            case ROUND_CHANGE:
                chatMessage.setType(chatMessage.getType());
                chatMessage.setRoomNumber(chatMessage.getRoomNumber());
                chatMessage.setPlayerId(chatMessage.getPlayerId());
                chatMessage.setCurrentRound(chatMessage.getCurrentRound());

                sendMessage(chatMessage, chatService);
                break;

            //맞춘 정답 개수
            case CORRECT_COUNT:
                chatMessage.setType(chatMessage.getType());
                chatMessage.setRoomNumber(chatMessage.getRoomNumber());
                chatMessage.setPlayerId(chatMessage.getPlayerId());
                chatMessage.setCorrectCount(chatMessage.getCorrectCount());

                sendMessage(chatMessage, chatService);
                break;

            //게임 종료
            case FINISH:
                chatMessage.setType(chatMessage.getType());
                chatMessage.setRoomNumber(chatMessage.getRoomNumber());

                sendMessage(chatMessage, chatService);
                chatService.deleteRoom(chatMessage.getRoomNumber());
                break;
        }
    }
}

 

 

 

ChatService 코드

@Slf4j
@RequiredArgsConstructor
@Service
public class ChatService {
    private final ObjectMapper objectMapper;

    @Autowired
    private RoomRepo roomRepo;

    @Autowired
    private PlayerRepo playerRepo;

    private Map<String, ChatRoom> chatRooms = new HashMap<>();


    //활성화된 모든 채팅방을 조회
    /*public List<ChatDto> findAllRoom() {
        List<ChatDto> collect = chatRooms.values().stream().map(chatRoom -> new ChatDto(chatRoom.getRoomId(), chatRoom.getName(), (long) chatRoom.getSessions().size())).collect(Collectors.toList());
        return collect;
    }*/

    //채팅방 하나를 조회
    public ChatRoom findRoomById(String roomNumber) {
        return chatRooms.get(roomNumber);
    }


    //새로운 방 생성
    public ChatRoom createRoom(String roomNumber) {
        ChatRoom chatRoom = ChatRoom.builder()
                .roomNumber(roomNumber)
                .playerRepo(playerRepo)
                .build();

        chatRooms.put(roomNumber, chatRoom);
        return chatRoom;
    }

    //방 삭제
    public void deleteRoom(String roomNumber) {
        //해당방에 아무도 없다면 자동 삭제
        chatRooms.remove(roomNumber);
    }

    public <T> void sendMessage(WebSocketSession session, T message) {
        try {
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
        } catch (IOException e) {
            log.error(e.getMessage(), e);
        }
    }
}

 

 

 

 

 

 

스프링 WebSocket 테스트

 

이런 식으로 코드를 작성하고 나면 PostMan에서 테스트를 직접 해볼 수 있습니다. 다음은 테스트 장면입니다.

 

 

위 처럼 ws를 사용하여 웹소켓 연결을 할 수 있습니다.

위 처럼 connet 성공시 아래쪽에 Conneted 알람이 뜹니다.

 

이제 메세지를 보낼 차례입니다. 저희는 메세지 브로커가 없기때문에 직접 type을 정하고, 등록하는 것을 직접해주어야 합니다.

 

위처럼 type을 'ENTER'로 정하고, roomNumber를 통해 ID값을 정해주었습니다.
이를 통해, 해당 클라이언트는 1234라는 ID를 가질 수 있으며, 이제 1234로 들어오는 메세지는 해당 클라이언트로 들어오게 됩니다.

 

이를 통해 다른 클라이언트도 똑같이 등록할 수 있으며, 이제 서로 메세지를 주고 받게 된다면 아래와 같이 Response에 메세지가 들어오게 됩니다.

 

노랑색이 해당 클라이언트가 보낸 메세지이고, 파랑색이 해당 클라이언트가 다른 클라이언트로부터 메세지를 받은 것입니다.

 

 

 

궁금하신점 있으시면 댓글 남겨주세요!
저두 아직 웹소켓을 완벽히 이해한건 아니라서, 같이 공부해보면 재밌을거 같습니다 ㅎㅎ