[빙터뷰] 웹소켓 클라이언트 개발

impala·2023년 6월 28일
0
post-thumbnail

개발서버를 배포한 이후 웹소켓을 사용해서 실시간 모의면접 기능을 개발했는데, 프론트엔드쪽 작업량이 많아 급하게 플러터를 배우고 웹소켓 통신의 클라이언트측 코드를 개발하기 시작했다.

실시간 모의면접 기능은 같은 태그를 선택한 사람들을 매칭하고 서버에서 무작위로 해당 태그에 속한 질문을 뽑아주면 실시간으로 질문에 답변하는 영상을 공유하는 기능이다. 이를 구현하기 위해서는 실시간으로 서버와 클라이언트가 통신할 수 있어야 하기 때문에 웹소켓을 사용하기로 결정했다.

웹소켓

웹소켓이란 요청을 보내야만 서버로부터 응답을 받을 수 있는 HTTP의 한계를 해결하기 위해 등장한 프로토콜로, TCP채널을 통해 실시간 양방향 통신을 가능하게 해주는 Stateful한 프로토콜이다.

웹소켓에 대한 자세한 내용은 링크에 정리하였다.

설계

설계 초기에는 STOMP프로토콜을 이용하여 메세지와 비디오 스트림을 주고받으려 했지만 STOMP를 이용하여 바이너리 데이터를 주고받는것이 생각만큼 제대로 이루어지지 않아 통신에 사용되는 프로토콜을 직접 정하게 되었다.

이 프로토콜을 통해 서버와 통신하는 과정은 아래 그림과 같다.

하지만 개발을 진행하면서 필요에 의해 몇가지 메세지 타입이 추가되었고, 메세지를 주고받는 과정도 약간의 변화가 있었다. 또한 클라이언트에서 실시간으로 서버에 비디오 스트림을 전달하는 것에 어려움이 있어서 이 부분은 서드파티로 위임하고 개발을 이어나갔다.

구현

플러터의 웹소켓 파트 디렉토리 구조는 다음과 같다.

websocket
    message
        game_info.dart
        member.dart
        message.dart
        message_type.dart
    widget
        ...        
    wsclient
        game_state.dart
        stage.dart
        websocket_client.dart

이 중 나는 message와 wsclient를 담당하였고, 원래 프론트엔드 개발을 담당하던 팀원이 위젯을 맡아 개발하였다. 주요 코드의 역할은 다음과 같다.

  • websocket_client : 서버와 웹소켓 연결 및 메세지 송수신을 담당하는 객체
  • game_state : 웹소켓 클라이언트와 플러터 위젯 사이에 정보를 전달하는 객체
  • message : 서버와 클라이언트가 주고받는 메세지

즉, 웹소켓 클라이언트는 서버와 message를 주고받고 이를 알맞게 변환하여 위젯에게 game_state를 알려주는 역할을 한다.

message

message_type.dart

// message_type.dart

enum MessageType {
  // 웹소켓 연결 = 큐 등록
  CREATE, // 매칭되면 서버에서 보내는거
  START, // 게임 시작하세요
  PARTICIPATE, // 이 질문에 대답 할거임 (클라 -> 서버) (내가 하겠다!)
  FINISH_PARTICIPATE, // 대답할 사람 모집 종료 (타이머 다 돌면 클라 -> 서버) (알아서 감)
  START_VIDEO, // 스트리밍 시작 신호(클라 -> 서버)
  FINISH_VIDEO, // 대답 끝남 (클라 -> 서버) (스트림 끝났을 때 눌러야함)
  INFO, // 게임 진행 정보 : 참가자, 순서, 라운드, 질문 등
  TURN, // 누구 차례인지 서버에서 알려주는거
  VIDEO, // 다른사람 발표 들을 준비
  POLL, // (서버 -> 클라) 투표 시작 하세요, (클라 -> 서버) 누가 제일 잘했는지 보냄 (누가? widget.client.state.poll값을 변경하고, sendmessage하면 됨. 서버에서는 제일 잘 한 사람 보냄)
  FINISH_POLL, // 투표 종료 (타이머 다 돌면 클라 -> 서버)ㄴ  (알아서 감)
  RESULT, // 투표 결과
  NEXT, // (클라 -> 서버) 다음 라운드 시작하셈  (다음 게임 레디 느낌? 다음 라운드로 넘어감)
  FINISH_GAME // 게임 종료 : 웹소켓 연결 끊어도 됨
}

message_type에는 서버와 통신하기 위해 정의한 프로토콜 문서를 바탕으로 메세지의 형식을 enum타입으로 선언해두었다.

message.dart

