Flutter/Dart - 채팅 앱 만들기(3) - 다트 웹 소켓 서버 기능 구현하기

반응형

개요

이전 포스팅에서 다뤘던 기능들과 플러터 앱의 UI를 결합하여,

이번에는 웹소켓 서버와 통신 하여 실제 실시간 채팅이 이루어지도록 기능을 구현해보자.

웹소켓과 채팅 기능의 이해를 돕기 위해 최대한의 최대한... 간소화 하였다.

UUID와 Shared Preference를 활용한 조금 더 정밀한 개인 메시지 전송 방법을 설명하려했지만,

그러면 포스팅의 내용이 너무 길어지고 복잡해질꺼 같아서 전부 다 뺐다.

프로젝트를 간단히 초기 설정된 닉네임으로만 통하여 메시지를 보내도록 수정하였다.

때문에 같은 닉네임을 사용한다면 사실상 1:1 개인 메시지의 기능이 아니게 되는 허점이 존재하지만 이해하길 바란다.

(이 기능을 잘 활용하면 특정 방에 접속한 유저들끼리 나눌 수 있는 '채팅방' 형태의 기능을 구현할 수도 있다.)

 

[ 시리즈 포스팅 내용 보기 ]

내용이 너무 길어지기 때문에 두 개의 포스팅으로 나눠서 작성하였다.

이번 포스팅에서는 아래와 같은 내용을 다룬다.

 

서버에 접속한 클라이언트 관리하는 방법

  • 클라이언트의 초기 접속 시 기능 수행 방법
    • 포스팅에서는 접속 시 사용자의 이름과 클라이언트의 정보를 서버에 저장한다.
  • 클라이언트의 접속 종료 시 기능 수행 방법
    • 포스팅에서는 접속 종료 시 서버에 저장된 목록에서 접속 종료한 클라이언트를 제거한다.

 

  •  

[ 각각의 클라이언트 1, 2, 3의 유저가 접속하여 메시지를 나누고 1과 2의 유저가 단 둘만의 메시지를 전달하는 예시 화면 ]

 

 

 

포스팅에서 다루는 플러터 채팅 앱은 깃허브에서 다운로드 할 수 있다.

 

GitHub - luvris2/flutter_chatting_app

Contribute to luvris2/flutter_chatting_app development by creating an account on GitHub.

github.com


웹소켓 서버

통신 구조 설계

클라이언트의 데이터를 서버에서 받아 처리할 구조를 만들어보자.

포스팅에서의 서버는 클라이언트로부터 3가지의 정보를 받아 처리하도록 설계하였다.

 

[ 서버에서 클라이언트 접속 정보 저장에 필요한 요소 ]

  • 클라이언트의 웹소켓 인스턴스
  • 사용자의 닉네임

 

[ 서버에서 클라이언트의 요청 처리에 필요한 요소 ]

  • 클라이언트의 웹소켓 인스턴스
  • 클라이언트의 데이터
    • 사용자의 닉네임
    • 메시지 내용
    • 데이터 분류 코드

기능 구현

변수 선언 및 초기화

서버에서 사용될 변수를 선언하고 초기화 한다.

사용될 변수는 서버가 구동될 호스트 주소와 포트 번호,

그리고 클라이언트 접속 목록을 관리할 리스트 타입의 변수이다.

/* 환경에 맞게 변경 */
String HOST = '192.168.10.103'; // 서버 호스트
int PORT = 4001; // 서버 포트
List<dynamic> clients = []; // 클라이언트 목록

서버 설정 및 생성

클라이언트가 접속할 웹소켓 서버를 생성해보자.

main 함수에서 서버를 설정하고 생성한다.

createServer 함수를 이용하여 생성된 HttpServer를 반환한다.

이러면 HttpServer가 생성된다.

(HttpServer는 이 후 클라이언트의 요청에 따라 웹 소켓으로 프로토콜을 업그레이드 한다.)

이후 수행될 clientConnections 함수는 밑에서 자세히 다룬다.

void main() async {
  // 서버 설정
  HttpServer server = await createServer();

  // 클라이언트 요청 및 메시지 처리
  clientConnections(server);
}

// 서버 생성
createServer() {
  print("서버가 생성되었습니다. $HOST:$PORT");
  return HttpServer.bind(HOST, PORT);
}

클라이언트 접속 처리

클라이언트가 HttpServer로 요청을 하면, 요청을 WebSocket으로 업그레이드하여 통신을 시도한다.

코드에서는 간단히 접속한 아이피 주소를 콘솔에 출력하였고,

이 후 클라이언트와 통신을 통해 상호작용할 임의의 함수인 webSocketActions 함수를 정의하여 기능을 수행하도록 한다.

