• [C++ 프로젝트] 채팅 프로그램 프로젝트 CHATBOX [2] 개발편

    2023. 11. 3.

    by. KAEY


    c++, Socket, SQL을 활용하여 채팅프로그램 만들기

     

    [C++ 프로젝트] 채팅 프로그램 프로젝트 CHATBOX [1] 설계편  [ 해당 게시글 링크 ]

    [C++ 프로젝트] 채팅 프로그램 프로젝트 CHATBOX [2] 개발편  [ 해당 게시글 링크 ]     ⬅️현재 게시물

    [C++ 프로젝트] 채팅 프로그램 프로젝트 CHATBOX [3] 발표편  [ 해당 게시글 링크 ]

    [C++ 프로젝트] 채팅 프로그램 프로젝트 CHATBOX [4] 회고편  [ 해당 게시글 링크 ] 

     

     


    개발 주안점

    우리가 프로젝트를 진행하면서 가장 중요하게 생각한 것은

    클라이언트 측에서 DB에 직접 접근을 해서는 안된다는 것 이었다. (악의적인 수정 방지 등..)

    그렇기 때문에 앞서 설계를 할 때도 상호간 요청에서 값들을 담거나, 요청에 대한 정의를 하는 등

    서버에서 DB를 처리를 하고 이를 클라이언트에게 보내는 것을 중요하게 생각했다.

    또한 받은 요청 값을 원하는 형태로 다시 분리해서 필요에 맞게 사용하는 것 또한 중요하게 생각했다.

     

     


    SERVER

    add_client()

    void add_client() {
        SOCKADDR_IN addr = {};
        int addrsize = sizeof(addr);
        char buf[MAX_SIZE] = { };
        ZeroMemory(&addr, addrsize); // addr의 메모리 영역을 0으로 초기화
    
        SOCKET_INFO new_client = {};
        new_client.sck = accept(server_sock.sck, (sockaddr*)&addr, &addrsize);
        recv(new_client.sck, buf, MAX_SIZE, 0);
    
        sck_list.push_back(new_client); // client 정보를 답는 sck_list 배열에 새로운 client 추가        
        std::thread th(recv_msg, client_count);
    
        sck_list[client_count].login_status = false;
        sck_list[client_count].user_number = client_count;
        cout << "========================" << endl;
        cout << "새로운 유저가 접근했습니다." << endl;
        cout << "sck_list[client_count].login_status = " << sck_list[client_count].login_status << endl;
        cout << "sck_list[client_count].user_number = " << sck_list[client_count].user_number << endl;
        cout << "========================" << endl;
        client_count++; // client 수 증가 
        th.join();
    }

     

    맨 처음 클라리언트가 접근하게 되면, 해당 클라이언트에게 메세지를 보낼 정보가 필요했다.

    로그인을 한 상태가 아니기에, 또 다른 값이 필요했고 소켓 리스트 안에 접근한 순서를 기준으로

    소켓 리스트를 생성하고 이 안에 유저의 로그인 상태, 번호를 부여해서 다뤘다.

     

     

    클라이언트가 실행하자 서버에서 즉각적으로 해당 접근을 파악하게 된다.

     

     

     

    recv_msg(int idx)

    void recv_msg(int idx) {
        string msg = "";
        while (1) {
            char buf[MAX_SIZE] = { };
            ZeroMemory(&buf, MAX_SIZE);
            if (recv(sck_list[idx].sck, buf, MAX_SIZE, 0) > 0) { // 오류가 발생하지 않으면 recv는 수신된 바이트 수를 반환. 0보다 크다는 것은 메시지가 왔다는 것.
                cout << "========================" << endl;
                msg = buf; 
                std::istringstream iss(buf);
                tokens.clear(); // 이전 토큰을 지우고 새로 시작안하면 값 변질되서 제대로 인식 못함 
                test_count = std::to_string(sck_list[idx].user_number);
                while (iss >> token) {
                    tokens.push_back(token);
                }
                db_init();
                // 토큰0 기준으로 1:로그인 / 2:id찾기 / 3:pw찾기 / 4:회원가입 / 5:대화 / 6:기존채팅 / 7:친추 / 8:비번수정
                // tokens[0] == 1 이면 로그인 요청
                if (tokens[0] == "1") {
                    cout << tokens[1] << "을 아이디 값으로 로그인 요청이 들어왔습니다." << endl;
                    db_login();
                };
                // tokens[0] == 2 이면 아이디 찾기 요청
                if (tokens[0] == "2") {
                    cout << tokens[1] << " 회원이 아이디 찾기 기능을 요청했습니다." << endl;
                    db_findID();
                };
                // tokens[0] == 3 이면 비밀번호 찾기 요청
                if (tokens[0] == "3") {
                    cout << tokens[1] << " 회원이 비밀번호 찾기 기능을 요청했습니다." << endl;
                    db_findPW();
                };
                // tokens[0] == 4 이면 회원가입 요청
                if (tokens[0] == "4") {
                    cout << " 회원가입 요청이 들어왔습니다." << endl;
                    db_join();
                };
                if (tokens[0] == "41") {
                    cout << " 아이디 확인 요청이 들어왔습니다." << endl;
                    db_join_check();
                };
    
                // tokens[0] == 5 이면 대화기능 요청
                if (tokens[0] == "5") {
                    Sleep(300);
                    cout << tokens[1] << " 회원이 친구 목록을 요청했습니다." << endl;
                    db_selectQuery_ver2();
                };
                // tokens[0] == 51 이면 보낸 메시지 저장 요청
                if (tokens[0] == "51") {
                    Sleep(300);
                    cout << tokens[1] << " 회원이 메세지 저장을 요청했습니다." << endl;
                    db_messageSend();
                };
    
                //tokens[0] == 52 이면 채팅 종료 요청
                if (tokens[0] == "52") {
                    Sleep(300);
                    cout << tokens[1] << " 회원이 채팅 종료를 요청했습니다." << endl;
                    dm_send_chatend(501, "server", "chat_end", "0");
                    dm_send_chatend(501, "server", "chat_end", "1");
                };
    
                // tokens[0] == 6 이면 기존 채팅방 요청
                if (tokens[0] == "6") {
                    cout << tokens[1] << " 회원이 유저의 기본 채팅방을 요청했습니다." << endl;
                    db_chat_list();
                };
    
                if (tokens[0] == "61") {
                    cout << tokens[1] << " 회원이 유저의 채팅방을 요청했습니다." << endl;
                    db_join_check_ver2();
                    if (user_check == true) {
                        db_chat_room();
                    }
                };
    
                // tokens[0] == 7 이면 친구 추가 기능 요청
                if (tokens[0] == "7") {
                    cout << tokens[1] << " 회원이 친구 추가 기능을 요청했습니다." << endl;
                    db_friend_register();
                };
    
                if (tokens[0] == "71") {
                    cout << tokens[1] << " 회원이 친구 목록 확인 기능을 요청했습니다." << endl;
                    db_friend_list();
                };
                // tokens[0] == 8 이면 비밀번호 수정 요청
                if (tokens[0] == "8") {
                    if (tokens[3] == "N") { //tokens[0] 이 8 이면서 tokens[3] 의 값은 Y와 N으로 비밀번호 확인 결과를 결정합니다.
                        cout << tokens[1] << " [비밀번호 확인 요청] 토큰[1]을 비밀번호값으로 바탕으로 비밀번호 변경 요청이 들어왔습니다." << endl;
                        test_count = std::to_string(sck_list[idx].user_number);
                        int result = 0;
                        db_UserEdit(); // 데이터베이스 쿼리 실행
                        tokens.clear(); // 이전 토큰을 지우고 새로 시작안하면 값 변질되서 제대로 인식 못함 ㅠㅠㅠㅠㅠ
                    }
                    if (tokens[3] == "Y") {
                        cout << tokens[1] << " [비밀번호 확인 완료] 토큰[1]을 비밀번호값으로 바탕으로 비밀번호 변경 요청이 들어왔습니다." << endl;
                        test_count = std::to_string(sck_list[idx].user_number);
                        int result = 0;
                        db_UserEdit_update();
                        tokens.clear(); // 이전 토큰을 지우고 새로 시작안하면 값 변질되서 제대로 인식 못함 ㅠㅠㅠㅠㅠ
                    }
                }
            }
            else { //그렇지 않을 경우 퇴장에 대한 신호로 생각하여 퇴장 메시지 전송
                msg = "[공지] " + sck_list[idx].user + " 님이 퇴장했습니다.";
                cout << msg << endl;
                send_msg(msg.c_str());
                del_client(idx); // 클라이언트 삭제
                return;
            }
        }
    }

     

    코드가 굉장히 길지만, 클라이언트가 보낸 요청에 대한 내용을 받고 요청이 무엇인지 판단하고

    이에 따른 처리를 하는 함수를 실행시키고 다시 값을 보내는 과정을 처리하는 핵심 코드이다.

    여기에 담긴 값들을 통해 DB 쿼리문을 통해 유저가 원하는 값을 서버가 불러오는 역할을 한다.

    지금은 string 의 값을 통해 구분했지만 enum 등을 통해 좀 더 직관적인 보완이 필요하다.

     

     


    CLIENT

    login()

    void login() {    
        while (!login_flag) {
            system("cls");
            login_Menu();
            if (login_flag == true) { break; }
            if (stop_flag == true) { break; }
    
            string User_input;
            string User_request = "1";
            cout << " 아이디 입력 >> ";
            cin >> User_input;
            my_nick = User_input;
            cout << " 비밀번호 입력 >> ";
            cin >> User_input;
            my_pw = User_input;
    
            string msg = User_request + " " + my_nick + " " + my_pw;
            send(client_sock, msg.c_str(), msg.length(), 0);
            break;
            
            std::thread th2(chat_recv);
    
            th2.join();
        }
    }

     

    클라이언트가 로그인 기능을 사용할 때 해당 값들을 서버로 보내는 과정이다.

    chat_recv 라는 기능을 하는 쓰레드를 생성하고 이를 통해서 서버와 해당 기능이 종료되기 까지

    소통을 진행하게 되는 것 이다.

     

     

     

    TCP 통신의 소켓 흐름을 위와 같이 정의하는데, 이를 보고 생각하면 편했다.

     

     


    실시간 채팅 관련 구현 (너무나 핵심)

    앞서 설계편에서 기획한 내용을 구체화하여 이를 좀 더 명확하게 채팅에 대한 프로세스 과정을

    도식화한 그림을 위와 같이 표현했다. 

     

     

    Client 부분

    conversation()

    void conversation() { //6 친구 목록 가져오기
        while (1) {
            if (conversation_flag == false) {
                system("cls");
                conversation_Menu();
                string User_request = "6"; // 대화하기 전 기존 채팅방 있는지 확인
                string msg = User_request + " " + login_User_id;
                send(client_sock, msg.c_str(), msg.length(), 0);
            }
    
            if (conversation_flag == true && user_check_flag == false) {
                while (1) {
                    cout << endl << " ============================================ " << endl;
                    cout << " ※ 대화를 원하는 사용자의 아이디를 입력하세요. (신규 대화도 가능) >> ";
                    cin >> friend_id;
    
                    // 나 자신과 대화 불가능
                    if (friend_id == login_User_id) {
                        cout << endl << " ============================================ " << endl;
                        cout << " ※ 대화를 진행할 수 없는 아이디입니다. " << endl;
                        cout << " ※ 아이디를 다시 확인해주세요. " << endl;
                        continue;
                    }
                    else { break; }
                }
    
                if (friend_id == "exit") {
                    stop_flag = true;
                    break;
                }
    
                string User_request = "61";
                string msg = User_request + " " + login_User_id + " " + friend_id;
                send(client_sock, msg.c_str(), msg.length(), 0);
            }
    
            //리스트 불러오면 true + 상대 아이디 있으면 true
            if (conversation_flag == true && user_check_flag == true) { break; }
    
            if (stop_flag == true) { break; }
    
            std::thread th(conversation_recv);
            th.join();
        }
    }

    클라이언트가 서버로 친구 목록을 요청하는 내용을 구현한 것은 위와 같다.

     

     

    conversation_recv()

    void conversation_recv() {
        while (!con_flag) {
            char buf[MAX_SIZE] = { };
            ZeroMemory(&buf, MAX_SIZE);
            if (recv(client_sock, buf, MAX_SIZE, 0) > 0) {
    
                // 문자열을 스트림에 넣고 공백을 기준으로 분할하여 벡터에 저장
                std::istringstream iss(buf);
                std::vector<std::string> tokens;
                std::string token;
    
                while (iss >> token) {
                    tokens.push_back(token);
                }
    
                // ( [0] : 요청 결과 (1=로그인 등) / [1] : 보낸 사람 ( 왠만해선 "server") / [2] : 결과값 (ID 찾기 성공 여부) / [3] : 받는 사람 / [4] : 찾은 친구 리스트(한줄) )
                if (tokens[1] == "server") { // 서버로부터 오는 메시지인 
                    if (tokens[0] == "6") {
                        result = tokens[2];
                        if (result == "1") { // 배열 비어있는건 empty 함수로 추가 하지 않도록 수정하기
                            cout << " ※ 기존 대화를 진행했던 친구 목록입니다. " << endl;
                            cout << " ============================================ " << endl;
                            for (int i = 4; i < tokens.size(); i++) {
                                cout << "  " << i - 3 << ". ID : " << tokens[i] << endl;
                            }
                            cout << " ============================================ " << endl;
    
                            conversation_flag = true;
                            Sleep(1000);
                            break;
                        }
                        else if (result == "2") {
                            cout << endl << " ============================================ " << endl;
                            cout << " ※ 기존 대화를 진행했던 친구가 없습니다. " << endl;
                            conversation_flag = true;
                            Sleep(1000);
                            break;
                            conversation();
                        }
                        else if (result == "3") {
                            cout << endl << " ============================================ " << endl;
                            cout << " ※ 아이디 검색 실패! 현재 등록되어 있는 사용자가 아닙니다." << endl;
                            user_check_flag = false;
                            Sleep(1000);
                            break;
                            conversation();
                        }
                        else if (result == "4") {
                            cout << endl << " ============================================ " << endl;
                            cout << " ※ 아이디 검색 성공! 대화방을 불러옵니다." << endl;
                            //break;
                        }
    
                    }
                    else if (tokens[0] == "601") { //정상적으로 다 실행됐을 때 여기로 와야함
                        chatting_friend = tokens[3];
                        chatting_roomnum = tokens[4];
                        user_check_flag = true; // 이 부분 필요
                        break;
                    }
                }
            }
        }
    }

     

    위의 conversation() 에 대한 응답이 오게 되면 그 응답을 클라이언트의 conversation_recv()에서 처리를 한다.

    마지막에 응답에 대한 결과를 chatting_friend 와 같이 전역 변수로 저장을 했는데,

    이는 실제로 구현했을 때, 쿠키와 세션의 개념에서 사용자의 PC에 저장하는 쿠키의 개념으로 설계했다.

    어차피 매번 새로운 요청이 오면, 해당 변수 역시 값이 다시 정의되므로 큰 문제도 없을 것 같았다.

     

     

    🤔 만약 이런 식으로 저장하는 과정이 없다면 채팅을 구현하는 과정에서 저 변수를 유지하기 위해 

    쓰레드가 일 해야하는 부분의 함수가 너무 길어지고 늘어져서 요청이 많아질 수록 충돌이 나는 경우가 생겼다.

    mutex를 잘 넣으면 구현이 될 것 같았는데, 접속한 클라이언트가 늘어나는 경우에

    다른 클라이언트의 쓰레드 요청과 처리에 문제가 생길 수 있었다. 더 좋은 방법이 있는지 궁금하다.

     

     

     

    SERVER 부분

    	string user_2;
    
        stmt = con->createStatement();
        res = stmt->executeQuery("SELECT user_id_1, user_id_2 FROM chatroom WHERE room_num = '" + tokens[3] + "'");
        // 결과 출력
        while (res->next()) {
            //cout << "현재 접속중인 방 번호 " << res2->getString("room_num") << endl; // ("필드이름")을 써야함. 필드이름 원하는거!
            cout << "유저 1의 ID : " << res->getString("user_id_1") << endl; // ("필드이름")을 써야함. 필드이름 원하는거!
            cout << "유저 2의 ID : " << res->getString("user_id_2") << endl; // ("필드이름")을 써야함. 필드이름 원하는거!
            string temp1 = res->getString("user_id_1");
            if (tokens[1] == temp1) {
                user_2 = res->getString("user_id_2");
            }
            else if (tokens[1] != temp1) {
                user_2 = res->getString("user_id_1");
            }
        }
        string query_msg = "message_room_" + tokens[3];
        std::string query = "SELECT number, user_id, content, time FROM " + query_msg;
    
        pstmt2 = con->prepareStatement(query);
        res2 = pstmt2->executeQuery();
    
        while (res2->next()) {
            std::vector<std::string> row;
            row.push_back(res2->getString("number"));
            row.push_back(res2->getString("user_id"));
            row.push_back(res2->getString("content"));
            row.push_back(res2->getString("time"));
            result.push_back(row);
        }

    클라이언트가 친구와의 대화 내용을 요청했을 때의 서버가 처리하는 과정이다.

    요청한 사람의 아이디와 방 번호를 바탕으로 서버가 처리하게 되며,

    방 번호 DB에 저장되어 있는 참여자 아이디 두 개를 클라이언트가 요청한 사람의 아이디와

    비교하여, 일치하지 않는 아이디 값을 대화하고 있는 상대로 지정한다.

     

    또한 C++에서 query문을 동적 할당을 하여 쿼리문을 실행하는 것은 불가능하기에,

    위의 코드에서 uery_msg ~ query 문으로 문자열을 추가하는 형식으로 쿼리문을 작성해서 실행해야 한다.

     

     

    그렇게 되면 result 라는 배열에는 친구와의 대화 내용이 저장된다.

     

     

    void dm_send_db(int server_request, const string& sender, const std::string& recipientUser, const std::string& user_2, const std::vector<std::vector<std::string>>& result) {
        string serv_request = std::to_string(server_request);
        std::string resultStr;
        for (const std::vector<std::string>& row : result) {
            for (const std::string& value : row) {
                resultStr += value + " "; // 공백을 구분자로 사용
            }
            resultStr += "\n"; // 각 행을 개행 문자로 구분
        }
        string msg = serv_request + " " + sender + " " + recipientUser + " " + user_2 + "/" + resultStr;
        for (int i = 0; i < client_count; i++) {
            if (std::to_string(sck_list[i].user_number) == recipientUser) {
                cout << "dm_send_db " << msg << endl;
                send(sck_list[i].sck, msg.c_str(), msg.length(), 0);
                return; // 특정 사용자에게 메시지를 보내면 함수 종료
            }
        }
        // 사용자를 찾지 못한 경우, 에러 메시지 출력 또는 다른 처리를 추가할 수 있습니다.
    }

     

    단 서버가 클라이언트로 보낼 때, 배열을 담아서 보내는 건 불가능하므로, 

    이를 보내는 함수에서 한 번, 공백을 구분자로 해서 늘리고 개행 문자를 추가해 구분할 수 있도록 한다.

    또한 클라이언트에서 이를 받아서 가공할 때, 대화 내용만을 추출해내기 위해

    보내기 전 메세지(msg) 부분을 기존과 다르게 대화 내용 앞에는 "/" 라는 특정 표시를 한다.

    클라이언트에서 처리할 때 이를 기준으로 분할하면 설계 부분에서 설계한 형태를 유지하되,

    대화 내용이 담긴 부분만을 추출하고, 개행 문자로 구분되어 있어서 데이터를 다루는 것에 용이하다.

     

     

    실제로 구현하고 실행되는 화면. 

    기획하고 설계했던 것을 전부 다 표현되었다.

    눈에 직접적으로 보이지는 않지만, 채팅 내역을 담는 채팅방에 대한 DB 개념도 들어있다.

     

     


    마치며

    사용자가 다른 사용자와 실시간 채팅하고, 채팅 내역이 업데이트 되어 실시간으로 보이는 것.이게 이번 프로젝트에서 가장 알파이며 오메가라고 생각한 기능이었다.그랬기 때문에 설계부터 구현까지 공들인 부분이 많고, 프로세스 과정 역시 많다.

    이 과정들을 하나 하나 짚어가며 개발하는게 생각보다 재밌게 했어서.. 진짜 큰 의미가 있던 프로젝트가 아니였나라는 생각이 든다.

     

     


    댓글 (비로그인 댓글 허용하지 않습니다.)