C++ 예제 (소켓 서버, 이미지, 파일전송)
이번 주제는 C++과 MFC를 이용한 소켓 프로그래밍입니다.
심화반 수강생 중 C++로 소켓 프로그래밍을 진행하는 학생이 있어 참조할만한 간단
예제로 만들어 보았습니다.
인터넷 검색을 통해 찾아본 대부분의 소켓프로그래밍 예제는 1:1 연결이거나, 1:N
이지만 문자열만 전송가능하거나, 또는 이미지만 전송가능한 경우가
대부분이었습니다.
이 예제는
문자열, 이미지, 파일에 대한 전송이 모두 가능하도록 만들어져 있습니다.
아래 링크된 서버와 클라이언트 (여러개) 실행파일을 이용해 테스트 가능합니다.
1. 서버 실행, 접속 대기
2. N개의 클라이언트 실행, 서버로 접속
3. 클라이언트 데이터 전송 (문자, 이미지, 파일 등)
4. 서버 데이터 수신
5. 접속된 모든 클라이언트로 수신 데이터 전송
- 서버 프로그램 실행파일 : 서버
이 글에서는 서버에 대한 내용을 다룹니다.
개요
서버의 주요 사항은 아래와 같습니다.
- Winsock API Ver. 2.2 를 이용한 서버 소켓 클래스 생성
이미지 파일이 전송된 경우, 클라이언트측 받은 메시지 처리 컨트롤인
CListBox의 해당 메시지를 더블클릭하면 이미지가 잘 전송된 모습을 확인할
수 있습니다.
이미지 사이즈는 모니터 해상도보다 클 수 있으므로,
CImage class의 StreatchBlt 함수로 스케일을 조정해 대화상자에 띄운
모습입니다.
[클라이언트 이미지 수신] |
설계 방향
- MFC 대화상자 프로젝트 + Socket API Function을 다루는 Server class 로 구성
-
Server class는 MFC class 의 사용을 최소화
채팅 프로토콜
소켓으로 전송되는 데이터는
문자열이든, 이미지든 파일이든 구분할 수 없는 binary 형태의 Byte
Array 데이터
이므로, 어떤 데이터가 어떤 사이즈로 전송되는지 서버와 클라이언트간
약속을 정해야 합니다.
이를 프로토콜이라고 표현하고, 본 예제에서 사용하기 위해 만든
프로토콜은 아래와 같습니다.
만약 채팅만 가능한 소켓 프로그래밍이라면 굳이 프로토콜 필요없이 서버측,
클라이언트측 전송되는 모든 패킷은 문자열이라 가정하고 진행하면 됩니다.
하지만 이미지, 파일 전송까지 고려해 본다면 소켓을 통해 전송되는 데이터가
무엇인지, 그리고 그 사이즈는 얼마인지 서로 프로토콜을 정해
사전에 약속해야 합니다.
간단히 문자열, 이미지, 파일만 구별 가능한 프로토콜을 작성해 보았습니다.
DATA_SIZE는 정수형 변수로 뒤에 전송될 DATA의
byte size를 의미합니다.
signed int type은 4바이트, 2의 32승까지만 수를 표현할 수 있으므로,
unsigned long long type (64bit 의 경우, 8 byte) 인 size_t를
사용합니다.
사실 signed int는 표현가능한 범위 (-2,147,483,648 ~ 2,147,483,647) 중에서도
양수만 사용되므로, 대용량 파일이나, 이미지 전송의 경우 적합하지 않습니다.
왜냐하면 전송되는 이미지나 파일의 크기는 4바이트로 표현 가능한 범위를 넘어설
수 있기 때문입니다.
[채팅 프로토콜] |
채팅 프로토콜
TYPE 0 : NICKNAME
TYPE 1 : TEXT
TYPE 2 : IMAGE NAME (파일 이름)
TYPE 3 : IMAGE DATA (실제 이미지 데이터)
TYPE 4 : FILE NAME (파일 이름)
TYPE 5 : FILE DATA (실제 파일 데이터)
- DATA_SIZE (8 byte, size_t) : 소켓 전송 데이터의 크기
- DATA (n byte, signed char) : 소켓에 전송 실제 데이터 (문자, 이미지, 파일)
프로토콜 예시
TYPE + DATA_SIZE + DATA
1. 문자 전송
TYPE + DATA_SIZE + DATA
2. 이미지 전송
2-1. 이미지 파일 이름 전송
TYPE + FILE_NAME_SIZE + FILE_NAME
2-2. 이미지 파일 전송
TYPE + FILE_SIZE + FILE_DATA
3. 파일 전송
3-1. 일반 파일 이름 전송
TYPE + FILE_NAME_SIZE + FILE_NAME
3-2. 일반 파일 전송
TYPE + FILE_SIZE + FILE_DATA
소스코드 분석
1. 소켓 초기화
MFC 대화상자 프로젝트를 하나 생성합니다.
소켓 라이브러리 초기화를 위해 MFC CWinApp class의 코드를
수정합니다.
CWinApp.h
- virtual int ExitInstance() , 함수 재정의
- WSADATA wsaData , 소켓 초기화 구조체 멤버변수 선언
class CXXXApp : public CWinApp
{
public:
CXXXApp();
public:
virtual BOOL InitInstance();
virtual int ExitInstance();
DECLARE_MESSAGE_MAP()
private:
WSADATA wsaData;
};
CWinApp.cpp
- 파일 맨 위쪽에 소켓 라이브러리, 및 헤더 파일 로드
#pragma comment(lib,"Ws2_32.lib") #include <WinSock2.h>
- InitInstance() 함수 소켓 라이브러리 초기화 코드 추가
BOOL CXXXApp::InitInstance() { int result = WSAStartup(MAKEWORD(2, 2), &wsaData); if (result != 0) { return false; } 생략... }
- ExitInstance() 함수 소켓 라이브러리 종료 코드 추가
int CXXXApp::ExitInstance()
{
WSACleanup();
return CWinApp::ExitInstance();
}
2. 소켓 클래스 생성
빈 Server.h, Server.cpp 파일 추가.
소켓 API를 사용할 서버 소켓 클래스를 선언합니다.
Server.h
- DATA_TYPE 열거형 선언, 전송 프로토콜에서 사용될 패킷 구분자
- 대화상자 클래스로 보낼 메시지 선언, UM_XXX
- 멤버함수, 및 멤버 변수 선언 (*.cpp 에서 설명)
#pragma once #include <WinSock2.h> #include <vector> #include <thread> #include <string> #include <mutex> const int MAX_BUF = 4096; enum DATA_TYPE { _NICK, _TEXT, _IMAGE_NAME, _IMAGE, _FILE, _FILE_NAME, _UNKNOWN }; const unsigned int UM_CONNECT_CLIENT = WM_USER + 1; const unsigned int UM_DISCONNECT_CLIENT = WM_USER + 2; const unsigned int UM_RECV_NICK = WM_USER + 3; const unsigned int UM_RECV_TEXT = WM_USER + 4; const unsigned int UM_RECV_IMAGE = WM_USER + 5; const unsigned int UM_RECV_FILE = WM_USER + 6; class Server { public: Server(CWnd* pParent); ~Server(); public: bool startServer(std::wstring ip, int port = 7000); void endServer(); static std::wstring getMyip(); inline void addClient(const SOCKET& sock) { m_clientSock.push_back(sock); } inline void addThread(std::thread* const pT) { m_clientThread.push_back(pT); } bool removeClient(const SOCKET& sock, const SOCKADDR_IN& addr); void setAddrFromSocket(const SOCKADDR_IN& addr, UINT_PTR& wp, LONG_PTR& lp); void sendText(std::wstring msg, const DATA_TYPE& type = _TEXT); void recvFinished(const DATA_TYPE& type, const char* buf, const size_t& recv_size, const size_t& data_size, const char* nick, const size_t& nick_size); static const std::string UnicodeToMultibyte(const unsigned int& code_page, const std::wstring& strWide); static const std::wstring MultibyteToUnicode(const unsigned int& code_page, const char* pBuf, const int& size); private: void broadcastNick(const char* buf, const size_t& size); void broadcast(const char* buf, const size_t& size); bool sendSocket(const char* buf, const size_t& size); public: bool bListen, bClient; CWnd* m_pParent; private: SOCKET listen_sock; SOCKADDR_IN addr; std::thread* m_pListenThread; std::mutex m_mutex; std::vector<std::thread*> m_clientThread; std::vector<SOCKET> m_clientSock; };
Server.cpp
- 헤더 파일 및 전역 함수
#include "pch.h" #include "Server.h" #include <chrono> #include <ws2tcpip .h> using namespace std; unsigned int listenThread(LPVOID p, const SOCKET& sock); unsigned int clientThread(LPVOID p, SOCKET sock, SOCKADDR_IN addr);
- Server class 생성자, 소멸자
Server::Server(CWnd* pParent) :listen_sock(INVALID_SOCKET), addr{}, m_pListenThread(nullptr), bListen(true), bClient(true), m_pParent(pParent) { } Server::~Server() { endServer(); }
- startServer() 함수
- AF_INET (IPV4), SOCK_STREAM (TCP) 소켓 생성
- 생성된 소켓 바인딩 후 listen(), accept() 처리용 쓰레드 생성
bool Server::startServer(std::wstring ip, int port) { if (listen_sock == INVALID_SOCKET) { listen_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listen_sock == INVALID_SOCKET) return false; addr.sin_family = AF_INET; //addr.sin_addr.s_addr = htonl(INADDR_ANY); inet_pton(AF_INET, (PCSTR)ip.c_str(), &(addr.sin_addr)); addr.sin_port = htons(port); int result = ::bind(listen_sock, (SOCKADDR*) &addr, (int)sizeof(addr)); if (result == SOCKET_ERROR) { closesocket(listen_sock); return false; } if (m_pListenThread == nullptr) { bListen = bClient = true; m_pListenThread = new thread(&listenThread, this, listen_sock); } } return true; }
- endServer() 함수
- 리슨소켓 및 접속된 클라이언트 소켓 close()
- 접속된 클라이언트 소켓 저장 벡터 삭제
-
동적할당된 클라이언트 처리용 쓰레드 메모리 해제, 벡터 삭제
void Server::endServer() { bListen = bClient = false; // close listen socket if (listen_sock != INVALID_SOCKET) { closesocket(listen_sock); listen_sock = INVALID_SOCKET; } // stop listen thread if (m_pListenThread) { if (m_pListenThread->joinable()) m_pListenThread->join(); delete m_pListenThread; m_pListenThread = nullptr; } // close client socket m_mutex.lock(); vector<SOCKET>::iterator sitr; for (sitr = m_clientSock.begin(); sitr != m_clientSock.end(); ++sitr) { closesocket(*sitr); } m_clientSock.clear(); m_mutex.unlock(); // stop client thread vector<thread*>::iterator itr; for (itr = m_clientThread.begin(); itr != m_clientThread.end(); ++itr) { if ((*itr)->joinable()) (*itr)->join(); delete (*itr); } m_clientThread.clear(); }
-
getMyip(), 자신의 ip를 얻는 함수
- static 함수구성, Server::getMyip() 형태로 사용
- inet_ntoa() 대신 InetNtop() 사용
wstring Server::getMyip() { char host[MAX_PATH] = { 0, }; int result = gethostname(host, sizeof(host)); if (result == SOCKET_ERROR) return wstring(); // ip V4 만 가능 //PHOSTENT pHostInfo; //pHostInfo = gethostbyname(host); //if (pHostInfo == nullptr) // return string(); //if (pHostInfo->h_addrtype == AF_INET) //ip V4 //{ // in_addr addr; // memset(&addr, 0, sizeof(addr)); // int i = 0; // while (pHostInfo->h_addr_list[i] != 0) // { // addr.s_addr = *(u_long *)pHostInfo->h_addr_list[i++]; // inet_ntoa(addr); // } //} //// ip V6 대응 char port_str[16] = {0,}; sprintf_s(port_str, "%d", 7000); addrinfo hint, *pResult; memset(&hint, 0, sizeof(hint)); //result = getaddrinfo(host, port_str, &hint, &pResult); result = getaddrinfo(host, "7000", &hint, &pResult); if (result != 0) return wstring(); addrinfo *ptr = nullptr; sockaddr_in *pIpv4; sockaddr_in6 *pIpv6; wchar_t ip_str[46]; DWORD ip_size = sizeof(ip_str); memset(&ip_str, 0, ip_size); wstring ip; for (ptr = pResult; ptr != NULL; ptr = ptr->ai_next) { switch (ptr->ai_family) { case AF_UNSPEC: break; case AF_INET: printf("AF_INET (IPv4)\n"); pIpv4 = (struct sockaddr_in *) ptr->ai_addr; //inet_ntoa(pIpv4->sin_addr); InetNtop(AF_INET, &pIpv4->sin_addr, ip_str, ip_size); ip = ip_str; break; case AF_INET6: // the InetNtop function is available on Windows Vista and later pIpv6 = (struct sockaddr_in6 *) ptr->ai_addr; InetNtop(AF_INET6, &pIpv6->sin6_addr, ip_str, ip_size); break; case AF_NETBIOS: break; default: break; } } return ip; }
- removeClient() 함수
- 접속된 클라이언트 중 일부가 연결이 끊어진 경우 처리 (해당 소켓만 벡터에서 제거)
// 일부 클라이언트만 죽는 경우도 있으므로 벡터에서 삭제 bool Server::removeClient(const SOCKET & sock, const SOCKADDR_IN& addr) { m_mutex.lock(); vector<SOCKET>::iterator itr; for (itr = m_clientSock.begin(); itr != m_clientSock.end(); ++itr) { if ((*itr) == sock) { m_clientSock.erase(itr); UINT_PTR wp; LONG_PTR lp; setAddrFromSocket(addr, wp, lp); m_pParent->PostMessageW(UM_DISCONNECT_CLIENT, wp, lp); break; } } m_mutex.unlock(); return true; }
-
setAddrFromSocket() 함수
- SOCKADDR 구조체 IP, PORT 정보를 class간 메시지 전송을 위해 WPARAM, LPARAM으로 맵핑
void Server::setAddrFromSocket(const SOCKADDR_IN & addr, UINT_PTR & wp, LONG_PTR & lp) { wp = addr.sin_addr.s_addr; lp = htons(addr.sin_port); }
- listenThread() 함수
-
accept() 함수 블러킹 처리를 위한 쓰레드, 클라이언트 접속을
처리
- listen(), accept() 처리 후 클라이언트 접속시 개별 쓰레드 생성
unsigned int listenThread(LPVOID p, const SOCKET& sock) { Server *pS = reinterpret_cast<Server>(p); while (pS->bListen) { if (listen(sock, SOMAXCONN) == SOCKET_ERROR) { pS->bListen = false; break; } SOCKET client = INVALID_SOCKET; SOCKADDR_IN addr; memset(&addr, 0, sizeof(addr)); int size = sizeof(addr); client = accept(sock, (SOCKADDR*)&addr, &size); if (client != INVALID_SOCKET) { pS->addClient(client); thread* pT = new thread(&clientThread, p, client, addr); pS->addThread(pT); // Parent로 클라이언트 정보 전송 UINT_PTR wp; LONG_PTR lp; pS->setAddrFromSocket(addr, wp, lp); pS->m_pParent->PostMessageW(UM_CONNECT_CLIENT, wp, lp); } else { break; } } closesocket(sock); return 0; }
- sendSocket() 함수
- 클라이언트 소켓으로 패킷 전송
bool Server::sendSocket(const char * buf, const size_t & size) { broadcast(buf, size) return false; }
- sendText() 함수
- UTF-8 문자를 Multibyte Char로 변환, 프로토콜 작성 후 전송
void Server::sendText(std::wstring msg, const DATA_TYPE& type) { if (msg.empty()) return; // utf-8 -> char string str = Server::UnicodeToMultibyte(CP_UTF8, msg); size_t txt_size = str.length(); size_t packet_size = 1 + sizeof(size_t) + txt_size; char* buf = new char[packet_size]; memset(buf, 0, packet_size); buf[0] = type; memcpy(&buf[1], &txt_size, sizeof(size_t)); memcpy(&buf[1 + sizeof(size_t)], &str[0], txt_size); bool result = sendSocket(buf, packet_size); delete[] buf; }
- recvFinished() 함수
- 클라이언트 소켓으로 전송된 패킷 recv() 가 종료시 호출
- 프로토콜 타입에 따라 문자, 이미지, 파일 등 분류 처리
void Server::recvFinished(const DATA_TYPE& type, const char* buf, const size_t& recv_size, const size_t& data_size, const char* nick, const size_t& nick_size) { if (m_pParent) { switch (type) { case _NICK: m_pParent->SendMessage(UM_RECV_NICK, (WPARAM)&buf[1 + sizeof(size_t)], data_size); break; case _TEXT: broadcast(buf, recv_size); m_pParent->SendMessage(UM_RECV_TEXT, (WPARAM)&buf[1 + sizeof(size_t)], data_size); break; case _IMAGE: case _FILE: broadcast(buf, recv_size); break; case _IMAGE_NAME: case _FILE_NAME: broadcast(buf, recv_size); m_pParent->SendMessage(UM_RECV_TEXT, (WPARAM)&buf[1 + sizeof(size_t)], data_size); break; } } }
- broadcastNick() 함수
- 접속된 클라이언트 닉네임 전송용
void Server::broadcastNick(const char * buf, const size_t & size) { size_t data_size = 2 + size; // '[' ']' size_t packet_size = 1 + sizeof(size_t) + data_size; char* buf2 = new char[packet_size]; memset(buf2, 0, data_size); buf2[0] = _TEXT; memcpy(&buf2[1], &data_size, sizeof(size_t)); buf2[1+sizeof(size_t)] = '['; memcpy(&buf2[1+sizeof(size_t)+1], buf, size); buf2[packet_size - 1] = ']'; vector<SOCKET>::iterator itr; for (itr = m_clientSock.begin(); itr != m_clientSock.end(); ++itr) { size_t send_size = 0; do { send_size = send(*itr, &buf2[send_size], packet_size - send_size, 0); } while (send_size < packet_size); } }
- broadcast() 함수
- 특정 클라이언트로부터 수신된 패킷을 모든 클라이언트에게 전송
void Server::broadcast(const char* buf, const size_t& size) { vector<SOCKET>::iterator itr; for (itr = m_clientSock.begin(); itr != m_clientSock.end(); ++itr) { size_t send_size = 0; do { send_size += send(*itr, &buf[send_size], size-send_size, 0); } while (send_size < size); } }
- UnicodeToMultibyte() 함수
- UTF-8 wchar_t 문자를 char로 변환해 소켓 전송할 용도
-
WideCharToMultiByte()를 2번 호출하는 이유는
우선 변환된 크기를 알아내기 위함
const string Server::UnicodeToMultibyte(const unsigned int& code_page, const std::wstring& strWide) { if (strWide.empty()) return string().c_str(); int size = WideCharToMultiByte(code_page, 0, strWide.c_str(), (int)strWide.size(), NULL, 0, NULL, NULL); string str(size, 0); size = WideCharToMultiByte(code_page, 0, strWide.c_str(), (int)strWide.size(), &str[0], size, NULL, NULL); return str; }
- MultibyteToUnicode() 함수
- char 문자를 UTF-8 wchar_t로 변환
const wstring Server::MultibyteToUnicode(const unsigned int& code_page, const char* buf, const int& size) { int str_size = size; if (str_size <= 0) return wstring().c_str(); str_size = MultiByteToWideChar(code_page, 0, buf, size, NULL, 0); wstring str(str_size, 0); str_size = MultiByteToWideChar(code_page, 0, buf, size, &str[0], size); return str.c_str(); }
- clientThead() 함수
- 각 클라이언트 소켓으로 수신된 recv() 처리 쓰레드 함수
- 소켓으로 전송되는 패킷은 단편화(데이터 나누어 수신)가 발생
-
실제 소켓으로 수신되는 패킷 사이즈는
네트워크 사정에 따라 달라지므로 처리
필요
-
프로토콜에서 패킷 사이즈 수신 후 그
사이즈 만큼만 수신하도록 처리
unsigned int clientThread(LPVOID p, SOCKET sock, SOCKADDR_IN addr) { Server *pS = reinterpret_cast<Server*>(p); char buf[MAX_BUF]; char* pRecvBuf = nullptr; char nick[128]; memset(nick, 0, sizeof(nick)); size_t nick_size=0; while (pS->bClient) { memset(buf, 0, MAX_BUF); size_t total_size = 0; int recv_size = 0; size_t data_size = 0; size_t pos = 0; DATA_TYPE type = _UNKNOWN; bool bDisconnect = false; do { recv_size = recv(sock, buf, MAX_BUF, 0); if (recv_size < 0) { bDisconnect = true; break; } else { total_size += recv_size; if (type == _UNKNOWN && recv_size>= 1+sizeof(size_t)) { type = static_cast<DATA_TYPE>(buf[0]); memcpy(&data_size, &buf[1], sizeof(size_t)); if (pRecvBuf == nullptr) { size_t packet_size = 1 + sizeof(size_t) + data_size; pRecvBuf = new char[packet_size]; memset(pRecvBuf, 0, packet_size); } } memcpy(&pRecvBuf[pos], buf, recv_size); pos += recv_size; } } while (total_size < data_size + 1 + sizeof(size_t)); if (bDisconnect) { pS->removeClient(sock, addr); std::this_thread::sleep_for(100ms); break; } if (total_size == data_size + 1 + sizeof(size_t)) { if (nick_size == 0 && type == _NICK) { memcpy(nick, &buf[1 + sizeof(size_t)], data_size); nick_size = data_size; } pS->recvFinished(type, pRecvBuf, total_size, data_size, nick, nick_size); if (pRecvBuf) { delete[] pRecvBuf; pRecvBuf = nullptr; } } } closesocket(sock); sock = INVALID_SOCKET; return 0; }
주로 Local host에서 서버와 클라이언트를 테스트하다보니 실제 외부 네트워크와
접속시 버그가 있는 경우도 있을 것입니다.
버그가 있다면 댓글로 남겨주시면 감사하겠습니다.
대화상자 클래스에 대한 분석은 MFC CDialog class에 대한 일반적인
내용이므로 생략합니다.
유첨된 전체 소스코드를 참조바랍니다.
코드를 작성한 환경은 아래와 같습니다.
- 개발환경 : Windows 10 pro(64bit), VS 2017 C++, MFC
감사합니다.
학습에 크게 도움이 되었습니다. 좋은 포스팅 감사합니다.
답글삭제별말씀을요. 화이팅입니다.
삭제소스 공유 감사드립니다!
답글삭제서버에 접속한 IP, PORT 표시하는 부분에 PORT가 다른 숫자로 나오는데 재대로 나오게 할려면 어떻게 하나요?
답글삭제서버 접속시 표시되는 포트번호는 (서버 리슨 소켓 포트번호 X) 클라이언트측 소켓 포트 번호이며, 운영체제에 의해 자동 할당 됩니다. 주로 50,000 번 대역 입니다.
삭제소스 공유 감사합니다.
삭제전체 소스코드 다운 받으면 .rar 확장자로 다운이 받아지는데 어떻게 열 수 있나요? 혹시 메일로 받을 수 있을까요?
답글삭제rar는 압축파일이므로 압축을 풀면 됩니다.
삭제멀티 바이트 환경에서 동작하게 하려고 'wchar_t -> char', 'std::wstring'->'std::string' 로 수정하여 테스트 했습니다. 문자열 전송 시 제대로 전송이 되지 않는데, 추가로 고려해야 할 사항이 있을까요?
답글삭제자료형과 관련된 전용함수, 프로젝트 설정(멀티바이트프로젝트로 생성)도 같이 변경해 시도하면 좋을 것 같습니다.
삭제