// message.dart

class Message {
  MessageType type;
  String roomId;
  String sessionId;
  String currentBroadcaster;
  GameInfo gameInfo;
  List<MemberInfo> memberInfos;
  String poll;
  String agoraToken;

  Message(
      {this.type,
      this.roomId,
      this.sessionId,
      this.currentBroadcaster,
      this.gameInfo,
      this.memberInfos,
      this.poll,
      this.agoraToken});

  factory Message.fromJson(Map<String, dynamic> json) {
    return Message(
      type: MessageType.values.firstWhere(
          (type) => type.toString() == 'MessageType.${json["type"]}',
          orElse: () => null),
      roomId: json['roomId'] as String,
      sessionId: json['sessionId'] as String,
      currentBroadcaster: json['currentBroadcaster'] as String,
      gameInfo: GameInfo.fromJson(json['gameInfo'] as Map<String, dynamic>),
      memberInfos: (json['memberInfos'] as List<dynamic>)
          ?.map((memberInfos) => MemberInfo.fromJson(memberInfos))
          ?.toList(),
      poll: json['poll'] as String,
      agoraToken: json['agoraToken'] as String,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'type': type.toString().split('.').last,
      'roomId': roomId,
      'sessionId': sessionId,
      'currentBroadcaster': currentBroadcaster,
      'gameInfo': gameInfo?.toJson(),
      'memberInfos':
          memberInfos?.map((memberInfos) => memberInfos.toJson())?.toList(),
      'poll': poll,
      'agoraToken': agoraToken,
    };
  }
}

message는 서버와 통신하기 위한 메세지를 정의한 것으로, json으로 변환하기 위해 tojson()과 fromJson()을 정의하였다.
message에는 메세지의 형식과 매칭된 방의 id, 내 세션의 id등 게임 전체에 필요한 정보들이 담겨있다.

game_info.dart

//game_info.dart

class GameInfo {
  List<String> question;
  List<String> participant;
  List<String> order;
  int round;

  GameInfo({
    this.question,
    this.participant,
    this.order,
    this.round
  });

  factory GameInfo.fromJson(Map<String, dynamic> json){
    return GameInfo(
        question: (json['question'] as List<dynamic>).cast<String>(),
        participant: (json['participant'] as List<dynamic>).cast<String>(),
        order: (json['order'] as List<dynamic>).cast<String>(),
        round: json['round'] as int
    );
  }

  Map<String, dynamic> toJson(){
    return {
      'question': question,
      'participant': participant,
      'order': order,
      'round': round
    };
  }
}

game_info역시 message에 들어있는 하위 타입으로 json으로 변환하기 위해 toJson과 fromJson을 정의했다.
game_info는 각 라운드마다 갱신되는 정보들이 담겨있다.

member.dart

//member.dart

class MemberInfo {
  String sessionId;
  String name;
  String encodedImage;

  MemberInfo({
    this.sessionId,
    this.name,
    this.encodedImage
  });

  factory MemberInfo.fromJson(Map<String, dynamic> json){
    return MemberInfo(
      sessionId: json['sessionId'] as String,
      name: json['name'] as String,
      encodedImage: json['encodedImage'] as String
    );
  }

  Map<String, dynamic> toJson(){
    return {
      'sessionId': sessionId,
      'name': name,
      'encodedImage': encodedImage
    };
  }
}

member는 각 참가자의 정보를 담고있는 타입으로 매칭시 다른 사용자를 식별하기 위해 사용된다.
member또한 message안에 포함되므로 직렬화를 위해 toJson과 fromJson을 정의했다.

wsclient

stage.dart

// stage.dart

enum Stage {
  // 클라이언트가 받아서 위젯한테 알려줌.
  GAME_MATCHED, // 게임이 매칭됨 -> 게임 홈 화면 그리기
  ROUND_START, // 라운드 시작 -> 라운드 정보 띄우고 질문 보여주기 + 참가 신청 받기
  SHOW_PARTICIPANT, // 참가 신청 종료 -> 참가자 정보랑 순서 보여주기
  READY_STREAMING, // (답변자) 답변 준비 -> 카메라 띄우기
  FINISH_STREAMING, // (답변자) 답변 완료 -> 시청자 모두 나가기
  WATCH_STREAMING, // (나머지) 답변 시청 -> 비디오 스트림 화면
  START_POLL, // 제일 잘 한사람 투표 -> 투표 화면 그리기
  SHOW_RESULT, // 투표 결과 -> 투표 결과 화면 그리기
  GAME_FINISH // 게임 종료 -> 홈 화면으로 나가기
}

