프로젝트 - 빙터뷰 [WebSocket 서버 개발]

Chan Young Jeong·2023년 6월 30일
0

프로젝트 빙터뷰

목록 보기
9/9

실시간 모의 면접 기능을 개발하기 위해 웹소켓을 사용하였습니다. 실시간 모의 면접 기능은 같은 태그를 선택한 사용자끼리 무작위로 매칭을 하여 해당 태그에 맞는 질문이 무작위로 선택되어 해당 질문에 대해 참가자들이 답변을 하고 해당 답변 영상을 공유하고 평가하는 기능입니다.

전체 코드

설계

클라이언트와 서버가 통신하기 위해 웹소켓 통신용 프로토콜을 정의하였습니다.
면접 연습을 진행할 때 각 방에서의 진행 상황에 맞춰 서버에서 처리를 해야하기 때문에 진행 상황에 따라 보내는 메시지 타입을 다르게 정의하였습니다.

웹소켓 프로토콜

모의 면접 진행 과정

가장 먼저 클라이언트에서 게임 참여를 위해 웹소켓을 OPEN합니다. 정해진 인원인 5명이 모두 모이면 게임 방을 만들고 게임이 시작됩니다. 서버에서는 질문(QUESTION)을 보내고 참가자들은 질문에 대해 참가의사를 보냅니다(PARTICPATE). 그리고 서버에서 참여의사를 보낸 참가자들을 대상으로 차례로 비디오 스트림을 보내도록 TURN 메세지를 보내어 비디오 스트림을 전송할 수 있도록 합니다. FINISH_VIDEO 를 보내면 다음 차례의 참가자에게 TURN 메세지를 보내어 비디오 스트림을 보낼 수 있도록 하고 이를 마지막 참가자까지 반복합니다. 모든 참가자의 발표가 끝나면 POLL 메세지를 보내 투표하고 투표 결과 RESULT를 보내어 클라이언트에서 이에 맞게 처리할 수 있도록 합니다. 그리고 다음 라운드로 넘어갈 수 있도록 NEXT 메세지를 보냅니다. 게임이 모두 끝나면 FINISH_GAME 메세지를 보내 게임이 끝났다는 걸 알립니다.

개발

WebSocketConfigurer

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

    private final SocketHandler socketHandler;
    private final WebSocketAuthenticationInterceptor webSocketAuthenticationInterceptor;
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(socketHandler, "/ving")
                .addInterceptors(webSocketAuthenticationInterceptor)
                .setAllowedOrigins("*");
    }
}

  • @EnableWebSocket은 WebSocket을 활성화하는 어노테이션입니다. 이를 통해 Spring 애플리케이션에서 WebSocket을 사용할 수 있도록 설정됩니다.

  • WebSocketConfigurer 인터페이스를 구현하는 WebSocketConfig 클래스는 registerWebSocketHandlers 메소드를 오버라이딩하여 WebSocket 관련 설정을 수행합니다.

  • registry.addHandler(socketHandler, "/ving")는 socketHandler를 "/ving" 경로에 등록하는 메소드입니다. 클라이언트가 "/ving" 경로로 WebSocket 연결을 요청하면, socketHandler가 처리하게 됩니다.

  • .addInterceptors(webSocketAuthenticationInterceptor)는 webSocketAuthenticationInterceptor를 WebSocket 핸들러에 인터셉터로 추가하는 메소드입니다. 이는 WebSocket 연결 핸들러에 인증 처리를 수행하는 인터셉터를 등록하는 역할을 합니다.

  • .setAllowedOrigins("*")는 모든 오리진(Origin)에서의 요청을 허용하는 메소드입니다. WebSocket 연결에 대한 CORS(Cross-Origin Resource Sharing) 설정을 수행하며, *를 통해 모든 오리진에서의 요청을 허용합니다.

이렇게 설정된 WebSocketConfig 클래스는 Spring 애플리케이션에서 WebSocket을 활성화하고, "/ving" 경로로의 연결을 socketHandler가 처리하도록 설정하며, 인증 처리를 위해 webSocketAuthenticationInterceptor를 사용합니다. 또한, 모든 오리진에서의 요청을 허용하도록 설정하였습니다.

HandshakeInterceptor

https://micropilot.tistory.com/2768
Spring 4부터 지원하는 WebSocketHandler 에서는 접속자에 대한 정보가 WebSocketSession에 저장되는데 이는 HttpSession 클래스와 다르기 때문에 이용자의 ID 등 개발자가 필요한 이용자의 정보를 저장할 때는 HttpSession에 저장해 둔 이용자의 ID를 WebSocketHandler의 WebSocketSession에 저장해두고 이용자를 관리할 수 있다. WebSocketHandler 를 구현한 클래스 안에서는 HttpServletRequest 등의 서블릿 관련 클래스를 사용할 수 없으므로 WebSocketHandler 보다 앞서 실행되는 HttpSessionHandshakeInterceptor 인터셉터를 이용하여 이용자의 HttpSession에 접속하고 세션에 저장된 내용을 WebSocketHandler로 전달해주면 WebSocketHandler 안에서도 이용자의 ID 등 필요한 이용자 정보를 사용하고 관리할 수 있게 된다.