클라이언트가 접속 할 경우 print를 통해 다음과 같은 메시지가 서버 콘솔에 출력된다.

  • 예) 클라이언트 접속 : 192.168.10.103
// 클라이언트 요청 및 메시지 처리
clientConnections(HttpServer server) async {
  // 클라이언트 요청 비동기 처리
  await for (var req in server) {
    // HTTP 요청을 웹 소켓 프로토콜로 업그레이드
    await WebSocketTransformer.upgrade(req).then((WebSocket websocket) async {
      // 디버그용 클라이언트 식별 print
      print("클라이언트 접속 : ${req.connectionInfo!.remoteAddress.address}");

      // 클라이언트 통신
      await webSocketActions(websocket);
    });
  }
}

클라이언트 상호작용 처리

클라이언트와 상호작용을 하는 임의의 함수인 webSocketActions를 정의한다.

이 함수에서는 다음과 같은 기능을 수행하도록 설계하였다.

  • 클라이언트 초기 접속 시 접속 정보 저장 (addClient 함수)
  • 메시지 데이터 송수신 처리 (webSocketListen 함수)
  • 클라이언트 연결 종료 시 접속 정보 삭제 (removeClient 함수)
// 클라이언트와 상호작용하는 함수
webSocketActions(WebSocket websocket) {
  // 클라이언트로 받은 데이터 메시지 처리
  websocket.listen((data) {
    // JSON 데이터 파싱
    var dataInfo = convertToJson(data);
    String messageType = dataInfo['type'].split("|")[0];

    // 초기 접속 시 클라이언트 접속 정보 저장
    if (messageType == "init") {
      addClient(websocket, dataInfo['username']);
    }
    // 연결되어 있는 클라이언트에게 보낼 데이터 송신 처리
    else {
      webSocketListen(websocket, data);
    }
  },
      // 클라이언트 연결 종료 시 서버 목록에 제거
      onDone: () {
    removeClient(websocket);
  },
      // 에러 처리
      onError: (e) {
    print("[server.dart] (webSocketActions) onError : $e");
  });
}

클라이언트 초기 접속 시 접속 정보 저장

webSocketActions 함수에서 수행되는 기능 중 하나로, addClient라고 함수명을 명명하였다.

접속한 클라이언트의 접속 정보를 사전에 정의한 리스트 타입의 변수인 clients 에 저장한다.

접속 정보는 클라이언트의 웹소켓 인스턴스 정보와 사용자가 지정한 닉네임으로 구성한다.

// 클라이언트 초기 접속 정보 저장
addClient(client, username) {
  print("클라이언트 접속 정보 : username($username)");
  clients.add([client, username]);
}

클라이언트측 메시지 데이터 송수신 처리

webSocketActions 함수에서 수행되는 기능 중 하나로, webSocketListen이라고 함수명을 명명하였다.

클라이언트측에서 받은 JSON 데이터를 파싱하여, 사전에 정의한 데이터 유형을 파악 한 후, 유형에 맞는 대상 클라이언트에게 메시지를 다시 보내는 역할을 한다. 클라이언트에서 보내는 데이터의 내용은 다음과 같다.

아래의 형식은 정해진 형식이 없기 때문에 개발자가 알아서 본인에 맞게 형식을 만들고,

소켓 서버에서는 정의한 형식에 맞게 처리 해주는 로직을 만들면 된다.  

  • username : 사용자의 닉네임
  • message : 메시지 내용
  • type : 데이터 분류 코드
    • init : 클라이언트 접속 정보 초기화
    • all : 모든 클라이언트에게 메시지 전송
    • whisper|username : 특정 클라이언트에게 메시지 전송, '|'(or) 기호를 구분자로 뒤에 위치하는 문자열로 특정 클라이언트를 지정한다.
// 클라이언트로 받은 데이터 메시지 처리
webSocketListen(WebSocket websocket, data) {
  // JSON 데이터 파싱
  print("클라이언트로부터의 메시지 : $data");
  var dataInfo = convertToJson(data);
  String messageType = dataInfo['type'].split("|")[0];

  for (var client in clients) {
    // 전체 사용자에게 메시지 보내기
    if (messageType == "all") {
      client[0].add(data);
    }
    // 귓속말 대상과 자신에게만 메시지를 보내기
    else if (messageType == "whisper") {
      String whisper = dataInfo['type'].split("|")[1];
      if (client[1] == whisper || client[1] == dataInfo['username']) {
        client[0].add(data);
      }
    }
  }
}

클라이언트 연결 종료 시 접속 정보 삭제

webSocketActions 함수에서 수행되는 기능 중 하나로, removeClient라고 함수명을 명명하였다.