stage는 클라이언트와 위젯이 정보를 주고받을 때 사용하는 메세지 타입으로, 현재 게임의 상태를 enum타입으로 정의하였다. 사실 message_type을 사용해서 위젯에게 정보를 전달할 수도 있었지만 stage를 추가한 이유는, 서버와 통신하는 프로토콜이 변경되더라도 클라이언트 코드가 받는 영향을 최소화 할 수 있도록 websocket_client에서 변환과정을 거치도록 구현하였다.

game_state.dart

//game_state.dart

class GameState with ChangeNotifier {
  Stage stage;
  String sessionId;
  String roomId;
  String agoraToken;
  List<MemberInfo> memberInfos;
  GameInfo gameInfo = GameInfo();
  String currentBroadcaster;
  String poll;
  int duration;

  notifyState(Stage stage) {
    this.stage = stage;
    notifyListeners();
  }
}

game_state는 위젯과 웹소켓 클라이언트가 정보를 교환하기 위한 객체로, 클라이언트는 서버로부터 메세지를 받으면 gameState를 수정하고 ChangeNotifier를 통해 위젯에게 신호를 보내 위젯을 업데이트할 수 있다.

websocket_client.dart

// websocket_client.dart

class WebSocketClient {
  String token;

  Future<String> get_token() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    return (prefs.getString('access_token'));
  }

  static String BASE_URL = myUri.substring(7);

  ///singleton instance
  static WebSocketClient _instance;

  ///WebSocket member fields
  IOWebSocketChannel _channel;
  bool _isConnected = false;
  final _sendBuffer = Queue();

  ///WebSocket connections member fields
  final _reconnectIntervalMs = 5000;
  Timer _reconnectTimer;
  int _reconnectCount = 5;
  // Timer heartBeatTimer;
  // final _heartbeatInterval = 10;

  ///Game state
  GameState state = new GameState();

  ///timer state
  int _participateSec = 15; //질문 확인하고 참가하는 시간 60초
  int _pollSec = 10; //투표하는 시간 30초
  bool _isAlive = false;

  ///private constructor
  WebSocketClient._();

  /// singleton 객체 획득 메소드
  static WebSocketClient getInstance() {
    if (_instance == null) {
      print("[websocket_client] create new ws instanse");
      _instance = WebSocketClient._();
    }
    return _instance;
  }

  IOWebSocketChannel get channel => _channel;

  /**
   * public methods
   */

  ///웹소켓 연결
  connectToSocket({List<int> selectedTagId}) async {
    if (!_isConnected) {
      String endPoint = "ws://$BASE_URL/ving";

      String tags = "";
      if(selectedTagId != null) {
        tags = selectedTagId.map((tag) => tag.toString()).join(';');
      }
      token = await get_token();

      WebSocket.connect(endPoint, headers: {'Authorization': 'Bearer $token', 'X-Tag-Id': tags})
          .then((ws) {
        _channel = IOWebSocketChannel(ws);
        if (_channel != null) {
          print("Websocket connected");
          _reconnectCount = 120;
          _reconnectTimer?.cancel();
          _listenToMessage();
          _isConnected = true;
          // _startHeartBeatTimer();

          //버퍼에 남아있는 메세지를 모두 전송
          while (_sendBuffer.isNotEmpty) {
            String text = _sendBuffer.first;
            _sendBuffer.remove(text);
            _push(text);
          }
        }
      }).onError((error, stackTrace) {
        //에러 발생시 현재 연결을 끊고 다시 연결 시도
        print("failed to connect channel");
        disconnect();
        _reconnect();
      });
    }
  }

  ///웹소켓 연결 해제
  disconnect() {
    print("disconnect channel");
    state.notifyState(null);
    _channel?.sink?.close(status.goingAway);
    _reconnectTimer?.cancel();
    _isConnected = false;
    // heartBeatTimer?.cancel();
  }

  ///json으로 변환 후 메세지 전송
  sendMessage(MessageType type) {
    if (type == MessageType.PARTICIPATE || type == MessageType.POLL) {
      if (!_isAlive) {
        return;
      }
    } else if (type == MessageType.FINISH_VIDEO){
      state.notifyState(Stage.FINISH_STREAMING);
    }

    Message message = Message(
        type: type,
        roomId: state.roomId,
        sessionId: state.sessionId,
        gameInfo: state.gameInfo,
        memberInfos: [],
        poll: state.poll);

    _push(jsonEncode(message));
  }

  /**
   * private methods
   */

  ///재연결
  _reconnect() async {
    if ((_reconnectTimer == null ||
            !_reconnectTimer.isActive) // 재연결을 시도하는 중이 아니고
        &&
        _reconnectCount > 0) {
      // 재연결 횟수가 남아있으면
      print("try to reconnect");
      _reconnectTimer = Timer.periodic(Duration(seconds: _reconnectIntervalMs),
          (Timer timer) async {
        if (_reconnectCount == 0) {
          _reconnectTimer?.cancel();
          return;
        }
        await connectToSocket(); //연결 시도
        _reconnectCount--;
      });
    }
  }

  ///메세지 수신
  _listenToMessage() {
    _channel.stream.listen((msg) {
      Message message = Message.fromJson(jsonDecode(msg));
      print("[MESSEGE_ARRIVED] ${message.type}, ${message.currentBroadcaster}");
      switch (message.type) {
        case MessageType.CREATE:
          //set : 방 정보, 세션 정보
          state.sessionId = message.sessionId;
          state.roomId = message.roomId;
          state.memberInfos = message.memberInfos;
          state.agoraToken = message.agoraToken;

          //signal : game matched
          state.notifyState(Stage.GAME_MATCHED);

          break;

        case MessageType.START:
          //set : 질문3개, 현재 라운드, 참가신청 받는 시간
          state.gameInfo.question = message.gameInfo.question;
          state.gameInfo.round = message.gameInfo.round;
          state.duration = _participateSec;
          state.gameInfo.participant = [];

          //timer start
          _setTimer(Duration(seconds: _participateSec),
              MessageType.FINISH_PARTICIPATE);

          //signal : show question & ask participate
          state.notifyState(Stage.ROUND_START);
          break;

        case MessageType.INFO:
          //set : 답변 순서, 참가자 정보
          state.gameInfo.order = message.gameInfo.order;
          state.gameInfo.participant = message.gameInfo.participant;

          //signal : show participant
          state.notifyState(Stage.SHOW_PARTICIPANT);
          break;

        case MessageType.TURN:
          //set : 현재 발표자
          state.currentBroadcaster = message.sessionId;

          //signal : ready & start streaming
          state.notifyState(Stage.READY_STREAMING);
          break;

        case MessageType.VIDEO:
          //set : 현재 발표자
          state.currentBroadcaster = message.currentBroadcaster;

          //signal : show video stream
          state.notifyState(Stage.WATCH_STREAMING);
          break;

        case MessageType.FINISH_VIDEO:
          //signal : finish streaming
          state.notifyState(Stage.FINISH_STREAMING);
          break;

        case MessageType.POLL:
          //set : 투표 시간
          state.duration = _pollSec;
          state.currentBroadcaster = null;

          //timer start
          _setTimer(Duration(seconds: _pollSec), MessageType.FINISH_POLL);

          //signal : start poll
          state.notifyState(Stage.START_POLL);
          break;

        case MessageType.RESULT:
          //set : 투표 결과
          state.poll = message.poll;

          //signal : show result
          state.notifyState(Stage.SHOW_RESULT);
          break;

        case MessageType.FINISH_GAME:

          //signal : game finish
          state.notifyState(Stage.GAME_FINISH);
          break;

        default:
          break;
      }
    }, onDone: () {
      print("fail to receive message");
      disconnect();
      _reconnect();
    }, onError: (error) {
      print(error);
    });
  }

  ///메세지 전송
  _push(String text) {
    if (_isConnected) {
      print("message sent");
      _channel.sink.add(text);
    } else {
      print("message added to buffer");
      _sendBuffer.add(text);
    }
  }

  ///타이머 설정
  _setTimer(Duration duration, MessageType type) {
    _isAlive = true;
    Future.delayed(duration, () {
      sendMessage(type);
      _isAlive = false;
    });
  }
}

websocketClient는 서버와 직접 웹소켓을 통해 통신을 하는 객체로 크게 connect(연결), send(송신), listen(수신)을 담당한다.

  • connectToSocket : 사용자가 선택한 태그정보를 http헤더에 담아 서버로 웹소켓 연결 요청을 보냄. 채널이 연결되면 객체를 초기화한다.
  • sendMessage : 서버로 전송하고자하는 메세지 타입을 받아 game_state의 정보를 바탕으로 메세지를 생성하여 메세지 채널로 전송한다.
  • _listenToMessage : 서버로부터 받은 Json메세지를 파싱하여 Message객체로 변환하고, 메세지의 타입에 맞는 작업을 수행함. 주로 gameState를 업데이트하고 notifyState를 통해 위젯에게 업데이트 신호를 전달한다.

이때, 서버와 동시에 두개 이상의 웹소켓 연결을 허용하지 않으므로 WebsocketClient객체는 싱글톤 객체로 관리한다.

0개의 댓글