HandshakeInterceptor : Interceptor for WebSocket handshake requests. Can be used to inspect the handshake request and response as well as to pass attributes to the target WebSocketHandler.

@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketAuthenticationInterceptor implements HandshakeInterceptor {

    private final MemberRepository memberRepository;

    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        JwtUserDetails principal = (JwtUserDetails) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        Long memberId = principal.getId();
        Member member = memberRepository.findById(memberId).orElseThrow(() -> new EntityNotFoundException("찾을 수 없는 사용자입니다."));

        /**
         * attribute에 사용할 정보 저장 태그, 이름, 프로필 URL 
         */
        attributes.put("X-Tag-Id", request.getHeaders().getFirst("X-Tag-Id"));
        attributes.put("name", member.getName());
        attributes.put("imageUrl", member.getProfileImageUrl());
        return true;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
    }


}
  • MemberRepository를 주입받아 멤버 정보를 조회하는 역할을 합니다.

  • beforeHandshake 메서드는 WebSocket 핸드셰이크가 이루어지기 전에 호출됩니다. 이 메서드에서는 사용자 인증 정보를 통해 멤버 정보를 조회하고, 해당 정보를 WebSocket 핸드셰이크 과정에서 사용할 수 있도록 속성(attributes)에 저장합니다.

  • 사용자 인증 정보는 SecurityContextHolder를 통해 가져옵니다. SecurityContextHolder는 현재 실행 중인 스레드의 인증 정보를 제공합니다.

  • 조회한 멤버 정보는 attributes 맵에 추가됩니다. 이러한 속성은 WebSocket 연결 이후에 WebSocket 핸들러에서 사용할 수 있습니다.

TextWebSocketHandler

TextWebSocketHandler는 WebSocketHandler 인터페이스를 구현한 클래스로, 텍스트 기반의 WebSocket 통신을 처리하는 핸들러입니다. TextWebSocketHandler 클래스에서 대표적으로 사용되는 메서드는 다음과 같습니다:

  • handleTextMessage():
    이 메서드는 클라이언트로부터 텍스트 메시지를 수신할 때 호출됩니다. 오버라이딩하여 실제로 받은 메시지를 처리하는 로직을 구현할 수 있습니다.

  • afterConnectionEstablished():
    이 메서드는 클라이언트와 WebSocket 연결이 성공적으로 수립된 후에 호출됩니다. 오버라이딩하여 연결 설정 및 초기화 작업을 수행할 수 있습니다.

  • afterConnectionClosed():
    이 메서드는 클라이언트와 WebSocket 연결이 닫혔을 때 호출됩니다. 오버라이딩하여 연결 종료 후의 정리 작업을 수행할 수 있습니다.

public class SocketHandler extends TextWebSocketHandler {

    private final Map<Integer, Queue<WebSocketSession>> waitingQueueByTag = new HashMap<>();
    private final GameRoomRepository gameRoomRepository = new GameRoomRepository();
    private final QuestionRepository questionRepository;
    private final TagQuestionRepository tagQuestionRepository;

    private final AgoraTokenBuilder tokenBuilder;

    ObjectMapper objectMapper = new ObjectMapper();

    public static final int NUMBEROFPLAYERS = 5;
    public static final int NUMBEROFQUESTIONS = 3;

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // attribute에 담은 tag 정보 가져오기
        List<Integer> tagIds = Arrays.stream(session.getAttributes().get("X-Tag-Id").toString().split(";"))
                .map(Integer::parseInt).collect(Collectors.toList());
        Integer tag = tagIds.get(tagIds.size() - 1);
        Queue<WebSocketSession> waitingQueue;
        /**
         * 기업별로 대기큐
         */
        if (waitingQueueByTag.containsKey(tag)) {
            waitingQueue = waitingQueueByTag.get(tag);
        }else{
            waitingQueue = new LinkedBlockingQueue<>();
            waitingQueueByTag.put(tag, waitingQueue);
        }
        waitingQueue.offer(session);