서버에서 접속한 클라이언트들의 정보를 가지고 있는 리스트 변수인 clients 에서 접속을 종료한 클라이언트의 정보를 제거한다.

클라이언트를 식별하기 위해서 포스팅에서는 간단히 유저의 이름을 식별하여 유저 이름에 맞는 클라이언트의 정보를 리스트에서 제외하였다.

// 클라이언트 연결 종료 시 서버 목록에서 제거
removeClient(WebSocket websocket) {
  for (var i = 0; i < clients.length; i++) {
    var client = clients[i];
    if (client[0] == websocket) {
      print("클라이언트 접속 종료 : ${client[1]}");
      clients.removeAt(i); // 해당 클라이언트 목록에서 제거
      print("접속중인 클라이언트 목록 : $clients");
      break; // 클라이언트를 찾았으므로 반복문 종료
    }
  }
}

JSON 데이터 파싱 함수

클라이언트측에서 받은 JSON 데이터를 파싱하는 함수이다.

// JSON 데이터 파싱 함수
convertToJson(data) {
  Map<String, dynamic> converData = jsonDecode(data);
  return converData;
}

소켓 서버 전체 소스 코드

더보기
import 'dart:convert';
import 'dart:io';

String HOST = '192.168.10.103'; // 서버 호스트
int PORT = 4001; // 서버 포트
List<dynamic> clients = []; // 클라이언트 목록

void main() async {
  // 서버 설정
  HttpServer server = await createServer();

  // 클라이언트 요청 및 메시지 처리
  clientConnections(server);
}

// 서버 생성
createServer() {
  print("서버가 생성되었습니다. $HOST:$PORT");
  return HttpServer.bind(HOST, PORT);
}

// 클라이언트 요청 및 메시지 처리
clientConnections(HttpServer server) async {
  // 클라이언트 요청 비동기 처리
  await for (var req in server) {
    // HTTP 요청을 웹 소켓 프로토콜로 업그레이드
    await WebSocketTransformer.upgrade(req).then((WebSocket websocket) async {
      // 디버그용 클라이언트 식별 print
      print("클라이언트 접속 : ${req.connectionInfo!.remoteAddress.address}");

      // 클라이언트 통신
      await webSocketActions(websocket);
    });
  }
}

// 클라이언트 초기 접속 정보 저장
addClient(client, username) {
  print("클라이언트 접속 정보 : username($username)");
  clients.add([client, username]);
}

// 클라이언트 연결 종료 시 서버 목록에서 제거
removeClient(WebSocket websocket) {
  for (var i = 0; i < clients.length; i++) {
    var client = clients[i];
    if (client[0] == websocket) {
      print("클라이언트 접속 종료 : ${client[1]}");
      clients.removeAt(i); // 해당 클라이언트 목록에서 제거
      print("접속중인 클라이언트 목록 : $clients");
      break; // 클라이언트를 찾았으므로 반복문 종료
    }
  }
}

// 클라이언트와 상호작용하는 함수
webSocketActions(WebSocket websocket) {
  // 클라이언트로 받은 데이터 메시지 처리
  websocket.listen((data) {
    // JSON 데이터 파싱
    var dataInfo = convertToJson(data);
    String messageType = dataInfo['type'].split("|")[0];

    // 초기 접속 시 클라이언트 접속 정보 저장
    if (messageType == "init") {
      addClient(websocket, dataInfo['username']);
    }
    // 연결되어 있는 클라이언트에게 보낼 데이터 송신 처리
    else {
      webSocketListen(websocket, data);
    }
  },
      // 클라이언트 연결 종료 시 서버 목록에 제거
      onDone: () {
    removeClient(websocket);
  },
      // 에러 처리
      onError: (e) {
    print("[server.dart] (webSocketActions) onError : $e");
  });
}

// JSON 데이터 파싱 함수
convertToJson(data) {
  Map<String, dynamic> converData = jsonDecode(data);
  return converData;
}

// 클라이언트로 받은 데이터 메시지 처리
webSocketListen(WebSocket websocket, data) {
  // JSON 데이터 파싱
  print("클라이언트로부터의 메시지 : $data");
  var dataInfo = convertToJson(data);
  String messageType = dataInfo['type'].split("|")[0];

  for (var client in clients) {
    // 전체 사용자에게 메시지 보내기
    if (messageType == "all") {
      client[0].add(data);
    }
    // 귓속말 대상과 자신에게만 메시지를 보내기
    else if (messageType == "whisper") {
      String whisper = dataInfo['type'].split("|")[1];
      if (client[1] == whisper || client[1] == dataInfo['username']) {
        client[0].add(data);
      }
    }
  }
}
반응형