        log.info("Server : waitingQueue add {} ,  tag {}", session, tag);
        if ( NUMBEROFPLAYERS <= waitingQueue.size()) {
            GameRoom room = createRoom(waitingQueue,tag);
            log.info("Server : Make Game Room {} , tagId {}", room.getRoomId(), tag);
        }

    }
  @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        log.info("sessionId {} connectin closed", session.getId());
        List<Integer> tagIds = Arrays.stream(session.getAttributes().get("X-Tag-Id").toString().split(";"))
                .map(Integer::parseInt).collect(Collectors.toList());
        Integer tag = tagIds.get(tagIds.size() - 1);
        Queue<WebSocketSession> queue = waitingQueueByTag.get(tag);
        boolean remove = queue.remove(session);
        log.info("sessionId {} removed from queue {}", session.getId(), remove);

    }
 @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception{
        String payload = message.getPayload();

        GameMessage gameMessage = objectMapper.readValue(payload, GameMessage.class);
        String roomId = gameMessage.getRoomId();
        MessageType type = gameMessage.getType();
        String sessionId = gameMessage.getSessionId();
        GameRoom gameRoom = gameRoomRepository.getGameRoomMap().get(roomId);
        GameInfo gameInfo = gameRoom.getGameInfo();


        switch (type) {
            case PARTICIPATE ->
            {
                log.info("CLIENT SEND PARTICIPANT, {}" , sessionId);
                gameRoom.addParticipant(sessionId);
            }
            case FINISH_PARTICIPATE -> {
                log.info("CLIENT SEND PARTICIPANT FINISH");
                if(!gameInfo.isFinishParticipate()){
                    gameInfo.setFinishParticipate(true);
                    gameRoom.setRandomOrder();
                    GameMessage infoGameMessage = new GameMessage();
                    infoGameMessage.infoGameMessage(roomId, gameInfo);
                    gameRoom.handleMessage(infoGameMessage,objectMapper);

                }
            }
            case START_VIDEO -> {
                // 여기가 START_VIDEO 메세지를 서버가 받았을 때 코드
                log.info("CLIENT SENT START VIDEO {}", sessionId);

                GameMessage videoGameMessage = new GameMessage();
                // sessionId = currentBroadCaster sessionId
                videoGameMessage.videoGameMessage(roomId, gameInfo,sessionId);
                gameRoom.handleMessage(videoGameMessage,objectMapper);
            }

            case FINISH_VIDEO ->{
                log.info("CLIENT SEND FINISH VIDEO, {}",sessionId);

                GameMessage finishVideoMessage = new GameMessage();
                finishVideoMessage.finishVideoMessage(roomId,gameInfo,sessionId);
                gameRoom.handleMessage(finishVideoMessage,objectMapper);

                Thread.sleep(1000);

                if (gameRoom.getGameInfo().getOrder().size() == 0) {
                    log.info("NO MORE PARTICIPANT");
                    GameMessage pollGameMessage = new GameMessage();
                    pollGameMessage.pollGameMessage(roomId, gameInfo);
                    gameRoom.handleMessage(pollGameMessage,objectMapper);
                } else {
                    log.info("NEXT PARTICIPANT");
                    GameMessage turnGameMessage = new GameMessage();
                    turnGameMessage.turnGameMessage(roomId, gameInfo);
                    gameRoom.handleMessage(turnGameMessage, objectMapper);
                }

            }
            case POLL ->{
                log.info("CLIENT SEND POLL");
                gameInfo.addPoll(gameMessage.getPoll());
            }
            case FINISH_POLL ->{
                log.info("CLIENT SEND POLL FINISH");
                if (!gameInfo.isFinishPoll()) {
                    gameInfo.setFinishPoll(true);
                    GameMessage resultGameMessage = new GameMessage();
                    String winner = gameRoom.getResult();
                    resultGameMessage.setPoll(winner);
                    resultGameMessage.resultGameMessage(roomId, gameInfo);
                    gameRoom.handleMessage(resultGameMessage, objectMapper);
                }
            }
            case NEXT->{
                log.info("CLIENT SEND NEXT");
                if (gameInfo.getRound() == 3) {
                    log.info("NO MORE ROUND, finish game");
                    log.info("Remove RoomId , SessionId");
                    gameRoomRepository.removeGameRoom(roomId);
                    // finish
                    GameMessage finishGameMessage = new GameMessage();
                    finishGameMessage.finishGameMessage(roomId,gameInfo);
                    gameRoom.handleMessage(finishGameMessage, objectMapper);


                }else{
                    gameInfo.addNext();
                    if (gameInfo.getNext() == NUMBEROFPLAYERS) {
                        log.info("GO NEXT ROUND");
                        gameInfo.initGameInfo();
                        //next round
                        GameMessage questionGameMessage = new GameMessage();
                        gameInfo.increaseRound();
                        questionGameMessage.questionGameMessage(roomId,gameInfo);
                        gameRoom.handleMessage(questionGameMessage, objectMapper);
                    }

                }
            }

        }

    }

라이브 스트리밍 서비스

라이브 스트리밍을 위해서는 웹소켓이 아닌 RTMP나 HLS같은 실시간 미디어 전송에 특화된 프로토콜을 사용하고, 비디오/오디오 코덱과 인코딩 및 CDN등 여러 기술이 복합적으로 얽혀서 사용된다는 것을 알게 되어 이 모든것을 남은 기간 안에 공부하여 직접 개발하는 것은 불가능하다고 판단하고 서드파티를 통해 관련 작업을 위임하였다.

0개의 댓글

관련 채용 정보